archgen-skill 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -6
- package/bin/archgen.mjs +22 -5
- package/lib/block.js +1 -1
- package/lib/bootstrap.js +103 -0
- package/lib/doctor.js +45 -2
- package/lib/init.js +32 -3
- package/lib/install.js +20 -1
- package/lib/mcp-server.js +159 -0
- package/lib/migrations/001-stamp-provenance.mjs +3 -3
- package/lib/spawn-npm.js +54 -0
- package/lib/store.js +75 -5
- package/lib/uninstall-project.js +16 -3
- package/lib/version.js +11 -3
- package/package.json +3 -3
- package/vendor/skills/archgen/SKILL.md +363 -213
- package/vendor/skills/archgen/archgen.config.json +1 -1
- package/vendor/skills/archgen/references/code-standards.md +2 -2
- package/vendor/skills/archgen/assets/mcp-template.json +0 -42
- package/vendor/skills/archgen/assets/skill-registry.json +0 -24
- package/vendor/skills/archgen/references/artifact-templates.md +0 -406
- package/vendor/skills/archgen/references/interview.md +0 -275
- package/vendor/skills/archgen/references/mcp-and-skills.md +0 -117
- package/vendor/skills/archgen/references/orchestration.md +0 -292
- package/vendor/skills/archgen/references/platforms.md +0 -138
- package/vendor/skills/archgen/scripts/doc-index.mjs +0 -457
- package/vendor/skills/archgen/scripts/impact.mjs +0 -77
- package/vendor/skills/archgen/scripts/lib/graph.mjs +0 -222
- package/vendor/skills/archgen/scripts/lib/plan-coverage.mjs +0 -32
- package/vendor/skills/archgen/scripts/lib/scope.mjs +0 -65
- package/vendor/skills/archgen/scripts/lib/yaml.mjs +0 -492
- package/vendor/skills/archgen/scripts/next-tasks.mjs +0 -55
- package/vendor/skills/archgen/scripts/plan-graph.mjs +0 -329
- package/vendor/skills/archgen/scripts/set-status.mjs +0 -82
- package/vendor/skills/archgen/scripts/update-agents.mjs +0 -213
- package/vendor/skills/archgen/scripts/validate.mjs +0 -87
- package/vendor/skills/archgen/scripts/verify-plan.mjs +0 -70
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
# Platform capability matrix (August 2026)
|
|
2
|
-
|
|
3
|
-
Facts verified against official vendor docs; cells marked UNVERIFIED could not be
|
|
4
|
-
confirmed. The orchestrator loads ONLY the row matching Step-0 detection.
|
|
5
|
-
|
|
6
|
-
## Skill install paths
|
|
7
|
-
|
|
8
|
-
| Platform | Project path | Global path | Notes |
|
|
9
|
-
|---|---|---|---|
|
|
10
|
-
| Claude Code | `.claude/skills/<name>/` | `~/.claude/skills/<name>/` | Plugin marketplace: `/plugin install x@mkt`; skills hot-reload |
|
|
11
|
-
| OpenCode | `.opencode/skills/` (+ reads `.claude/`, `.agents/`) | `~/.config/opencode/skills/` (+ `~/.claude/`, `~/.agents/`) | Walks up to git root |
|
|
12
|
-
| Cursor | `.agents/skills/`, `.cursor/skills/` | `~/.agents/skills/`, `~/.cursor/skills/` | ≥2.4 native; also scans `.claude/`, `.codex/`; nesting OK |
|
|
13
|
-
| Windsurf | `.windsurf/skills/` | `~/.codeium/windsurf/skills/` | Enterprise system paths also read |
|
|
14
|
-
| VS Code + Copilot | `.github/skills/` (+ `.claude/`, `.agents/`) | `~/.copilot/skills/` (+ `~/.claude/`, `~/.agents/`) | Extra dirs via chat.agentSkillsLocations |
|
|
15
|
-
| Codex CLI | `.agents/skills/` (crawls up to repo root) | `~/.agents/skills/` (legacy `~/.codex/skills/`) | `$skill-installer` built in |
|
|
16
|
-
| Gemini CLI | `.gemini/skills/` or `.agents/skills/` | `~/.gemini/skills/` or `~/.agents/skills/` | `gemini skills install <git-url> --consent` |
|
|
17
|
-
| Antigravity | `<ws>/.agents/skills/` | `~/.gemini/config/skills/` | Backward-supports `.agent/skills`; name frontmatter OPTIONAL here |
|
|
18
|
-
| Zed | `<project>/.agents/skills/` ONLY | `~/.agents/skills/` ONLY | FLAT layout — no nesting; worktree-trust gate on project skills |
|
|
19
|
-
| Kiro | `.kiro/skills/` | `~/.kiro/skills/` | Custom agents need explicit `resources: ["skill://.kiro/skills/**/SKILL.md"]` |
|
|
20
|
-
| Trae | `.trae/skills/` (opt-in `.agents/`) | `~/.trae/skills/` | `.trae` wins collisions; CN variant separate |
|
|
21
|
-
| Claude Desktop | — (account-scoped) | GUI ZIP upload only | Scripts run sandboxed, NOT local shell |
|
|
22
|
-
|
|
23
|
-
Universal fallback: `.agents/skills/` project + `~/.agents/skills/` user works
|
|
24
|
-
across most harnesses. Primary distribution: `npx skills add <repo>` (75+ agents).
|
|
25
|
-
|
|
26
|
-
## archgen installed layout
|
|
27
|
-
|
|
28
|
-
`install.sh` / `npx archgen-skill install` maintain ONE canonical store plus
|
|
29
|
-
thin bridges — the skill is never copied twice:
|
|
30
|
-
|
|
31
|
-
| Path | Role |
|
|
32
|
-
|---|---|
|
|
33
|
-
| `.agents/skills/archgen/` | Canonical store — `SKILL.md`, `scripts/`, `references/`, `assets/` |
|
|
34
|
-
| `.claude/skills/archgen` | Optional symlink → canonical store, for Claude Code discovery |
|
|
35
|
-
| `CLAUDE.md` | One-line `@AGENTS.md` bridge only — no content of its own |
|
|
36
|
-
| `AGENTS.md` | The hub every harness reads; hosts the features registry between `<!-- archgen:features:start -->` and `<!-- archgen:features:end -->` |
|
|
37
|
-
|
|
38
|
-
The registry table (`| Feature | Status | Updated |`, one row per
|
|
39
|
-
`.archgen/<slug>/`) is machine-maintained by `scripts/update-agents.mjs` after
|
|
40
|
-
every generate/plan/wave run — never hand-edit it; edit `.archgen/` state and
|
|
41
|
-
re-run the script instead.
|
|
42
|
-
|
|
43
|
-
## MCP configuration
|
|
44
|
-
|
|
45
|
-
Four different top-level keys exist — NEVER assume `mcpServers`:
|
|
46
|
-
|
|
47
|
-
| Platform | File(s) | Key | stdio fields | Remote URL field | CLI add? |
|
|
48
|
-
|---|---|---|---|---|---|
|
|
49
|
-
| Claude Code | `.mcp.json` (project) / `~/.claude.json` (local/user) | `mcpServers` | command,args[,env] type:"stdio" | `url`+headers type:"http"/"sse" | `claude mcp add --scope project\|local\|user --transport stdio\|http\|sse` |
|
|
50
|
-
| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) / `%APPDATA%\Claude\` (Win) / `~/.config/Claude/` (Linux) | `mcpServers` | command,args,env | UI Connectors preferred; file `url` UNVERIFIED | none |
|
|
51
|
-
| OpenCode | `opencode.json(c)` / `~/.config/opencode/opencode.json(c)` | **`mcp`** | type:"local", **command:[array]**, environment{} | type:"remote", `url`, headers | `opencode mcp add` (interactive only) |
|
|
52
|
-
| Cursor | `.cursor/mcp.json` / `~/.cursor/mcp.json` | `mcpServers` | command,args,env,cwd | `url`+headers (`${env:VAR}` interp.) | none (deep-links UNVERIFIED) |
|
|
53
|
-
| VS Code Copilot | `.vscode/mcp.json` / profile mcp.json / portable `.mcp.json` | **`servers`** (+inputs[]) | type:"stdio",command,args,env | type:"http"/"sse", url, headers | `copilot mcp add`; palette "MCP: Add Server" |
|
|
54
|
-
| Windsurf | `~/.codeium/windsurf/mcp_config.json` (global only) | `mcpServers` | command,args,env | **`serverUrl`** OR `url` + headers | none (Marketplace UI/deeplink) |
|
|
55
|
-
| Codex CLI | `~/.codex/config.toml` / project `.codex/config.toml` (trust-gated) | `[mcp_servers.<id>]` TOML | command,args,env,cwd | `url`, bearer_token_env_var, http_headers | `codex mcp add <n> --url \| -- cmd args` |
|
|
56
|
-
| Gemini CLI | `~/.gemini/settings.json` / `.gemini/settings.json` | `mcpServers` | command,args,env,cwd,trust | `httpUrl`(HTTP)/`url`(SSE)+headers | `gemini mcp add -s user\|project -t stdio\|sse\|http` |
|
|
57
|
-
| Antigravity | `~/.gemini/config/mcp_config.json` / `.agents/mcp_config.json` | `mcpServers` | command,args,env,cwd | **`serverUrl` MANDATORY — `url` rejected** + headers/oauth | none (`/mcp` overlay manager) |
|
|
58
|
-
| Zed | `~/.config/zed/settings.json` / `.zed/settings.json` | **`context_servers`** | command,args,env | `url`+headers (OAuth auto-prompt) | none (Settings UI writes file) |
|
|
59
|
-
| Kiro | `.kiro/settings/mcp.json` / `~/.kiro/settings/mcp.json` | `mcpServers` | command,args,env,disabled,autoApprove | `url`+headers+oauth | `/mcp add` (CLI session) |
|
|
60
|
-
| Trae IDE | `.trae/mcp.json` | `mcpServers` | command,args,env | url + type:"sse"/"http" | none (UI manual add) |
|
|
61
|
-
|
|
62
|
-
Restart semantics: full restart needed = Claude Desktop, Windsurf, Zed.
|
|
63
|
-
Hot-reload = Kiro, Gemini, Codex. Approval prompt on first detection =
|
|
64
|
-
Claude Code project-scope servers.
|
|
65
|
-
|
|
66
|
-
## Sub-agent dispatch capability
|
|
67
|
-
|
|
68
|
-
| Harness | Native mechanism | Parallel background | Isolation for parallel edits |
|
|
69
|
-
|---|---|---|---|
|
|
70
|
-
| Claude Code | `Agent` tool + subagent_type; `.claude/agents/*.md` | Yes (default since v2.1.198; cap ~20) | BEST: `isolation: worktree` frontmatter; `/batch` |
|
|
71
|
-
| OpenCode | task/subagent tool + subagent_type | Yes (fg + background) | None in core → use file-ownership discipline or team-mode plugin worktrees |
|
|
72
|
-
| Codex CLI | model-mediated `spawn_agent`; max_depth=1 | Yes but children can't nest | None built-in → flatten plans into top-level `spawn_agent` calls (children cannot nest); add manual git worktrees if edits collide |
|
|
73
|
-
| Gemini CLI | subagents-as-tools; `@agent-name` | Yes BUT strictly hub-and-spoke (no nested calls) | None → restrict parallel workers to read-only roles unless given external worktrees |
|
|
74
|
-
| Cursor | Subagents w/ `is_background` frontmatter; `/multitask` | Yes | Opt-in per-subagent worktree or cloud VM |
|
|
75
|
-
| Antigravity | `invoke_subagent` (async by design); `/fork` | Yes | `/fork` workspace separation; per-subagent worktrees UNVERIFIED |
|
|
76
|
-
|
|
77
|
-
## Unknown platform procedure
|
|
78
|
-
|
|
79
|
-
1. WEB SEARCH: `<platform> official docs MCP configuration` and
|
|
80
|
-
`<platform> agent skills install path`.
|
|
81
|
-
2. Prefer official vendor domains over blog posts; note the date of sources.
|
|
82
|
-
3. Present findings + proposed config diff to the user. [approval gate]
|
|
83
|
-
4. Apply, verify, and offer to append findings to this table upstream.
|
|
84
|
-
|
|
85
|
-
## Codegraph indexing scope
|
|
86
|
-
|
|
87
|
-
Current mainstream codegraph indexers extract SOURCE symbols only
|
|
88
|
-
(functions/classes/imports) — they do not index markdown headings, and YAML
|
|
89
|
-
support ranges from absent to file-level config tracking; none can graph
|
|
90
|
-
tasks.yaml dependencies (audited 8 codegraph-family tools Aug 2026; local
|
|
91
|
-
binary v1.0.1 empirically confirms). Generic code indexers verifiably do not
|
|
92
|
-
extract markdown headings or YAML task semantics, so archgen ships its own
|
|
93
|
-
deterministic graph tools:
|
|
94
|
-
|
|
95
|
-
- Source navigation → codegraph MCP when configured (offered at Step 0).
|
|
96
|
-
- YAML plan graphs → exclusively `scripts/plan-graph.mjs`.
|
|
97
|
-
- Markdown artifact navigation → `scripts/doc-index.mjs`.
|
|
98
|
-
|
|
99
|
-
If the harness exposes a markdown-heading indexer (e.g. a
|
|
100
|
-
`codegraph_index_markdown`-style tool), prefer it for general repo docs but
|
|
101
|
-
still use `doc-index.mjs` inside `.archgen/` — it understands TASK/FR
|
|
102
|
-
reference semantics. Brownfield surveys prefer codegraph once present.
|
|
103
|
-
|
|
104
|
-
### Codegraph install & index (concrete — researched Aug 2026, v1.0.1)
|
|
105
|
-
|
|
106
|
-
CodeGraph = `@colbymchenry/codegraph` (npm), CLI `codegraph`, MIT, 100% local.
|
|
107
|
-
It persists a SQLite symbol graph in `.codegraph/` and serves it over MCP. It
|
|
108
|
-
indexes SOURCE symbols only (scope above) — pair with `doc-index.mjs` /
|
|
109
|
-
`plan-graph.mjs` for markdown/YAML. Requires Node 22 or 24 (native SQLite
|
|
110
|
-
bindings; other versions fall back to a 5–10x-slower WASM backend).
|
|
111
|
-
|
|
112
|
-
Install the CLI (one-time, global):
|
|
113
|
-
- `npm i -g @colbymchenry/codegraph` (any OS with Node), or
|
|
114
|
-
- `curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh` (macOS/Linux), or
|
|
115
|
-
- zero-install: `npx @colbymchenry/codegraph`.
|
|
116
|
-
|
|
117
|
-
Wire the harness (writes MCP config + instructions — INSTALL-MCP approval gate):
|
|
118
|
-
- `codegraph install --yes` (auto-detect agents, global)
|
|
119
|
-
- `codegraph install --target=<agent> --yes` (explicit harness)
|
|
120
|
-
- `codegraph install --target=auto --location=local --yes` (project-local)
|
|
121
|
-
- `codegraph install --print-config <agent>` (preview snippet, no writes)
|
|
122
|
-
- Manual config shape: assets/mcp-template.json (stdio `codegraph serve --mcp`
|
|
123
|
-
or npx `-y @colbymchenry/codegraph mcp`).
|
|
124
|
-
|
|
125
|
-
Index the project (per project; creates `.codegraph/` + builds the graph):
|
|
126
|
-
- `codegraph init` (add `--yes` to skip prompts); one-shot bootstrap:
|
|
127
|
-
`codegraph install --yes --init`. Auto-syncs on file change thereafter.
|
|
128
|
-
- `codegraph status` to verify; `codegraph uninit` removes the index;
|
|
129
|
-
`codegraph uninstall` de-wires the agents; `codegraph upgrade` updates.
|
|
130
|
-
|
|
131
|
-
`.codegraph/` is deterministic and small — committing it gives teammates the
|
|
132
|
-
index on first checkout (team recommendation); git-ignore it for solo repos.
|
|
133
|
-
Never point two OSes (Windows + WSL) at one `.codegraph/`.
|
|
134
|
-
|
|
135
|
-
Agent flow: detect `.codegraph/` at repo root. Present → use `codegraph_explore`
|
|
136
|
-
for source navigation. Absent → offer once (INSTALL-MCP approval pattern,
|
|
137
|
-
recommendation-first), run the commands above on approval; on decline use
|
|
138
|
-
Glob+Grep.
|
|
@@ -1,457 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// doc-index.mjs — deterministic markdown-artifact lens over `.archgen/<slug>/`:
|
|
3
|
-
// overview tree, backlink queries, reference-integrity gate, freshness audit,
|
|
4
|
-
// Mermaid inventory.
|
|
5
|
-
// Usage:
|
|
6
|
-
// node doc-index.mjs <slug-dir> overview JSON
|
|
7
|
-
// node doc-index.mjs <slug-dir> --refs-to <token> backlink JSON
|
|
8
|
-
// node doc-index.mjs <slug-dir> --validate integrity JSON (exit 1 when broken)
|
|
9
|
-
// node doc-index.mjs <slug-dir> --stale freshness JSON
|
|
10
|
-
// node doc-index.mjs <slug-dir> --diagrams Mermaid inventory JSON
|
|
11
|
-
// Any mode accepts `--file <path>` (repo-root-relative or slug-relative) to
|
|
12
|
-
// scope it to one document. Paths in output are repo-root-relative and
|
|
13
|
-
// slash-normalized; identical input yields byte-identical stdout.
|
|
14
|
-
// Scope-hardened: refuses any input outside .archgen/<slug>/ (see lib/scope.mjs).
|
|
15
|
-
// Exit codes: 0 ok · 1 broken references (--validate) · 2 structurally invalid
|
|
16
|
-
// tasks.yaml during --validate (duplicate task id / dangling depends_on, via
|
|
17
|
-
// GraphError) · 4 invalid input/flags.
|
|
18
|
-
//
|
|
19
|
-
// DEDUP GUARANTEES (stateless idempotency): identical input yields byte-
|
|
20
|
-
// identical output AND zero duplicated entities inside one output. The file
|
|
21
|
-
// inventory dedups by fs.realpathSync — a symlinked doc aliasing another
|
|
22
|
-
// tracked doc, case-colliding spellings on case-insensitive filesystems, and
|
|
23
|
-
// `/./` path noise all collapse to ONE entry; the canonical repo-root-relative
|
|
24
|
-
// spelling kept is the non-symlink one when the group contains it, else the
|
|
25
|
-
// lexicographically first (overview reports how many via `duplicatesSkipped`).
|
|
26
|
-
// --validate counts UNIQUE refs in `checked.*` and lists each broken ref ONCE,
|
|
27
|
-
// at its first occurrence line; duplicate FR-id DEFINITIONS inside docs/prd.md
|
|
28
|
-
// are their own broken kind 'dup-definition' (ambiguity is surfaced, never
|
|
29
|
-
// silently resolved). By deliberate contrast, --refs-to backlinks list EVERY
|
|
30
|
-
// occurrence line — that mode is a "where is this mentioned" lens, not a gate.
|
|
31
|
-
//
|
|
32
|
-
// SCALE GUARDS: all traversals are iterative (explicit stack — no recursion on
|
|
33
|
-
// tree depth); overview returns at most the first 100 files, sorted, with
|
|
34
|
-
// `truncated:true` above that (totalFiles still reports the true deduped
|
|
35
|
-
// count) so agents are never silently surprised by deterministic truncation;
|
|
36
|
-
// fence pairing follows CommonMark (an info-carrying ``` inside an open fence
|
|
37
|
-
// is content, not an opener), which keeps nested-fence blocks from producing
|
|
38
|
-
// phantom diagram entries or false titles.
|
|
39
|
-
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
40
|
-
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
41
|
-
import { parseYaml } from './lib/yaml.mjs';
|
|
42
|
-
import { buildGraph, GraphError } from './lib/graph.mjs';
|
|
43
|
-
import { resolveSlugInput, ScopeError } from './lib/scope.mjs';
|
|
44
|
-
|
|
45
|
-
const USAGE = 'usage: doc-index.mjs <slug-dir> [--refs-to <token>] [--validate] [--stale] [--diagrams] [--file <path>]';
|
|
46
|
-
// Overview emits at most this many files (sorted); above it, truncated:true.
|
|
47
|
-
const OVERVIEW_MAX_FILES = 100;
|
|
48
|
-
function die(msg, code = 4) { console.error(msg); process.exit(code); }
|
|
49
|
-
|
|
50
|
-
// --- argument parsing -------------------------------------------------------
|
|
51
|
-
const argv = process.argv.slice(2);
|
|
52
|
-
const opts = { input: null, refsTo: null, validate: false, stale: false, diagrams: false, file: null };
|
|
53
|
-
for (let i = 0; i < argv.length; i++) {
|
|
54
|
-
const a = argv[i];
|
|
55
|
-
if (a === '--refs-to') opts.refsTo = argv[++i];
|
|
56
|
-
else if (a === '--validate') opts.validate = true;
|
|
57
|
-
else if (a === '--stale') opts.stale = true;
|
|
58
|
-
else if (a === '--diagrams') opts.diagrams = true;
|
|
59
|
-
else if (a === '--file') opts.file = argv[++i];
|
|
60
|
-
else if (a.startsWith('--')) die(`${USAGE} — unknown option '${a}'`);
|
|
61
|
-
else if (opts.input === null) opts.input = a;
|
|
62
|
-
else die(`${USAGE} — unexpected extra argument '${a}'`);
|
|
63
|
-
}
|
|
64
|
-
if (!opts.input) die(USAGE);
|
|
65
|
-
if (opts.refsTo === undefined || opts.file === undefined) die(`${USAGE} — --refs-to/--file require a value`);
|
|
66
|
-
if (opts.refsTo === '') die(`${USAGE} — --refs-to requires a non-empty token`);
|
|
67
|
-
const modes = [opts.refsTo !== null, opts.validate, opts.stale, opts.diagrams].filter(Boolean).length;
|
|
68
|
-
if (modes > 1) die(`${USAGE} — --refs-to/--validate/--stale/--diagrams are mutually exclusive`);
|
|
69
|
-
|
|
70
|
-
// --- input resolution -------------------------------------------------------
|
|
71
|
-
// The scope gate is the ONLY way in: the slug dir must be an immediate child of
|
|
72
|
-
// a `.archgen` segment (symlinks judged by their real target); everything below
|
|
73
|
-
// runs off the resolved absolute root.
|
|
74
|
-
let scoped;
|
|
75
|
-
try {
|
|
76
|
-
scoped = resolveSlugInput(opts.input);
|
|
77
|
-
} catch (e) {
|
|
78
|
-
if (e instanceof ScopeError) die(`doc-index: ${e.message}`);
|
|
79
|
-
die(`cannot read ${opts.input} (missing or not a directory)`);
|
|
80
|
-
}
|
|
81
|
-
const slugDir = scoped.root;
|
|
82
|
-
const repoRoot = dirname(slugDir); // the `.archgen` directory — output vocabulary stays root-relative
|
|
83
|
-
const slug = scoped.slug;
|
|
84
|
-
|
|
85
|
-
/** Repo-root-relative, slash-normalized path (never absolute in output). */
|
|
86
|
-
const relFromRoot = (abs) => relative(repoRoot, abs).split(sep).join('/');
|
|
87
|
-
const relFromSlug = (abs) => relative(slugDir, abs).split(sep).join('/');
|
|
88
|
-
|
|
89
|
-
// Optional --file scoping: accept repo-root-relative (the output vocabulary) or
|
|
90
|
-
// slug-relative paths; anything resolving outside the slug directory is refused.
|
|
91
|
-
// The hit is judged by real path so a symlink inside the slug cannot smuggle in
|
|
92
|
-
// out-of-scope content either.
|
|
93
|
-
let fileScope = null; // slug-relative posix path or null
|
|
94
|
-
if (opts.file !== null) {
|
|
95
|
-
const candidates = [join(repoRoot, opts.file), join(slugDir, opts.file)];
|
|
96
|
-
const hit = candidates.find((p) => { try { return statSync(p).isFile(); } catch { return false; } });
|
|
97
|
-
if (!hit) die(`cannot read --file ${opts.file} (missing or not a file)`);
|
|
98
|
-
let hitReal;
|
|
99
|
-
try { hitReal = realpathSync(hit); } catch { hitReal = resolve(hit); }
|
|
100
|
-
if (!hitReal.startsWith(slugDir + sep)) die(`--file ${opts.file} is outside ${opts.input}`);
|
|
101
|
-
fileScope = relFromSlug(hit);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// --- shared engine ----------------------------------------------------------
|
|
105
|
-
const MD_RE = /\.md$/i;
|
|
106
|
-
const YAML_RE = /\.ya?ml$/i;
|
|
107
|
-
|
|
108
|
-
/** Read text as UTF-8 with BOM stripped and CRLF/CR normalized to LF. */
|
|
109
|
-
function readText(abs) {
|
|
110
|
-
let t;
|
|
111
|
-
try { t = readFileSync(abs, 'utf8'); } catch { return null; }
|
|
112
|
-
if (t.charCodeAt(0) === 0xfeff) t = t.slice(1);
|
|
113
|
-
return t.replace(/\r\n?/g, '\n');
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Depth-first collect of files matching `filter`, dot-directories skipped,
|
|
117
|
-
* DEDUPLICATED by real path, sorted lexicographically by slug-relative path —
|
|
118
|
-
* the determinism backbone.
|
|
119
|
-
*
|
|
120
|
-
* Dedup contract (A3): every tracked document is indexed/emitted exactly once
|
|
121
|
-
* regardless of how many directory entries reach it. Candidates are keyed by
|
|
122
|
-
* fs.realpathSync, which collapses symlink aliases (a doc symlinked inside the
|
|
123
|
-
* slug pointing at another tracked doc) and case-colliding spellings on
|
|
124
|
-
* case-insensitive filesystems; `/./` noise never survives realpath either.
|
|
125
|
-
* The canonical spelling kept per group is the non-symlink entry when one
|
|
126
|
-
* exists (the file's own name beats an alias), else the lexicographically
|
|
127
|
-
* first candidate — deterministic under identical input, always.
|
|
128
|
-
* Dangling symlinks (realpath throws) are kept and keyed by their spelled
|
|
129
|
-
* path: they alias nothing, so they cannot be duplicates of anything.
|
|
130
|
-
*/
|
|
131
|
-
function walkFiles(filter) {
|
|
132
|
-
const candidates = [];
|
|
133
|
-
const stack = [''];
|
|
134
|
-
while (stack.length) {
|
|
135
|
-
const rel = stack.pop();
|
|
136
|
-
for (const e of readdirSync(rel ? join(slugDir, rel) : slugDir, { withFileTypes: true })) {
|
|
137
|
-
if (e.name.startsWith('.')) continue;
|
|
138
|
-
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
139
|
-
if (e.isDirectory()) stack.push(r);
|
|
140
|
-
else if (filter.test(e.name)) candidates.push(r);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
const chosen = new Map(); // real key -> { rel, isLink }
|
|
144
|
-
let duplicatesSkipped = 0;
|
|
145
|
-
for (const r of candidates.sort()) {
|
|
146
|
-
const abs = join(slugDir, r);
|
|
147
|
-
let key;
|
|
148
|
-
let isLink;
|
|
149
|
-
try {
|
|
150
|
-
key = realpathSync(abs);
|
|
151
|
-
isLink = key !== join(slugDir, r); // slugDir is already fully resolved (scope gate)
|
|
152
|
-
} catch {
|
|
153
|
-
key = resolve(abs); // dangling symlink: keep, keyed by spelled path
|
|
154
|
-
isLink = false;
|
|
155
|
-
}
|
|
156
|
-
const prev = chosen.get(key);
|
|
157
|
-
if (!prev) { chosen.set(key, { rel: r, isLink }); continue; }
|
|
158
|
-
duplicatesSkipped++;
|
|
159
|
-
if (prev.isLink && !isLink) chosen.set(key, { rel: r, isLink }); // real spelling wins over alias
|
|
160
|
-
}
|
|
161
|
-
return { files: [...chosen.values()].map((v) => v.rel).sort(), duplicatesSkipped };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Whole-word token matcher: a hit must not be glued to word characters on
|
|
165
|
-
// either side, but path punctuation (`/`, `.`, `-`) may border it — so
|
|
166
|
-
// "0001" finds "decisions/0001-postgres-over-mongo.md" while "TASK-05"
|
|
167
|
-
// still refuses to match "TASK-055" or "XTASK-05".
|
|
168
|
-
function wholeWordSource(token) {
|
|
169
|
-
const esc = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
170
|
-
return `(?<![A-Za-z0-9_])${esc}(?![A-Za-z0-9_])`;
|
|
171
|
-
}
|
|
172
|
-
const TASK_ID_SOURCE = '(?<![A-Za-z0-9_])TASK-\\d+(?![A-Za-z0-9_])';
|
|
173
|
-
const FR_ID_SOURCE = '(?<![A-Za-z0-9_])FR-[A-Z0-9]+-\\d+(?![A-Za-z0-9_])';
|
|
174
|
-
|
|
175
|
-
// Artifact-relative cross-references (artifact-templates.md rule 5): a slash-
|
|
176
|
-
// carrying path ending in a doc/yaml extension, e.g. `../architecture.yaml`.
|
|
177
|
-
// Bare same-dir names are only trusted inside explicit markdown links
|
|
178
|
-
// (`](c4-container.md)`) — prose mentions like "tasks.yaml" resolve
|
|
179
|
-
// file-relatively and would be false positives.
|
|
180
|
-
const ARTIFACT_PATH_RE = /(?<![A-Za-z0-9_./-])((?:\.\.?\/)?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\.(?:md|markdown|ya?ml))(?![A-Za-z0-9_/-])/g;
|
|
181
|
-
const MD_LINK_RE = /\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
182
|
-
const GENERATED_AT_RE = /generated_at:\s*(\S+)/;
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Parse one markdown document: ATX headings (H1-H3 reported, any level kept
|
|
186
|
-
* for diagram titles), fence bookkeeping, and artifact-path references.
|
|
187
|
-
* Headings and links ignore fenced code; line numbers are 1-based post-CRLF
|
|
188
|
-
* normalization.
|
|
189
|
-
*/
|
|
190
|
-
function parseMarkdown(text) {
|
|
191
|
-
const lines = text.split('\n');
|
|
192
|
-
const headings = [];
|
|
193
|
-
const allHeadings = [];
|
|
194
|
-
const fences = [];
|
|
195
|
-
const links = [];
|
|
196
|
-
let inFence = false;
|
|
197
|
-
for (let i = 0; i < lines.length; i++) {
|
|
198
|
-
const line = lines[i];
|
|
199
|
-
if (/^ {0,3}```/.test(line)) {
|
|
200
|
-
const info = line.replace(/^ {0,3}```/, '').trim();
|
|
201
|
-
// CommonMark pairing (nested-fence guard): inside an open fence only a
|
|
202
|
-
// BARE ``` closes it — an info-carrying ``` (e.g. a ```mermaid example
|
|
203
|
-
// quoted inside a ```text block) is CONTENT, never an opener. This keeps
|
|
204
|
-
// nested fences from producing phantom diagram entries or false titles.
|
|
205
|
-
if (inFence) {
|
|
206
|
-
if (info === '') { fences.push({ line: i + 1, info: null }); inFence = false; }
|
|
207
|
-
} else {
|
|
208
|
-
fences.push({ line: i + 1, info });
|
|
209
|
-
inFence = true;
|
|
210
|
-
}
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
if (inFence) continue;
|
|
214
|
-
const h = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
|
|
215
|
-
if (h) {
|
|
216
|
-
const entry = { level: h[1].length, text: h[2], line: i + 1 };
|
|
217
|
-
allHeadings.push(entry);
|
|
218
|
-
if (entry.level <= 3) headings.push(entry);
|
|
219
|
-
continue;
|
|
220
|
-
}
|
|
221
|
-
ARTIFACT_PATH_RE.lastIndex = 0;
|
|
222
|
-
let m;
|
|
223
|
-
while ((m = ARTIFACT_PATH_RE.exec(line)) !== null) links.push({ line: i + 1, target: m[1] });
|
|
224
|
-
MD_LINK_RE.lastIndex = 0;
|
|
225
|
-
let lm;
|
|
226
|
-
while ((lm = MD_LINK_RE.exec(line)) !== null) {
|
|
227
|
-
const t = lm[1];
|
|
228
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(t) || t.startsWith('#')) continue; // scheme'd or pure anchor
|
|
229
|
-
if (/\.(md|markdown|ya?ml)$/i.test(t.split('#')[0])) links.push({ line: i + 1, target: t });
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
// A target reachable by both extractors counts once.
|
|
233
|
-
const seen = new Set();
|
|
234
|
-
const uniqueLinks = links.filter((l) => {
|
|
235
|
-
const k = `${l.line}\u0000${l.target}`;
|
|
236
|
-
if (seen.has(k)) return false;
|
|
237
|
-
seen.add(k);
|
|
238
|
-
return true;
|
|
239
|
-
});
|
|
240
|
-
return { lines, headings, allHeadings, fences, links: uniqueLinks, balanced: fences.length % 2 === 0 };
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Every markdown doc under the slug (or just the --file scope), pre-parsed.
|
|
244
|
-
* Returns { docs, duplicatesSkipped } so overview can report collapsed aliases. */
|
|
245
|
-
function collectDocs() {
|
|
246
|
-
const { files, duplicatesSkipped } = walkFiles(MD_RE);
|
|
247
|
-
const docs = files
|
|
248
|
-
.filter((r) => fileScope === null || r === fileScope)
|
|
249
|
-
.map((r) => {
|
|
250
|
-
const abs = join(slugDir, r);
|
|
251
|
-
const text = readText(abs) ?? '';
|
|
252
|
-
return { rel: r, abs, ...parseMarkdown(text) };
|
|
253
|
-
});
|
|
254
|
-
return { docs, duplicatesSkipped };
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const wordCount = (lines) => lines.join(' ').split(/\s+/).filter(Boolean).length;
|
|
258
|
-
|
|
259
|
-
// --- modes ------------------------------------------------------------------
|
|
260
|
-
if (opts.validate || opts.stale) {
|
|
261
|
-
// Both gated modes need tasks.yaml: validate cross-checks its ids, stale
|
|
262
|
-
// derives the freshness threshold from it. Absent/unparseable = bad input.
|
|
263
|
-
const tasksRel = 'tasks.yaml';
|
|
264
|
-
const tasksAbs = join(slugDir, tasksRel);
|
|
265
|
-
let raw;
|
|
266
|
-
try { raw = readFileSync(tasksAbs, 'utf8'); } catch { die(`cannot read ${tasksRel} in ${opts.input} (missing or unreadable)`); }
|
|
267
|
-
let taskIds = new Set();
|
|
268
|
-
let tasks = null; // hoisted: the validate branch's structural gate needs it
|
|
269
|
-
if (opts.validate) {
|
|
270
|
-
let data;
|
|
271
|
-
try { ({ data } = parseYaml(raw, { filename: tasksRel })); } catch (e) {
|
|
272
|
-
die(`${tasksRel}: unparseable YAML (${String(e?.message ?? e).replace(/^[^:]*:\d+:\s*/, '')})`);
|
|
273
|
-
}
|
|
274
|
-
tasks = data?.tasks;
|
|
275
|
-
if (!Array.isArray(tasks)) die(`${tasksRel}: missing 'tasks:' sequence`);
|
|
276
|
-
taskIds = new Set(tasks.map((t) => String(t?.id)));
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
if (opts.stale) {
|
|
280
|
-
// Freshness stamp: the file's `generated_at:` provenance comment when
|
|
281
|
-
// present, else its filesystem mtime. Threshold = max(tasks.yaml
|
|
282
|
-
// generated_at, tasks.yaml mtime) — the newest claim tasks.yaml makes.
|
|
283
|
-
const stampOf = (abs) => {
|
|
284
|
-
const text = readText(abs);
|
|
285
|
-
if (text) {
|
|
286
|
-
for (const line of text.split('\n')) {
|
|
287
|
-
const m = GENERATED_AT_RE.exec(line);
|
|
288
|
-
if (m && Number.isFinite(Date.parse(m[1]))) return m[1];
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
return new Date(statSync(abs).mtimeMs).toISOString();
|
|
292
|
-
};
|
|
293
|
-
const threshold = Math.max(
|
|
294
|
-
(() => { const m = GENERATED_AT_RE.exec(raw); const t = m ? Date.parse(m[1]) : NaN; return Number.isFinite(t) ? t : -Infinity; })(),
|
|
295
|
-
statSync(tasksAbs).mtimeMs,
|
|
296
|
-
);
|
|
297
|
-
const stale = walkFiles(/\.md$|\.ya?ml$/i)
|
|
298
|
-
.files
|
|
299
|
-
.filter((r) => r !== tasksRel && (fileScope === null || r === fileScope))
|
|
300
|
-
.map((r) => ({ file: relFromRoot(join(slugDir, r)), generatedAt: stampOf(join(slugDir, r)) }))
|
|
301
|
-
.filter((e) => Date.parse(e.generatedAt) < threshold);
|
|
302
|
-
console.log(JSON.stringify({ newestSource: 'tasks.yaml', stale }, null, 2));
|
|
303
|
-
} else {
|
|
304
|
-
// Structural gate first: a tasks.yaml with duplicate task ids or dangling
|
|
305
|
-
// depends_on is unexecutable — surface it as exit 2 (GraphError class),
|
|
306
|
-
// naming the offending id, before any reference scanning.
|
|
307
|
-
try {
|
|
308
|
-
buildGraph(tasks);
|
|
309
|
-
} catch (e) {
|
|
310
|
-
if (e instanceof GraphError) die(e.message, 2);
|
|
311
|
-
throw e;
|
|
312
|
-
}
|
|
313
|
-
// --validate: five broken classes — unknown TASK-nn, unresolved FR-id,
|
|
314
|
-
// dangling artifact link, unbalanced (odd) mermaid/code fences, and
|
|
315
|
-
// 'dup-definition' (the same FR id defined on 2+ lines inside prd.md is
|
|
316
|
-
// ambiguity, and highest-quality data forbids silently picking one).
|
|
317
|
-
// Plus non-fatal `warnings` (heading-level sanity) that never move `ok`
|
|
318
|
-
// or the exit code — only the verifier owns pass/fail.
|
|
319
|
-
//
|
|
320
|
-
// Counting semantics: `checked.*` counts UNIQUE refs; every entry in
|
|
321
|
-
// `broken[]` appears exactly ONCE, at its first occurrence line. (--refs-to
|
|
322
|
-
// intentionally lists EVERY occurrence line instead — lens vs gate.)
|
|
323
|
-
const { docs } = collectDocs();
|
|
324
|
-
const broken = [];
|
|
325
|
-
const warnings = [];
|
|
326
|
-
const checked = { taskRefs: 0, frRefs: 0, links: 0, mermaidFences: 0 };
|
|
327
|
-
|
|
328
|
-
// ref -> { n, first } : occurrence COUNT plus FIRST site (docs arrive
|
|
329
|
-
// path-sorted and lines ascend, so `first` is deterministic).
|
|
330
|
-
const taskOcc = new Map();
|
|
331
|
-
const frDefOcc = new Map(); // occurrences inside docs/prd.md define
|
|
332
|
-
const frRefOcc = new Map(); // everywhere else references
|
|
333
|
-
const PRD_REL = 'docs/prd.md';
|
|
334
|
-
for (const doc of docs) {
|
|
335
|
-
const isPrd = doc.rel === PRD_REL;
|
|
336
|
-
const bump = (sink, ref, at) => {
|
|
337
|
-
const e = sink.get(ref);
|
|
338
|
-
if (e) e.n++;
|
|
339
|
-
else sink.set(ref, { n: 1, first: at });
|
|
340
|
-
};
|
|
341
|
-
const scan = (source, sink) => {
|
|
342
|
-
const re = new RegExp(source, 'g');
|
|
343
|
-
for (let i = 0; i < doc.lines.length; i++) {
|
|
344
|
-
re.lastIndex = 0;
|
|
345
|
-
let m;
|
|
346
|
-
while ((m = re.exec(doc.lines[i])) !== null) bump(sink, m[0], { file: relFromRoot(doc.abs), line: i + 1 });
|
|
347
|
-
}
|
|
348
|
-
};
|
|
349
|
-
scan(TASK_ID_SOURCE, taskOcc);
|
|
350
|
-
scan(FR_ID_SOURCE, isPrd ? frDefOcc : frRefOcc);
|
|
351
|
-
checked.taskRefs = taskOcc.size;
|
|
352
|
-
checked.frRefs = frRefOcc.size;
|
|
353
|
-
|
|
354
|
-
// Links: dedup by (file, target) — the same link repeated in one file is
|
|
355
|
-
// ONE ref: counted once in checked.links, reported once if broken.
|
|
356
|
-
const linkSeen = new Set();
|
|
357
|
-
for (const link of doc.links) {
|
|
358
|
-
const lkey = `${doc.rel}\u0000${link.target}`;
|
|
359
|
-
if (linkSeen.has(lkey)) continue;
|
|
360
|
-
linkSeen.add(lkey);
|
|
361
|
-
checked.links++;
|
|
362
|
-
const target = link.target.split('#')[0];
|
|
363
|
-
if (target && !existsSync(resolve(dirname(doc.abs), target))) {
|
|
364
|
-
broken.push({ kind: 'link', ref: link.target, file: relFromRoot(doc.abs), line: link.line });
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
checked.mermaidFences += doc.fences.length;
|
|
369
|
-
if (!doc.balanced) {
|
|
370
|
-
broken.push({
|
|
371
|
-
kind: 'mermaid',
|
|
372
|
-
ref: `unbalanced fences (${doc.fences.length})`,
|
|
373
|
-
file: relFromRoot(doc.abs),
|
|
374
|
-
line: doc.fences[doc.fences.length - 1].line,
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Cheap heading-level sanity: an H3 with no preceding H2 in the same
|
|
379
|
-
// file signals a skipped structural level (H1 titles don't count as
|
|
380
|
-
// section context). Warning only — never fatal.
|
|
381
|
-
let seenH2 = false;
|
|
382
|
-
for (const h of doc.headings) {
|
|
383
|
-
if (h.level === 2) seenH2 = true;
|
|
384
|
-
else if (h.level === 3 && !seenH2) warnings.push({ kind: 'heading-skip', file: relFromRoot(doc.abs), line: h.line });
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
for (const [ref, occ] of taskOcc) if (!taskIds.has(ref)) broken.push({ kind: 'task', ref, ...occ.first });
|
|
388
|
-
for (const [ref, occ] of frRefOcc) if (!frDefOcc.has(ref)) broken.push({ kind: 'fr', ref, ...occ.first });
|
|
389
|
-
for (const [ref, occ] of frDefOcc) if (occ.n > 1) broken.push({ kind: 'dup-definition', ref, ...occ.first });
|
|
390
|
-
broken.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line || (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : a.ref < b.ref ? -1 : 1)));
|
|
391
|
-
warnings.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
|
|
392
|
-
const ok = broken.length === 0;
|
|
393
|
-
console.log(JSON.stringify({ ok, checked, broken, warnings }, null, 2));
|
|
394
|
-
process.exit(ok ? 0 : 1);
|
|
395
|
-
}
|
|
396
|
-
} else if (opts.refsTo !== null) {
|
|
397
|
-
// Backlink query: any line in any markdown artifact containing the token as
|
|
398
|
-
// a whole word — TASK-nn, FR-<MODULE>-nn, ADR NNNN[-slug], or a relative
|
|
399
|
-
// artifact path alike; the matcher is uniform by design.
|
|
400
|
-
// INTENTIONAL asymmetry vs --validate: this lens lists EVERY occurrence
|
|
401
|
-
// line (a "where is this mentioned" report), while --validate reports each
|
|
402
|
-
// unique broken ref once at its first occurrence. Do not "dedup" this mode.
|
|
403
|
-
const re = new RegExp(wholeWordSource(opts.refsTo));
|
|
404
|
-
const matches = [];
|
|
405
|
-
for (const doc of collectDocs().docs) {
|
|
406
|
-
for (let i = 0; i < doc.lines.length; i++) {
|
|
407
|
-
if (re.test(doc.lines[i])) matches.push({ file: relFromRoot(doc.abs), line: i + 1, text: doc.lines[i].trim() });
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
console.log(JSON.stringify({ ref: opts.refsTo, matches }, null, 2));
|
|
411
|
-
} else if (opts.diagrams) {
|
|
412
|
-
const diagrams = [];
|
|
413
|
-
for (const doc of collectDocs().docs) {
|
|
414
|
-
for (let k = 0; k < doc.fences.length; k++) {
|
|
415
|
-
const f = doc.fences[k];
|
|
416
|
-
if (f.info !== 'mermaid') continue;
|
|
417
|
-
const closer = doc.fences[k + 1]; // fence toggling pairs opens with closes
|
|
418
|
-
const endLine = closer ? closer.line : doc.lines.length + 1;
|
|
419
|
-
const body = doc.lines.slice(f.line, endLine - 1);
|
|
420
|
-
const comment = body.map((l) => /^\s*%%\s?(.*)$/.exec(l)).find(Boolean);
|
|
421
|
-
const heading = [...doc.allHeadings].reverse().find((h) => h.line < f.line);
|
|
422
|
-
diagrams.push({
|
|
423
|
-
file: relFromRoot(doc.abs),
|
|
424
|
-
startLine: f.line,
|
|
425
|
-
title: heading ? heading.text : comment ? comment[1].trim() : null,
|
|
426
|
-
nodeCount: body.filter((l) => /^\s*[A-Za-z0-9_]+[[{(]/.test(l)).length,
|
|
427
|
-
balanced: Boolean(closer),
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
console.log(JSON.stringify({ diagrams }, null, 2));
|
|
432
|
-
} else {
|
|
433
|
-
// Overview (scale guard): the deduped inventory is always computed in full
|
|
434
|
-
// and sorted, but only the FIRST 100 files are emitted — with
|
|
435
|
-
// `truncated:true` whenever anything was cut — so agents reading the JSON
|
|
436
|
-
// are never silently surprised by deterministic truncation. totalFiles
|
|
437
|
-
// reports the true post-dedup count either way.
|
|
438
|
-
const { docs, duplicatesSkipped } = collectDocs();
|
|
439
|
-
const allFiles = docs.map((doc) => ({
|
|
440
|
-
path: relFromRoot(doc.abs),
|
|
441
|
-
title: doc.headings.find((h) => h.level === 1)?.text ?? basename(doc.rel),
|
|
442
|
-
headings: doc.headings,
|
|
443
|
-
wordCount: wordCount(doc.lines),
|
|
444
|
-
}));
|
|
445
|
-
const truncated = allFiles.length > OVERVIEW_MAX_FILES;
|
|
446
|
-
const files = truncated ? allFiles.slice(0, OVERVIEW_MAX_FILES) : allFiles;
|
|
447
|
-
console.log(JSON.stringify({ slug, files, totalFiles: allFiles.length, duplicatesSkipped, truncated }, null, 2));
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// ---------------------------------------------------------------------------
|
|
451
|
-
// WHY doc-index.mjs EXISTS: the codegraph-family indexers our users already run
|
|
452
|
-
// verifiably do not cover this ground — audited 8 tools, Aug 2026: none extract
|
|
453
|
-
// markdown headings or YAML task semantics (installed codegraph v1.0.1 indexes
|
|
454
|
-
// neither .md nor .yaml), so everything archgen writes into .archgen/<slug>/ is
|
|
455
|
-
// invisible to every symbol graph. This script is archgen's deterministic
|
|
456
|
-
// markdown layer: zero dependencies, byte-stable JSON, safe to gate CI on.
|
|
457
|
-
// ---------------------------------------------------------------------------
|