📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -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.
|
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
|
### Verify the install
|
||||||
|
|
||||||
```bash
|
```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` |
|
| 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 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` |
|
| 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` |
|
| 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` |
|
| 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 |
|
| Custom path | `npx agentic-awesome-skills --path ./my-skills` | Depends on your tool |
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Source
|
# Source
|
||||||
|
|
||||||
- Repo: https://github.com/sickn33/antigravity-awesome-skills
|
- Repo: https://github.com/sickn33/antigravity-awesome-skills
|
||||||
- Ref: 6b7501e33dd3fe4ff3715e5bd79dec2881e8b7d4
|
- Ref: 314c8c499af407db8160a3eeac9043b58dc9c41a
|
||||||
- Remove-Paths:
|
- Remove-Paths:
|
||||||
- Snapshot: 2026-07-12
|
- Snapshot: 2026-07-13
|
||||||
- Sync-Mode: copy_skill_dirs
|
- Sync-Mode: copy_skill_dirs
|
||||||
- Notes: vendored into playbook branch thirdparty/skill
|
- Notes: vendored into playbook branch thirdparty/skill
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ function bundleDocUrl(): string {
|
|||||||
return `${repoBaseUrl}/blob/main/docs/users/bundles.md`;
|
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 {
|
export function Plugins(): React.ReactElement {
|
||||||
usePageMeta(buildPluginsMeta(specializedPlugins.length));
|
usePageMeta(buildPluginsMeta(specializedPlugins.length));
|
||||||
|
|
||||||
@@ -36,7 +40,7 @@ export function Plugins(): React.ReactElement {
|
|||||||
Choose the focused AAS plugin for your AI coding workflow
|
Choose the focused AAS plugin for your AI coding workflow
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 max-w-4xl text-sm leading-relaxed text-slate-600 sm:text-base dark:text-slate-300">
|
<p className="mt-4 max-w-4xl text-sm leading-relaxed text-slate-600 sm:text-base dark:text-slate-300">
|
||||||
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,
|
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.
|
OSS maintenance, mobile apps, automation, or agent and MCP systems.
|
||||||
</p>
|
</p>
|
||||||
@@ -55,6 +59,14 @@ export function Plugins(): React.ReactElement {
|
|||||||
>
|
>
|
||||||
Browse full skill catalog
|
Browse full skill catalog
|
||||||
</Link>
|
</Link>
|
||||||
|
<a
|
||||||
|
href={gettingStartedDocUrl()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center justify-center rounded-lg border border-slate-400/80 bg-white/80 px-4 py-2.5 text-sm font-semibold text-slate-900 shadow-[inset_0_1px_0_rgba(255,255,255,0.9),0_10px_20px_-16px_rgba(15,23,42,0.7)] transition-colors hover:border-slate-500 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-800/70 dark:text-slate-100 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
Install one skill with GitHub CLI
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ describe('Plugins', () => {
|
|||||||
expect(screen.getByText('AAS Web App Builder')).toBeInTheDocument();
|
expect(screen.getByText('AAS Web App Builder')).toBeInTheDocument();
|
||||||
expect(screen.getByText('AAS Security Engineer')).toBeInTheDocument();
|
expect(screen.getByText('AAS Security Engineer')).toBeInTheDocument();
|
||||||
expect(screen.getByText('AAS Marketing, SEO & Growth')).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(screen.getByText(/Plugins, bundles, and workflows serve different decisions/i)).toBeInTheDocument();
|
||||||
expect(document.title).toContain('AAS Specialized Plugins');
|
expect(document.title).toContain('AAS Specialized Plugins');
|
||||||
expect(document.querySelector('meta[name="description"]')).toHaveAttribute(
|
expect(document.querySelector('meta[name="description"]')).toHaveAttribute(
|
||||||
|
|||||||
@@ -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
|
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
|
### 2. Pick Your Persona
|
||||||
|
|
||||||
Find the bundle that matches your role (see [bundles.md](bundles.md)):
|
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/` |
|
| **Cursor** | ✅ Native | `.cursor/skills/` |
|
||||||
| **OpenCode** | ✅ Full Support | `.agents/skills/` (prefer reduced installs with `--risk`, `--category`, or `--tags`) |
|
| **OpenCode** | ✅ Full Support | `.agents/skills/` (prefer reduced installs with `--risk`, `--category`, or `--tags`) |
|
||||||
| **AdaL CLI** | ✅ Full Support | `.adal/skills/` |
|
| **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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
- ✅ **Cursor** (IDE tích hợp AI)
|
||||||
- ✅ **Antigravity IDE**
|
- ✅ **Antigravity IDE**
|
||||||
- ✅ **OpenCode**
|
- ✅ **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?
|
### Những kỹ năng này có được sử dụng miễn phí không?
|
||||||
|
|
||||||
|
|||||||
@@ -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/` |
|
| **Cursor** | ✅ Hỗ trợ gốc | `.cursor/skills/` |
|
||||||
| **OpenCode** | ✅ Hỗ trợ đầy đủ | `.agents/skills/` |
|
| **OpenCode** | ✅ Hỗ trợ đầy đủ | `.agents/skills/` |
|
||||||
| **AdaL CLI** | ✅ Hỗ trợ đầy đủ | `.adal/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
|
||||||
|
> ```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ AI 代理很聪明,但仍需要**特定于任务的操作指令**。技能是
|
|||||||
| **Antigravity** | IDE | `(Agent Mode) Use skill...` | 全局:`~/.agents/skills/` · 工作区:`.agent/skills/` |
|
| **Antigravity** | IDE | `(Agent Mode) Use skill...` | 全局:`~/.agents/skills/` · 工作区:`.agent/skills/` |
|
||||||
| **Antigravity CLI (`agy`)** | CLI | `/skill-name help me...` | `~/.gemini/antigravity-cli/skills/` |
|
| **Antigravity CLI (`agy`)** | CLI | `/skill-name help me...` | `~/.gemini/antigravity-cli/skills/` |
|
||||||
| **Cursor** | IDE | `@skill-name (in Chat)` | `.cursor/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/` |
|
| **OpenCode** | CLI | `opencode run @skill-name` | `.agents/skills/` |
|
||||||
| **AdaL CLI** | CLI | `(Auto) Skills load on-demand` | `.adal/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` |
|
| 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 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` |
|
| 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` |
|
| 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` |
|
| AdaL CLI | `npx agentic-awesome-skills --path .adal/skills` | `Use brainstorming to plan a feature` |
|
||||||
| 自定义路径 | `npx agentic-awesome-skills --path ./my-skills` | 取决于你的工具 |
|
| 自定义路径 | `npx agentic-awesome-skills --path ./my-skills` | 取决于你的工具 |
|
||||||
|
|
||||||
|
> **GitHub Copilot(预览)**:GitHub CLI 的 `gh skill` 目前处于 preview 阶段。由于本仓库规模较大且同时包含 canonical 与 plugin 镜像,请使用精确路径,避免歧义;除非你确实要安装所有发现的副本,否则不要使用 `--all`。
|
||||||
|
|
||||||
## 按工具查看最佳技能
|
## 按工具查看最佳技能
|
||||||
|
|
||||||
如果你想要比"浏览所有 1,936+ 技能"更快的答案,请从工具特定指南开始:
|
如果你想要比"浏览所有 1,936+ 技能"更快的答案,请从工具特定指南开始:
|
||||||
|
|||||||
@@ -107,7 +107,14 @@ git clone https://github.com/sickn33/agentic-awesome-skills.git .agent/skills
|
|||||||
| **Cursor** | ✅ 原生支持 | `.cursor/skills/` |
|
| **Cursor** | ✅ 原生支持 | `.cursor/skills/` |
|
||||||
| **OpenCode** | ✅ 完全支持 | `.agents/skills/`(建议用 `--risk`、`--category` 或 `--tags` 做缩小安装) |
|
| **OpenCode** | ✅ 完全支持 | `.agents/skills/`(建议用 `--risk`、`--category` 或 `--tags` 做缩小安装) |
|
||||||
| **AdaL CLI** | ✅ 完全支持 | `.adal/skills/` |
|
| **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
|
||||||
|
> ```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@
|
|||||||
"build": "npm run chain && npm run catalog",
|
"build": "npm run chain && npm run catalog",
|
||||||
"check:stale-claims": "node tools/scripts/run-python.js tools/scripts/check_stale_claims.py",
|
"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",
|
"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: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",
|
"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",
|
"audit:consistency": "node tools/scripts/run-python.js tools/scripts/audit_consistency.py",
|
||||||
|
|||||||
@@ -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(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)]
|
||||||
|
.map((match) => withTrailingSlash(match[1]))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!locations.length) errors.push('current sitemap: contains no valid <loc> 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 };
|
||||||
@@ -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(/<loc>\s*([^<]+?)\s*<\/loc>/gi)].map((match) => xmlUnescape(match[1].trim()));
|
||||||
|
if (!rawLocations.length) throw new Error('sitemap contains no <loc> URLs');
|
||||||
|
if (new Set(rawLocations).size !== rawLocations.length) throw new Error('sitemap contains duplicate <loc> 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 `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="refresh" content="0; url=${escaped}">
|
||||||
|
<link rel="canonical" href="${escaped}">
|
||||||
|
<title>Agentic Awesome Skills has moved</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Agentic Awesome Skills has moved</h1>
|
||||||
|
<p>Continue to <a href="${escaped}">${escaped}</a>.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacySitemap(redirects) {
|
||||||
|
const entries = redirects.map(({ from }) => ` <url><loc>${xmlEscape(from)}</loc></url>`).join('\n');
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
${entries}
|
||||||
|
</urlset>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
+236
@@ -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'), `<?xml version="1.0"?><urlset><url><loc>${current}</loc></url><url><loc>${current}plugins/</loc></url></urlset>`);
|
||||||
|
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'), '<?xml version="1.0"?><urlset><url><loc>https://evil.example/not-the-project/</loc></url></urlset>');
|
||||||
|
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');
|
||||||
+191
@@ -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 `<?xml version="1.0"?><urlset>${locations.map((location) => `<url><loc>${location}</loc></url>`).join('')}</urlset>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, /<a href="https:\/\/example\.github\.io\/agentic-awesome-skills\/plugins\/">/);
|
||||||
|
assert.strictEqual((fs.readFileSync(path.join(outputOne, 'antigravity-awesome-skills/sitemap.xml'), 'utf8').match(/<loc>/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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user