archgen-skill 0.0.3 → 0.0.5
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 +31 -33
- package/bin/archgen.mjs +31 -1
- package/lib/block.js +147 -16
- package/lib/config.js +96 -0
- package/lib/doctor.js +144 -28
- package/lib/init.js +14 -3
- package/lib/install.js +124 -16
- package/lib/migrate.js +88 -0
- package/lib/migrations/001-stamp-provenance.mjs +77 -0
- package/lib/migrations/index.mjs +170 -0
- package/lib/restore.js +239 -0
- package/lib/store.js +125 -15
- package/lib/uninstall-project.js +6 -3
- package/lib/version-stamp.js +49 -0
- package/package.json +39 -10
- package/vendor/skills/archgen/SKILL.md +218 -145
- package/vendor/skills/archgen/archgen.config.json +16 -0
- package/vendor/skills/archgen/assets/skill-registry.json +4 -3
- package/vendor/skills/archgen/references/artifact-templates.md +308 -7
- package/vendor/skills/archgen/references/interview.md +226 -17
- package/vendor/skills/archgen/references/mcp-and-skills.md +88 -17
- package/vendor/skills/archgen/references/orchestration.md +230 -44
- package/vendor/skills/archgen/references/platforms.md +56 -14
- package/vendor/skills/archgen/scripts/doc-index.mjs +457 -0
- package/vendor/skills/archgen/scripts/impact.mjs +14 -1
- package/vendor/skills/archgen/scripts/lib/graph.mjs +99 -5
- package/vendor/skills/archgen/scripts/lib/scope.mjs +65 -0
- package/vendor/skills/archgen/scripts/lib/yaml.mjs +19 -3
- package/vendor/skills/archgen/scripts/plan-graph.mjs +329 -0
- package/vendor/skills/archgen/scripts/set-status.mjs +51 -9
- package/vendor/skills/archgen/scripts/update-agents.mjs +16 -5
- package/vendor/skills/archgen/scripts/validate.mjs +18 -1
- package/vendor/skills/archgen/scripts/verify-plan.mjs +8 -3
|
@@ -0,0 +1,457 @@
|
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
@@ -8,7 +8,20 @@ import { parseYaml } from './lib/yaml.mjs';
|
|
|
8
8
|
const [, , file, target] = process.argv;
|
|
9
9
|
if (!file || !target) { console.error('usage: impact.mjs <tasks.yaml> <taskId-or-artifactPath>'); process.exit(4); }
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
let raw;
|
|
12
|
+
try {
|
|
13
|
+
raw = readFileSync(file, 'utf8');
|
|
14
|
+
} catch {
|
|
15
|
+
console.error(`cannot read ${file} (missing or unreadable)`);
|
|
16
|
+
process.exit(4);
|
|
17
|
+
}
|
|
18
|
+
let data;
|
|
19
|
+
try {
|
|
20
|
+
({ data } = parseYaml(raw, { filename: file }));
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.error(`${file}: unparseable YAML (${String(e?.message ?? e).replace(/^[^:]*:\d+:\s*/, '')})`);
|
|
23
|
+
process.exit(4);
|
|
24
|
+
}
|
|
12
25
|
const tasks = data?.tasks;
|
|
13
26
|
if (!Array.isArray(tasks)) { console.error(`${file}: missing 'tasks:' sequence`); process.exit(4); }
|
|
14
27
|
|
|
@@ -2,16 +2,26 @@
|
|
|
2
2
|
// WHY a lib: validate.mjs, next-tasks.mjs and verify-plan.mjs must agree on
|
|
3
3
|
// cycle detection and wave semantics; three copies would drift (Metis finding).
|
|
4
4
|
|
|
5
|
+
/** True iff `t` is a mapping object (not null, not an array, not a scalar). */
|
|
6
|
+
function isTaskMapping(t) {
|
|
7
|
+
return t !== null && typeof t === 'object' && !Array.isArray(t);
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
/** Build adjacency (prerequisite → dependents) + reverse maps from tasks.
|
|
6
11
|
* Normative semantics: depends_on lists PREREQUISITE ids; a task is ready iff
|
|
7
|
-
* every prerequisite is done; wave 1 = empty depends_on.
|
|
12
|
+
* every prerequisite is done; wave 1 = empty depends_on.
|
|
13
|
+
* Entries must be mappings with a non-empty string id — anything else is a
|
|
14
|
+
* GraphError naming the index, so consumers exit with a clean contract code
|
|
15
|
+
* instead of an uncaught TypeError on malformed tasks.yaml shapes. */
|
|
8
16
|
export function buildGraph(tasks) {
|
|
9
17
|
/** @type {Map<string, string[]>} prereq -> [dependent ids] */
|
|
10
18
|
const dependents = new Map();
|
|
11
19
|
/** @type {Map<string, string[]>} id -> prerequisite ids */
|
|
12
20
|
const prerequisites = new Map();
|
|
13
21
|
const byId = new Map();
|
|
14
|
-
for (const t of tasks) {
|
|
22
|
+
for (const [i, t] of tasks.entries()) {
|
|
23
|
+
if (!isTaskMapping(t)) throw new GraphError(`tasks[${i}]: not a mapping (task entries must be 'key: value' mappings)`);
|
|
24
|
+
if (typeof t.id !== 'string' || t.id.length === 0) throw new GraphError(`tasks[${i}]: missing string 'id' (every task needs a non-empty string id)`);
|
|
15
25
|
if (byId.has(t.id)) throw new GraphError(`duplicate task id: ${t.id}`);
|
|
16
26
|
byId.set(t.id, t);
|
|
17
27
|
prerequisites.set(t.id, Array.isArray(t.depends_on) ? t.depends_on : []);
|
|
@@ -113,16 +123,100 @@ export function computeWaves(byId, prerequisites) {
|
|
|
113
123
|
}
|
|
114
124
|
|
|
115
125
|
/** First ownership conflict inside a single wave, or null.
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
126
|
+
* Two rules, both conservative:
|
|
127
|
+
* 1. Literal equality — two tasks must never claim the SAME glob string.
|
|
128
|
+
* 2. Glob intersection — when a '**' is involved, patterns whose non-glob
|
|
129
|
+
* prefix segments overlap compatibly are flagged (e.g. "src/**" vs a
|
|
130
|
+
* TypeScript-scoped variant of it). When segment comparison is uncertain
|
|
131
|
+
* (wildcards), the pair IS flagged: false positives merely force a rename;
|
|
132
|
+
* false negatives let parallel workers corrupt each other's files.
|
|
133
|
+
*/
|
|
119
134
|
export function findOwnershipConflict(wave) {
|
|
120
135
|
const seen = new Map();
|
|
136
|
+
const claimed = [];
|
|
121
137
|
for (const t of wave) {
|
|
122
138
|
for (const g of t.file_ownership ?? []) {
|
|
123
139
|
if (seen.has(g)) return { glob: g, a: seen.get(g), b: t.id };
|
|
140
|
+
for (const prev of claimed) {
|
|
141
|
+
if (globsMayOverlap(prev.glob, g)) return { glob: g, a: prev.id, b: t.id };
|
|
142
|
+
}
|
|
124
143
|
seen.set(g, t.id);
|
|
144
|
+
claimed.push({ glob: g, id: t.id });
|
|
125
145
|
}
|
|
126
146
|
}
|
|
127
147
|
return null;
|
|
128
148
|
}
|
|
149
|
+
|
|
150
|
+
/** Conservative may-intersect test for two ownership globs. */
|
|
151
|
+
function globsMayOverlap(a, b) {
|
|
152
|
+
if (a === b) return true;
|
|
153
|
+
// Deepen the check only when a recursive '**' is involved; plain literal
|
|
154
|
+
// globs keep the cheap equality rule above.
|
|
155
|
+
if (!a.includes('**') && !b.includes('**')) return false;
|
|
156
|
+
const as = a.split('/');
|
|
157
|
+
const bs = b.split('/');
|
|
158
|
+
const n = Math.min(as.length, bs.length);
|
|
159
|
+
for (let i = 0; i < n; i++) {
|
|
160
|
+
const x = as[i];
|
|
161
|
+
const y = bs[i];
|
|
162
|
+
if (x === y) continue;
|
|
163
|
+
if (x === '**' || y === '**') return true; // '**' swallows any divergence below it
|
|
164
|
+
if (x.includes('*') || y.includes('*') || x.includes('?') || y.includes('?')) return true; // uncertain -> flag
|
|
165
|
+
return false; // both segments literal and different: paths diverge here
|
|
166
|
+
}
|
|
167
|
+
return true; // one pattern is a segment-prefix of the other
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- additive (hardening pass): dedup normalization + quality surfacing -----
|
|
171
|
+
|
|
172
|
+
/** Collapse redundant depends_on entries BEFORE graph construction so no
|
|
173
|
+
* downstream consumer can ever see the same edge twice (idempotency: identical
|
|
174
|
+
* input ⇒ byte-identical output with zero duplicated entities).
|
|
175
|
+
* First occurrence wins; order otherwise preserved. Tasks without an array
|
|
176
|
+
* depends_on pass through untouched.
|
|
177
|
+
* @param {Array<any>} tasks
|
|
178
|
+
* @returns {{tasks: Array<any>, duplicatesCollapsed: number}} */
|
|
179
|
+
export function dedupeDependencies(tasks) {
|
|
180
|
+
let duplicatesCollapsed = 0;
|
|
181
|
+
const normalized = tasks.map((t) => {
|
|
182
|
+
// Malformed entries pass through untouched; buildGraph owns reporting them
|
|
183
|
+
// (touching t.depends_on here would throw before the contract check runs).
|
|
184
|
+
if (!isTaskMapping(t) || !Array.isArray(t.depends_on)) return t;
|
|
185
|
+
const seen = new Set();
|
|
186
|
+
const deps = [];
|
|
187
|
+
for (const d of t.depends_on) {
|
|
188
|
+
if (seen.has(d)) { duplicatesCollapsed++; continue; }
|
|
189
|
+
seen.add(d);
|
|
190
|
+
deps.push(d);
|
|
191
|
+
}
|
|
192
|
+
return deps.length === t.depends_on.length ? t : { ...t, depends_on: deps };
|
|
193
|
+
});
|
|
194
|
+
return { tasks: normalized, duplicatesCollapsed };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Data-quality facts computed from parsed data WITHOUT judging them — the
|
|
198
|
+
* verifier owns pass/fail; indexers only surface facts deterministically.
|
|
199
|
+
* - selfDeps: tasks whose depends_on includes their own id (note: such a task
|
|
200
|
+
* is also a 1-node cycle, so the CLI cycle gate normally exits 2 first;
|
|
201
|
+
* the field keeps the shape uniform for lib-level consumers).
|
|
202
|
+
* - emptyOwnership: missing or empty file_ownership (parallel workers would
|
|
203
|
+
* have no disjointness contract).
|
|
204
|
+
* - blankAcceptance: missing or empty acceptance (no objective done-criteria).
|
|
205
|
+
* @param {Array<any>} tasks
|
|
206
|
+
* @returns {{selfDeps: number, emptyOwnership: number, blankAcceptance: number}} */
|
|
207
|
+
export function computeQualityStats(tasks) {
|
|
208
|
+
let selfDeps = 0;
|
|
209
|
+
let emptyOwnership = 0;
|
|
210
|
+
let blankAcceptance = 0;
|
|
211
|
+
for (const t of tasks) {
|
|
212
|
+
// Malformed entries are buildGraph's to report; counting them here would
|
|
213
|
+
// throw on property access before any consumer sees a contract error.
|
|
214
|
+
if (!isTaskMapping(t)) continue;
|
|
215
|
+
if (Array.isArray(t.depends_on) && t.depends_on.includes(t.id)) selfDeps++;
|
|
216
|
+
const own = t.file_ownership;
|
|
217
|
+
if (!Array.isArray(own) || own.length === 0) emptyOwnership++;
|
|
218
|
+
const acc = t.acceptance;
|
|
219
|
+
if (!Array.isArray(acc) || acc.length === 0) blankAcceptance++;
|
|
220
|
+
}
|
|
221
|
+
return { selfDeps, emptyOwnership, blankAcceptance };
|
|
222
|
+
}
|