@ngockhoale/ukit 2.6.6 → 2.6.8

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +40 -177
  3. package/manifests/documentation.yaml +143 -15
  4. package/manifests/hostCapabilities.yaml +49 -0
  5. package/manifests/instructionRules.yaml +383 -0
  6. package/manifests/platform.full.yaml +15 -0
  7. package/package.json +3 -1
  8. package/scripts/bench/goldTasks.json +38 -0
  9. package/scripts/bench/runGold.mjs +220 -0
  10. package/scripts/docs/render-instructions.mjs +42 -0
  11. package/scripts/release/verify-release.mjs +6 -0
  12. package/src/cli/commands/code.js +182 -0
  13. package/src/cli/commands/doctor.js +35 -3
  14. package/src/cli/commands/indexTools.js +102 -1
  15. package/src/cli/commands/memory.js +137 -0
  16. package/src/cli/index.js +7 -0
  17. package/src/core/codeintel/compiler.js +316 -0
  18. package/src/core/codeintel/diagnostics.js +114 -0
  19. package/src/core/codeintel/freshness.js +295 -0
  20. package/src/core/codeintel/impact.js +251 -0
  21. package/src/core/codeintel/invalidation.js +150 -0
  22. package/src/core/codeintel/manifest.js +176 -0
  23. package/src/core/codeintel/packet.js +146 -0
  24. package/src/core/codeintel/providers.js +201 -0
  25. package/src/core/codeintel/retriever.js +372 -0
  26. package/src/core/codeintel/router.js +149 -0
  27. package/src/core/codeintel/semanticProvider.js +235 -0
  28. package/src/core/docContracts.js +723 -0
  29. package/src/core/memory/migrate.js +324 -0
  30. package/src/core/memory/records.js +172 -0
  31. package/src/core/memory/retrieval.js +161 -11
  32. package/src/core/memory/store.js +398 -0
  33. package/src/core/memory/storeV2.js +171 -0
  34. package/src/core/memory/storeV2Loader.js +22 -0
  35. package/src/core/projectImportant.js +1 -1
  36. package/src/core/runtimeConfig.js +125 -0
  37. package/src/core/runtimePaths.js +3 -0
  38. package/src/core/uninstall.js +1 -1
  39. package/src/index/taskRouting.js +39 -0
  40. package/src/render/instructionRenderer.js +226 -0
  41. package/templates/.claude/ukit/index/route-task.mjs +40 -0
  42. package/templates/.gitignore +2 -2
  43. package/templates/.omp/RULES.md +1 -0
  44. package/templates/AGENTS.md +89 -218
  45. package/templates/CLAUDE.md +85 -212
  46. package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +5 -0
  47. package/templates/docs/BUGFIX.md +2 -19
  48. package/templates/docs/BUG_INDEX.md +43 -0
  49. package/templates/docs/BUG_METRICS.md +1 -5
  50. package/templates/docs/BUG_TEMPLATE.md +1 -11
  51. package/templates/docs/UKIT_INTERNALS.md +223 -0
  52. package/templates/instructions/core.md +157 -0
  53. package/templates/instructions/layout.yaml +149 -0
  54. package/templates/instructions/overlays/agents.md +15 -0
  55. package/templates/instructions/overlays/claude.md +3 -0
  56. package/templates/instructions/overlays/omp-rules.md +74 -0
  57. package/templates/instructions/overlays/repo.md +9 -0
  58. package/templates/instructions/repo-vars.yaml +23 -0
  59. package/templates/ukit/storage/config.json +30 -0
@@ -0,0 +1,723 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import YAML from 'yaml';
4
+
5
+ /**
6
+ * Doc contract validator (DOC-203, SPEC FR-005).
7
+ *
8
+ * `runDocContractChecks({ rootDir, scope? })` runs the seven v1 rules —
9
+ * links, stale-version, render-vars, commands, budgets, registry, templates —
10
+ * and returns a machine-readable report. It NEVER throws: unreadable input
11
+ * becomes a failed or skipped check with detail. TASK-210 wires this into
12
+ * `ukit doctor --docs`.
13
+ *
14
+ * CheckResult = { id, label, severity:'error'|'warning', passed:boolean,
15
+ * skipped?:boolean, detail?:string, remedy?:string }
16
+ * → { checks: CheckResult[], summary: { errors, warnings, passed, skipped } }
17
+ */
18
+
19
+ const REGISTRY_PATH = 'manifests/documentation.yaml';
20
+ const PACKAGE_PATH = 'package.json';
21
+ const MD_LINK_RE = /\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
22
+ const VERSION_LITERAL_RE = /v\d+\.\d+\.\d+/g;
23
+ const RENDER_VAR_RE = /\{\{[\s\S]*?\}\}/g;
24
+ const COMMAND_REF_RE = /\.claude\/commands\/ukit\/([A-Za-z0-9._-]+\.md)/g;
25
+ const EXECUTOR_FIELDS = ['EXECUTOR_TOOL', 'EXECUTOR_MODEL', 'EXECUTOR_SUBAGENT', 'RED_OUTPUT'];
26
+ // DOC-304: a registered doc untouched for this many days counts as `stale`.
27
+ export const ARCHIVE_STALE_DAYS = 180;
28
+
29
+ // Registry schema enums — mirrored from tests/consistency/docRegistry.test.js
30
+ // (TASK-209 interface: mirror, do not import, to avoid a test→src cycle).
31
+ const CLASSES = ['owner', 'canonical', 'generated', 'runtime', 'derived', 'archive'];
32
+ const AUDIENCES = ['agent', 'maintainer', 'user', 'owner', 'contributor'];
33
+ const OWNERS = ['product', 'runtime', 'adapter', 'security', 'release', 'project-owner'];
34
+ const MERGE_STRATEGIES = ['skip', 'overwrite_with_backup', 'merge_env_overwrite_with_backup', 'none'];
35
+ const LOAD_POLICIES = ['always', 'on-demand', 'task-routed', 'continuation-only', 'never'];
36
+ const ARCHIVE_POLICIES = ['never', 'replace-with-snapshot', 'date-rotate', 'immutable', 'owner-decides'];
37
+ const BUDGET_ENFORCEMENTS = ['none', 'warning', 'error'];
38
+ const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
39
+
40
+ // Files that legitimately pin a vX.Y.Z literal — mirrored from
41
+ // tests/consistency/staleVersionProse.test.js (same justification rule:
42
+ // every entry MUST state why the prose is allowed to pin a version).
43
+ const STALE_VERSION_ALLOWLIST = {
44
+ 'CLAUDE.md':
45
+ 'repo-rendered instructions embed {{runtime.nodeVersion}} resolved to the dev Node (e.g. v22.22.1) — environment fact, not a UKit release pin',
46
+ 'AGENTS.md':
47
+ 'same interpolated runtime.nodeVersion — environment fact, not a UKit release pin',
48
+ 'docs/WORKLOG_ARCHIVE.md':
49
+ 'worklog history records past versions as release facts — immutable historical prose',
50
+ 'templates/.gitignore':
51
+ 'comment records the release that removed a legacy adapter — historical fact',
52
+ 'templates/.claude/agents/code-reviewer.md':
53
+ 'rubric row quotes the literal "v1.5.5+ contract" reviewer refusal wording — contractual string',
54
+ 'templates/.omp/agents/code-reviewer.md':
55
+ 'omp mirror of the same reviewer rubric row — contractual string',
56
+ 'templates/.claude/ukit/index/unic-gateway.mjs':
57
+ 'code comments record the omp version gateway detection was verified against — evidence',
58
+ 'templates/.omp/hooks/pre/ukit-bridge.js':
59
+ 'code comments record omp versions verified during source reads — evidence',
60
+ 'templates/.omp/config.yml':
61
+ 'comment records the omp version the config keys were verified against — evidence',
62
+ };
63
+
64
+ // Files covered by stale-version + render-vars beyond `templates/` generated
65
+ // surfaces: the repo-rendered root instructions (registry class is `canonical`
66
+ // because they are also the project-owner file) and derived mirrors.
67
+ const RENDERED_EXTRA_TARGETS = ['CLAUDE.md', 'AGENTS.md'];
68
+
69
+ async function readText(absPath) {
70
+ try {
71
+ return await fs.readFile(absPath, 'utf8');
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ async function existsPath(absPath) {
78
+ try {
79
+ await fs.stat(absPath);
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ async function isDir(absPath) {
87
+ try {
88
+ return (await fs.stat(absPath)).isDirectory();
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ // Recursive repo-relative file walk under rootDir/dirRel. `filter` selects
95
+ // files by extension etc. Never throws (unreadable dirs are skipped).
96
+ async function walkFiles(rootDir, dirRel, filter = () => true) {
97
+ const out = [];
98
+ const dirAbs = dirRel ? path.join(rootDir, dirRel) : rootDir;
99
+ let names;
100
+ try {
101
+ names = await fs.readdir(dirAbs);
102
+ } catch {
103
+ return out;
104
+ }
105
+ for (const name of names) {
106
+ const rel = dirRel ? `${dirRel}/${name}` : name;
107
+ let st;
108
+ try {
109
+ st = await fs.stat(path.join(rootDir, rel));
110
+ } catch {
111
+ continue; // dangling symlink / unreadable
112
+ }
113
+ if (st.isDirectory()) out.push(...(await walkFiles(rootDir, rel, filter)));
114
+ else if (filter(name, rel)) out.push(rel);
115
+ }
116
+ return out;
117
+ }
118
+
119
+ function makeCheck(id, label, severity, extra) {
120
+ return { id, label, severity, passed: false, ...extra };
121
+ }
122
+
123
+ function skippedCheck(id, label, detail) {
124
+ return makeCheck(id, label, 'error', {
125
+ skipped: true,
126
+ detail,
127
+ remedy: `create or restore ${detail.match(/[\w./-]+\.\w+/)?.[0] || 'the required file'}`,
128
+ });
129
+ }
130
+
131
+ // ---- registry validation (mirrors docRegistry.test.js validateRegistry) ----
132
+
133
+ function validateRegistryDoc(doc, fileExists) {
134
+ const problems = [];
135
+ if (!doc || typeof doc !== 'object') return ['registry is not an object'];
136
+ if (doc.version !== 1) problems.push('version must be 1');
137
+ if (doc.generated !== false) problems.push('generated must be false');
138
+ if (!Array.isArray(doc.entries) || doc.entries.length === 0) {
139
+ problems.push('entries must be a non-empty array');
140
+ return problems;
141
+ }
142
+ const seen = new Map();
143
+ doc.entries.forEach((e, i) => {
144
+ const tag = `entry[${i}] (${e && e.path ? e.path : 'no-path'})`;
145
+ if (!e || typeof e !== 'object') {
146
+ problems.push(`${tag}: not an object`);
147
+ return;
148
+ }
149
+ if (typeof e.id !== 'string' || !KEBAB.test(e.id)) problems.push(`${tag}: id missing/not kebab-case`);
150
+ if (typeof e.path !== 'string' || e.path.length === 0) problems.push(`${tag}: path missing`);
151
+ else {
152
+ if (seen.has(e.path)) problems.push(`${tag}: duplicate path '${e.path}'`);
153
+ else seen.set(e.path, i);
154
+ }
155
+ if (!CLASSES.includes(e.class)) problems.push(`${tag}: bad class '${e.class}'`);
156
+ if (!Array.isArray(e.audience) || e.audience.length === 0 || e.audience.some((a) => !AUDIENCES.includes(a))) {
157
+ problems.push(`${tag}: audience must be a non-empty subset of ${AUDIENCES.join(',')}`);
158
+ }
159
+ if (!OWNERS.includes(e.owner)) problems.push(`${tag}: bad owner '${e.owner}'`);
160
+ if (typeof e.source_of_truth !== 'string' || e.source_of_truth.length === 0) {
161
+ problems.push(`${tag}: source_of_truth missing`);
162
+ }
163
+ if (!MERGE_STRATEGIES.includes(e.merge_strategy)) problems.push(`${tag}: bad merge_strategy '${e.merge_strategy}'`);
164
+ if (!LOAD_POLICIES.includes(e.load_policy)) problems.push(`${tag}: bad load_policy '${e.load_policy}'`);
165
+ if (!Array.isArray(e.validation) || e.validation.length === 0) {
166
+ problems.push(`${tag}: validation must be a non-empty list`);
167
+ } else {
168
+ for (const v of e.validation) {
169
+ if (typeof v !== 'string') {
170
+ problems.push(`${tag}: validation item not a string`);
171
+ continue;
172
+ }
173
+ if (v === 'doctor' || v === 'manual' || v === 'none') continue;
174
+ if (v.startsWith('test:')) {
175
+ if (!fileExists(v.slice(5))) problems.push(`${tag}: validation test path '${v.slice(5)}' missing on disk`);
176
+ continue;
177
+ }
178
+ problems.push(`${tag}: unknown validation '${v}'`);
179
+ }
180
+ }
181
+ if (!ARCHIVE_POLICIES.includes(e.archive_policy)) problems.push(`${tag}: bad archive_policy '${e.archive_policy}'`);
182
+ if (e.budget !== undefined) {
183
+ if (typeof e.budget !== 'object' || e.budget === null) problems.push(`${tag}: budget not an object`);
184
+ else {
185
+ if (!Number.isInteger(e.budget.max_lines) || e.budget.max_lines <= 0) {
186
+ problems.push(`${tag}: budget.max_lines must be a positive int`);
187
+ }
188
+ if (!BUDGET_ENFORCEMENTS.includes(e.budget.enforcement)) {
189
+ problems.push(`${tag}: bad budget.enforcement '${e.budget.enforcement}'`);
190
+ }
191
+ }
192
+ }
193
+ if (e.notes !== undefined && typeof e.notes !== 'string') problems.push(`${tag}: notes must be a string`);
194
+ });
195
+ return problems;
196
+ }
197
+
198
+ // ---- individual rule runners (each returns one CheckResult) ----
199
+
200
+ // Repo-relative .md files whose registry entry class is canonical or
201
+ // generated — the "canonical docs" scope for links/stale-version/commands.
202
+ async function canonicalMdFiles(rootDir, registry) {
203
+ const files = new Set();
204
+ for (const e of registry.entries) {
205
+ if (typeof e.path !== 'string') continue;
206
+ if (!['canonical', 'generated'].includes(e.class)) continue;
207
+ if (e.path.endsWith('/')) {
208
+ for (const f of await walkFiles(rootDir, e.path.slice(0, -1), (n) => n.endsWith('.md'))) {
209
+ files.add(f);
210
+ }
211
+ } else if (e.path.endsWith('.md')) {
212
+ if (await existsPath(path.join(rootDir, e.path))) files.add(e.path);
213
+ }
214
+ }
215
+ return [...files];
216
+ }
217
+
218
+ async function checkLinks(rootDir, registry) {
219
+ const id = 'links';
220
+ const label = 'relative markdown links in canonical docs resolve';
221
+ const files = await canonicalMdFiles(rootDir, registry);
222
+ if (files.length === 0) {
223
+ return makeCheck(id, label, 'error', { skipped: true, detail: 'no canonical .md files in scope' });
224
+ }
225
+ const broken = [];
226
+ for (const rel of files) {
227
+ const content = await readText(path.join(rootDir, rel));
228
+ if (content === null) continue;
229
+ const lines = content.split('\n');
230
+ for (let i = 0; i < lines.length; i++) {
231
+ MD_LINK_RE.lastIndex = 0;
232
+ let m;
233
+ while ((m = MD_LINK_RE.exec(lines[i])) !== null) {
234
+ const target = m[2].split('#')[0];
235
+ if (!target || /^(https?:|mailto:|tel:)/i.test(target)) continue;
236
+ if (target.startsWith('/') || target.startsWith('<') || target.startsWith('#')) continue;
237
+ // Placeholder links (e.g. `[action](request)`, `(link)`) carry no path
238
+ // separator and no file extension — they are illustrative, not links.
239
+ if (!target.includes('/') && !/\.[a-zA-Z0-9]{1,8}$/.test(target)) continue;
240
+ const resolved = path.normalize(path.join(path.dirname(rel), target));
241
+ let ok = await existsPath(path.join(rootDir, resolved));
242
+ // Tolerances: (a) root-relative links (docs write `docs/x.md` from repo
243
+ // root), (b) runtime surfaces shipped under templates/ (`.omp/...` in
244
+ // source repo lives at templates/.omp/...).
245
+ if (!ok) ok = await existsPath(path.join(rootDir, target));
246
+ if (!ok) ok = await existsPath(path.join(rootDir, 'templates', target));
247
+ if (!ok) broken.push(`${rel}:${i + 1} → ${target}`);
248
+ }
249
+ }
250
+ }
251
+ if (broken.length > 0) {
252
+ return makeCheck(id, label, 'error', {
253
+ detail: broken.slice(0, 10).join('; ') + (broken.length > 10 ? ` (+${broken.length - 10} more)` : ''),
254
+ remedy: 'fix or remove the broken relative links',
255
+ });
256
+ }
257
+ return makeCheck(id, label, 'error', { passed: true });
258
+ }
259
+
260
+ async function checkStaleVersion(rootDir, registry) {
261
+ const id = 'stale-version';
262
+ const label = 'no vX.Y.Z literals ≠ package.json version on scanned surfaces';
263
+ const pkgRaw = await readText(path.join(rootDir, PACKAGE_PATH));
264
+ let pkgVersion = null;
265
+ try {
266
+ pkgVersion = JSON.parse(pkgRaw || '').version;
267
+ } catch {
268
+ /* fall through */
269
+ }
270
+ if (typeof pkgVersion !== 'string') {
271
+ return makeCheck(id, label, 'error', { skipped: true, detail: 'package.json version unreadable' });
272
+ }
273
+ // Scanned set mirrors staleVersionProse: any-extension files under
274
+ // canonical/generated templates/ entries + the rendered root instructions.
275
+ const files = new Set(RENDERED_EXTRA_TARGETS);
276
+ for (const e of registry.entries) {
277
+ if (typeof e.path !== 'string' || !e.path.startsWith('templates/')) continue;
278
+ if (!['canonical', 'generated'].includes(e.class)) continue;
279
+ if (e.path.endsWith('/')) {
280
+ for (const f of await walkFiles(rootDir, e.path.slice(0, -1))) files.add(f);
281
+ } else {
282
+ files.add(e.path);
283
+ }
284
+ }
285
+ const violations = [];
286
+ for (const rel of files) {
287
+ if (rel in STALE_VERSION_ALLOWLIST) continue;
288
+ const content = await readText(path.join(rootDir, rel));
289
+ if (content === null) continue;
290
+ const stripped = content.replace(RENDER_VAR_RE, '');
291
+ for (const m of stripped.matchAll(VERSION_LITERAL_RE)) {
292
+ if (m[0] !== `v${pkgVersion}`) {
293
+ violations.push(`${rel}: stale literal '${m[0]}' (package.json is ${pkgVersion})`);
294
+ }
295
+ }
296
+ }
297
+ if (violations.length > 0) {
298
+ return makeCheck(id, label, 'error', {
299
+ detail: violations.slice(0, 10).join('; ') + (violations.length > 10 ? ` (+${violations.length - 10} more)` : ''),
300
+ remedy: 'interpolate {{ukit.version}} or add an allowlist entry with a written justification',
301
+ });
302
+ }
303
+ return makeCheck(id, label, 'error', { passed: true });
304
+ }
305
+
306
+ async function checkRenderVars(rootDir, registry) {
307
+ const id = 'render-vars';
308
+ const label = 'no unresolved {{var}} placeholders in rendered targets';
309
+ // Rendered surfaces: repo outputs (CLAUDE.md/AGENTS.md) + derived mirrors.
310
+ // templates/ generated files legitimately keep {{var}} for install-time
311
+ // interpolation, so they are out of scope.
312
+ const targets = new Set(RENDERED_EXTRA_TARGETS);
313
+ for (const e of registry.entries) {
314
+ if (typeof e.path !== 'string' || e.class !== 'derived' || e.path.startsWith('templates/')) continue;
315
+ // Only .md doc surfaces are render targets — scripts/components/templates
316
+ // inside derived dirs legitimately use {{ }} as their own placeholders.
317
+ if (e.path.endsWith('/')) {
318
+ for (const f of await walkFiles(rootDir, e.path.slice(0, -1), (n) => n.endsWith('.md'))) {
319
+ targets.add(f);
320
+ }
321
+ } else if (e.path.endsWith('.md')) {
322
+ targets.add(e.path);
323
+ }
324
+ }
325
+ const leftovers = [];
326
+ for (const rel of targets) {
327
+ const content = await readText(path.join(rootDir, rel));
328
+ if (content === null) continue;
329
+ const m = content.match(/\{\{[^{}]*\}\}/);
330
+ if (m) leftovers.push(`${rel}: unresolved '${m[0]}'`);
331
+ }
332
+ if (leftovers.length > 0) {
333
+ return makeCheck(id, label, 'error', {
334
+ detail: leftovers.slice(0, 10).join('; '),
335
+ remedy: 're-run yarn docs:render (or the responsible renderer) so variables resolve',
336
+ });
337
+ }
338
+ return makeCheck(id, label, 'error', { passed: true });
339
+ }
340
+
341
+ async function checkCommands(rootDir, registry) {
342
+ const id = 'commands';
343
+ const label = '.claude/commands/ukit/*.md referenced in canonical docs exist';
344
+ const files = await canonicalMdFiles(rootDir, registry);
345
+ if (files.length === 0) {
346
+ return makeCheck(id, label, 'error', { skipped: true, detail: 'no canonical .md files in scope' });
347
+ }
348
+ const missing = [];
349
+ for (const rel of files) {
350
+ const content = await readText(path.join(rootDir, rel));
351
+ if (content === null) continue;
352
+ for (const m of content.matchAll(COMMAND_REF_RE)) {
353
+ const name = m[1];
354
+ const inRepo = await existsPath(path.join(rootDir, '.claude/commands/ukit', name));
355
+ const inTemplates = await existsPath(
356
+ path.join(rootDir, 'templates/.claude/commands/ukit', name),
357
+ );
358
+ if (!inRepo && !inTemplates) missing.push(`${rel}: .claude/commands/ukit/${name}`);
359
+ }
360
+ }
361
+ if (missing.length > 0) {
362
+ return makeCheck(id, label, 'error', {
363
+ detail: missing.slice(0, 10).join('; '),
364
+ remedy: 'create the referenced command file or fix the reference',
365
+ });
366
+ }
367
+ return makeCheck(id, label, 'error', { passed: true });
368
+ }
369
+
370
+ async function checkBudgets(rootDir, registry) {
371
+ const id = 'budgets';
372
+ const label = 'registry line budgets obeyed (error enforcement blocks)';
373
+ const errorViolations = [];
374
+ const warningViolations = [];
375
+ for (const e of registry.entries) {
376
+ if (!e.budget || typeof e.path !== 'string' || e.path.endsWith('/')) continue;
377
+ if (!Number.isInteger(e.budget.max_lines)) continue;
378
+ const content = await readText(path.join(rootDir, e.path));
379
+ if (content === null) continue;
380
+ const lines = content.split('\n').length;
381
+ if (lines <= e.budget.max_lines) continue;
382
+ const msg = `${e.path}: ${lines} lines > max_lines ${e.budget.max_lines}`;
383
+ if (e.budget.enforcement === 'error') errorViolations.push(msg);
384
+ else if (e.budget.enforcement === 'warning') warningViolations.push(msg);
385
+ }
386
+ if (errorViolations.length > 0) {
387
+ return makeCheck(id, label, 'error', {
388
+ detail: errorViolations.join('; '),
389
+ remedy: 'shrink the file below its budget or raise the registry budget deliberately',
390
+ });
391
+ }
392
+ if (warningViolations.length > 0) {
393
+ return makeCheck(id, label, 'warning', {
394
+ passed: true, // advisory only — surfaced in detail, never blocks
395
+ detail: warningViolations.join('; '),
396
+ remedy: 'advisory only — shrink when convenient or revisit the budget',
397
+ });
398
+ }
399
+ return makeCheck(id, label, 'error', { passed: true });
400
+ }
401
+
402
+ // ---- dead-docs helpers (DOC-303; exported for TASK-216 archive-suggestions) ----
403
+
404
+ // Exclusions per FR-006: release/changelog-style root docs, anything under an
405
+ // `archive/` dir, node_modules, and dot-dirs/dot-files are never orphans.
406
+ const DEAD_DOC_EXCLUDED_BASENAMES = new Set(['CHANGELOG.md', 'README.md']);
407
+ function deadDocExcluded(rel) {
408
+ const segments = rel.split('/');
409
+ if (segments.some((s) => s === 'node_modules' || s === 'archive' || s.startsWith('.'))) {
410
+ return true;
411
+ }
412
+ return DEAD_DOC_EXCLUDED_BASENAMES.has(segments[segments.length - 1]);
413
+ }
414
+
415
+ /**
416
+ * Repo-relative `.md` files in dead-doc scope (FR-005): root `*.md`, `docs/**`,
417
+ * and any registered dir entry — minus FR-006 exclusions. Never throws.
418
+ * Returns Promise<string[]> sorted.
419
+ */
420
+ export async function collectScannableDocs(rootDir, registry) {
421
+ const files = new Set();
422
+ // root *.md (non-recursive)
423
+ try {
424
+ for (const name of await fs.readdir(rootDir)) {
425
+ if (!name.endsWith('.md')) continue;
426
+ const st = await fs.stat(path.join(rootDir, name)).catch(() => null);
427
+ if (st && st.isFile()) files.add(name);
428
+ }
429
+ } catch {
430
+ /* unreadable root — nothing scannable */
431
+ }
432
+ // docs/** (registered dir entries are already inside docs/, but an entry may
433
+ // point elsewhere — e.g. a project-local doc dir — so add them explicitly).
434
+ const dirs = new Set(['docs']);
435
+ for (const e of registry.entries || []) {
436
+ if (typeof e.path === 'string' && e.path.endsWith('/')) dirs.add(e.path.slice(0, -1));
437
+ }
438
+ for (const dirRel of dirs) {
439
+ for (const f of await walkFiles(rootDir, dirRel, (n) => n.endsWith('.md'))) {
440
+ files.add(f);
441
+ }
442
+ }
443
+ return [...files].filter((rel) => !deadDocExcluded(rel)).sort();
444
+ }
445
+
446
+ /**
447
+ * Inbound markdown-link index over `mdFiles`: Map<relPath, Set<referrerRelPath>>.
448
+ * A link `[..](target)` counts when it resolves to another scanned file —
449
+ * resolved relative to the referrer's dir first, then relative to repo root
450
+ * (docs commonly write `docs/x.md` from root). Self-links do not count.
451
+ * Never throws; every scanned file gets an entry (empty set = no inbound).
452
+ */
453
+ export async function computeInboundLinkIndex(rootDir, mdFiles) {
454
+ const index = new Map(mdFiles.map((rel) => [rel, new Set()]));
455
+ const scanned = new Set(mdFiles);
456
+ for (const rel of mdFiles) {
457
+ const content = await readText(path.join(rootDir, rel));
458
+ if (content === null) continue;
459
+ for (const line of content.split('\n')) {
460
+ MD_LINK_RE.lastIndex = 0;
461
+ let m;
462
+ while ((m = MD_LINK_RE.exec(line)) !== null) {
463
+ const target = m[2].split('#')[0];
464
+ if (!target || /^(https?:|mailto:|tel:)/i.test(target)) continue;
465
+ if (target.startsWith('/') || target.startsWith('<') || target.startsWith('#')) continue;
466
+ const candidates = [
467
+ path.normalize(path.join(path.dirname(rel), target)),
468
+ path.normalize(target),
469
+ ];
470
+ for (const resolved of candidates) {
471
+ const norm = resolved.split(path.sep).join('/');
472
+ if (norm !== rel && scanned.has(norm)) index.get(norm).add(rel);
473
+ }
474
+ }
475
+ }
476
+ }
477
+ return index;
478
+ }
479
+
480
+ // True when `rel` is a registry entry path (file entry, or inside a dir entry)
481
+ // or is referenced as some entry's `source_of_truth` target.
482
+ function registryCovers(registry, rel) {
483
+ for (const e of registry.entries || []) {
484
+ if (typeof e.path === 'string') {
485
+ if (e.path === rel) return true;
486
+ if (e.path.endsWith('/') && rel.startsWith(e.path)) return true;
487
+ }
488
+ if (typeof e.source_of_truth === 'string') {
489
+ if (e.source_of_truth === rel) return true;
490
+ if (e.source_of_truth.endsWith('/') && rel.startsWith(e.source_of_truth)) return true;
491
+ }
492
+ }
493
+ return false;
494
+ }
495
+
496
+ async function checkDeadDocs(rootDir, registry) {
497
+ const id = 'dead-docs';
498
+ const label = 'docs in scope are registered, referenced, or inbound-linked (orphans advisory)';
499
+ const mdFiles = await collectScannableDocs(rootDir, registry);
500
+ const inbound = await computeInboundLinkIndex(rootDir, mdFiles);
501
+ const dead = mdFiles.filter(
502
+ (rel) => !registryCovers(registry, rel) && (inbound.get(rel)?.size ?? 0) === 0,
503
+ );
504
+ if (dead.length > 0) {
505
+ return makeCheck(id, label, 'warning', {
506
+ passed: true, // advisory only — never blocks, never counts as an error
507
+ detail: dead.slice(0, 10).join('; ') + (dead.length > 10 ? ` (+${dead.length - 10} more)` : ''),
508
+ remedy: 'register the doc in manifests/documentation.yaml, link it from a canonical doc, or archive/remove it',
509
+ });
510
+ }
511
+ return makeCheck(id, label, 'warning', { passed: true });
512
+ }
513
+
514
+ // ---- archive-suggestions (DOC-304 / FR-007..008) ----
515
+
516
+ // Registered file entries whose class is canonical or owner get scored on
517
+ // staleness signals; ≥2 signals ranks them as archive candidates (advisory).
518
+ async function checkArchiveSuggestions(rootDir, registry, nowMs) {
519
+ const id = 'archive-suggestions';
520
+ const label = 'registered canonical/owner docs scored for archive candidacy (advisory)';
521
+ const mdFiles = await collectScannableDocs(rootDir, registry);
522
+ const inbound = await computeInboundLinkIndex(rootDir, mdFiles);
523
+ const staleCutoffMs = nowMs - ARCHIVE_STALE_DAYS * 24 * 60 * 60 * 1000;
524
+ const candidates = [];
525
+ for (const e of registry.entries || []) {
526
+ if (!['canonical', 'owner'].includes(e.class)) continue;
527
+ if (typeof e.path !== 'string' || e.path.endsWith('/') || !e.path.endsWith('.md')) continue;
528
+ const abs = path.join(rootDir, e.path);
529
+ const content = await readText(abs);
530
+ if (content === null) continue;
531
+ const signals = [];
532
+ if ((inbound.get(e.path)?.size ?? 0) === 0) signals.push('unreferenced');
533
+ if (e.budget && Number.isInteger(e.budget.max_lines)) {
534
+ const lines = content.split('\n').length;
535
+ if (lines > e.budget.max_lines) signals.push('over-budget');
536
+ }
537
+ let st = null;
538
+ try {
539
+ st = await fs.stat(abs);
540
+ } catch {
541
+ /* unreadable — mtime signal skipped */
542
+ }
543
+ if (st && st.mtimeMs < staleCutoffMs) signals.push('stale');
544
+ if (signals.length >= 2) candidates.push({ path: e.path, signals });
545
+ }
546
+ candidates.sort((a, b) => b.signals.length - a.signals.length);
547
+ if (candidates.length > 0) {
548
+ const detail = candidates
549
+ .slice(0, 10)
550
+ .map((c) => `${c.path} [${c.signals.join(', ')}]`)
551
+ .join('; ');
552
+ return makeCheck(id, label, 'warning', {
553
+ passed: true, // advisory only — nothing is moved, never blocks
554
+ detail: detail + (candidates.length > 10 ? ` (+${candidates.length - 10} more)` : ''),
555
+ remedy: 'advisory only — consider archiving the listed docs under their archive_policy',
556
+ });
557
+ }
558
+ return makeCheck(id, label, 'warning', { passed: true });
559
+ }
560
+
561
+ async function checkRegistry(rootDir, registry, fileExists) {
562
+ const id = 'registry';
563
+ const label = 'manifests/documentation.yaml schema + disk cross-checks';
564
+ const problems = validateRegistryDoc(registry, fileExists);
565
+ for (const e of registry.entries || []) {
566
+ if (typeof e.path !== 'string' || e.path.length === 0) continue;
567
+ if (!fileExists(e.path)) problems.push(`entry path '${e.path}' does not exist on disk`);
568
+ if (typeof e.source_of_truth === 'string' && e.source_of_truth.length > 0 && !fileExists(e.source_of_truth)) {
569
+ problems.push(`entry '${e.path}': source_of_truth '${e.source_of_truth}' does not exist on disk`);
570
+ }
571
+ }
572
+ if (problems.length > 0) {
573
+ return makeCheck(id, label, 'error', {
574
+ detail: problems.slice(0, 10).join('; ') + (problems.length > 10 ? ` (+${problems.length - 10} more)` : ''),
575
+ remedy: 'fix manifests/documentation.yaml entries per docRegistry.test.js schema',
576
+ });
577
+ }
578
+ return makeCheck(id, label, 'error', { passed: true });
579
+ }
580
+
581
+ async function checkTemplates(rootDir) {
582
+ const id = 'templates';
583
+ const label = '_TEMPLATE.md copies declare the Executor Report header fields (FR-013)';
584
+ const copies = [
585
+ 'docs/AI_HANDOFF/tasks/_TEMPLATE.md',
586
+ 'templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md',
587
+ ];
588
+ const foundAny = await Promise.all(
589
+ copies.map((rel) => existsPath(path.join(rootDir, rel))),
590
+ );
591
+ if (!foundAny.some(Boolean)) {
592
+ return makeCheck(id, label, 'error', {
593
+ skipped: true,
594
+ detail: 'no _TEMPLATE.md copies present',
595
+ });
596
+ }
597
+ const problems = [];
598
+ for (const rel of copies) {
599
+ const content = await readText(path.join(rootDir, rel));
600
+ if (content === null) {
601
+ problems.push(`${rel}: missing`);
602
+ continue;
603
+ }
604
+ for (const field of EXECUTOR_FIELDS) {
605
+ if (!content.includes(field)) problems.push(`${rel}: missing ${field}`);
606
+ }
607
+ }
608
+ if (problems.length > 0) {
609
+ return makeCheck(id, label, 'error', {
610
+ detail: problems.join('; '),
611
+ remedy: 'add the required header line: - EXECUTOR_TOOL: … / EXECUTOR_MODEL: … / EXECUTOR_SUBAGENT: … / RED_OUTPUT: …',
612
+ });
613
+ }
614
+ return makeCheck(id, label, 'error', { passed: true });
615
+ }
616
+
617
+ /**
618
+ * Run all v1 doc-contract rules against rootDir. Never throws.
619
+ * `scope` is reserved ('repo' is the only mode today).
620
+ */
621
+ export async function runDocContractChecks({ rootDir, scope = 'repo', now } = {}) {
622
+ const checks = [];
623
+ const root = typeof rootDir === 'string' && rootDir.length > 0 ? rootDir : process.cwd();
624
+ // FR-008: injectable clock (ms epoch or Date) for deterministic staleness.
625
+ const nowMs = now instanceof Date ? now.getTime() : typeof now === 'number' ? now : Date.now();
626
+
627
+ const registryRaw = await readText(path.join(root, REGISTRY_PATH));
628
+ let registry = null;
629
+ if (registryRaw !== null) {
630
+ try {
631
+ registry = YAML.parse(registryRaw);
632
+ } catch (err) {
633
+ checks.push(
634
+ makeCheck('registry', 'manifests/documentation.yaml parses', 'error', {
635
+ detail: `YAML parse failed: ${err && err.message ? err.message : err}`,
636
+ remedy: `fix syntax in ${REGISTRY_PATH}`,
637
+ }),
638
+ );
639
+ }
640
+ }
641
+
642
+ if (registry === null) {
643
+ if (!checks.some((c) => c.id === 'registry')) {
644
+ checks.push(skippedCheck('registry', 'manifests/documentation.yaml schema', `${REGISTRY_PATH} absent`));
645
+ }
646
+ for (const [id, label] of [
647
+ ['links', 'relative markdown links in canonical docs resolve'],
648
+ ['stale-version', 'no stale version literals'],
649
+ ['render-vars', 'no unresolved {{var}} placeholders'],
650
+ ['commands', 'referenced command files exist'],
651
+ ['budgets', 'registry line budgets obeyed'],
652
+ ['dead-docs', 'docs in scope are registered, referenced, or inbound-linked'],
653
+ ['archive-suggestions', 'registered canonical/owner docs scored for archive candidacy'],
654
+ ]) {
655
+ checks.push(skippedCheck(id, label, `${REGISTRY_PATH} absent — no registry scope`));
656
+ }
657
+ checks.push(await checkTemplates(root));
658
+ return summarize(checks);
659
+ }
660
+
661
+ // existsOnDisk equivalent (dir entries end '/'; resolve symlinks so dev
662
+ // mirrors count). Bound once for the registry check.
663
+ const fileExistsCache = new Map();
664
+ const fileExists = (rel) => {
665
+ if (fileExistsCache.has(rel)) return fileExistsCache.get(rel);
666
+ const p = rel.endsWith('/') ? rel.slice(0, -1) : rel;
667
+ const promise = fs
668
+ .realpath(path.join(root, p))
669
+ .then(() => true)
670
+ .catch(() => existsPath(path.join(root, p)));
671
+ fileExistsCache.set(rel, promise);
672
+ return promise;
673
+ };
674
+ const fileExistsSyncWrap = (rel) => {
675
+ // validateRegistryDoc is sync; warm the cache first for all referenced paths.
676
+ return fileExistsCache.get(rel) === true;
677
+ };
678
+
679
+ // Pre-warm existence cache for validation test paths + entry paths.
680
+ const warm = [];
681
+ for (const e of registry.entries || []) {
682
+ if (Array.isArray(e.validation)) {
683
+ for (const v of e.validation) if (typeof v === 'string' && v.startsWith('test:')) warm.push(v.slice(5));
684
+ }
685
+ if (typeof e.path === 'string') warm.push(e.path);
686
+ if (typeof e.source_of_truth === 'string') warm.push(e.source_of_truth);
687
+ }
688
+ await Promise.all(warm.map((rel) => fileExists(rel).then((r) => fileExistsCache.set(rel, r))));
689
+ for (const [k, v] of fileExistsCache) {
690
+ if (v instanceof Promise) {
691
+ const resolved = await v;
692
+ fileExistsCache.set(k, resolved);
693
+ }
694
+ }
695
+
696
+ checks.push(await checkRegistry(root, registry, fileExistsSyncWrap));
697
+ checks.push(await checkLinks(root, registry));
698
+ checks.push(await checkStaleVersion(root, registry));
699
+ checks.push(await checkRenderVars(root, registry));
700
+ checks.push(await checkCommands(root, registry));
701
+ checks.push(await checkBudgets(root, registry));
702
+ checks.push(await checkDeadDocs(root, registry));
703
+ checks.push(await checkArchiveSuggestions(root, registry, nowMs));
704
+ checks.push(await checkTemplates(root));
705
+
706
+ return summarize(checks);
707
+ }
708
+
709
+ function summarize(checks) {
710
+ const summary = { errors: 0, warnings: 0, passed: 0, skipped: 0 };
711
+ for (const c of checks) {
712
+ if (c.skipped) {
713
+ summary.skipped += 1;
714
+ } else if (c.passed) {
715
+ summary.passed += 1;
716
+ } else if (c.severity === 'error') {
717
+ summary.errors += 1;
718
+ } else {
719
+ summary.warnings += 1;
720
+ }
721
+ }
722
+ return { checks, summary };
723
+ }