harness-everything 0.40.0 → 0.41.1
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/.agents/harness-everything/project-docs.json +9 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +51 -0
- package/benchmarks/results/f1-doc-path-baseline/1-source-clean.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/1-source-clean.stdout.json +20 -0
- package/benchmarks/results/f1-doc-path-baseline/2-injected-architecture.md +229 -0
- package/benchmarks/results/f1-doc-path-baseline/2-source-injected.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/2-source-injected.stdout.json +33 -0
- package/benchmarks/results/f1-doc-path-baseline/3-source-reverted.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/3-source-reverted.stdout.json +20 -0
- package/benchmarks/results/f1-doc-path-baseline/4a-packaged-clean.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/4a-packaged-clean.stdout.json +20 -0
- package/benchmarks/results/f1-doc-path-baseline/4b-packaged-injected.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/4b-packaged-injected.stdout.json +33 -0
- package/benchmarks/results/f1-doc-path-baseline/5-source-missing-config.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/5-source-missing-config.stdout.json +15 -0
- package/benchmarks/results/f1-doc-path-baseline/5b-packaged-missing-config.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/5b-packaged-missing-config.stdout.json +15 -0
- package/benchmarks/results/f1-doc-path-baseline/README.md +54 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/1-source-clean.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/1-source-clean.stdout.json +115 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/2-injected-document.md +328 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/2-source-injected.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/2-source-injected.stdout.json +127 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/3-source-reverted.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/3-source-reverted.stdout.json +115 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/4a-packaged-clean.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/4a-packaged-clean.stdout.json +115 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/4b-packaged-injected.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/4b-packaged-injected.stdout.json +127 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/5-source-missing-config.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/5-source-missing-config.stdout.json +15 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/5b-packaged-missing-config.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/5b-packaged-missing-config.stdout.json +15 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/6-supplementary-fixture-ok.input.md +8 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/6-supplementary-fixture-ok.stdout.json +16 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/README.md +34 -0
- package/benchmarks/results/f1-doc-path-baseline/probe-31-docs/after.json +248 -0
- package/benchmarks/results/f1-doc-path-baseline/probe-31-docs/before.json +254 -0
- package/benchmarks/results/f1-doc-path-baseline/probe-31-docs/probe.js +26 -0
- package/benchmarks/results/f2-doc-craft-baseline/README.md +32 -0
- package/benchmarks/results/f2-doc-craft-baseline/arm-f1-report.md +84 -0
- package/benchmarks/results/f2-doc-craft-baseline/arm-f1-task.md +21 -0
- package/benchmarks/results/f2-doc-craft-baseline/arm-f1.diff +19 -0
- package/benchmarks/results/f2-doc-craft-baseline/arm-f2-report.md +110 -0
- package/benchmarks/results/f2-doc-craft-baseline/arm-f2-task.md +21 -0
- package/benchmarks/results/f2-doc-craft-baseline/arm-f2.diff +19 -0
- package/benchmarks/results/f3-derived-views-baseline/README.md +34 -0
- package/benchmarks/results/f3-derived-views-baseline/arm-f2-report.md +43 -0
- package/benchmarks/results/f3-derived-views-baseline/arm-f2-task.md +21 -0
- package/benchmarks/results/f3-derived-views-baseline/arm-f2.diff +21 -0
- package/benchmarks/results/f3-derived-views-baseline/arm-f3-report.md +57 -0
- package/benchmarks/results/f3-derived-views-baseline/arm-f3-task.md +21 -0
- package/benchmarks/results/f3-derived-views-baseline/arm-f3.diff +21 -0
- package/benchmarks/results/live-host/claude-325-3b-s2b/README.md +99 -0
- package/benchmarks/results/live-host/claude-325-3b-s2b/capture.sanitized.jsonl +26 -0
- package/ci/archive/325-prep/README.md +71 -0
- package/ci/{prep → archive/325-prep}/doc-reconciliation-red.js +9 -7
- package/ci/archive/325-prep/f4-expected-red.json +34 -0
- package/ci/archive/325-prep/historical-qualification.test.js +143 -0
- package/ci/fixtures/doc-obligations/reconciliation-prep/cases.json +17 -4
- package/ci/fixtures/doc-obligations/reconciliation-prep/pending-red.json +8 -56
- package/ci/fixtures/doc-obligations/reconciliation-prep/trust-contract.json +2 -2
- package/ci/fixtures/doc-obligations/reconciliation-s1/cases.json +17 -5
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/harness.js +415 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/off-handoff-golden.json +16 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-post-tool-use-failure.json +11 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-post-tool-use-success-minimal.json +9 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-post-tool-use-success.json +13 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-subagent-start.json +7 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-subagent-stop-freetext-reject.json +11 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-subagent-stop-reject.json +11 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-subagent-stop.json +11 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-task-result.json +19 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/off-capture-only.json +99 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/off-legacy.json +67 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-concurrent-writers.json +62 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-lookalikes-runtime-mutations.json +67 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-malformed.json +85 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-normal-session.json +74 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-runtime-drift-later-npm-test.json +58 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-valid-direct-quoted-review.json +85 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/f4/cases.json +2267 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/wiring/cases.json +1110 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/wiring/legacy-off-golden.json +674 -0
- package/ci/lib/doc-reconciliation-file-spy.js +23 -0
- package/ci/lib/doc-reconciliation-prep-fixtures.js +136 -10
- package/ci/lib/doc-reconciliation-qualification-preload.js +37 -0
- package/ci/lib/doc-reconciliation-replay.js +104 -0
- package/ci/lib/doc-reconciliation-staged-tree.js +102 -0
- package/ci/mechanism-2p-opencode-v2-entrypoint.test.js +179 -0
- package/ci/mechanism-325-3b-preparation.test.js +27 -101
- package/ci/mechanism-325-3b-s2b-capture.test.js +561 -0
- package/ci/mechanism-325-3b-s2b-wiring.test.js +668 -0
- package/ci/mechanism-325-f1-doc-check.test.js +674 -0
- package/ci/mechanism-325-f2-doc-craft.test.js +597 -0
- package/ci/mechanism-325-f3-derived-views.test.js +798 -0
- package/ci/mechanism-325-f4-invariants.test.js +606 -0
- package/ci/mechanism-325-f4-projections.test.js +382 -0
- package/ci/mechanism-325-f4-qualification.test.js +228 -0
- package/ci/mechanism-325-f4-trust-boundary.test.js +880 -0
- package/ci/mechanism-365-step-edit-accounting.test.js +395 -0
- package/ci/mutation-check.js +26 -3
- package/docs/architecture.md +5 -0
- package/docs/doc-workflow-reconciliation.md +331 -4
- package/docs/document-lifecycle.md +11 -0
- package/docs/governed-index.md +20 -0
- package/docs/platform-capabilities.md +22 -2
- package/grill-with-docs/SKILL.md +1 -1
- package/grill-with-docs/references/session-playbook.md +4 -0
- package/harness-everything/SKILL.md +1 -1
- package/harness-everything/references/doc-check.md +186 -0
- package/harness-everything/references/documentation/_shared/maintenance.md +41 -0
- package/harness-everything/references/documentation/_shared/writing-craft.md +59 -0
- package/harness-everything/references/triage-and-tiers.md +4 -0
- package/harness-everything/scripts/doc-check.js +442 -0
- package/harness-everything/scripts/doc-lifecycle.js +3 -0
- package/hooks/hooks.json +46 -0
- package/hooks/scripts/lib/doc-reconciliation-capture.sanitized.jsonl +26 -0
- package/hooks/scripts/lib/doc-reconciliation-pilot.js +171 -2
- package/hooks/scripts/lib/doc-reconciliation-provider.js +1059 -0
- package/hooks/scripts/lib/doc-reconciliation-qualification.json +34 -0
- package/hooks/scripts/lib/workflow-obligations.js +7 -0
- package/hooks/scripts/state-persist.js +105 -3
- package/hooks/scripts/workflow-stop-gate.js +7 -2
- package/opencode-plugin/README.md +15 -0
- package/opencode-plugin/index.mjs +77 -0
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-capture.sanitized.jsonl +26 -0
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-pilot.js +171 -2
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-provider.js +1059 -0
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-qualification.json +34 -0
- package/plugins/harness-everything/hooks/scripts/lib/workflow-obligations.js +7 -0
- package/plugins/harness-everything/hooks/scripts/state-persist.js +105 -3
- package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +7 -2
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/skills/grill-with-docs/SKILL.md +1 -1
- package/plugins/harness-everything/skills/grill-with-docs/references/session-playbook.md +4 -0
- package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
- package/plugins/harness-everything/skills/harness-everything/references/doc-check.md +186 -0
- package/plugins/harness-everything/skills/harness-everything/references/documentation/_shared/maintenance.md +41 -0
- package/plugins/harness-everything/skills/harness-everything/references/documentation/_shared/writing-craft.md +59 -0
- package/plugins/harness-everything/skills/harness-everything/references/triage-and-tiers.md +4 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-check.js +442 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-lifecycle.js +3 -0
- package/plugins/harness-everything/skills/repo-docs/SKILL.md +1 -1
- package/plugins/harness-everything/skills/repo-docs/references/process-guide.md +4 -0
- package/plugins/harness-everything/skills/to-spec/SKILL.md +1 -1
- package/plugins/harness-everything/skills/to-spec/references/process.md +4 -0
- package/plugins/harness-everything/skills/to-tickets/SKILL.md +1 -1
- package/plugins/harness-everything/skills/to-tickets/references/publishing-and-templates.md +4 -0
- package/repo-docs/SKILL.md +1 -1
- package/repo-docs/references/process-guide.md +4 -0
- package/sbom.cdx.json +7 -7
- package/to-spec/SKILL.md +1 -1
- package/to-spec/references/process.md +4 -0
- package/to-tickets/SKILL.md +1 -1
- package/to-tickets/references/publishing-and-templates.md +4 -0
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// F1 oc2 lock suite: the editor-lane doc-check contract, locked with one mechanism suite.
|
|
5
|
+
//
|
|
6
|
+
// Every case runs against both legs: the canonical harness-everything/scripts/doc-check.js and the packaged
|
|
7
|
+
// plugins/harness-everything/skills/harness-everything/scripts/doc-check.js. The script is spawned as a subprocess with
|
|
8
|
+
// cwd set to os.tmpdir(), so it must work from any cwd. Expected values come from
|
|
9
|
+
// harness-everything/references/doc-check.md and the F1 packet table, never from a run of the script.
|
|
10
|
+
// Exit 1 means at least one assertion FAILED.
|
|
11
|
+
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { spawnSync } = require('child_process');
|
|
17
|
+
|
|
18
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
19
|
+
const MODULE = 'doc-check.js';
|
|
20
|
+
const LINT = 'doc-lifecycle.js';
|
|
21
|
+
const CONFIG = '.agents/harness-everything/project-docs.json';
|
|
22
|
+
const LEGS = [
|
|
23
|
+
{ name: 'source', dir: path.join(ROOT, 'harness-everything', 'scripts') },
|
|
24
|
+
{ name: 'packaged', dir: path.join(ROOT, 'plugins', 'harness-everything', 'skills', 'harness-everything', 'scripts') }
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
let passed = 0;
|
|
28
|
+
let failed = 0;
|
|
29
|
+
let skipped = 0;
|
|
30
|
+
const failures = [];
|
|
31
|
+
const tmpDirs = [];
|
|
32
|
+
|
|
33
|
+
function record(status, id, leg, detail) {
|
|
34
|
+
const where = leg ? ` [${leg}]` : '';
|
|
35
|
+
if (status === 'PASS') passed += 1;
|
|
36
|
+
else if (status === 'SKIP') skipped += 1;
|
|
37
|
+
else { failed += 1; failures.push(`${id}${where}`); }
|
|
38
|
+
console.log(` ${status} ${id}${where}${detail ? ` — ${detail}` : ''}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function truncate(text) {
|
|
42
|
+
return text.length > 300 ? `${text.slice(0, 300)}…` : text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function same(actual, expected, label) {
|
|
46
|
+
const a = JSON.stringify(actual);
|
|
47
|
+
const e = JSON.stringify(expected);
|
|
48
|
+
if (a !== e) throw new Error(`${label}: got ${truncate(String(a))} expected ${truncate(String(e))}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- Fixture helpers -----------------------------------------------------------------------------------------------
|
|
52
|
+
function removeTree(dir) {
|
|
53
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeDoc(ws, relative, text) {
|
|
57
|
+
const absolute = path.join(ws, relative);
|
|
58
|
+
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
59
|
+
fs.writeFileSync(absolute, text);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fresh workspace per case, in a tmp dir whose name contains a space (intended). The config is written here unless the
|
|
63
|
+
// case needs a missing config.
|
|
64
|
+
function makeWorkspace(config) {
|
|
65
|
+
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'f1 doc check '));
|
|
66
|
+
tmpDirs.push(ws);
|
|
67
|
+
if (config) writeDoc(ws, CONFIG, `${JSON.stringify(config, null, 2)}\n`);
|
|
68
|
+
return ws;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const REFERENCE = config => ({ governed: [{ path: 'docs/a.md', role: 'reference' }], ...config });
|
|
72
|
+
|
|
73
|
+
function refDoc(body) {
|
|
74
|
+
return `---\nrole: reference\nstatus: active\n---\n\n${body}\n`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function runCheck(scriptDir, options) {
|
|
78
|
+
return spawnSync(process.execPath, [
|
|
79
|
+
path.join(scriptDir, MODULE), 'check',
|
|
80
|
+
'--workspace', options.workspace,
|
|
81
|
+
'--config', options.config,
|
|
82
|
+
...(options.args || [])
|
|
83
|
+
], { encoding: 'utf8', cwd: os.tmpdir(), ...(options.timeoutMs ? { timeout: options.timeoutMs } : {}) });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function jsonOut(result) {
|
|
87
|
+
try { return JSON.parse(result.stdout); } catch {
|
|
88
|
+
throw new Error(`stdout is not JSON (exit ${result.status}): ${truncate(result.stdout || result.stderr)}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function assertUsageJson(result) {
|
|
93
|
+
const out = jsonOut(result);
|
|
94
|
+
same(Object.keys(out).sort(), ['coverage', 'links', 'lint'], 'top-level keys');
|
|
95
|
+
same(Object.keys(out.lint).sort(), ['error', 'exit'], 'lint keys');
|
|
96
|
+
same(out.lint.exit, 2, 'lint.exit');
|
|
97
|
+
if (typeof out.lint.error !== 'string' || !out.lint.error) throw new Error('lint.error is missing');
|
|
98
|
+
same(out.links, { checked: 0, broken: [], skipped: 'lint-usage-error' }, 'links');
|
|
99
|
+
same(out.coverage, { unsupported: [] }, 'coverage');
|
|
100
|
+
same(result.stdout, `${JSON.stringify(out, null, 2)}\n`, 'single JSON object on stdout');
|
|
101
|
+
same(result.stderr, '', 'stderr');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function noDiagnosticOfCode(out, code, extraLabel) {
|
|
105
|
+
const hits = (out.lint.diagnostics || []).filter(item => item.code === code);
|
|
106
|
+
if (hits.length) throw new Error(`${extraLabel}: unexpected ${code} diagnostic(s): ${truncate(JSON.stringify(hits))}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function assertPerLeg(id, fn) {
|
|
110
|
+
for (const leg of LEGS) {
|
|
111
|
+
if (!fs.existsSync(path.join(leg.dir, MODULE))) {
|
|
112
|
+
record('FAIL', id, leg.name, `module-missing: ${path.relative(ROOT, path.join(leg.dir, MODULE))}`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
try { fn(leg); record('PASS', id, leg.name); } catch (error) {
|
|
116
|
+
record('FAIL', id, leg.name, error.message.split('\n')[0]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---- Cases 1–10, 12–15 ---------------------------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
// Case 1: clean doc, local link resolves.
|
|
124
|
+
assertPerLeg('case-1:clean-link-resolves', leg => {
|
|
125
|
+
const ws = makeWorkspace(REFERENCE());
|
|
126
|
+
writeDoc(ws, 'docs/a.md', refDoc('See [b](b.md).'));
|
|
127
|
+
writeDoc(ws, 'docs/b.md', 'target doc.\n');
|
|
128
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
129
|
+
same(result.status, 0, 'exit');
|
|
130
|
+
const out = jsonOut(result);
|
|
131
|
+
same(out.links.checked, 1, 'links.checked');
|
|
132
|
+
same(out.links.broken, [], 'links.broken');
|
|
133
|
+
noDiagnosticOfCode(out, 'unresolved-ref', 'case 1');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Case 2: same setup, link target missing on disk.
|
|
137
|
+
function case2Assertions(scriptDir) {
|
|
138
|
+
const ws = makeWorkspace(REFERENCE());
|
|
139
|
+
writeDoc(ws, 'docs/a.md', refDoc('See [missing](missing.md).'));
|
|
140
|
+
const result = runCheck(scriptDir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
141
|
+
same(result.status, 1, 'exit');
|
|
142
|
+
const out = jsonOut(result);
|
|
143
|
+
if (!Array.isArray(out.links.broken) || out.links.broken.length !== 1) {
|
|
144
|
+
throw new Error(`broken: ${truncate(JSON.stringify(out.links.broken))}`);
|
|
145
|
+
}
|
|
146
|
+
const item = out.links.broken[0];
|
|
147
|
+
same(item.path, 'docs/a.md', 'broken[0].path');
|
|
148
|
+
same(item.target, 'missing.md', 'broken[0].target');
|
|
149
|
+
if (!Number.isInteger(item.line) || item.line < 1) throw new Error('broken[0].line is not a 1-based line number');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
assertPerLeg('case-2:missing-local-target-reported', leg => case2Assertions(leg.dir));
|
|
153
|
+
|
|
154
|
+
// Case 3: bad status is a lint error.
|
|
155
|
+
assertPerLeg('case-3:invalid-status-lint-error', leg => {
|
|
156
|
+
const ws = makeWorkspace(REFERENCE());
|
|
157
|
+
writeDoc(ws, 'docs/a.md', '---\nrole: reference\nstatus: bogus\n---\n\nSome standing text.\n');
|
|
158
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
159
|
+
same(result.status, 1, 'exit');
|
|
160
|
+
const out = jsonOut(result);
|
|
161
|
+
if (!(out.lint.diagnostics || []).some(item => item.code === 'invalid-status' && item.path === 'docs/a.md')) {
|
|
162
|
+
throw new Error(`no invalid-status diagnostic: ${truncate(JSON.stringify(out.lint.diagnostics))}`);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Case 4: link syntax inside a fence and inside inline code is not checked.
|
|
167
|
+
assertPerLeg('case-4:fenced-and-inline-code-not-checked', leg => {
|
|
168
|
+
const ws = makeWorkspace(REFERENCE());
|
|
169
|
+
writeDoc(ws, 'docs/a.md', refDoc('Cached example:\n\n```\n[b](missing.md)\n```\n\nInline verbatim: `[c](also-missing.md)` stays text.\n'));
|
|
170
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
171
|
+
same(result.status, 0, 'exit');
|
|
172
|
+
const out = jsonOut(result);
|
|
173
|
+
same(out.links.checked, 0, 'links.checked');
|
|
174
|
+
same(out.links.broken, [], 'links.broken');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Case 5: external URLs and fragments are skipped on purpose; sibling file with fragment is checked.
|
|
178
|
+
assertPerLeg('case-5:external-fragment-skipped-sibling-checked', leg => {
|
|
179
|
+
const ws = makeWorkspace(REFERENCE());
|
|
180
|
+
writeDoc(ws, 'docs/a.md', refDoc('Remote [x](https://e.com), fragment [y](#frag), sibling [z](b.md#sec).'));
|
|
181
|
+
writeDoc(ws, 'docs/b.md', 'target doc.\n');
|
|
182
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
183
|
+
same(result.status, 0, 'exit');
|
|
184
|
+
const out = jsonOut(result);
|
|
185
|
+
same(out.links.checked, 1, 'links.checked');
|
|
186
|
+
same(out.links.broken, [], 'links.broken');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Case 6: reference-style link syntax is listed as unsupported, not checked.
|
|
190
|
+
assertPerLeg('case-6:reference-syntax-listed-unsupported', leg => {
|
|
191
|
+
const ws = makeWorkspace(REFERENCE());
|
|
192
|
+
writeDoc(ws, 'docs/a.md', refDoc('See [x][r].\n\n[r]: missing.md\n'));
|
|
193
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
194
|
+
same(result.status, 0, 'exit');
|
|
195
|
+
const out = jsonOut(result);
|
|
196
|
+
const unsupported = out.coverage.unsupported || [];
|
|
197
|
+
const pathText = JSON.stringify(unsupported);
|
|
198
|
+
if (!pathText.includes('"reference-link"') || !pathText.includes('"reference-definition"')) {
|
|
199
|
+
throw new Error(`coverage.unsupported lacks reference syntax: ${truncate(pathText)}`);
|
|
200
|
+
}
|
|
201
|
+
// Syntax entries are path-scoped; the run-level "no domains declared" note (case 16) has no path.
|
|
202
|
+
if (!unsupported.filter(item => item.syntax).every(item => item.path === 'docs/a.md')) throw new Error('unsupported path mismatch');
|
|
203
|
+
same(out.links.checked, 0, 'links.checked');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Case 7: existing but ungoverned changed path is unsupported; links not checked.
|
|
207
|
+
assertPerLeg('case-7:ungoverned-changed-path-unsupported', leg => {
|
|
208
|
+
const ws = makeWorkspace(REFERENCE());
|
|
209
|
+
writeDoc(ws, 'docs/a.md', refDoc('Plain text only.\n'));
|
|
210
|
+
writeDoc(ws, 'docs/ungoverned.md', refDoc('See [b](b.md).\n'));
|
|
211
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/ungoverned.md'] });
|
|
212
|
+
const out = jsonOut(result);
|
|
213
|
+
same(out.links.checked, 0, 'links.checked');
|
|
214
|
+
same(out.links.broken, [], 'links.broken');
|
|
215
|
+
if (!(out.coverage.unsupported || []).some(item => item.path === 'docs/ungoverned.md')) {
|
|
216
|
+
throw new Error(`no unsupported entry for docs/ungoverned.md: ${truncate(JSON.stringify(out.coverage))}`);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Case 8: missing config is a usage/config error, not a check.
|
|
221
|
+
assertPerLeg('case-8:missing-config-exit-2', leg => {
|
|
222
|
+
const ws = makeWorkspace(null);
|
|
223
|
+
writeDoc(ws, 'docs/a.md', refDoc('Some standing text.\n'));
|
|
224
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
225
|
+
same(result.status, 2, 'exit');
|
|
226
|
+
const out = jsonOut(result);
|
|
227
|
+
same(out.lint.exit, 2, 'lint.exit');
|
|
228
|
+
same(out.links.skipped, 'lint-usage-error', 'links.skipped');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Case 9: no --changed-path is a usage error.
|
|
232
|
+
assertPerLeg('case-9:missing-changed-path-exit-2', leg => {
|
|
233
|
+
const ws = makeWorkspace(REFERENCE());
|
|
234
|
+
writeDoc(ws, 'docs/a.md', refDoc('Some standing text.\n'));
|
|
235
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: [] });
|
|
236
|
+
same(result.status, 2, 'exit');
|
|
237
|
+
assertUsageJson(result);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Case 10: no --base shows the immutability factor as not-observed.
|
|
241
|
+
assertPerLeg('case-10:no-base-immutability-not-observed', leg => {
|
|
242
|
+
const ws = makeWorkspace(REFERENCE());
|
|
243
|
+
writeDoc(ws, 'docs/a.md', refDoc('Plain text only.\n'));
|
|
244
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
245
|
+
same(result.status, 0, 'exit');
|
|
246
|
+
const out = jsonOut(result);
|
|
247
|
+
same(out.lint.exit, 0, 'lint.exit');
|
|
248
|
+
if (!out.lint.evidence || typeof out.lint.evidence.immutability !== 'string') {
|
|
249
|
+
throw new Error(`lint.evidence missing: ${truncate(JSON.stringify(out.lint.evidence))}`);
|
|
250
|
+
}
|
|
251
|
+
same(out.lint.evidence.immutability, 'not-observed', 'lint.evidence.immutability');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ---- Case 11: byte parity between the two script copies -------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
function sha256(file) {
|
|
257
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
{
|
|
261
|
+
const id = 'case-11:byte-parity-source-packaged';
|
|
262
|
+
try {
|
|
263
|
+
same(sha256(path.join(LEGS[1].dir, MODULE)), sha256(path.join(LEGS[0].dir, MODULE)), 'sha256(doc-check.js) source = packaged');
|
|
264
|
+
record('PASS', id, 'both');
|
|
265
|
+
} catch (error) {
|
|
266
|
+
record('FAIL', id, 'both', error.message.split('\n')[0]);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---- Case 12: the repo's own dogfood -------------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
assertPerLeg('case-12:repo-dogfood-exit-0', leg => {
|
|
273
|
+
const result = spawnSync(process.execPath, [
|
|
274
|
+
path.join(leg.dir, MODULE), 'check',
|
|
275
|
+
'--workspace', ROOT,
|
|
276
|
+
'--config', CONFIG,
|
|
277
|
+
'--changed-path', 'docs/architecture.md',
|
|
278
|
+
'--changed-path', 'docs/doc-workflow-reconciliation.md'
|
|
279
|
+
], { encoding: 'utf8', cwd: os.tmpdir() });
|
|
280
|
+
if (result.status !== 0) {
|
|
281
|
+
throw new Error(`exit ${result.status}; stdout=${truncate(result.stdout)} stderr=${truncate(result.stderr)}`);
|
|
282
|
+
}
|
|
283
|
+
const out = jsonOut(result);
|
|
284
|
+
same(out.links.broken, [], 'links.broken');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ---- Cases 13–15: bare-token reference rules (the F1 real-path fix) -------------------------------------------------
|
|
288
|
+
|
|
289
|
+
// Case 13: bare SHA-256/UTF-8 tokens without a declared domain are not references.
|
|
290
|
+
assertPerLeg('case-13:bare-tech-tokens-not-references', leg => {
|
|
291
|
+
const ws = makeWorkspace(REFERENCE());
|
|
292
|
+
writeDoc(ws, 'docs/a.md', refDoc('Digests use SHA-256 and sources are read as UTF-8.\n'));
|
|
293
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
294
|
+
same(result.status, 0, 'exit');
|
|
295
|
+
const out = jsonOut(result);
|
|
296
|
+
noDiagnosticOfCode(out, 'unresolved-ref', 'case 13');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Case 14: declared-domain bare tokens are still checked; a dangling GRPH-NNN fails.
|
|
300
|
+
assertPerLeg('case-14:declared-domain-dangling-token-fails', leg => {
|
|
301
|
+
const ws = makeWorkspace(REFERENCE({ domains: ['GRPH'] }));
|
|
302
|
+
writeDoc(ws, 'docs/a.md', refDoc('See GRPH-007 for the drawing format.\n'));
|
|
303
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
304
|
+
same(result.status, 1, 'exit');
|
|
305
|
+
const out = jsonOut(result);
|
|
306
|
+
const hit = (out.lint.diagnostics || []).find(item => item.code === 'unresolved-ref');
|
|
307
|
+
if (!hit) throw new Error(`no unresolved-ref diagnostic: ${truncate(JSON.stringify(out.lint.diagnostics))}`);
|
|
308
|
+
same(hit.id, 'GRPH-007', 'unresolved-ref id');
|
|
309
|
+
same(hit.path, 'docs/a.md', 'unresolved-ref path');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Case 15: an active ADR's alias resolves citations of the old ID (domain not declared).
|
|
313
|
+
assertPerLeg('case-15:adr-alias-resolves-old-id', leg => {
|
|
314
|
+
const ws = makeWorkspace({
|
|
315
|
+
domains: ['GRPH'],
|
|
316
|
+
governed: [{ path: 'docs/adr.md', role: 'adr' }, { path: 'docs/notes.md', role: 'reference' }]
|
|
317
|
+
});
|
|
318
|
+
writeDoc(ws, 'docs/adr.md', '---\nrole: adr\nstatus: accepted\nid: GRPH-001\naliases:\n - OLD-001\n---\n\nDecision: the layout named GRPH-001 stands.\n');
|
|
319
|
+
writeDoc(ws, 'docs/notes.md', refDoc('Retrieval cites OLD-001.\n'));
|
|
320
|
+
const result = runCheck(leg.dir, {
|
|
321
|
+
workspace: ws,
|
|
322
|
+
config: CONFIG,
|
|
323
|
+
args: ['--changed-path', 'docs/adr.md', '--changed-path', 'docs/notes.md']
|
|
324
|
+
});
|
|
325
|
+
same(result.status, 0, 'exit');
|
|
326
|
+
const out = jsonOut(result);
|
|
327
|
+
noDiagnosticOfCode(out, 'unresolved-ref', 'case 15');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ---- Revert check: the suite detects removal of the broken-link scan ------------------------------------------------
|
|
331
|
+
// Copy the source scripts to tmp, neutralise the broken.push line in the copy, and confirm case 2 then FAILS.
|
|
332
|
+
|
|
333
|
+
{
|
|
334
|
+
const id = 'revert-check:mutated-copy-fails-case-2';
|
|
335
|
+
// Copy the whole scripts directory: doc-lifecycle.js lazily requires ./project-docs-resolver.
|
|
336
|
+
function fullCopy(destination) {
|
|
337
|
+
fs.cpSync(LEGS[0].dir, destination, { recursive: true });
|
|
338
|
+
return destination;
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
const controlDir = fullCopy(fs.mkdtempSync(path.join(os.tmpdir(), 'f1 doc check control ')));
|
|
342
|
+
tmpDirs.push(controlDir);
|
|
343
|
+
case2Assertions(controlDir); // control: an intact copy must still satisfy case 2
|
|
344
|
+
|
|
345
|
+
const mutatedDir = fullCopy(fs.mkdtempSync(path.join(os.tmpdir(), 'f1 doc check revert ')));
|
|
346
|
+
tmpDirs.push(mutatedDir);
|
|
347
|
+
const scriptFile = path.join(mutatedDir, MODULE);
|
|
348
|
+
const original = fs.readFileSync(scriptFile, 'utf8');
|
|
349
|
+
const mutated = original.replace('broken.push({ path: relative, line, target });', ';');
|
|
350
|
+
if (mutated === original) throw new Error('mutation did not apply: broken.push line not found in the copy');
|
|
351
|
+
fs.writeFileSync(scriptFile, mutated);
|
|
352
|
+
case2Assertions(mutatedDir);
|
|
353
|
+
record('FAIL', id, 'source', 'mutated copy still satisfies case 2; the broken-link assertion does not detect removal of the broken.push line');
|
|
354
|
+
} catch (error) {
|
|
355
|
+
record('PASS', id, 'source', `case 2 fails on the mutated copy, so the assertion detects it (${error.message.split('\n')[0]})`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---- Cases 16–28: F1-T2 lock for the F1-R review fixes ------------------------------------------------------------
|
|
360
|
+
// Expected values come from the F1-T2 packet table and the F1-W3 fix spec, not from running the code. The cases there
|
|
361
|
+
// marked by the packet may be RED until haiku's F1-W3 implementer lands; that is expected and is reported per case.
|
|
362
|
+
|
|
363
|
+
// Case 16: with no domains declared, the run says so exactly once in coverage.unsupported.
|
|
364
|
+
assertPerLeg('case-16:no-domains-declared-entry-once', leg => {
|
|
365
|
+
const ws = makeWorkspace(REFERENCE());
|
|
366
|
+
writeDoc(ws, 'docs/a.md', refDoc('Plain text only.\n'));
|
|
367
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
368
|
+
const out = jsonOut(result);
|
|
369
|
+
const entries = (out.coverage.unsupported || []).filter(item => typeof item.reason === 'string' && item.reason.startsWith('no domains declared'));
|
|
370
|
+
if (entries.length !== 1) {
|
|
371
|
+
throw new Error(`expected exactly one "no domains declared" entry, got ${truncate(JSON.stringify(out.coverage.unsupported))}`);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// Case 17: with domains declared there is no such entry.
|
|
376
|
+
assertPerLeg('case-17:domains-declared-no-entry', leg => {
|
|
377
|
+
const ws = makeWorkspace(REFERENCE({ domains: ['GRPH'] }));
|
|
378
|
+
writeDoc(ws, 'docs/a.md', refDoc('Plain text only.\n'));
|
|
379
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
380
|
+
const out = jsonOut(result);
|
|
381
|
+
const trailing = JSON.stringify(out.coverage.unsupported || []);
|
|
382
|
+
if (trailing.includes('no domains declared')) throw new Error(`unexpected reason: ${truncate(trailing)}`);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// Case 18: a malformed PRD-ID reference (PRD-012) is a lint error, not silently skipped.
|
|
386
|
+
assertPerLeg('case-18:malformed-prd-id-invalid-ref', leg => {
|
|
387
|
+
const ws = makeWorkspace(REFERENCE());
|
|
388
|
+
writeDoc(ws, 'docs/a.md', refDoc('See PRD-012 for the product spec.\n'));
|
|
389
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
390
|
+
same(result.status, 1, 'exit');
|
|
391
|
+
const out = jsonOut(result);
|
|
392
|
+
if (!(out.lint.diagnostics || []).some(item => item.code === 'invalid-ref')) {
|
|
393
|
+
throw new Error(`no invalid-ref diagnostic: ${truncate(JSON.stringify(out.lint.diagnostics))}`);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// Case 19: an unreadable governed doc is a defect (broken entry with reason unreadable), not "not governed".
|
|
398
|
+
assertPerLeg('case-19:unreadable-governed-doc-broken-entry', leg => {
|
|
399
|
+
if (process.platform === 'win32') { record('SKIP', undefined, leg.name, 'win32: chmod 000 semantics differ'); return; }
|
|
400
|
+
const ws = makeWorkspace(REFERENCE());
|
|
401
|
+
writeDoc(ws, 'docs/a.md', refDoc('Plain text only.\n'));
|
|
402
|
+
const absolute = path.join(ws, 'docs/a.md');
|
|
403
|
+
fs.chmodSync(absolute, 0o000);
|
|
404
|
+
try {
|
|
405
|
+
let readable = false;
|
|
406
|
+
try { fs.accessSync(absolute, fs.constants.R_OK); readable = true; } catch { readable = false; }
|
|
407
|
+
if (readable) { record('SKIP', 'case-19:unreadable-governed-doc-broken-entry', leg.name, 'environment can read a chmod-000 file (running as root?)'); return; }
|
|
408
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
409
|
+
same(result.status, 1, 'exit');
|
|
410
|
+
const out = jsonOut(result);
|
|
411
|
+
const hit = (out.links.broken || []).find(item => item.path === 'docs/a.md');
|
|
412
|
+
if (!hit || hit.reason !== 'unreadable') {
|
|
413
|
+
throw new Error(`no broken entry with reason "unreadable": ${truncate(JSON.stringify(out.links))}`);
|
|
414
|
+
}
|
|
415
|
+
} finally {
|
|
416
|
+
try { fs.chmodSync(absolute, 0o644); } catch { /* removal works regardless */ }
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Case 20: a changed path that is not on disk says exactly that.
|
|
421
|
+
assertPerLeg('case-20:changed-path-not-on-disk-reason', leg => {
|
|
422
|
+
const ws = makeWorkspace(REFERENCE());
|
|
423
|
+
writeDoc(ws, 'docs/a.md', refDoc('Plain text only.\n'));
|
|
424
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/gone.md'] });
|
|
425
|
+
const out = jsonOut(result);
|
|
426
|
+
const hit = (out.coverage.unsupported || []).find(item => item.path === 'docs/gone.md');
|
|
427
|
+
if (!hit) throw new Error(`no unsupported entry for docs/gone.md: ${truncate(JSON.stringify(out.coverage))}`);
|
|
428
|
+
same(hit.reason, 'not on disk', 'unsupported reason');
|
|
429
|
+
same(result.status, 0, 'exit');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// Case 21: an existing but ungoverned changed path says exactly that.
|
|
433
|
+
assertPerLeg('case-21:existing-ungoverned-changed-path-reason', leg => {
|
|
434
|
+
const ws = makeWorkspace(REFERENCE());
|
|
435
|
+
writeDoc(ws, 'docs/a.md', refDoc('Plain text only.\n'));
|
|
436
|
+
writeDoc(ws, 'docs/free.md', 'free file, not governed.\n');
|
|
437
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/free.md'] });
|
|
438
|
+
const out = jsonOut(result);
|
|
439
|
+
const hit = (out.coverage.unsupported || []).find(item => item.path === 'docs/free.md');
|
|
440
|
+
if (!hit) throw new Error(`no unsupported entry for docs/free.md: ${truncate(JSON.stringify(out.coverage))}`);
|
|
441
|
+
same(hit.reason, 'not governed', 'unsupported reason');
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// Case 22: angle-bracket destinations, query strings and titles are valid link forms.
|
|
445
|
+
assertPerLeg('case-22:angle-query-title-links-checked', leg => {
|
|
446
|
+
const ws = makeWorkspace(REFERENCE());
|
|
447
|
+
writeDoc(ws, 'docs/a.md', refDoc('Angle [a](<./b.md>), query [q](./b.md?x=1), titled [t](./b.md "Title").'));
|
|
448
|
+
writeDoc(ws, 'docs/b.md', 'target doc.\n');
|
|
449
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
450
|
+
same(result.status, 0, 'exit');
|
|
451
|
+
const out = jsonOut(result);
|
|
452
|
+
same(out.links.checked, 3, 'links.checked');
|
|
453
|
+
same(out.links.broken, [], 'links.broken');
|
|
454
|
+
if (JSON.stringify(out.coverage.unsupported || []).includes('angle-bracket-destination')) {
|
|
455
|
+
throw new Error(`unexpected angle-bracket-destination entry: ${truncate(JSON.stringify(out.coverage))}`);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Case 23: a double-backtick code span is not scanned.
|
|
460
|
+
assertPerLeg('case-23:double-backtick-span-not-checked', leg => {
|
|
461
|
+
const ws = makeWorkspace(REFERENCE());
|
|
462
|
+
writeDoc(ws, 'docs/a.md', refDoc('Verbatim text: ``[x](./nope.md)`` is not a link.\n'));
|
|
463
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
464
|
+
same(result.status, 0, 'exit');
|
|
465
|
+
const out = jsonOut(result);
|
|
466
|
+
same(out.links.checked, 0, 'links.checked');
|
|
467
|
+
same(out.links.broken, [], 'links.broken');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// Case 24: a scheme-less www. destination is unsupported, not a broken local file.
|
|
471
|
+
assertPerLeg('case-24:scheme-less-url-unsupported', leg => {
|
|
472
|
+
const ws = makeWorkspace(REFERENCE());
|
|
473
|
+
writeDoc(ws, 'docs/a.md', refDoc('Site [w](www.example.com) reference.\n'));
|
|
474
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
475
|
+
const out = jsonOut(result);
|
|
476
|
+
const hit = (out.coverage.unsupported || []).find(item => item.syntax === 'scheme-less-url');
|
|
477
|
+
if (!hit) throw new Error(`no scheme-less-url entry: ${truncate(JSON.stringify(out.coverage))}`);
|
|
478
|
+
same(out.links.checked, 0, 'links.checked');
|
|
479
|
+
same(out.links.broken, [], 'links.broken');
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// Case 25: non-JSON lint output is a guarded lint-pipe failure, never a stack trace.
|
|
483
|
+
assertPerLeg('case-25:non-json-lint-output-guarded', leg => {
|
|
484
|
+
const stubDir = fs.mkdtempSync(path.join(os.tmpdir(), 'f1 doc check stub '));
|
|
485
|
+
tmpDirs.push(stubDir);
|
|
486
|
+
fs.cpSync(path.join(leg.dir, MODULE), path.join(stubDir, MODULE));
|
|
487
|
+
fs.writeFileSync(path.join(stubDir, LINT), '#!/usr/bin/env node\n\u0027use strict\u0027;\nif (require.main === module) { process.stdout.write(\u0027not json\\n\u0027); process.exit(1); }\nmodule.exports = { collectDocs: () => [] };\n');
|
|
488
|
+
const ws = makeWorkspace(REFERENCE());
|
|
489
|
+
writeDoc(ws, 'docs/a.md', refDoc('Plain text only.\n'));
|
|
490
|
+
const result = runCheck(stubDir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
491
|
+
same(result.status, 2, 'exit');
|
|
492
|
+
const out = jsonOut(result);
|
|
493
|
+
if (typeof out.lint.error !== 'string' || !out.lint.error.includes('not JSON')) {
|
|
494
|
+
throw new Error(`lint.error does not mention "not JSON": ${truncate(JSON.stringify(out.lint))}`);
|
|
495
|
+
}
|
|
496
|
+
if ((result.stderr || '').includes('SyntaxError')) throw new Error('stack trace leaked to stderr');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Case 26: a flag-shaped value is a usage error, not a silently consumed path.
|
|
500
|
+
assertPerLeg('case-26:flag-shaped-value-exit-2', leg => {
|
|
501
|
+
const ws = makeWorkspace(REFERENCE());
|
|
502
|
+
writeDoc(ws, 'docs/a.md', refDoc('Plain text only.\n'));
|
|
503
|
+
const result = spawnSync(process.execPath, [
|
|
504
|
+
path.join(leg.dir, MODULE), 'check',
|
|
505
|
+
'--workspace', ws, '--config', CONFIG,
|
|
506
|
+
'--changed-path', '--base'
|
|
507
|
+
], { encoding: 'utf8', cwd: os.tmpdir() });
|
|
508
|
+
same(result.status, 2, 'exit');
|
|
509
|
+
assertUsageJson(result);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// Case 27: a repeated --config is a usage error even when both configs are valid.
|
|
513
|
+
assertPerLeg('case-27:repeated-config-exit-2', leg => {
|
|
514
|
+
const ws = makeWorkspace(REFERENCE({ domains: ['GRPH'] }));
|
|
515
|
+
writeDoc(ws, 'docs/a.md', refDoc('Plain text only.\n'));
|
|
516
|
+
writeDoc(ws, 'a.json', `${JSON.stringify(REFERENCE({ domains: ['GRPH'] }), null, 2)}\n`);
|
|
517
|
+
writeDoc(ws, 'b.json', `${JSON.stringify(REFERENCE({ domains: ['GRPH'] }), null, 2)}\n`);
|
|
518
|
+
const result = runCheck(leg.dir, { workspace: ws, config: 'a.json', args: ['--config', 'b.json', '--changed-path', 'docs/a.md'] });
|
|
519
|
+
same(result.status, 2, 'exit');
|
|
520
|
+
assertUsageJson(result);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// ---- Case 28: the shared obligation derivation is not broken by the SHA-256 rule ----------------------------------------------------------------
|
|
524
|
+
// A valid revisioned PRD whose prose names SHA-256, cited by an added test. The REQ must resolve, not analysis-error.
|
|
525
|
+
// Fixture construction mirrors the approvalDigest contract (frontmatter minus status/effective_at/approval plus body);
|
|
526
|
+
// the expected value (resolution resolved) comes from the F1-W3 finding-3 note, never from running the code.
|
|
527
|
+
|
|
528
|
+
const CASE28 = (() => {
|
|
529
|
+
const prdBody = '# Graph\n\n## Context\n\nThe router is deterministic and stores a SHA-256 digest.\n\n### REQ-GRPH-020 (aliases: REQ-GRAPH-020): Export\n\nThe graph exports a stable index.\n\nAcceptance: the index is byte-identical across runs.\n';
|
|
530
|
+
const sortedJson = value => {
|
|
531
|
+
if (Array.isArray(value)) return `[${value.map(sortedJson).join(',')}]`;
|
|
532
|
+
if (value && typeof value === 'object') {
|
|
533
|
+
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${sortedJson(value[key])}`).join(',')}}`;
|
|
534
|
+
}
|
|
535
|
+
return JSON.stringify(value);
|
|
536
|
+
};
|
|
537
|
+
const digest = 'sha256:' + crypto.createHash('sha256').update(
|
|
538
|
+
Buffer.concat([
|
|
539
|
+
Buffer.from(`${sortedJson({ id: 'PRD-GRPH-001', revision: 1, drafted_by: 'Alice Example' })}\n`, 'utf8'),
|
|
540
|
+
Buffer.from(prdBody, 'utf8')
|
|
541
|
+
])).digest('hex');
|
|
542
|
+
const prd = `---
|
|
543
|
+
id: "PRD-GRPH-001"
|
|
544
|
+
revision: 1
|
|
545
|
+
status: "effective"
|
|
546
|
+
drafted_by: "Alice Example"
|
|
547
|
+
effective_at: "2026-10-17T00:01:00Z"
|
|
548
|
+
approval:
|
|
549
|
+
by: "Bob Independent"
|
|
550
|
+
at: "2026-10-17T00:00:00Z"
|
|
551
|
+
ref: "https://approvals.example.invalid/PRD-GRPH-001"
|
|
552
|
+
role: "human"
|
|
553
|
+
content_digest: "${digest}"
|
|
554
|
+
---
|
|
555
|
+
${prdBody}`;
|
|
556
|
+
return {
|
|
557
|
+
base: { files: { 'docs/prd/graph.md': prd } },
|
|
558
|
+
candidate: { files: {
|
|
559
|
+
'docs/prd/graph.md': prd,
|
|
560
|
+
'ci/mechanism-lint.test.js': '// REQ-GRPH-020\nassert.equal(runGraph(), 2);\n'
|
|
561
|
+
} }
|
|
562
|
+
};
|
|
563
|
+
})();
|
|
564
|
+
|
|
565
|
+
assertPerLeg('case-28:sha256-prose-keeps-req-resolution-resolved', leg => {
|
|
566
|
+
const moduleFile = path.join(leg.dir, 'doc-obligations.js');
|
|
567
|
+
if (!fs.existsSync(moduleFile)) throw new Error(`module-missing: ${path.relative(ROOT, moduleFile)}`);
|
|
568
|
+
delete require.cache[require.resolve(moduleFile)];
|
|
569
|
+
const { deriveDocFacts } = require(moduleFile);
|
|
570
|
+
const config = JSON.parse(fs.readFileSync(path.join(ROOT, 'ci', 'fixtures', 'doc-obligations', 'derive', 'config.json'), 'utf8'));
|
|
571
|
+
const { facts } = deriveDocFacts({ base: CASE28.base, candidate: CASE28.candidate, config });
|
|
572
|
+
const cited = (facts.citedReqs || []).filter(item => item.id === 'REQ-GRPH-020');
|
|
573
|
+
if (cited.length !== 1) throw new Error(`REQ-GRPH-020 not cited exactly once: ${truncate(JSON.stringify(facts.citedReqs))}`);
|
|
574
|
+
same(cited[0].resolution, 'resolved', 'cited REQ resolution');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// Case 29: nested and escaped label brackets must not hide missing destinations.
|
|
578
|
+
assertPerLeg('case-29:nested-and-escaped-labels-find-broken-links', leg => {
|
|
579
|
+
const ws = makeWorkspace(REFERENCE({ domains: ['GRPH'] }));
|
|
580
|
+
writeDoc(ws, 'docs/a.md', refDoc('See [API [v2]](missing-nested.md) and [API \\[v3\\]](missing-escaped.md).'));
|
|
581
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
582
|
+
same(result.status, 1, 'exit');
|
|
583
|
+
const out = jsonOut(result);
|
|
584
|
+
same((out.links.broken || []).map(item => item.target), ['missing-nested.md', 'missing-escaped.md'], 'broken targets');
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Case 30: balanced parentheses belong to the destination, not the link delimiter.
|
|
588
|
+
assertPerLeg('case-30:balanced-parentheses-in-destination-resolve', leg => {
|
|
589
|
+
const ws = makeWorkspace(REFERENCE({ domains: ['GRPH'] }));
|
|
590
|
+
writeDoc(ws, 'docs/a.md', refDoc('See [release](release(v2).md).'));
|
|
591
|
+
writeDoc(ws, 'docs/release(v2).md', 'release notes\n');
|
|
592
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
593
|
+
same(result.status, 0, 'exit');
|
|
594
|
+
const out = jsonOut(result);
|
|
595
|
+
same(out.links.checked, 1, 'links.checked');
|
|
596
|
+
same(out.links.broken, [], 'links.broken');
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// Case 31: malformed inline-link syntax must be reported as unsupported, never silently omitted.
|
|
600
|
+
assertPerLeg('case-31:unparsed-inline-link-reported-unsupported', leg => {
|
|
601
|
+
const ws = makeWorkspace(REFERENCE({ domains: ['GRPH'] }));
|
|
602
|
+
writeDoc(ws, 'docs/a.md', refDoc('See [broken](missing.md without closing paren.'));
|
|
603
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
604
|
+
same(result.status, 0, 'exit');
|
|
605
|
+
const out = jsonOut(result);
|
|
606
|
+
if (!(out.coverage.unsupported || []).some(item => item.path === 'docs/a.md' && item.syntax === 'inline-link-unparsed')) {
|
|
607
|
+
throw new Error(`unparsed inline link missing from coverage.unsupported: ${truncate(JSON.stringify(out.coverage))}`);
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// Case 32: an image nested in a link label is a link target. A missing image is broken; a present one passes.
|
|
612
|
+
assertPerLeg('case-32:image-in-link-label-checked', leg => {
|
|
613
|
+
const ws = makeWorkspace(REFERENCE({ domains: ['GRPH'] }));
|
|
614
|
+
writeDoc(ws, 'docs/a.md', refDoc('See [text ](doc.md).'));
|
|
615
|
+
writeDoc(ws, 'docs/doc.md', 'doc\n');
|
|
616
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
617
|
+
same(result.status, 1, 'exit with a missing image');
|
|
618
|
+
const out = jsonOut(result);
|
|
619
|
+
same((out.links.broken || []).map(item => item.target), ['i.png'], 'broken targets');
|
|
620
|
+
same(out.links.checked, 2, 'links.checked');
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// Control for case 32: the same label with the image present passes.
|
|
624
|
+
assertPerLeg('case-32-control:image-in-link-label-present-passes', leg => {
|
|
625
|
+
const ws = makeWorkspace(REFERENCE({ domains: ['GRPH'] }));
|
|
626
|
+
writeDoc(ws, 'docs/a.md', refDoc('See [text ](doc.md).'));
|
|
627
|
+
writeDoc(ws, 'docs/doc.md', 'doc\n');
|
|
628
|
+
writeDoc(ws, 'docs/i.png', 'png\n');
|
|
629
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
630
|
+
same(result.status, 0, 'exit');
|
|
631
|
+
const out = jsonOut(result);
|
|
632
|
+
same(out.links.broken, [], 'links.broken');
|
|
633
|
+
same(out.links.checked, 2, 'links.checked');
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// Case 33: a link nested in a link label. CommonMark makes only the inner link a link; doc-check checks both targets, so
|
|
637
|
+
// neither is skipped silently. Both missing gives both broken, compared as sorted sets.
|
|
638
|
+
assertPerLeg('case-33:link-nested-in-link-label-both-checked', leg => {
|
|
639
|
+
const ws = makeWorkspace(REFERENCE({ domains: ['GRPH'] }));
|
|
640
|
+
writeDoc(ws, 'docs/a.md', refDoc('See [outer [inner](b.md)](doc.md).'));
|
|
641
|
+
const result = runCheck(leg.dir, { workspace: ws, config: CONFIG, args: ['--changed-path', 'docs/a.md'] });
|
|
642
|
+
same(result.status, 1, 'exit');
|
|
643
|
+
const out = jsonOut(result);
|
|
644
|
+
same((out.links.broken || []).map(item => item.target).sort(), ['b.md', 'doc.md'], 'broken targets');
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
// Case 34: deeply nested labels complete within a generous cross-platform bound and count every target once.
|
|
648
|
+
assertPerLeg('case-34:deeply-nested-labels-iterative-and-complete', leg => {
|
|
649
|
+
const depth = 6000;
|
|
650
|
+
const ws = makeWorkspace(REFERENCE({ domains: ['GRPH'] }));
|
|
651
|
+
writeDoc(ws, 'docs/a.md', refDoc('['.repeat(depth) + 'x' + '](a)'.repeat(depth)));
|
|
652
|
+
writeDoc(ws, 'docs/a', 'target\n');
|
|
653
|
+
const result = runCheck(leg.dir, {
|
|
654
|
+
workspace: ws, config: CONFIG, timeoutMs: 30000,
|
|
655
|
+
args: ['--changed-path', 'docs/a.md']
|
|
656
|
+
});
|
|
657
|
+
same(result.status, 0, 'exit within 30 seconds');
|
|
658
|
+
const out = jsonOut(result);
|
|
659
|
+
same(out.links.checked, depth, 'all nested targets checked once');
|
|
660
|
+
same(out.links.broken, [], 'broken targets');
|
|
661
|
+
same(out.coverage.unsupported, [], 'unsupported syntax');
|
|
662
|
+
same(result.stdout, `${JSON.stringify(out, null, 2)}\n`, 'single JSON object on stdout');
|
|
663
|
+
same(result.stderr, '', 'stderr');
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// ---- Summary -------------------------------------------------------------------------------------------------------
|
|
667
|
+
|
|
668
|
+
console.log(`\nF1 doc-check summary: ${passed} PASS, ${failed} FAIL, ${skipped} SKIP${failed ? `\nFAILED assertions (${failures.length}):\n ${failures.join('\n ')}` : ''}${failed === 0 && skipped > 0 ? ' (skips are environment-gated cases only)' : ''}`);
|
|
669
|
+
|
|
670
|
+
for (const dir of tmpDirs) {
|
|
671
|
+
try { removeTree(dir); } catch { /* tmp leftovers are harmless */ }
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
process.exitCode = failed > 0 ? 1 : 0;
|