From cb3f8713eea0acee5536d4d2acef1d5f4d75620b Mon Sep 17 00:00:00 2001 From: "ci[bot]" Date: Mon, 13 Jul 2026 02:44:46 +0000 Subject: [PATCH] :package: deps(thirdparty): update snapshots --- antigravity-awesome-skills/README.md | 14 +- antigravity-awesome-skills/SOURCE.md | 4 +- .../apps/web-app/src/pages/Plugins.tsx | 14 +- .../src/pages/__tests__/Plugins.test.tsx | 6 + .../docs/users/getting-started.md | 12 +- .../docs/vietnamese/FAQ.vi.md | 9 +- .../docs/vietnamese/GETTING_STARTED.vi.md | 9 +- .../docs_zh-CN/README.md | 6 +- .../docs_zh-CN/users/getting-started.md | 9 +- antigravity-awesome-skills/package.json | 3 + .../audit_search_migration_readiness.js | 399 +++++++++++++ .../scripts/generate-pages-redirect-bridge.js | 293 ++++++++++ .../scripts/normalize_traffic_snapshots.js | 541 ++++++++++++++++++ .../audit_search_migration_readiness.test.js | 236 ++++++++ .../generate_pages_redirect_bridge.test.js | 191 +++++++ .../tests/normalize_traffic_snapshots.test.js | 199 +++++++ 16 files changed, 1935 insertions(+), 10 deletions(-) create mode 100644 antigravity-awesome-skills/tools/scripts/audit_search_migration_readiness.js create mode 100644 antigravity-awesome-skills/tools/scripts/generate-pages-redirect-bridge.js create mode 100644 antigravity-awesome-skills/tools/scripts/normalize_traffic_snapshots.js create mode 100644 antigravity-awesome-skills/tools/scripts/tests/audit_search_migration_readiness.test.js create mode 100644 antigravity-awesome-skills/tools/scripts/tests/generate_pages_redirect_bridge.test.js create mode 100644 antigravity-awesome-skills/tools/scripts/tests/normalize_traffic_snapshots.test.js diff --git a/antigravity-awesome-skills/README.md b/antigravity-awesome-skills/README.md index ab33f453..7a3382d7 100644 --- a/antigravity-awesome-skills/README.md +++ b/antigravity-awesome-skills/README.md @@ -79,6 +79,18 @@ npx agentic-awesome-skills --agy The npm installer uses a shallow, release-pinned clone by default so first-run installs stay lighter than a full repository history checkout while matching the published npm package version. Use `--tag main` only when you intentionally want the current repository tip. +### Focused single-skill install with GitHub CLI (preview) + +GitHub CLI can preview and install one exact skill for Copilot and other supported hosts. Use an exact `SKILL.md` path in this large, mirrored repository so the selected source is unambiguous and discovery stays fast: + +```bash +gh skill preview sickn33/agentic-awesome-skills skills/brainstorming/SKILL.md +gh skill install sickn33/agentic-awesome-skills skills/brainstorming/SKILL.md \ + --agent github-copilot --scope user --pin v14.2.0 +``` + +`gh skill` support is currently a GitHub CLI preview and may change. Install a focused skill or plugin surface for the job; do not use `--all` unless you intentionally want every discovered canonical and mirrored skill. + ### Verify the install ```bash @@ -142,7 +154,7 @@ Use the same repository, but install or invoke it in the way your host expects. | Antigravity CLI (`agy`) | `npx agentic-awesome-skills --agy` | `/brainstorming help me plan a feature` | | Kiro CLI | `npx agentic-awesome-skills --kiro` | `Use brainstorming to plan a feature` | | Kiro IDE | `npx agentic-awesome-skills --path ~/.kiro/skills` | `Use @brainstorming to plan a feature` | -| GitHub Copilot | _No installer — paste skills or rules manually_ | `Ask Copilot to use brainstorming to plan a feature` | +| GitHub Copilot | `gh skill install sickn33/agentic-awesome-skills skills/brainstorming/SKILL.md --agent github-copilot --scope user --pin v14.2.0` (preview) | `Ask Copilot to use brainstorming to plan a feature` | | OpenCode | `npx agentic-awesome-skills --path .agents/skills --category development,backend --risk safe,none` | `opencode run @brainstorming help me plan a feature` | | AdaL CLI | `npx agentic-awesome-skills --path .adal/skills` | `Use brainstorming to plan a feature` | | Custom path | `npx agentic-awesome-skills --path ./my-skills` | Depends on your tool | diff --git a/antigravity-awesome-skills/SOURCE.md b/antigravity-awesome-skills/SOURCE.md index a158b6e3..d5e8de18 100644 --- a/antigravity-awesome-skills/SOURCE.md +++ b/antigravity-awesome-skills/SOURCE.md @@ -1,8 +1,8 @@ # Source - Repo: https://github.com/sickn33/antigravity-awesome-skills -- Ref: 6b7501e33dd3fe4ff3715e5bd79dec2881e8b7d4 +- Ref: 314c8c499af407db8160a3eeac9043b58dc9c41a - Remove-Paths: -- Snapshot: 2026-07-12 +- Snapshot: 2026-07-13 - Sync-Mode: copy_skill_dirs - Notes: vendored into playbook branch thirdparty/skill diff --git a/antigravity-awesome-skills/apps/web-app/src/pages/Plugins.tsx b/antigravity-awesome-skills/apps/web-app/src/pages/Plugins.tsx index 1ef8c95c..fa427b2d 100644 --- a/antigravity-awesome-skills/apps/web-app/src/pages/Plugins.tsx +++ b/antigravity-awesome-skills/apps/web-app/src/pages/Plugins.tsx @@ -17,6 +17,10 @@ function bundleDocUrl(): string { return `${repoBaseUrl}/blob/main/docs/users/bundles.md`; } +function gettingStartedDocUrl(): string { + return `${repoBaseUrl}/blob/main/docs/users/getting-started.md`; +} + export function Plugins(): React.ReactElement { usePageMeta(buildPluginsMeta(specializedPlugins.length)); @@ -36,7 +40,7 @@ export function Plugins(): React.ReactElement { Choose the focused AAS plugin for your AI coding workflow

- AAS specialized plugins are focused, domain-specific distributions of the 1,550+ skill library. + AAS specialized plugins are focused, domain-specific distributions of the full skill library. Start here when you know the job: web apps, security, data analytics, documents, DevOps, QA, OSS maintenance, mobile apps, automation, or agent and MCP systems.

@@ -55,6 +59,14 @@ export function Plugins(): React.ReactElement { > Browse full skill catalog + + Install one skill with GitHub CLI + diff --git a/antigravity-awesome-skills/apps/web-app/src/pages/__tests__/Plugins.test.tsx b/antigravity-awesome-skills/apps/web-app/src/pages/__tests__/Plugins.test.tsx index 219f8252..9ae106d1 100644 --- a/antigravity-awesome-skills/apps/web-app/src/pages/__tests__/Plugins.test.tsx +++ b/antigravity-awesome-skills/apps/web-app/src/pages/__tests__/Plugins.test.tsx @@ -11,6 +11,12 @@ describe('Plugins', () => { expect(screen.getByText('AAS Web App Builder')).toBeInTheDocument(); expect(screen.getByText('AAS Security Engineer')).toBeInTheDocument(); expect(screen.getByText('AAS Marketing, SEO & Growth')).toBeInTheDocument(); + expect(screen.getByText(/distributions of the full skill library/i)).toBeInTheDocument(); + expect(screen.queryByText(/1,550\+/i)).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Install one skill with GitHub CLI/i })).toHaveAttribute( + 'href', + expect.stringContaining('docs/users/getting-started.md'), + ); expect(screen.getByText(/Plugins, bundles, and workflows serve different decisions/i)).toBeInTheDocument(); expect(document.title).toContain('AAS Specialized Plugins'); expect(document.querySelector('meta[name="description"]')).toHaveAttribute( diff --git a/antigravity-awesome-skills/docs/users/getting-started.md b/antigravity-awesome-skills/docs/users/getting-started.md index 1def9788..e53a62f1 100644 --- a/antigravity-awesome-skills/docs/users/getting-started.md +++ b/antigravity-awesome-skills/docs/users/getting-started.md @@ -44,6 +44,16 @@ If you see a 404 error, use: `npx github:sickn33/agentic-awesome-skills` git clone https://github.com/sickn33/agentic-awesome-skills.git .agent/skills ``` +**Option C — one exact skill with GitHub CLI (preview):** + +```bash +gh skill preview sickn33/agentic-awesome-skills skills/brainstorming/SKILL.md +gh skill install sickn33/agentic-awesome-skills skills/brainstorming/SKILL.md \ + --agent github-copilot --scope user --pin v14.2.0 +``` + +GitHub CLI skill support is currently in preview. In this large repository, use an exact `SKILL.md` path to avoid ambiguous canonical/plugin mirrors and unnecessary full-tree discovery. Avoid `--all` unless you intentionally want every discovered skill. + ### 2. Pick Your Persona Find the bundle that matches your role (see [bundles.md](bundles.md)): @@ -110,7 +120,7 @@ Once installed, just talk to your AI naturally. | **Cursor** | ✅ Native | `.cursor/skills/` | | **OpenCode** | ✅ Full Support | `.agents/skills/` (prefer reduced installs with `--risk`, `--category`, or `--tags`) | | **AdaL CLI** | ✅ Full Support | `.adal/skills/` | -| **Copilot** | ⚠️ Text Only | Manual copy-paste | +| **Copilot** | ✅ Native (preview) | `gh skill install ... --agent github-copilot` at project or user scope | --- diff --git a/antigravity-awesome-skills/docs/vietnamese/FAQ.vi.md b/antigravity-awesome-skills/docs/vietnamese/FAQ.vi.md index 47759563..6abf6b89 100644 --- a/antigravity-awesome-skills/docs/vietnamese/FAQ.vi.md +++ b/antigravity-awesome-skills/docs/vietnamese/FAQ.vi.md @@ -25,7 +25,14 @@ Nó giống như việc sở hữu một thư viện - tất cả sách đều - ✅ **Cursor** (IDE tích hợp AI) - ✅ **Antigravity IDE** - ✅ **OpenCode** -- ⚠️ **GitHub Copilot** (Hỗ trợ một phần qua việc copy-paste) +- 🧪 **GitHub Copilot** (hỗ trợ preview qua GitHub CLI `gh skill`) + +Với Copilot, `gh skill` hiện vẫn ở trạng thái preview. Repository lớn này có cả bản canonical và bản mirror trong plugin, vì vậy hãy dùng đường dẫn chính xác; chỉ dùng `--all` khi bạn thực sự muốn cài mọi bản được phát hiện: + +```bash +gh skill preview sickn33/agentic-awesome-skills skills/brainstorming/SKILL.md +gh skill install sickn33/agentic-awesome-skills skills/brainstorming/SKILL.md --agent github-copilot --scope user --pin v14.2.0 +``` ### Những kỹ năng này có được sử dụng miễn phí không? diff --git a/antigravity-awesome-skills/docs/vietnamese/GETTING_STARTED.vi.md b/antigravity-awesome-skills/docs/vietnamese/GETTING_STARTED.vi.md index 9f9e0cc1..4fa72dcc 100644 --- a/antigravity-awesome-skills/docs/vietnamese/GETTING_STARTED.vi.md +++ b/antigravity-awesome-skills/docs/vietnamese/GETTING_STARTED.vi.md @@ -78,7 +78,14 @@ Sau khi cài đặt, bạn chỉ cần trò chuyện với AI một cách tự n | **Cursor** | ✅ Hỗ trợ gốc | `.cursor/skills/` | | **OpenCode** | ✅ Hỗ trợ đầy đủ | `.agents/skills/` | | **AdaL CLI** | ✅ Hỗ trợ đầy đủ | `.adal/skills/` | -| **Copilot** | ⚠️ Chỉ văn bản | Copy-paste thủ công | +| **Copilot** | 🧪 Hỗ trợ preview qua `gh skill` | Dùng GitHub CLI với đường dẫn skill chính xác | + +> **GitHub Copilot (preview):** `gh skill` hiện vẫn ở trạng thái preview. Vì repository lớn này có cả bản canonical và bản mirror trong plugin, hãy dùng đường dẫn chính xác để tránh nhầm lẫn; chỉ dùng `--all` khi bạn thực sự muốn cài mọi bản được phát hiện: +> +> ```bash +> gh skill preview sickn33/agentic-awesome-skills skills/brainstorming/SKILL.md +> gh skill install sickn33/agentic-awesome-skills skills/brainstorming/SKILL.md --agent github-copilot --scope user --pin v14.2.0 +> ``` --- diff --git a/antigravity-awesome-skills/docs_zh-CN/README.md b/antigravity-awesome-skills/docs_zh-CN/README.md index 902db8fa..489988a0 100644 --- a/antigravity-awesome-skills/docs_zh-CN/README.md +++ b/antigravity-awesome-skills/docs_zh-CN/README.md @@ -166,7 +166,7 @@ AI 代理很聪明,但仍需要**特定于任务的操作指令**。技能是 | **Antigravity** | IDE | `(Agent Mode) Use skill...` | 全局:`~/.agents/skills/` · 工作区:`.agent/skills/` | | **Antigravity CLI (`agy`)** | CLI | `/skill-name help me...` | `~/.gemini/antigravity-cli/skills/` | | **Cursor** | IDE | `@skill-name (in Chat)` | `.cursor/skills/` | -| **Copilot** | Ext | `(Paste content manually)` | N/A | +| **Copilot** | Ext(`gh skill` 预览) | `gh skill preview sickn33/agentic-awesome-skills skills/brainstorming/SKILL.md` | GitHub CLI `gh skill` | | **OpenCode** | CLI | `opencode run @skill-name` | `.agents/skills/` | | **AdaL CLI** | CLI | `(Auto) Skills load on-demand` | `.adal/skills/` | @@ -233,11 +233,13 @@ Codex 插件通过仓库本地插件入口指向相同的精选 `skills/` 树, | Antigravity CLI (`agy`) | `npx agentic-awesome-skills --agy` | `/brainstorming help me plan a feature` | | Kiro CLI | `npx agentic-awesome-skills --kiro` | `Use brainstorming to plan a feature` | | Kiro IDE | `npx agentic-awesome-skills --path ~/.kiro/skills` | `Use @brainstorming to plan a feature` | -| GitHub Copilot | _无安装器 — 手动粘贴技能或规则_ | `Ask Copilot to use brainstorming to plan a feature` | +| GitHub Copilot | `gh skill install sickn33/agentic-awesome-skills skills/brainstorming/SKILL.md --agent github-copilot --scope user --pin v14.2.0`(预览) | `Ask Copilot to use brainstorming to plan a feature` | | OpenCode | `npx agentic-awesome-skills --path .agents/skills --category development,backend --risk safe,none` | `opencode run @brainstorming help me plan a feature` | | AdaL CLI | `npx agentic-awesome-skills --path .adal/skills` | `Use brainstorming to plan a feature` | | 自定义路径 | `npx agentic-awesome-skills --path ./my-skills` | 取决于你的工具 | +> **GitHub Copilot(预览)**:GitHub CLI 的 `gh skill` 目前处于 preview 阶段。由于本仓库规模较大且同时包含 canonical 与 plugin 镜像,请使用精确路径,避免歧义;除非你确实要安装所有发现的副本,否则不要使用 `--all`。 + ## 按工具查看最佳技能 如果你想要比"浏览所有 1,936+ 技能"更快的答案,请从工具特定指南开始: diff --git a/antigravity-awesome-skills/docs_zh-CN/users/getting-started.md b/antigravity-awesome-skills/docs_zh-CN/users/getting-started.md index f79e970d..c08d2de2 100644 --- a/antigravity-awesome-skills/docs_zh-CN/users/getting-started.md +++ b/antigravity-awesome-skills/docs_zh-CN/users/getting-started.md @@ -107,7 +107,14 @@ git clone https://github.com/sickn33/agentic-awesome-skills.git .agent/skills | **Cursor** | ✅ 原生支持 | `.cursor/skills/` | | **OpenCode** | ✅ 完全支持 | `.agents/skills/`(建议用 `--risk`、`--category` 或 `--tags` 做缩小安装) | | **AdaL CLI** | ✅ 完全支持 | `.adal/skills/` | -| **Copilot** | ⚠️ 仅文本 | 手动复制粘贴 | +| **Copilot** | 🧪 `gh skill` 预览支持 | 使用 GitHub CLI 的 `gh skill`,并指定精确技能路径 | + +> **GitHub Copilot(预览)**:`gh skill` 目前处于 preview 阶段。由于本仓库规模较大且同时包含 canonical 与 plugin 镜像,请使用精确路径,避免歧义;除非你确实要安装所有发现的副本,否则不要使用 `--all`: +> +> ```bash +> gh skill preview sickn33/agentic-awesome-skills skills/brainstorming/SKILL.md +> gh skill install sickn33/agentic-awesome-skills skills/brainstorming/SKILL.md --agent github-copilot --scope user --pin v14.2.0 +> ``` --- diff --git a/antigravity-awesome-skills/package.json b/antigravity-awesome-skills/package.json index 67d0d5fb..15b8293f 100644 --- a/antigravity-awesome-skills/package.json +++ b/antigravity-awesome-skills/package.json @@ -34,6 +34,9 @@ "build": "npm run chain && npm run catalog", "check:stale-claims": "node tools/scripts/run-python.js tools/scripts/check_stale_claims.py", "check:live-seo": "node tools/scripts/check-live-seo-geo.js", + "pages:redirect-bridge": "node tools/scripts/generate-pages-redirect-bridge.js", + "traffic:normalize": "node tools/scripts/normalize_traffic_snapshots.js", + "traffic:migration-readiness": "node tools/scripts/audit_search_migration_readiness.js", "check:warning-budget": "node tools/scripts/run-python.js tools/scripts/check_validation_warning_budget.py", "check:readme-credits": "node tools/scripts/run-python.js tools/scripts/check_readme_credits.py", "audit:consistency": "node tools/scripts/run-python.js tools/scripts/audit_consistency.py", diff --git a/antigravity-awesome-skills/tools/scripts/audit_search_migration_readiness.js b/antigravity-awesome-skills/tools/scripts/audit_search_migration_readiness.js new file mode 100644 index 00000000..d0c07596 --- /dev/null +++ b/antigravity-awesome-skills/tools/scripts/audit_search_migration_readiness.js @@ -0,0 +1,399 @@ +#!/usr/bin/env node +'use strict'; + +/* + * This audit deliberately uses only saved evidence. It is not a substitute for + * a live deploy check and must never turn a legacy-only dashboard capture into + * a green migration. + */ + +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_LEGACY_PACKAGE = 'antigravity-awesome-skills'; +const DEFAULT_CURRENT_PACKAGE = 'agentic-awesome-skills'; +const DEFAULT_CURRENT_PAGES_URL = 'https://sickn33.github.io/agentic-awesome-skills/'; +const DEFAULT_LEGACY_PAGES_URL = 'https://sickn33.github.io/antigravity-awesome-skills/'; +const SNAPSHOT_DIRECTORY = /^\d{4}-\d{2}-\d{2}$/; +const DEFAULT_MAX_EVIDENCE_AGE_DAYS = 7; +const DASHBOARD_FILES = { + gsc: { + file: 'google-search-console.json', + hosts: new Set(['search.google.com']), + pathPrefix: '/search-console/', + primaryParam: 'resource_id', + }, + bing: { + file: 'bing-webmaster-search-performance.json', + hosts: new Set(['bing.com', 'www.bing.com']), + pathPrefix: '/webmasters/', + primaryParam: 'siteUrl', + }, +}; + +function normaliseUrl(value) { + if (typeof value !== 'string' || !value.trim()) return null; + try { + const url = new URL(value.trim()); + if (url.protocol !== 'https:' && url.protocol !== 'http:') return null; + url.hash = ''; + url.search = ''; + return url.toString(); + } catch (_) { + return null; + } +} + +function withTrailingSlash(value) { + const url = normaliseUrl(value); + return url && url.endsWith('/') ? url : url && `${url}/`; +} + +function readJson(filePath, errors, label) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + errors.push(`${label}: ${error.message}`); + return null; + } +} + +function parseSitemap(filePath, errors) { + let source; + try { + source = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + errors.push(`current sitemap: ${error.message}`); + return []; + } + const locations = [...source.matchAll(/\s*([^<\s]+)\s*<\/loc>/gi)] + .map((match) => withTrailingSlash(match[1])) + .filter(Boolean); + if (!locations.length) errors.push('current sitemap: contains no valid URLs'); + return [...new Set(locations)].sort(); +} + +function inferLegacyPagesUrl(currentPagesUrl) { + if (!currentPagesUrl) return null; + const inferred = withTrailingSlash(currentPagesUrl.replace('/agentic-awesome-skills/', '/antigravity-awesome-skills/')); + return inferred && inferred !== currentPagesUrl ? inferred : null; +} + +function isValidIsoDate(value) { + if (typeof value !== 'string' || !SNAPSHOT_DIRECTORY.test(value)) return false; + const [year, month, day] = value.split('-').map(Number); + const parsed = new Date(Date.UTC(year, month - 1, day)); + return parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day; +} + +function readSnapshots(snapshotRoot, filename, errors) { + let entries; + try { + entries = fs.readdirSync(snapshotRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && isValidIsoDate(entry.name)) + .map((entry) => entry.name) + .sort(); + } catch (error) { + errors.push(`snapshot root: ${error.message}`); + return []; + } + return entries.flatMap((directory) => { + const filePath = path.join(snapshotRoot, directory, filename); + if (!fs.existsSync(filePath)) return []; + const data = readJson(filePath, errors, `${directory}/${filename}`); + return data && typeof data === 'object' ? [{ directory, filePath, data }] : []; + }); +} + +function propertyEvidence(data, dashboardConfig) { + const signals = []; + for (const [source, raw] of [ + ['source_property', data.source_property], + ['property_url', data.property_url], + ['propertyUrl', data.propertyUrl], + ['site_url', data.site_url], + ['siteUrl', data.siteUrl], + ]) { + const value = withTrailingSlash(raw); + if (value) signals.push({ source, value }); + } + let dashboardValid = false; + let dashboardValidationReason = 'missing dashboard_url'; + if (typeof data.dashboard_url === 'string') { + try { + const dashboard = new URL(data.dashboard_url); + const hostValid = dashboard.protocol === 'https:' && dashboardConfig.hosts.has(dashboard.hostname.toLowerCase()); + const pathValid = dashboard.pathname.startsWith(dashboardConfig.pathPrefix); + const primary = withTrailingSlash(dashboard.searchParams.get(dashboardConfig.primaryParam)); + dashboardValid = Boolean(hostValid && pathValid && primary); + dashboardValidationReason = dashboardValid ? null : 'dashboard host, path, or primary property parameter is invalid'; + if (dashboardValid) { + for (const param of ['resource_id', 'siteUrl']) { + const value = withTrailingSlash(dashboard.searchParams.get(param)); + if (value) signals.push({ source: `dashboard_url:${param}`, value }); + } + } + } catch (_) { + dashboardValidationReason = 'dashboard_url is malformed'; + } + } + const distinct = [...new Set(signals.map((signal) => signal.value))]; + const intendedProperty = withTrailingSlash(data.intended_property); + const property = distinct.length === 1 ? distinct[0] : null; + return { + property, + signals, + property_conflict: distinct.length > 1, + intended_property: intendedProperty, + intended_matches_observed: !intendedProperty || (property !== null && intendedProperty === property), + dashboard_valid: dashboardValid, + dashboard_validation_reason: dashboardValidationReason, + }; +} + +function classifyProperty(property, currentPagesUrl, legacyPagesUrl) { + const value = withTrailingSlash(property); + if (!value) return 'unknown'; + if (currentPagesUrl && value === currentPagesUrl) return 'current'; + if (legacyPagesUrl && value === legacyPagesUrl) return 'legacy'; + return 'other'; +} + +function validMetric(value) { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function captureFreshness(capturedAt, snapshotDate, asOfDate, maxAgeDays) { + const captured = typeof capturedAt === 'string' ? new Date(capturedAt) : null; + if (!captured || Number.isNaN(captured.getTime())) { + return { valid: false, age_days: null, reason: 'missing or invalid captured_at_utc' }; + } + const capturedDate = captured.toISOString().slice(0, 10); + const ageDays = (Date.parse(`${asOfDate}T00:00:00Z`) - Date.parse(`${capturedDate}T00:00:00Z`)) / 86400000; + if (capturedDate !== snapshotDate) return { valid: false, age_days: ageDays, reason: 'capture date does not match snapshot directory' }; + if (capturedDate > asOfDate) return { valid: false, age_days: ageDays, reason: 'capture is in the future' }; + if (ageDays > maxAgeDays) return { valid: false, age_days: ageDays, reason: 'capture is stale' }; + return { valid: true, age_days: ageDays, reason: null }; +} + +function dashboardEvidence(snapshotRoot, dashboardConfig, currentPagesUrl, legacyPagesUrl, errors, asOfDate, maxAgeDays) { + const evidence = readSnapshots(snapshotRoot, dashboardConfig.file, errors).map(({ directory, filePath, data }) => { + const property = propertyEvidence(data, dashboardConfig); + const signalPropertyClasses = [...new Set(property.signals.map((signal) => + classifyProperty(signal.value, currentPagesUrl, legacyPagesUrl)))]; + return { + snapshot: directory, + file: filePath, + status: data.status || 'unknown', + ...property, + signal_property_classes: signalPropertyClasses, + property_class: classifyProperty(property.property, currentPagesUrl, legacyPagesUrl), + captured_at_utc: data.captured_at_utc || null, + freshness: captureFreshness(data.captured_at_utc, directory, asOfDate, maxAgeDays), + metrics_complete: validMetric(data?.totals?.clicks) && validMetric(data?.totals?.impressions), + }; + }); + const current = evidence.filter((entry) => entry.status === 'success' + && entry.property_class === 'current' + && !entry.property_conflict + && entry.intended_matches_observed + && entry.dashboard_valid + && entry.freshness.valid + && entry.metrics_complete); + const legacy = evidence.filter((entry) => entry.status === 'success' && entry.property_class === 'legacy'); + return { + status: current.length ? 'pass' : 'fail', + current_evidence: current, + rejected_current_evidence: evidence.filter((entry) => + (entry.property_class === 'current' || entry.signal_property_classes.includes('current')) && !current.includes(entry)), + legacy_evidence: legacy, + other_evidence: evidence.filter((entry) => entry.property_class === 'other' || entry.property_class === 'unknown'), + message: current.length + ? 'Fresh, schema-complete saved current-property evidence is present.' + : 'No fresh, schema-complete saved evidence for the exact current property; legacy-only or spoofed evidence is not a migration fix.', + }; +} + +function manifestPairs(payload) { + if (Array.isArray(payload)) return payload; + if (!payload || typeof payload !== 'object') return []; + if (Array.isArray(payload.redirects)) return payload.redirects; + if (Array.isArray(payload.routes)) return payload.routes; + if (payload.routes && typeof payload.routes === 'object') { + return Object.entries(payload.routes).map(([from, to]) => ({ from, to })); + } + if (payload.redirects && typeof payload.redirects === 'object') { + return Object.entries(payload.redirects).map(([from, to]) => ({ from, to })); + } + return []; +} + +function redirectCoverage(manifestPath, currentUrls, currentPagesUrl, legacyPagesUrl, errors) { + if (!manifestPath) return { status: 'fail', expected: currentUrls.length, covered: 0, missing: currentUrls, message: 'No redirect manifest was supplied.' }; + const payload = readJson(manifestPath, errors, 'redirect manifest'); + if (!payload) return { status: 'fail', expected: currentUrls.length, covered: 0, missing: currentUrls, message: 'Redirect manifest is unreadable.' }; + const redirects = new Map(); + const duplicates = []; + const invalid = []; + for (const pair of manifestPairs(payload)) { + const from = withTrailingSlash(pair && (pair.from || pair.source || pair.legacy)); + const to = withTrailingSlash(pair && (pair.to || pair.destination || pair.current)); + if (!from || !to) { + invalid.push(pair); + } else if (redirects.has(from)) { + duplicates.push(from); + } else { + redirects.set(from, to); + } + } + const expectedPairs = new Map(currentUrls.map((currentUrl) => [ + withTrailingSlash(currentUrl.replace(currentPagesUrl, legacyPagesUrl)), + currentUrl, + ])); + const missing = currentUrls.filter((currentUrl) => { + const legacyUrl = withTrailingSlash(currentUrl.replace(currentPagesUrl, legacyPagesUrl)); + return redirects.get(legacyUrl) !== currentUrl; + }); + const unexpected = [...redirects.entries()] + .filter(([from, to]) => expectedPairs.get(from) !== to) + .map(([from, to]) => ({ from, to })); + const exact = currentUrls.length && !missing.length && !duplicates.length && !invalid.length && !unexpected.length + && redirects.size === expectedPairs.size; + return { + status: exact ? 'pass' : 'fail', + expected: currentUrls.length, + covered: currentUrls.length - missing.length, + missing, + duplicates, + invalid, + unexpected, + message: exact ? 'Redirect manifest maps every expected legacy route exactly once and contains no extras.' : 'Redirect manifest does not exactly cover the expected legacy route set once each.', + }; +} + +function packageIdentity(currentPackagePath, legacyPackagePath, currentPackageName, errors) { + const current = readJson(currentPackagePath, errors, 'current package metadata'); + const legacy = legacyPackagePath ? readJson(legacyPackagePath, errors, 'legacy package metadata') : null; + const currentName = currentPackageName || DEFAULT_CURRENT_PACKAGE; + const currentOk = Boolean(current && current.name === currentName && typeof current.version === 'string' && current.version); + const explicitReplacement = legacy && (legacy.replacementPackage || legacy.replacement_package); + const escapedName = currentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const deprecatedMentionsExactPackage = typeof legacy?.deprecated === 'string' + && new RegExp(`(^|[^a-z0-9_-])${escapedName}([^a-z0-9_-]|$)`, 'i').test(legacy.deprecated); + const legacyOk = Boolean(legacy + && legacy.name === DEFAULT_LEGACY_PACKAGE + && typeof legacy.version === 'string' + && (explicitReplacement === currentName || deprecatedMentionsExactPackage)); + return { + status: currentOk && legacyOk ? 'pass' : 'fail', + current: current ? { name: current.name || null, version: current.version || null } : null, + legacy: legacy ? { name: legacy.name || null, version: legacy.version || null, deprecated: legacy.deprecated || null } : null, + message: currentOk && legacyOk + ? 'Current package identity and explicit legacy migration path are present.' + : 'Both a current package identity and an explicit legacy-to-current package migration path are required.', + }; +} + +function auditMigrationReadiness(options = {}) { + const repoRoot = path.resolve(options.repoRoot || path.resolve(__dirname, '..', '..')); + const errors = []; + const sitemapPath = path.resolve(repoRoot, options.sitemapPath || 'apps/web-app/public/sitemap.xml'); + const snapshotRoot = path.resolve(repoRoot, options.snapshotRoot || '.codex/traffic-snapshots'); + const currentPackagePath = path.resolve(repoRoot, options.currentPackagePath || 'package.json'); + const asOfDate = options.asOfDate || new Date().toISOString().slice(0, 10); + const maxEvidenceAgeDays = Number(options.maxEvidenceAgeDays ?? DEFAULT_MAX_EVIDENCE_AGE_DAYS); + if (!isValidIsoDate(asOfDate)) throw new Error(`invalid as-of date: ${asOfDate}`); + if (!Number.isFinite(maxEvidenceAgeDays) || maxEvidenceAgeDays < 0) throw new Error('max evidence age must be a non-negative number'); + const currentUrls = parseSitemap(sitemapPath, errors); + const currentPagesUrl = withTrailingSlash(options.currentPagesUrl || DEFAULT_CURRENT_PAGES_URL); + const legacyPagesUrl = withTrailingSlash(options.legacyPagesUrl || DEFAULT_LEGACY_PAGES_URL || inferLegacyPagesUrl(currentPagesUrl)); + const identitiesDistinct = Boolean(currentPagesUrl && legacyPagesUrl && currentPagesUrl !== legacyPagesUrl); + const currentSitemap = { + status: identitiesDistinct && currentUrls.includes(currentPagesUrl) && currentUrls.every((url) => url.startsWith(currentPagesUrl)) ? 'pass' : 'fail', + current_pages_url: currentPagesUrl, + urls: currentUrls, + message: identitiesDistinct && currentUrls.includes(currentPagesUrl) && currentUrls.every((url) => url.startsWith(currentPagesUrl)) + ? 'Current sitemap includes its root and contains only the configured current Pages identity.' + : 'Current sitemap is missing its root, malformed, uses identical legacy/current identities, or includes URLs outside the configured current Pages identity.', + }; + const checks = { + identity_anchors: { + status: identitiesDistinct ? 'pass' : 'fail', + current_package_name: options.currentPackageName || DEFAULT_CURRENT_PACKAGE, + current_pages_url: currentPagesUrl, + legacy_pages_url: legacyPagesUrl, + message: identitiesDistinct ? 'Configured current and legacy identities are present and distinct.' : 'Current and legacy Pages identities must be explicit and distinct.', + }, + current_sitemap_identity: currentSitemap, + google_search_console: dashboardEvidence(snapshotRoot, DASHBOARD_FILES.gsc, currentPagesUrl, legacyPagesUrl, errors, asOfDate, maxEvidenceAgeDays), + bing_webmaster: dashboardEvidence(snapshotRoot, DASHBOARD_FILES.bing, currentPagesUrl, legacyPagesUrl, errors, asOfDate, maxEvidenceAgeDays), + redirect_manifest_coverage: redirectCoverage( + options.redirectManifestPath && path.resolve(repoRoot, options.redirectManifestPath), currentUrls, currentPagesUrl, legacyPagesUrl, errors, + ), + npm_identities: packageIdentity( + currentPackagePath, + options.legacyPackagePath && path.resolve(repoRoot, options.legacyPackagePath), + options.currentPackageName, + errors, + ), + }; + const failedChecks = Object.entries(checks).filter(([, check]) => check.status !== 'pass').map(([name]) => name); + return { + schema_version: 1, + status: failedChecks.length || errors.length ? 'not_ready' : 'ready', + evidence_policy: { as_of_date: asOfDate, max_age_days: maxEvidenceAgeDays }, + identities: { current_pages_url: currentPagesUrl, legacy_pages_url: legacyPagesUrl }, + checks, + third_party_state: { + status: 'not_assessed', + message: 'Third-party directories are outside this local audit. A request or partial third-party update is not reported as fixed.', + }, + errors, + failed_checks: failedChecks, + }; +} + +function writeJsonAtomically(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`; + try { + fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + fs.renameSync(temporary, filePath); + } finally { + if (fs.existsSync(temporary)) fs.unlinkSync(temporary); + } +} + +function parseArgs(argv) { + const options = {}; + const aliases = { + '--repo-root': 'repoRoot', '--snapshot-root': 'snapshotRoot', '--sitemap': 'sitemapPath', + '--redirect-manifest': 'redirectManifestPath', '--current-package': 'currentPackagePath', + '--legacy-package': 'legacyPackagePath', '--current-pages-url': 'currentPagesUrl', + '--legacy-pages-url': 'legacyPagesUrl', '--current-package-name': 'currentPackageName', '--output': 'outputPath', + '--as-of': 'asOfDate', '--max-evidence-age-days': 'maxEvidenceAgeDays', + }; + for (let index = 0; index < argv.length; index += 1) { + const key = aliases[argv[index]]; + if (!key || !argv[index + 1] || argv[index + 1].startsWith('--')) throw new Error(`Unknown option or missing value: ${argv[index]}`); + options[key] = argv[index + 1]; + index += 1; + } + return options; +} + +if (require.main === module) { + try { + const options = parseArgs(process.argv.slice(2)); + const report = auditMigrationReadiness(options); + const outputPath = path.resolve(options.repoRoot || path.resolve(__dirname, '..', '..'), options.outputPath || '.codex/traffic-snapshots/migration-readiness.json'); + writeJsonAtomically(outputPath, report); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + process.exitCode = report.status === 'ready' ? 0 : 1; + } catch (error) { + process.stderr.write(`migration readiness audit failed: ${error.message}\n`); + process.exitCode = 2; + } +} + +module.exports = { auditMigrationReadiness, parseArgs, redirectCoverage, writeJsonAtomically }; diff --git a/antigravity-awesome-skills/tools/scripts/generate-pages-redirect-bridge.js b/antigravity-awesome-skills/tools/scripts/generate-pages-redirect-bridge.js new file mode 100644 index 00000000..d9ef1181 --- /dev/null +++ b/antigravity-awesome-skills/tools/scripts/generate-pages-redirect-bridge.js @@ -0,0 +1,293 @@ +#!/usr/bin/env node +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const DEFAULT_SITEMAP = path.join(REPO_ROOT, 'apps', 'web-app', 'public', 'sitemap.xml'); +const DEFAULT_CURRENT_BASE = 'https://sickn33.github.io/agentic-awesome-skills/'; +const DEFAULT_LEGACY_BASE = 'https://sickn33.github.io/antigravity-awesome-skills/'; +const DEFAULT_EXPECTED_ROUTES = 46; +const SAFE_SEGMENT = /^[A-Za-z0-9._~-]+$/; + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function htmlEscape(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function xmlEscape(value) { + return htmlEscape(value); +} + +function xmlUnescape(value) { + const entities = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + }; + return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, entity) => entities[entity]); +} + +function canonicalBase(value, label) { + let url; + try { + url = new URL(value); + } catch (_) { + throw new Error(`${label} must be an absolute URL`); + } + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) { + throw new Error(`${label} must be a credential-free HTTPS URL without query or fragment`); + } + if (!url.pathname.endsWith('/')) throw new Error(`${label} must end with a slash`); + return url; +} + +function parseSitemap(source) { + const rawLocations = [...source.matchAll(/\s*([^<]+?)\s*<\/loc>/gi)].map((match) => xmlUnescape(match[1].trim())); + if (!rawLocations.length) throw new Error('sitemap contains no URLs'); + if (new Set(rawLocations).size !== rawLocations.length) throw new Error('sitemap contains duplicate URLs'); + return rawLocations; +} + +function safeRelativeRoute(currentUrl, currentBase) { + if (currentUrl.protocol !== 'https:' || currentUrl.origin !== currentBase.origin || currentUrl.search || currentUrl.hash) { + throw new Error(`sitemap URL is outside the current HTTPS identity: ${currentUrl.toString()}`); + } + if (!currentUrl.pathname.startsWith(currentBase.pathname) || !currentUrl.pathname.endsWith('/')) { + throw new Error(`sitemap URL is outside the current base path or lacks a trailing slash: ${currentUrl.toString()}`); + } + const relative = currentUrl.pathname.slice(currentBase.pathname.length); + const segments = relative.split('/').filter(Boolean); + for (const segment of segments) { + if (segment === '.' || segment === '..' || !SAFE_SEGMENT.test(segment)) { + throw new Error(`sitemap URL contains an unsafe path segment: ${currentUrl.toString()}`); + } + } + return segments.join('/'); +} + +function outputRelativePath(legacyBase, relativeRoute) { + const baseSegments = legacyBase.pathname.split('/').filter(Boolean); + for (const segment of baseSegments) { + if (segment === '.' || segment === '..' || !SAFE_SEGMENT.test(segment)) { + throw new Error('legacy base contains an unsafe path segment'); + } + } + return path.posix.join(...baseSegments, relativeRoute, 'index.html'); +} + +function redirectHtml(destination) { + const escaped = htmlEscape(destination); + return ` + + + + + + + Agentic Awesome Skills has moved + + +
+

Agentic Awesome Skills has moved

+

Continue to ${escaped}.

+
+ + +`; +} + +function legacySitemap(redirects) { + const entries = redirects.map(({ from }) => ` ${xmlEscape(from)}`).join('\n'); + return ` + +${entries} + +`; +} + +function isInside(parent, candidate) { + const relative = path.relative(parent, candidate); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); +} + +function physicalCandidate(candidate) { + const suffix = []; + let cursor = path.resolve(candidate); + while (!fs.existsSync(cursor)) { + const parent = path.dirname(cursor); + if (parent === cursor) throw new Error(`cannot resolve an existing ancestor for: ${candidate}`); + suffix.unshift(path.basename(cursor)); + cursor = parent; + } + return path.join(fs.realpathSync(cursor), ...suffix); +} + +function containsExistingSymlink(parent, candidate) { + const relative = path.relative(parent, candidate); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) return false; + let cursor = parent; + for (const segment of relative.split(path.sep)) { + cursor = path.join(cursor, segment); + if (!fs.existsSync(cursor)) return false; + if (fs.lstatSync(cursor).isSymbolicLink()) return true; + } + return false; +} + +function assertSafeOutput(outputDirectory, repoRoot) { + if (fs.existsSync(outputDirectory)) throw new Error(`output path already exists: ${outputDirectory}`); + const physicalRepoRoot = fs.realpathSync(repoRoot); + const codexDirectory = path.join(repoRoot, '.codex'); + const codexIsSymlink = fs.existsSync(codexDirectory) && fs.lstatSync(codexDirectory).isSymbolicLink(); + const physicalCodexDirectory = physicalCandidate(codexDirectory); + const physicalOutput = physicalCandidate(outputDirectory); + if (isInside(repoRoot, outputDirectory) && !isInside(codexDirectory, outputDirectory)) { + throw new Error('output inside the repository is allowed only under ignored .codex/'); + } + if (isInside(repoRoot, outputDirectory) && containsExistingSymlink(repoRoot, path.dirname(outputDirectory))) { + throw new Error('in-repository output paths may not traverse symlinks'); + } + if (isInside(physicalRepoRoot, physicalOutput) && (codexIsSymlink || !isInside(physicalCodexDirectory, physicalOutput))) { + throw new Error('physical output resolves inside the repository but outside ignored .codex/'); + } +} + +function writeBridge(stagingDirectory, redirects, manifest, legacyBase) { + for (const redirect of redirects) { + const filePath = path.join(stagingDirectory, ...redirect.output_file.split('/')); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, redirectHtml(redirect.to), 'utf8'); + } + const legacyDirectory = path.join(stagingDirectory, ...legacyBase.pathname.split('/').filter(Boolean)); + fs.mkdirSync(legacyDirectory, { recursive: true }); + fs.writeFileSync(path.join(legacyDirectory, 'sitemap.xml'), legacySitemap(redirects), 'utf8'); + fs.writeFileSync(path.join(stagingDirectory, 'redirect-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + fs.writeFileSync(path.join(stagingDirectory, '.nojekyll'), '', 'utf8'); +} + +function generateBridge(options) { + const repoRoot = path.resolve(options.repoRoot || REPO_ROOT); + const sitemapPath = path.resolve(options.sitemapPath || DEFAULT_SITEMAP); + if (!options.outputDirectory) throw new Error('--output is required'); + const outputDirectory = path.resolve(options.outputDirectory); + const currentBase = canonicalBase(options.currentBase || DEFAULT_CURRENT_BASE, 'current base'); + const legacyBase = canonicalBase(options.legacyBase || DEFAULT_LEGACY_BASE, 'legacy base'); + if (currentBase.toString() === legacyBase.toString()) throw new Error('current and legacy bases must be distinct'); + const expectedRoutes = Number(options.expectedRoutes ?? DEFAULT_EXPECTED_ROUTES); + if (!Number.isSafeInteger(expectedRoutes) || expectedRoutes <= 0) throw new Error('expected route count must be a positive integer'); + assertSafeOutput(outputDirectory, repoRoot); + + const sitemapSource = fs.readFileSync(sitemapPath, 'utf8'); + const locations = parseSitemap(sitemapSource); + if (locations.length !== expectedRoutes) { + throw new Error(`sitemap route count ${locations.length} does not match locked expectation ${expectedRoutes}`); + } + + const redirects = locations.map((location) => { + const currentUrl = new URL(location); + const relativeRoute = safeRelativeRoute(currentUrl, currentBase); + const from = new URL(relativeRoute ? `${relativeRoute}/` : '', legacyBase).toString(); + const to = currentUrl.toString(); + return { + from, + to, + output_file: outputRelativePath(legacyBase, relativeRoute), + }; + }); + if (!redirects.some(({ to }) => to === currentBase.toString())) throw new Error('sitemap does not contain the current root route'); + if (new Set(redirects.map(({ from }) => from)).size !== redirects.length) throw new Error('legacy mapping is not one-to-one'); + if (new Set(redirects.map(({ output_file }) => output_file)).size !== redirects.length) throw new Error('multiple routes map to the same output file'); + redirects.sort((left, right) => left.from.localeCompare(right.from)); + + const legacySitemapPath = path.posix.join(...legacyBase.pathname.split('/').filter(Boolean), 'sitemap.xml'); + if (redirects.some(({ output_file }) => output_file.startsWith(`${legacySitemapPath}/`))) { + throw new Error(`generated route collides with reserved legacy sitemap path: ${legacySitemapPath}`); + } + + const manifest = { + schema_version: 1, + deployment_scope: 'separate GitHub Pages user-site subdirectory', + not_for_current_project_pages: true, + source_sitemap_sha256: sha256(sitemapSource), + current_base: currentBase.toString(), + legacy_base: legacyBase.toString(), + route_count: redirects.length, + legacy_sitemap: legacySitemapPath, + redirects, + }; + + fs.mkdirSync(path.dirname(outputDirectory), { recursive: true }); + let stagingDirectory = null; + try { + stagingDirectory = fs.mkdtempSync(path.join(path.dirname(outputDirectory), `.${path.basename(outputDirectory)}.${process.pid}.`)); + writeBridge(stagingDirectory, redirects, manifest, legacyBase); + fs.renameSync(stagingDirectory, outputDirectory); + } finally { + if (stagingDirectory && fs.existsSync(stagingDirectory)) fs.rmSync(stagingDirectory, { recursive: true, force: true }); + } + return manifest; +} + +function parseArgs(argv) { + const options = {}; + const aliases = { + '--output': 'outputDirectory', + '--sitemap': 'sitemapPath', + '--current-base': 'currentBase', + '--legacy-base': 'legacyBase', + '--expected-routes': 'expectedRoutes', + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--help' || arg === '-h') { + options.help = true; + continue; + } + const key = aliases[arg]; + const value = argv[index + 1]; + if (!key || !value || value.startsWith('--')) throw new Error(`unknown option or missing value: ${arg}`); + options[key] = value; + index += 1; + } + return options; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + process.stdout.write('Usage: node tools/scripts/generate-pages-redirect-bridge.js --output NEW_DIR [--sitemap FILE] [--expected-routes N]\n'); + return; + } + const manifest = generateBridge(options); + process.stdout.write(`Generated ${manifest.route_count} redirect pages in ${path.resolve(options.outputDirectory)}\n`); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + process.stderr.write(`redirect bridge generation failed: ${error.message}\n`); + process.exitCode = 1; + } +} + +module.exports = { + generateBridge, + htmlEscape, + parseArgs, + parseSitemap, + redirectHtml, +}; diff --git a/antigravity-awesome-skills/tools/scripts/normalize_traffic_snapshots.js b/antigravity-awesome-skills/tools/scripts/normalize_traffic_snapshots.js new file mode 100644 index 00000000..6d95e181 --- /dev/null +++ b/antigravity-awesome-skills/tools/scripts/normalize_traffic_snapshots.js @@ -0,0 +1,541 @@ +#!/usr/bin/env node + +/** + * Normalize the read-only captures in .codex/traffic-snapshots. + * + * The source dashboards have different grains. GitHub supplies rolling daily + * rows, while Search Console can be a snapshot total and Bing can supply both + * totals and daily rows. This script deliberately keeps dashboard properties + * separate: a legacy Pages property and a current Pages property are not a + * time series that may safely be summed together. + */ + +const fs = require("fs"); +const path = require("path"); + +const DEFAULT_INPUT = path.resolve(__dirname, "..", "..", ".codex", "traffic-snapshots"); +const DEFAULT_OUTPUT = path.join(DEFAULT_INPUT, "daily-normalized.json"); +const SNAPSHOT_DIRECTORY = /^\d{4}-\d{2}-\d{2}$/; +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; +const MONTH_NAMES = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"]; +const CURRENT_REPOSITORY = "sickn33/agentic-awesome-skills"; +const REPOSITORY_ALIASES = new Map([ + ["sickn33/antigravity-awesome-skills", CURRENT_REPOSITORY], + [CURRENT_REPOSITORY, CURRENT_REPOSITORY], +]); +const CURRENT_PAGES_PROPERTY = "https://sickn33.github.io/agentic-awesome-skills/"; +const LEGACY_PAGES_PROPERTY = "https://sickn33.github.io/antigravity-awesome-skills/"; + +const DASHBOARDS = [ + { + file: "google-search-console.json", + source: "google_search_console", + dashboardHosts: new Set(["search.google.com"]), + dashboardPathPrefix: "/search-console/", + primaryPropertyParam: "resource_id", + requiredDailyMetrics: ["clicks", "impressions"], + requiredTotalMetrics: ["clicks", "impressions"], + dailyMetrics: (value) => ({ + clicks: nonNegativeIntegerOrNull(value.clicks), + impressions: nonNegativeIntegerOrNull(value.impressions), + }), + totals: (value) => ({ + clicks: nonNegativeIntegerOrNull(value?.totals?.clicks ?? value.clicks), + impressions: nonNegativeIntegerOrNull(value?.totals?.impressions ?? value?.totals?.impressions_visible ?? value.impressions), + ctr: value?.totals?.ctr ?? value.ctr ?? null, + average_position: nonNegativeNumberOrNull(value?.totals?.average_position ?? value?.totals?.position ?? value.average_position), + }), + }, + { + file: "bing-webmaster-search-performance.json", + source: "bing_search", + dashboardHosts: new Set(["bing.com", "www.bing.com"]), + dashboardPathPrefix: "/webmasters/", + primaryPropertyParam: "siteUrl", + requiredDailyMetrics: ["clicks", "impressions"], + requiredTotalMetrics: ["clicks", "impressions"], + dailyMetrics: (value) => ({ + clicks: nonNegativeIntegerOrNull(value.clicks), + impressions: nonNegativeIntegerOrNull(value.impressions), + }), + totals: (value) => ({ + clicks: nonNegativeIntegerOrNull(value?.totals?.clicks ?? value.clicks), + impressions: nonNegativeIntegerOrNull(value?.totals?.impressions ?? value?.totals?.impressions_label ?? value.impressions), + ctr: value?.totals?.ctr ?? value.ctr ?? null, + }), + }, + { + file: "bing-webmaster-ai-performance.json", + source: "bing_ai", + dashboardHosts: new Set(["bing.com", "www.bing.com"]), + dashboardPathPrefix: "/webmasters/", + primaryPropertyParam: "siteUrl", + requiredDailyMetrics: ["citations"], + requiredTotalMetrics: ["citations"], + dailyMetrics: (value) => ({ + citations: nonNegativeIntegerOrNull(value.citations ?? value.total_citations), + avg_cited_pages: nonNegativeNumberOrNull(value.avg_cited_pages), + }), + totals: (value) => ({ + citations: nonNegativeIntegerOrNull(value.total_citations ?? value?.totals?.citations), + avg_cited_pages: nonNegativeNumberOrNull(value.avg_cited_pages ?? value?.totals?.avg_cited_pages), + }), + }, +]; + +function numberOrNull(value) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "string") return null; + + const raw = value.trim().replace(/\s/g, ""); + if (!raw) return null; + const hasK = /k$/i.test(raw); + let numeric = raw.replace(/k$/i, ""); + + // Dashboard captures use both 5.1K and Italian-style 39,4K labels. + if (hasK) { + numeric = numeric.replace(",", "."); + } else if (/^\d{1,3}(?:[.,]\d{3})+$/.test(numeric)) { + numeric = numeric.replace(/[.,]/g, ""); + } else { + numeric = numeric.replace(",", "."); + } + + const parsed = Number(numeric); + return Number.isFinite(parsed) ? (hasK ? parsed * 1000 : parsed) : null; +} + +function nonNegativeNumberOrNull(value) { + const parsed = numberOrNull(value); + return parsed !== null && parsed >= 0 ? parsed : null; +} + +function nonNegativeIntegerOrNull(value) { + const parsed = nonNegativeNumberOrNull(value); + return parsed !== null && Number.isInteger(parsed) ? parsed : null; +} + +function isCompleteMetrics(metrics) { + return Object.values(metrics).some((value) => value !== null); +} + +function normalizedDate(value) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (ISO_DATE.test(trimmed)) return isValidIsoDate(trimmed) ? trimmed : null; + if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) { + const date = trimmed.slice(0, 10); + return isValidIsoDate(date) ? date : null; + } + const match = trimmed.match( + /^(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),?\s+(\d{4})$/i, + ); + if (!match) return null; + const month = MONTH_NAMES.indexOf(match[1].toLowerCase()) + 1; + const date = `${match[3]}-${String(month).padStart(2, "0")}-${String(match[2]).padStart(2, "0")}`; + return isValidIsoDate(date) ? date : null; +} + +function isValidIsoDate(value) { + if (!ISO_DATE.test(value)) return false; + const [year, month, day] = value.split("-").map(Number); + const parsed = new Date(Date.UTC(year, month - 1, day)); + return parsed.getUTCFullYear() === year + && parsed.getUTCMonth() === month - 1 + && parsed.getUTCDate() === day; +} + +function canonicalProperty(candidate) { + if (typeof candidate !== "string" || !candidate.trim()) return null; + try { + const url = new URL(candidate.trim()); + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + url.hash = ""; + url.search = ""; + const pathname = url.pathname.endsWith("/") ? url.pathname : `${url.pathname}/`; + return `${url.protocol}//${url.host}${pathname}`; + } catch { + return null; + } +} + +function propertyFromDashboardUrl(dashboardUrl, config) { + if (typeof dashboardUrl !== "string") return { valid: false, signals: [], reason: "missing dashboard_url" }; + try { + const url = new URL(dashboardUrl); + const hostValid = url.protocol === "https:" && config.dashboardHosts.has(url.hostname.toLowerCase()); + const pathValid = url.pathname.startsWith(config.dashboardPathPrefix); + const primary = canonicalProperty(url.searchParams.get(config.primaryPropertyParam)); + if (!hostValid || !pathValid || !primary) { + return { valid: false, signals: [], reason: "dashboard host, path, or primary property parameter is invalid" }; + } + const signals = []; + for (const param of ["resource_id", "siteUrl"]) { + const value = canonicalProperty(url.searchParams.get(param)); + if (value) signals.push({ source: `dashboard_url:${param}`, value }); + } + return { valid: true, signals, reason: null }; + } catch { + return { valid: false, signals: [], reason: "dashboard_url is malformed" }; + } +} + +function sourceProperty(snapshot, config) { + const observed = []; + for (const [source, raw] of [ + ["source_property", snapshot.source_property], + ["property_url", snapshot.property_url], + ["propertyUrl", snapshot.propertyUrl], + ["site_url", snapshot.site_url], + ["siteUrl", snapshot.siteUrl], + ]) { + const value = canonicalProperty(raw); + if (value) observed.push({ source, value }); + } + const dashboard = propertyFromDashboardUrl(snapshot.dashboard_url, config); + if (snapshot.dashboard_url && !dashboard.valid) { + return { property: null, provenance: "invalid", conflict: true, reason: dashboard.reason }; + } + observed.push(...dashboard.signals); + const distinct = [...new Set(observed.map((signal) => signal.value))]; + if (distinct.length > 1) { + return { property: null, provenance: "conflict", conflict: true, reason: "observed property signals conflict" }; + } + const intended = canonicalProperty(snapshot.intended_property); + if (distinct.length === 1) { + return { + property: distinct[0], + provenance: "observed", + conflict: false, + reason: intended && intended !== distinct[0] ? "intended property disagrees with observed property" : null, + }; + } + if (intended) { + return { property: intended, provenance: "intended_only", conflict: false, reason: "property identity comes from intended_property only" }; + } + return { property: null, provenance: "unknown", conflict: false, reason: "no property identity signal" }; +} + +function propertyIdentity(property) { + const value = canonicalProperty(property); + if (value === LEGACY_PAGES_PROPERTY) return "legacy"; + if (value === CURRENT_PAGES_PROPERTY) return "current"; + return "unknown"; +} + +function coverageEnd(snapshot) { + const dailyValues = Array.isArray(snapshot.daily_values) ? snapshot.daily_values : []; + const dailyDates = dailyValues.map((row) => normalizedDate(row?.date)).filter(Boolean).sort(); + if (dailyDates.length) return dailyDates.at(-1); + + const text = [ + snapshot.date_range_visible, + snapshot.date_range, + snapshot.chart_visible?.description, + ].filter((value) => typeof value === "string").join(" "); + const isoDates = (text.match(/\d{4}-\d{2}-\d{2}/g) || []).map(normalizedDate).filter(Boolean); + if (isoDates?.length) return isoDates.sort().at(-1); + + const monthDates = [...text.matchAll( + /\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),\s+(\d{4})\b/gi, + )].map((match) => { + const month = MONTH_NAMES.indexOf(match[1].toLowerCase()) + 1; + return normalizedDate(`${match[3]}-${String(month).padStart(2, "0")}-${String(match[2]).padStart(2, "0")}`); + }).filter(Boolean); + return monthDates.length ? monthDates.sort().at(-1) : null; +} + +function readJson(filePath, warnings, label) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + warnings.push(`${label}: skipped malformed JSON (${error.message.split("\n")[0]})`); + return null; + } +} + +function setLatest(map, key, observation) { + const previous = map.get(key); + if (!previous || observation.observed_from_snapshot > previous.observed_from_snapshot) { + map.set(key, observation); + } +} + +function setBestDashboardEvidence(map, key, observation) { + const previous = map.get(key); + const strength = { observed: 2, intended_only: 1, unknown: 0 }; + const candidateStrength = strength[observation.property_provenance] ?? 0; + const previousStrength = strength[previous?.property_provenance] ?? -1; + if (!previous + || candidateStrength > previousStrength + || (candidateStrength === previousStrength && observation.observed_from_snapshot > previous.observed_from_snapshot)) { + map.set(key, observation); + } +} + +function snapshotDirectories(inputDirectory) { + if (!fs.existsSync(inputDirectory)) throw new Error(`input directory does not exist: ${inputDirectory}`); + if (!fs.statSync(inputDirectory).isDirectory()) throw new Error(`input path is not a directory: ${inputDirectory}`); + const snapshots = fs.readdirSync(inputDirectory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && SNAPSHOT_DIRECTORY.test(entry.name) && isValidIsoDate(entry.name)) + .map((entry) => entry.name) + .sort(); + if (!snapshots.length) throw new Error(`input directory has no valid snapshot directories: ${inputDirectory}`); + return snapshots; +} + +function canonicalRepository(value) { + if (typeof value !== "string" || !value.trim()) return null; + const normalized = value.trim().toLowerCase(); + return REPOSITORY_ALIASES.get(normalized) || normalized; +} + +function normalizeSnapshots(inputDirectory) { + const warnings = []; + const github = { views: new Map(), clones: new Map() }; + const dashboardDaily = new Map(); + const dashboardTotals = new Map(); + const discoveredSnapshots = snapshotDirectories(inputDirectory); + const snapshots = []; + const sourceRepositories = new Set(); + let repo = null; + let timezone = null; + + for (const snapshotDate of discoveredSnapshots) { + const directory = path.join(inputDirectory, snapshotDate); + const manifestPath = path.join(directory, "manifest.json"); + if (!fs.existsSync(manifestPath)) { + warnings.push(`${snapshotDate}/manifest.json: skipped snapshot without identity manifest`); + continue; + } + const manifest = readJson(manifestPath, warnings, `${snapshotDate}/manifest.json`); + const rawRepository = typeof manifest?.repo === "string" ? manifest.repo.trim() : null; + const snapshotRepository = canonicalRepository(rawRepository); + const snapshotTimezone = typeof manifest?.timezone === "string" ? manifest.timezone.trim() : null; + if (!snapshotRepository || !snapshotTimezone) { + warnings.push(`${snapshotDate}/manifest.json: skipped snapshot with missing repository or timezone identity`); + continue; + } + if ((repo && snapshotRepository !== repo) || (timezone && snapshotTimezone !== timezone)) { + warnings.push(`${snapshotDate}/manifest.json: skipped snapshot with repository or timezone mismatch`); + continue; + } + repo ||= snapshotRepository; + timezone ||= snapshotTimezone; + sourceRepositories.add(rawRepository); + snapshots.push(snapshotDate); + + for (const [kind, file] of [["views", "views.json"], ["clones", "clones.json"]]) { + const filePath = path.join(directory, file); + if (!fs.existsSync(filePath)) continue; + const payload = readJson(filePath, warnings, `${snapshotDate}/${file}`); + const values = Array.isArray(payload?.[kind]) ? payload[kind] : null; + if (!values) { + warnings.push(`${snapshotDate}/${file}: skipped missing ${kind} array`); + continue; + } + for (const value of values) { + const date = normalizedDate(value?.timestamp); + const count = nonNegativeIntegerOrNull(value?.count); + const uniques = nonNegativeIntegerOrNull(value?.uniques); + if (!date || !ISO_DATE.test(date) || count === null || uniques === null) { + warnings.push(`${snapshotDate}/${file}: skipped malformed daily row`); + continue; + } + setLatest(github[kind], date, { count, uniques, observed_from_snapshot: snapshotDate }); + } + } + + for (const config of DASHBOARDS) { + const filePath = path.join(directory, config.file); + if (!fs.existsSync(filePath)) continue; + const payload = readJson(filePath, warnings, `${snapshotDate}/${config.file}`); + if (!payload) continue; + if (payload.status !== "success") { + warnings.push(`${snapshotDate}/${config.file}: skipped non-success capture`); + continue; + } + + const propertyEvidence = sourceProperty(payload, config); + if (propertyEvidence.conflict) { + warnings.push(`${snapshotDate}/${config.file}: skipped capture because ${propertyEvidence.reason}`); + continue; + } + const property = propertyEvidence.property; + if (propertyEvidence.reason) warnings.push(`${snapshotDate}/${config.file}: ${propertyEvidence.reason}`); + const identity = propertyIdentity(property); + if (identity === "unknown") { + warnings.push(`${snapshotDate}/${config.file}: property identity is unknown and remains isolated`); + } + const end = coverageEnd(payload); + const base = { + source_property: property, + property_provenance: propertyEvidence.provenance, + property_identity: identity, + coverage_end: end, + observed_from_snapshot: snapshotDate, + }; + const propertyKey = property || `unknown:${snapshotDate}`; + const dailyValues = Array.isArray(payload.daily_values) ? payload.daily_values : []; + + for (const value of dailyValues) { + const date = normalizedDate(value?.date); + if (!date) { + warnings.push(`${snapshotDate}/${config.file}: skipped malformed dashboard daily row`); + continue; + } + const metrics = config.dailyMetrics(value); + if (!isCompleteMetrics(metrics)) { + warnings.push(`${snapshotDate}/${config.file}: skipped dashboard daily row without metrics`); + continue; + } + // Never stitch a partial payload together with an older snapshot. A + // dashboard row is useful only when its source-specific core metrics + // arrived in the same source observation. + if (config.requiredDailyMetrics.some((metric) => metrics[metric] === null)) { + warnings.push(`${snapshotDate}/${config.file}: skipped partial dashboard daily row`); + continue; + } + const key = `${config.source}\u0000${propertyKey}\u0000${date}`; + setBestDashboardEvidence(dashboardDaily, key, { source: config.source, date, ...base, ...metrics }); + } + + const totals = config.totals(payload); + if (config.requiredTotalMetrics.every((metric) => totals[metric] !== null)) { + const key = `${config.source}\u0000${propertyKey}\u0000${end || "unknown"}`; + setBestDashboardEvidence(dashboardTotals, key, { source: config.source, ...base, ...totals }); + } else if (isCompleteMetrics(totals)) { + warnings.push(`${snapshotDate}/${config.file}: skipped partial dashboard totals`); + } + } + } + + if (!snapshots.length) { + throw new Error(`no snapshots with a consistent repository and timezone identity were accepted from: ${inputDirectory}`); + } + + const rowsByDate = new Map(); + const ensureRow = (date) => { + if (!rowsByDate.has(date)) rowsByDate.set(date, { date }); + return rowsByDate.get(date); + }; + for (const [kind, values] of Object.entries(github)) { + for (const [date, observation] of values) { + const row = ensureRow(date); + row.github ||= {}; + row.github[kind] = observation; + } + } + for (const observation of dashboardDaily.values()) { + const row = ensureRow(observation.date); + row[observation.source] ||= []; + const { source, date, ...record } = observation; + row[source].push(record); + } + + const rows = [...rowsByDate.values()].sort((left, right) => left.date.localeCompare(right.date)); + for (const row of rows) { + for (const source of DASHBOARDS.map((config) => config.source)) { + if (Array.isArray(row[source])) { + row[source].sort((left, right) => + `${left.property_identity}\u0000${left.source_property || ""}\u0000${left.observed_from_snapshot}`.localeCompare( + `${right.property_identity}\u0000${right.source_property || ""}\u0000${right.observed_from_snapshot}`, + ), + ); + } + } + } + + const snapshot_totals = {}; + for (const config of DASHBOARDS) { + snapshot_totals[config.source] = [...dashboardTotals.values()] + .filter((record) => record.source === config.source) + .map(({ source, ...record }) => record) + .sort((left, right) => + `${left.property_identity}\u0000${left.source_property || ""}\u0000${left.coverage_end || ""}\u0000${left.observed_from_snapshot}`.localeCompare( + `${right.property_identity}\u0000${right.source_property || ""}\u0000${right.coverage_end || ""}\u0000${right.observed_from_snapshot}`, + ), + ); + } + + return { + schema_version: "2.0.0", + repo, + source_repositories: [...sourceRepositories].sort(), + timezone, + grain: "date", + caveats: [ + "GitHub rolling-window rows are deduplicated by date and retain the latest snapshot observation.", + "Dashboard data is separated by source_property and property_identity; legacy and current properties are never summed or overwritten.", + "coverage_end describes the latest date visible in a dashboard capture, not a promise of complete data.", + "Malformed, partial, or non-success captures are skipped with warnings rather than merged into another observation.", + ], + discovered_snapshots: discoveredSnapshots, + snapshots, + rows, + snapshot_totals, + warnings: warnings.sort(), + }; +} + +function atomicWrite(outputPath, value) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + const temporary = path.join( + path.dirname(outputPath), + `.${path.basename(outputPath)}.${process.pid}.${Date.now()}.tmp`, + ); + try { + fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + fs.renameSync(temporary, outputPath); + } finally { + if (fs.existsSync(temporary)) fs.unlinkSync(temporary); + } +} + +function parseArgs(args) { + const options = { input: DEFAULT_INPUT, output: DEFAULT_OUTPUT }; + for (let index = 0; index < args.length; index += 1) { + const option = args[index]; + if (option === "--input" || option === "--output") { + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${option} requires a value`); + options[option.slice(2)] = path.resolve(value); + index += 1; + } else if (option === "--help" || option === "-h") { + options.help = true; + } else { + throw new Error(`Unknown option: ${option}`); + } + } + return options; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log("Usage: node tools/scripts/normalize_traffic_snapshots.js [--input DIR] [--output FILE]"); + return; + } + const normalized = normalizeSnapshots(options.input); + atomicWrite(options.output, normalized); + console.log(`Normalized ${normalized.snapshots.length} snapshot directories to ${options.output}`); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(`traffic normalization failed: ${error.message}`); + process.exitCode = 1; + } +} + +module.exports = { + DEFAULT_INPUT, + DEFAULT_OUTPUT, + atomicWrite, + normalizeSnapshots, + parseArgs, + propertyIdentity, + sourceProperty, +}; diff --git a/antigravity-awesome-skills/tools/scripts/tests/audit_search_migration_readiness.test.js b/antigravity-awesome-skills/tools/scripts/tests/audit_search_migration_readiness.test.js new file mode 100644 index 00000000..f3199dd4 --- /dev/null +++ b/antigravity-awesome-skills/tools/scripts/tests/audit_search_migration_readiness.test.js @@ -0,0 +1,236 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const scriptPath = path.resolve(__dirname, '..', 'audit_search_migration_readiness.js'); +const { auditMigrationReadiness } = require(scriptPath); + +function writeJson(filePath, data) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); +} + +function fixture({ currentProperties = true, legacyOnly = false, malformed = false } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'migration-readiness-')); + const current = 'https://example.github.io/agentic-awesome-skills/'; + const legacy = 'https://example.github.io/antigravity-awesome-skills/'; + fs.mkdirSync(path.join(root, 'apps/web-app/public'), { recursive: true }); + fs.writeFileSync(path.join(root, 'apps/web-app/public/sitemap.xml'), `${current}${current}plugins/`); + writeJson(path.join(root, 'package.json'), { name: 'agentic-awesome-skills', version: '14.2.0' }); + writeJson(path.join(root, 'legacy-package.json'), { name: 'antigravity-awesome-skills', version: '13.13.0', deprecated: 'Moved to agentic-awesome-skills' }); + writeJson(path.join(root, 'redirects.json'), { redirects: [ + { from: legacy, to: current }, { from: `${legacy}plugins/`, to: `${current}plugins/` }, + ] }); + const property = legacyOnly ? legacy : current; + const snapshot = new Date().toISOString().slice(0, 10); + if (currentProperties || legacyOnly) { + for (const filename of ['google-search-console.json', 'bing-webmaster-search-performance.json']) { + const filePath = path.join(root, '.codex/traffic-snapshots', snapshot, filename); + if (malformed) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, '{broken'); + } else { + writeJson(filePath, { + status: 'success', + captured_at_utc: `${new Date().toISOString().slice(0, 10)}T12:00:00Z`, + dashboard_url: filename.startsWith('google') + ? `https://search.google.com/search-console/performance?resource_id=${encodeURIComponent(property)}` + : `https://www.bing.com/webmasters/searchperf?siteUrl=${encodeURIComponent(property)}`, + totals: { clicks: 1, impressions: 10 }, + }); + } + } + } + return { root, current, legacy }; +} + +function options(root) { + return { + repoRoot: root, + redirectManifestPath: 'redirects.json', + legacyPackagePath: 'legacy-package.json', + currentPagesUrl: 'https://example.github.io/agentic-awesome-skills/', + legacyPagesUrl: 'https://example.github.io/antigravity-awesome-skills/', + currentPackageName: 'agentic-awesome-skills', + asOfDate: new Date().toISOString().slice(0, 10), + }; +} + +{ + const { root } = fixture(); + const snapshot = new Date().toISOString().slice(0, 10); + for (const filename of ['google-search-console.json', 'bing-webmaster-search-performance.json']) { + writeJson(path.join(root, '.codex/traffic-snapshots', snapshot, filename), { + status: 'success', + captured_at_utc: `${snapshot}T12:00:00Z`, + dashboard_url: `https://attacker.example/?next=${encodeURIComponent('https://example.github.io/agentic-awesome-skills/')}`, + totals: { clicks: 1, impressions: 10 }, + }); + } + const report = auditMigrationReadiness(options(root)); + assert.strictEqual(report.status, 'not_ready', 'an unrelated URL containing the current property cannot pass'); + assert(report.failed_checks.includes('google_search_console')); +} + +{ + const { root, current, legacy } = fixture(); + const snapshot = new Date().toISOString().slice(0, 10); + for (const filename of ['google-search-console.json', 'bing-webmaster-search-performance.json']) { + writeJson(path.join(root, '.codex/traffic-snapshots', snapshot, filename), { + status: 'success', + captured_at_utc: `${snapshot}T12:00:00Z`, + source_property: current, + dashboard_url: filename.startsWith('google') + ? `https://search.google.com/search-console/performance?resource_id=${encodeURIComponent(legacy)}` + : `https://www.bing.com/webmasters/searchperf?siteUrl=${encodeURIComponent(legacy)}`, + totals: { clicks: 1, impressions: 10 }, + }); + } + const report = auditMigrationReadiness(options(root)); + assert.strictEqual(report.status, 'not_ready', 'conflicting observed property signals cannot pass'); + assert.strictEqual(report.checks.google_search_console.rejected_current_evidence[0].property_conflict, true); +} + +{ + const { root, current } = fixture(); + const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 10); + const today = new Date().toISOString().slice(0, 10); + for (const filename of ['google-search-console.json', 'bing-webmaster-search-performance.json']) { + writeJson(path.join(root, '.codex/traffic-snapshots', tomorrow, filename), { + status: 'success', + captured_at_utc: `${tomorrow}T12:00:00Z`, + dashboard_url: filename.startsWith('google') + ? `https://search.google.com/search-console/performance?resource_id=${encodeURIComponent(current)}` + : `https://www.bing.com/webmasters/searchperf?siteUrl=${encodeURIComponent(current)}`, + totals: { clicks: 1, impressions: 10 }, + }); + } + fs.rmSync(path.join(root, '.codex/traffic-snapshots', today), { recursive: true, force: true }); + const report = auditMigrationReadiness(options(root)); + assert.strictEqual(report.status, 'not_ready', 'evidence after the as-of date cannot pass'); + assert.strictEqual(report.checks.google_search_console.rejected_current_evidence[0].freshness.reason, 'capture is in the future'); +} + +{ + const { root, current } = fixture(); + const snapshot = new Date().toISOString().slice(0, 10); + for (const filename of ['google-search-console.json', 'bing-webmaster-search-performance.json']) { + writeJson(path.join(root, '.codex/traffic-snapshots', snapshot, filename), { + status: 'success', + captured_at_utc: `${snapshot}T12:00:00Z`, + dashboard_url: `https://attacker.example/${filename}?${filename.startsWith('google') ? 'resource_id' : 'siteUrl'}=${encodeURIComponent(current)}`, + totals: { clicks: 1, impressions: 10 }, + }); + } + const report = auditMigrationReadiness(options(root)); + assert.strictEqual(report.status, 'not_ready', 'recognized property params on an attacker host cannot pass'); + assert.strictEqual(report.checks.google_search_console.rejected_current_evidence.length, 0); +} + +{ + const { root, current, legacy } = fixture(); + const snapshot = new Date().toISOString().slice(0, 10); + for (const filename of ['google-search-console.json', 'bing-webmaster-search-performance.json']) { + const base = filename.startsWith('google') + ? 'https://search.google.com/search-console/performance' + : 'https://www.bing.com/webmasters/searchperf'; + writeJson(path.join(root, '.codex/traffic-snapshots', snapshot, filename), { + status: 'success', + captured_at_utc: `${snapshot}T12:00:00Z`, + dashboard_url: `${base}?resource_id=${encodeURIComponent(current)}&siteUrl=${encodeURIComponent(legacy)}`, + totals: { clicks: 1, impressions: 10 }, + }); + } + const report = auditMigrationReadiness(options(root)); + assert.strictEqual(report.status, 'not_ready', 'conflicting recognized dashboard property params cannot pass'); +} + +{ + const { root } = fixture(); + const snapshot = new Date().toISOString().slice(0, 10); + for (const filename of ['google-search-console.json', 'bing-webmaster-search-performance.json']) { + const filePath = path.join(root, '.codex/traffic-snapshots', snapshot, filename); + const payload = JSON.parse(fs.readFileSync(filePath, 'utf8')); + payload.totals = { clicks: 0.5, impressions: 1.25 }; + writeJson(filePath, payload); + } + const report = auditMigrationReadiness(options(root)); + assert.strictEqual(report.status, 'not_ready', 'fractional count metrics cannot satisfy evidence completeness'); +} + +{ + const { root } = fixture(); + const redirects = JSON.parse(fs.readFileSync(path.join(root, 'redirects.json'), 'utf8')); + redirects.redirects.push(redirects.redirects[0]); + writeJson(path.join(root, 'redirects.json'), redirects); + const report = auditMigrationReadiness(options(root)); + assert.strictEqual(report.status, 'not_ready', 'duplicate redirects violate exact-once coverage'); + assert.strictEqual(report.checks.redirect_manifest_coverage.duplicates.length, 1); +} + +{ + const { root } = fixture(); + fs.writeFileSync(path.join(root, 'apps/web-app/public/sitemap.xml'), 'https://evil.example/not-the-project/'); + writeJson(path.join(root, 'package.json'), { name: 'unrelated-package', version: '1.0.0' }); + const report = auditMigrationReadiness({ ...options(root), currentPagesUrl: undefined, legacyPagesUrl: undefined, currentPackageName: undefined }); + assert.strictEqual(report.status, 'not_ready', 'defaults stay anchored to the real AAS identities'); + assert(report.failed_checks.includes('current_sitemap_identity')); + assert(report.failed_checks.includes('npm_identities')); +} + +{ + const { root } = fixture(); + const report = auditMigrationReadiness(options(root)); + assert.strictEqual(report.status, 'ready'); + assert.deepStrictEqual(report.failed_checks, []); + assert.strictEqual(report.checks.redirect_manifest_coverage.covered, 2); +} + +{ + const { root } = fixture({ currentProperties: false }); + const report = auditMigrationReadiness(options(root)); + assert.strictEqual(report.status, 'not_ready'); + assert(report.failed_checks.includes('google_search_console')); + assert(report.failed_checks.includes('bing_webmaster')); +} + +{ + const { root } = fixture({ currentProperties: false, legacyOnly: true }); + const report = auditMigrationReadiness(options(root)); + assert.strictEqual(report.status, 'not_ready'); + assert.strictEqual(report.checks.google_search_console.legacy_evidence.length, 1); + assert.strictEqual(report.checks.google_search_console.current_evidence.length, 0); +} + +{ + const { root } = fixture({ malformed: true }); + const report = auditMigrationReadiness(options(root)); + assert.strictEqual(report.status, 'not_ready'); + assert(report.errors.some((error) => error.includes('google-search-console.json'))); +} + +{ + const { root } = fixture(); + const output = path.join(root, 'out.json'); + const command = [ + scriptPath, + '--repo-root', root, + '--redirect-manifest', 'redirects.json', + '--legacy-package', 'legacy-package.json', + '--current-pages-url', 'https://example.github.io/agentic-awesome-skills/', + '--legacy-pages-url', 'https://example.github.io/antigravity-awesome-skills/', + '--current-package-name', 'agentic-awesome-skills', + '--as-of', new Date().toISOString().slice(0, 10), + '--output', output, + ]; + const first = spawnSync(process.execPath, command, { encoding: 'utf8' }); + const firstFile = fs.readFileSync(output, 'utf8'); + const second = spawnSync(process.execPath, command, { encoding: 'utf8' }); + assert.strictEqual(first.status, 0, first.stderr); + assert.strictEqual(second.status, 0, second.stderr); + assert.strictEqual(fs.readFileSync(output, 'utf8'), firstFile); +} + +console.log('audit_search_migration_readiness tests passed'); diff --git a/antigravity-awesome-skills/tools/scripts/tests/generate_pages_redirect_bridge.test.js b/antigravity-awesome-skills/tools/scripts/tests/generate_pages_redirect_bridge.test.js new file mode 100644 index 00000000..c7790f78 --- /dev/null +++ b/antigravity-awesome-skills/tools/scripts/tests/generate_pages_redirect_bridge.test.js @@ -0,0 +1,191 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const scriptPath = path.resolve(__dirname, '..', 'generate-pages-redirect-bridge.js'); +const { generateBridge } = require(scriptPath); + +const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pages-redirect-bridge-')); +const sitemapPath = path.join(fixtureRoot, 'sitemap.xml'); +const current = 'https://example.github.io/agentic-awesome-skills/'; +const legacy = 'https://example.github.io/antigravity-awesome-skills/'; + +function sitemap(locations) { + return `${locations.map((location) => `${location}`).join('')}`; +} + +function readTree(root) { + const result = {}; + function visit(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const filePath = path.join(directory, entry.name); + if (entry.isDirectory()) visit(filePath); + else result[path.relative(root, filePath)] = fs.readFileSync(filePath, 'utf8'); + } + } + visit(root); + return result; +} + +try { + const locations = [current, `${current}plugins/`, `${current}topics/github-ai-skills-repository/`, `${current}skill/brainstorming/`]; + fs.writeFileSync(sitemapPath, sitemap(locations), 'utf8'); + const outputOne = path.join(fixtureRoot, '.codex', 'bridge-one'); + const manifest = generateBridge({ + repoRoot: fixtureRoot, + sitemapPath, + outputDirectory: outputOne, + currentBase: current, + legacyBase: legacy, + expectedRoutes: 4, + }); + assert.strictEqual(manifest.route_count, 4); + assert.strictEqual(manifest.redirects.length, 4); + assert.strictEqual(new Set(manifest.redirects.map((redirect) => redirect.from)).size, 4); + assert.strictEqual(new Set(manifest.redirects.map((redirect) => redirect.output_file)).size, 4); + + for (const relative of [ + 'antigravity-awesome-skills/index.html', + 'antigravity-awesome-skills/plugins/index.html', + 'antigravity-awesome-skills/topics/github-ai-skills-repository/index.html', + 'antigravity-awesome-skills/skill/brainstorming/index.html', + ]) { + assert(fs.existsSync(path.join(outputOne, relative)), `missing generated route: ${relative}`); + } + const pluginHtml = fs.readFileSync(path.join(outputOne, 'antigravity-awesome-skills/plugins/index.html'), 'utf8'); + assert.match(pluginHtml, /http-equiv="refresh" content="0; url=https:\/\/example\.github\.io\/agentic-awesome-skills\/plugins\/"/); + assert.match(pluginHtml, /rel="canonical" href="https:\/\/example\.github\.io\/agentic-awesome-skills\/plugins\/"/); + assert.match(pluginHtml, //); + assert.strictEqual((fs.readFileSync(path.join(outputOne, 'antigravity-awesome-skills/sitemap.xml'), 'utf8').match(//g) || []).length, 4); + + const outputTwo = path.join(fixtureRoot, '.codex', 'bridge-two'); + generateBridge({ + repoRoot: fixtureRoot, + sitemapPath, + outputDirectory: outputTwo, + currentBase: current, + legacyBase: legacy, + expectedRoutes: 4, + }); + assert.deepStrictEqual(readTree(outputTwo), readTree(outputOne), 'identical input produces byte-identical output'); + + assert.throws(() => generateBridge({ + repoRoot: fixtureRoot, + sitemapPath, + outputDirectory: outputOne, + currentBase: current, + legacyBase: legacy, + expectedRoutes: 4, + }), /output path already exists/); + + const foreignSitemap = path.join(fixtureRoot, 'foreign.xml'); + fs.writeFileSync(foreignSitemap, sitemap([current, 'https://attacker.example/skill/escape/']), 'utf8'); + assert.throws(() => generateBridge({ + repoRoot: fixtureRoot, + sitemapPath: foreignSitemap, + outputDirectory: path.join(fixtureRoot, '.codex', 'foreign'), + currentBase: current, + legacyBase: legacy, + expectedRoutes: 2, + }), /outside the current HTTPS identity/); + + const duplicateSitemap = path.join(fixtureRoot, 'duplicate.xml'); + fs.writeFileSync(duplicateSitemap, sitemap([current, current]), 'utf8'); + assert.throws(() => generateBridge({ + repoRoot: fixtureRoot, + sitemapPath: duplicateSitemap, + outputDirectory: path.join(fixtureRoot, '.codex', 'duplicate'), + currentBase: current, + legacyBase: legacy, + expectedRoutes: 2, + }), /duplicate/); + + const doubleEncodedSitemap = path.join(fixtureRoot, 'double-encoded.xml'); + fs.writeFileSync(doubleEncodedSitemap, sitemap([current, `${current}skill/&lt;escape/`]), 'utf8'); + assert.throws(() => generateBridge({ + repoRoot: fixtureRoot, + sitemapPath: doubleEncodedSitemap, + outputDirectory: path.join(fixtureRoot, '.codex', 'double-encoded'), + currentBase: current, + legacyBase: legacy, + expectedRoutes: 2, + }), /unsafe path segment/, 'XML entities must be decoded exactly once'); + + const trackedOutput = path.join(fixtureRoot, 'apps', 'web-app', 'public', 'bridge'); + assert.throws(() => generateBridge({ + repoRoot: fixtureRoot, + sitemapPath, + outputDirectory: trackedOutput, + currentBase: current, + legacyBase: legacy, + expectedRoutes: 4, + }), /only under ignored \.codex/); + + const symlinkRepo = path.join(fixtureRoot, 'symlink-repo'); + const trackedPublic = path.join(symlinkRepo, 'apps', 'web-app', 'public'); + fs.mkdirSync(trackedPublic, { recursive: true }); + const symlinkSitemap = path.join(symlinkRepo, 'sitemap.xml'); + fs.writeFileSync(symlinkSitemap, sitemap(locations), 'utf8'); + fs.symlinkSync(trackedPublic, path.join(symlinkRepo, '.codex')); + assert.throws(() => generateBridge({ + repoRoot: symlinkRepo, + sitemapPath: symlinkSitemap, + outputDirectory: path.join(symlinkRepo, '.codex', 'bridge'), + currentBase: current, + legacyBase: legacy, + expectedRoutes: 4, + }), /symlink|physical output/); + assert(!fs.existsSync(path.join(trackedPublic, 'bridge')), 'a .codex symlink cannot redirect writes into tracked public files'); + + const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pages-redirect-outside-')); + try { + fs.symlinkSync(trackedPublic, path.join(outsideRoot, 'linked-public')); + assert.throws(() => generateBridge({ + repoRoot: symlinkRepo, + sitemapPath: symlinkSitemap, + outputDirectory: path.join(outsideRoot, 'linked-public', 'bridge'), + currentBase: current, + legacyBase: legacy, + expectedRoutes: 4, + }), /physical output resolves inside the repository/); + assert(!fs.existsSync(path.join(trackedPublic, 'bridge'))); + } finally { + fs.rmSync(outsideRoot, { recursive: true, force: true }); + } + + const sentinelDirectory = path.join(path.dirname(outputOne), `.${path.basename(outputOne)}.${process.pid}.sentinel`); + fs.mkdirSync(sentinelDirectory); + fs.writeFileSync(path.join(sentinelDirectory, 'KEEP'), 'owned by another process', 'utf8'); + const outputThree = path.join(fixtureRoot, '.codex', 'bridge-three'); + generateBridge({ + repoRoot: fixtureRoot, + sitemapPath, + outputDirectory: outputThree, + currentBase: current, + legacyBase: legacy, + expectedRoutes: 4, + }); + assert.strictEqual(fs.readFileSync(path.join(sentinelDirectory, 'KEEP'), 'utf8'), 'owned by another process'); + + const cliOutput = path.join(fixtureRoot, '.codex', 'cli'); + const cli = spawnSync(process.execPath, [ + scriptPath, + '--sitemap', sitemapPath, + '--output', cliOutput, + '--current-base', current, + '--legacy-base', legacy, + '--expected-routes', '4', + ], { encoding: 'utf8' }); + assert.strictEqual(cli.status, 0, cli.stderr); + assert.match(cli.stdout, /Generated 4 redirect pages/); + + const missingOutput = spawnSync(process.execPath, [scriptPath, '--sitemap', sitemapPath], { encoding: 'utf8' }); + assert.strictEqual(missingOutput.status, 1); + assert.match(missingOutput.stderr, /--output is required/); + + console.log('generate_pages_redirect_bridge tests passed'); +} finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); +} diff --git a/antigravity-awesome-skills/tools/scripts/tests/normalize_traffic_snapshots.test.js b/antigravity-awesome-skills/tools/scripts/tests/normalize_traffic_snapshots.test.js new file mode 100644 index 00000000..7158c326 --- /dev/null +++ b/antigravity-awesome-skills/tools/scripts/tests/normalize_traffic_snapshots.test.js @@ -0,0 +1,199 @@ +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const scriptPath = path.resolve(__dirname, "..", "normalize_traffic_snapshots.js"); +const { normalizeSnapshots } = require(scriptPath); + +const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "traffic-normalizer-")); + +function writeJson(relativePath, value) { + const filePath = path.join(fixtureRoot, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value), "utf8"); +} + +function writeManifest(date, repo = "owner/repo", timezone = "Europe/Rome") { + writeJson(`${date}/manifest.json`, { repo, timezone }); +} + +function dashboard(property, dailyValues, extra = {}) { + return { + status: "success", + dashboard_url: `https://www.bing.com/webmasters/searchperf?siteUrl=${encodeURIComponent(property)}`, + date_range_visible: "3 M (April 12, 2026 to July 10, 2026)", + daily_values: dailyValues, + totals: { clicks: 10, impressions: 100 }, + ...extra, + }; +} + +try { + writeManifest("2026-07-01"); + writeJson("2026-07-01/views.json", { + views: [{ timestamp: "2026-06-30T00:00:00Z", count: 1, uniques: 1 }], + }); + writeJson("2026-07-01/clones.json", { + clones: [{ timestamp: "2026-06-30T00:00:00Z", count: 2, uniques: 1 }], + }); + writeJson("2026-07-01/bing-webmaster-search-performance.json", dashboard( + "https://sickn33.github.io/antigravity-awesome-skills/", + [{ date: "2026-06-30", clicks: 3, impressions: 30 }], + )); + writeJson("2026-07-01/google-search-console.json", { + status: "success", + intended_property: "https://sickn33.github.io/antigravity-awesome-skills/", + date_range_visible: "June 1, 2026 to July 9, 2026", + totals: { clicks: 4, impressions: "1.5K" }, + }); + + writeManifest("2026-07-02"); + writeJson("2026-07-02/views.json", { + views: [{ timestamp: "2026-06-30T00:00:00Z", count: 9, uniques: 8 }], + }); + writeJson("2026-07-02/clones.json", { + clones: [{ timestamp: "2026-06-30T00:00:00Z", count: 7, uniques: 6 }], + }); + writeJson("2026-07-02/bing-webmaster-search-performance.json", dashboard( + "https://sickn33.github.io/agentic-awesome-skills/", + [{ date: "2026-06-30", clicks: 11, impressions: 110 }], + )); + writeJson("2026-07-02/google-search-console.json", { + status: "success", + dashboard_url: "https://search.google.com/search-console/performance?resource_id=https%3A%2F%2Fsickn33.github.io%2Fagentic-awesome-skills%2F", + date_range_visible: "June 1, 2026 to July 10, 2026", + totals: { clicks: 5, impressions_visible: "2,5K" }, + }); + writeJson("2026-07-02/bing-webmaster-ai-performance.json", { + status: "success", + dashboard_url: "https://www.bing.com/webmasters/aiperformance?siteUrl=https%3A%2F%2Fsickn33.github.io%2Fagentic-awesome-skills%2F", + daily_values: [{ date: "June 30, 2026", total_citations: 7, avg_cited_pages: 1 }], + total_citations: 7, + }); + + // A malformed file and partial rows must not leak values into valid rows. + writeManifest("2026-07-03"); + fs.mkdirSync(path.join(fixtureRoot, "2026-07-03"), { recursive: true }); + fs.writeFileSync(path.join(fixtureRoot, "2026-07-03", "views.json"), "{not json", "utf8"); + writeJson("2026-07-03/clones.json", { clones: [{ timestamp: "2026-06-30T00:00:00Z", count: 99 }] }); + writeJson("2026-07-03/bing-webmaster-search-performance.json", dashboard( + "https://sickn33.github.io/agentic-awesome-skills/", + [{ date: "2026-06-30", impressions: 999 }], + )); + + const normalized = normalizeSnapshots(fixtureRoot); + assert.strictEqual(normalized.schema_version, "2.0.0"); + assert.strictEqual(normalized.repo, "owner/repo"); + assert.deepStrictEqual(normalized.source_repositories, ["owner/repo"]); + assert.deepStrictEqual(normalized.snapshots, ["2026-07-01", "2026-07-02", "2026-07-03"]); + const row = normalized.rows.find((value) => value.date === "2026-06-30"); + assert.strictEqual(row.github.views.count, 9, "latest GitHub view observation wins per date"); + assert.strictEqual(row.github.views.observed_from_snapshot, "2026-07-02"); + assert.strictEqual(row.github.clones.count, 7, "partial newer clone row cannot overwrite complete observation"); + assert.strictEqual(row.bing_search.length, 2, "different dashboard properties remain separate"); + assert.deepStrictEqual(row.bing_search.map((value) => value.property_identity), ["current", "legacy"]); + assert.deepStrictEqual(row.bing_search.map((value) => value.coverage_end), ["2026-06-30", "2026-06-30"]); + assert.deepStrictEqual(row.bing_search.map((value) => value.clicks), [11, 3]); + assert.strictEqual(row.bing_ai[0].citations, 7, "human-readable compact Bing dates and total_citations are supported"); + + const gscTotals = normalized.snapshot_totals.google_search_console; + assert.strictEqual(gscTotals.length, 2, "GSC legacy and current totals are never coalesced"); + assert.deepStrictEqual(gscTotals.map((value) => value.property_identity), ["current", "legacy"]); + assert.deepStrictEqual(gscTotals.map((value) => value.coverage_end), ["2026-07-10", "2026-07-09"]); + assert.deepStrictEqual(gscTotals.map((value) => value.impressions), [2500, 1500]); + assert.ok(normalized.warnings.some((warning) => warning.includes("malformed JSON"))); + assert.ok(normalized.warnings.some((warning) => warning.includes("partial dashboard"))); + + writeManifest("2026-07-04", "attacker/other-repo", "Mars/Olympus"); + writeJson("2026-07-04/views.json", { + views: [{ timestamp: "2026-06-30T00:00:00Z", count: 999, uniques: 999 }], + }); + writeManifest("2026-07-05"); + writeJson("2026-07-05/views.json", { + views: [{ timestamp: "2026-99-99T00:00:00Z", count: -4, uniques: 1 }], + }); + writeJson("2026-07-05/google-search-console.json", { + dashboard_url: "https://search.google.com/search-console/performance?resource_id=https%3A%2F%2Fsickn33.github.io%2Fagentic-awesome-skills%2F", + totals: { clicks: 1 }, + }); + writeJson("2026-07-05/bing-webmaster-ai-performance.json", { + status: "success", + dashboard_url: "https://www.bing.com/webmasters/aiperformance?siteUrl=https%3A%2F%2Fsickn33.github.io%2Fagentic-awesome-skills%2F", + date_range_visible: "2026-99-99 to 2026-99-99", + total_citations: 5, + }); + writeManifest("2026-07-06"); + writeJson("2026-07-06/google-search-console.json", { + status: "success", + intended_property: "https://sickn33.github.io/agentic-awesome-skills/", + dashboard_url: "https://search.google.com/search-console/performance?resource_id=https%3A%2F%2Fsickn33.github.io%2Fantigravity-awesome-skills%2F", + totals: { clicks: 8, impressions: 80 }, + }); + writeJson("2026-07-06/bing-webmaster-search-performance.json", { + status: "success", + dashboard_url: "https://attacker.example/webmasters/searchperf?siteUrl=https%3A%2F%2Fsickn33.github.io%2Fagentic-awesome-skills%2F", + totals: { clicks: 8, impressions: 80 }, + }); + writeManifest("2026-07-07"); + writeJson("2026-07-07/google-search-console.json", { + status: "success", + dashboard_url: "https://search.google.com/search-console/performance?resource_id=https%3A%2F%2Fsickn33.github.io%2Fagentic-awesome-skills%2F", + date_range_visible: "June 1, 2026 to July 10, 2026", + daily_values: [{ date: "2026-06-29", clicks: 5, impressions: 50 }], + totals: { clicks: 5, impressions: 50 }, + }); + writeManifest("2026-07-08"); + writeJson("2026-07-08/google-search-console.json", { + status: "success", + intended_property: "https://sickn33.github.io/agentic-awesome-skills/", + date_range_visible: "June 1, 2026 to July 10, 2026", + daily_values: [{ date: "2026-06-29", clicks: 999, impressions: 999 }], + totals: { clicks: 999, impressions: 999 }, + }); + const adversarial = normalizeSnapshots(fixtureRoot); + const stableRow = adversarial.rows.find((value) => value.date === "2026-06-30"); + assert.strictEqual(stableRow.github.views.count, 9, "mismatched repository snapshot cannot overwrite the series"); + assert(!adversarial.rows.some((value) => value.date === "2026-99-99"), "invalid calendar dates are rejected"); + assert(adversarial.warnings.some((warning) => warning.includes("repository or timezone mismatch"))); + assert(adversarial.warnings.some((warning) => warning.includes("non-success capture"))); + const invalidCoverageTotal = adversarial.snapshot_totals.bing_ai.find((value) => value.observed_from_snapshot === "2026-07-05"); + assert.strictEqual(invalidCoverageTotal.coverage_end, null, "impossible free-text coverage dates are rejected"); + const conflictedGsc = adversarial.snapshot_totals.google_search_console.find((value) => value.observed_from_snapshot === "2026-07-06"); + assert.strictEqual(conflictedGsc.property_identity, "legacy", "observed dashboard property wins over a conflicting intention"); + assert.strictEqual(conflictedGsc.property_provenance, "observed"); + assert(adversarial.warnings.some((warning) => warning.includes("intended property disagrees"))); + assert(!adversarial.snapshot_totals.bing_search.some((value) => value.observed_from_snapshot === "2026-07-06"), "attacker-host dashboard evidence is skipped"); + const preferredDaily = adversarial.rows.find((value) => value.date === "2026-06-29").google_search_console + .find((value) => value.property_identity === "current"); + assert.strictEqual(preferredDaily.clicks, 5, "observed daily evidence cannot be overwritten by newer intended-only data"); + assert.strictEqual(preferredDaily.property_provenance, "observed"); + const preferredTotal = adversarial.snapshot_totals.google_search_console + .find((value) => value.property_identity === "current" && value.coverage_end === "2026-07-10"); + assert.strictEqual(preferredTotal.clicks, 5, "observed totals cannot be overwritten by newer intended-only data"); + assert.strictEqual(preferredTotal.property_provenance, "observed"); + + const outputPath = path.join(fixtureRoot, "out", "daily-normalized.json"); + const first = spawnSync(process.execPath, [scriptPath, "--input", fixtureRoot, "--output", outputPath], { encoding: "utf8" }); + assert.strictEqual(first.status, 0, first.stderr); + const firstOutput = fs.readFileSync(outputPath, "utf8"); + const second = spawnSync(process.execPath, [scriptPath, "--input", fixtureRoot, "--output", outputPath], { encoding: "utf8" }); + assert.strictEqual(second.status, 0, second.stderr); + assert.strictEqual(fs.readFileSync(outputPath, "utf8"), firstOutput, "stable input produces stable output"); + + const missing = spawnSync(process.execPath, [scriptPath, "--input", path.join(fixtureRoot, "missing"), "--output", outputPath], { encoding: "utf8" }); + assert.strictEqual(missing.status, 1, "missing input is an operational failure"); + assert.match(missing.stderr, /input directory does not exist/); + + const rejectedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "traffic-normalizer-rejected-")); + writeJson(path.relative(fixtureRoot, path.join(rejectedRoot, "2026-07-01", "manifest.json")), { repo: "owner/repo" }); + const allRejected = spawnSync(process.execPath, [scriptPath, "--input", rejectedRoot, "--output", path.join(rejectedRoot, "out.json")], { encoding: "utf8" }); + assert.strictEqual(allRejected.status, 1, "all-rejected snapshots are an operational failure"); + assert.match(allRejected.stderr, /no snapshots .* were accepted/); + fs.rmSync(rejectedRoot, { recursive: true, force: true }); + + console.log("traffic snapshot normalizer tests passed."); +} finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); +}