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,798 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// F3 derived-views lock suite (strict RED): derived applicability in `check` and the pure
|
|
5
|
+
// `compare-derived` subcommand, per the A2 §1/§3/§4 spec frozen in the A2-advisor report:
|
|
6
|
+
// expected values come from the spec table never from running code.
|
|
7
|
+
//
|
|
8
|
+
// D cases run per leg (canonical scripts / packaged mirror) on tmp workspaces with a space in
|
|
9
|
+
// the name; P pins doc-check.js byte parity. D15 dogfoods the repo declaration.
|
|
10
|
+
//
|
|
11
|
+
// RED is expected until I2 lands: `compare-derived` is an unknown subcommand (exit 2, no JSON),
|
|
12
|
+
// and `check` marks nothing `unverified`. Each case must fail at an assertion, never crash.
|
|
13
|
+
// D1 (execution firebreak) and P1 may legitimately pass at RED.
|
|
14
|
+
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { spawnSync } = require('child_process');
|
|
20
|
+
|
|
21
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
22
|
+
const MODULE = 'doc-check.js';
|
|
23
|
+
const CONFIG = '.agents/harness-everything/project-docs.json';
|
|
24
|
+
const LEGS = [
|
|
25
|
+
{ name: 'source', dir: path.join(ROOT, 'harness-everything', 'scripts') },
|
|
26
|
+
{ name: 'packaged', dir: path.join(ROOT, 'plugins', 'harness-everything', 'skills', 'harness-everything', 'scripts') }
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
let passed = 0;
|
|
30
|
+
let failed = 0;
|
|
31
|
+
let skipped = 0;
|
|
32
|
+
const failures = [];
|
|
33
|
+
const tmpDirs = [];
|
|
34
|
+
const compareRuns = []; // every compare-derived stdout, for the D14 single-JSON-object lock
|
|
35
|
+
|
|
36
|
+
function record(status, id, leg, detail) {
|
|
37
|
+
const where = leg ? ` [${leg}]` : '';
|
|
38
|
+
if (status === 'PASS') passed += 1;
|
|
39
|
+
else if (status === 'SKIP') skipped += 1;
|
|
40
|
+
else { failed += 1; failures.push(`${id}${where}`); }
|
|
41
|
+
console.log(` ${status} ${id}${where}${detail ? ` — ${detail}` : ''}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function truncate(text) {
|
|
45
|
+
const s = text === undefined ? 'undefined' : String(text);
|
|
46
|
+
return s.length > 300 ? `${s.slice(0, 300)}…` : s;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function same(actual, expected, label) {
|
|
50
|
+
const a = JSON.stringify(actual);
|
|
51
|
+
const e = JSON.stringify(expected);
|
|
52
|
+
if (a !== e) throw new Error(`${label}: got ${truncate(a)} expected ${truncate(e)}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sha256(file) {
|
|
56
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---- Fixture helpers -----------------------------------------------------------------------------------------------
|
|
60
|
+
function removeTree(dir) {
|
|
61
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeDoc(ws, relative, text) {
|
|
65
|
+
const absolute = path.join(ws, relative);
|
|
66
|
+
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
67
|
+
fs.writeFileSync(absolute, text);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function makeWorkspace(config) {
|
|
71
|
+
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'f3 derived views '));
|
|
72
|
+
tmpDirs.push(ws);
|
|
73
|
+
if (config) writeDoc(ws, CONFIG, `${JSON.stringify(config, null, 2)}\n`);
|
|
74
|
+
return ws;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function candidateDir() {
|
|
78
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'f3 candidates '));
|
|
79
|
+
tmpDirs.push(dir);
|
|
80
|
+
return dir;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const TARGET_BYTES = Buffer.from('# Generated index\n\n- [architecture](architecture.md)\n', 'utf8');
|
|
84
|
+
const refDoc = body => `---\nrole: reference\nstatus: active\n---\n\n${body}\n`;
|
|
85
|
+
|
|
86
|
+
// ---- Config factories -----------------------------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
// Config with one declared view whose generator is a sentinel: if the command string is ever
|
|
89
|
+
// executed during discovery or comparison it writes the marker file, and D1 catches it.
|
|
90
|
+
function markerPath(ws) {
|
|
91
|
+
return path.join(ws, 'RAN').replace(/\\/g, '/');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sentinelGenerator(ws) {
|
|
95
|
+
return `node -e "require('fs').writeFileSync('${markerPath(ws)}','x')"`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function viewWorkspace(extraDerived) {
|
|
99
|
+
const ws = makeWorkspace(null);
|
|
100
|
+
const config = {
|
|
101
|
+
governed: [{ path: 'docs/a.md', role: 'reference' }],
|
|
102
|
+
derived: Array.isArray(extraDerived) ? extraDerived : [{ path: 'docs/index.md', generator: sentinelGenerator(ws) }]
|
|
103
|
+
};
|
|
104
|
+
writeDoc(ws, CONFIG, `${JSON.stringify(config, null, 2)}\n`);
|
|
105
|
+
return { ws };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function writeTarget(ws) {
|
|
109
|
+
writeDoc(ws, 'docs/index.md', TARGET_BYTES.toString('utf8'));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function markerRan(ws) {
|
|
113
|
+
return fs.existsSync(path.join(ws, 'RAN'));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function spawnIt(scriptDir, subcommand, flags, extra) {
|
|
117
|
+
return spawnSync(process.execPath, [
|
|
118
|
+
path.join(scriptDir, MODULE), subcommand,
|
|
119
|
+
...flags
|
|
120
|
+
], { encoding: 'utf8', cwd: os.tmpdir(), ...extra });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runCheck(scriptDir, workspace, args) {
|
|
124
|
+
return spawnIt(scriptDir, 'check', [
|
|
125
|
+
'--workspace', workspace,
|
|
126
|
+
'--config', CONFIG,
|
|
127
|
+
...(args || [])
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function runCompare(scriptDir, options) {
|
|
132
|
+
const result = spawnIt(scriptDir, 'compare-derived', [
|
|
133
|
+
...(options.workspace ? ['--workspace', options.workspace, '--config', options.config] : []),
|
|
134
|
+
...(options.view ? ['--view', options.view] : []),
|
|
135
|
+
...(options.candidate ? ['--candidate', options.candidate] : []),
|
|
136
|
+
...(options.status ? ['--generation-status', options.status] : []),
|
|
137
|
+
...(options.args || [])
|
|
138
|
+
], options.extra);
|
|
139
|
+
compareRuns.push({ output: result.stdout || '' });
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function jsonOut(result) {
|
|
144
|
+
try { return JSON.parse(result.stdout); } catch {
|
|
145
|
+
throw new Error(`stdout is not JSON (exit ${result.status}): ${truncate(result.stdout || result.stderr)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function assertPerLeg(id, fn) {
|
|
150
|
+
for (const leg of LEGS) {
|
|
151
|
+
if (!fs.existsSync(path.join(leg.dir, MODULE))) {
|
|
152
|
+
record('FAIL', id, leg.name, `module-missing: ${path.relative(ROOT, path.join(leg.dir, MODULE))}`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
try { fn(leg); record('PASS', id, leg.name); } catch (error) {
|
|
156
|
+
record('FAIL', id, leg.name, error.message.split('\n')[0]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- D cases -------------------------------------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
// D1: discovery and comparison are non-executing. The sentinel generator must never run —
|
|
164
|
+
// through `check` (normal and malformed config) or through `compare-derived`. At RED this
|
|
165
|
+
// firebreak passes (nothing runs anything); it must keep passing at GREEN.
|
|
166
|
+
function caseD1(leg) {
|
|
167
|
+
const { ws } = viewWorkspace();
|
|
168
|
+
writeDoc(ws, 'docs/a.md', refDoc('Plain governed text.\n'));
|
|
169
|
+
writeTarget(ws);
|
|
170
|
+
const malformed = {
|
|
171
|
+
ws: makeWorkspace(null),
|
|
172
|
+
};
|
|
173
|
+
writeDoc(malformed.ws, CONFIG, `${JSON.stringify({ governed: [{ path: 'docs/a.md', role: 'reference' }], derived: [{ path: 'docs/index.md' }] }, null, 2)}\n`);
|
|
174
|
+
const dir = candidateDir();
|
|
175
|
+
const candidate = path.join(dir, 'candidate.md');
|
|
176
|
+
fs.writeFileSync(candidate, TARGET_BYTES);
|
|
177
|
+
|
|
178
|
+
runCheck(leg.dir, ws, ['--changed-path', 'docs/a.md']);
|
|
179
|
+
runCompare(leg.dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'ok' });
|
|
180
|
+
if (markerRan(ws)) throw new Error('sentinel generator executed during check/compare');
|
|
181
|
+
|
|
182
|
+
writeDoc(malformed.ws, 'docs/a.md', refDoc('Plain governed text.\n'));
|
|
183
|
+
runCheck(leg.dir, malformed.ws, ['--changed-path', 'docs/a.md']);
|
|
184
|
+
runCompare(leg.dir, { workspace: malformed.ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'ok' });
|
|
185
|
+
if (markerRan(malformed.ws)) throw new Error('sentinel executed on malformed config');
|
|
186
|
+
}
|
|
187
|
+
assertPerLeg('case-d1:sentinel-never-executed', leg => caseD1(leg));
|
|
188
|
+
|
|
189
|
+
// D2: a governed source change with one derived view keeps the F2 check exit (0) as ordinary
|
|
190
|
+
// lint/link evidence, while coverage.unsupported names the affected view as unverified with the
|
|
191
|
+
// explicit conservative applicability label (A2 §1).
|
|
192
|
+
function caseD2(leg) {
|
|
193
|
+
const { ws } = viewWorkspace();
|
|
194
|
+
writeDoc(ws, 'docs/a.md', refDoc('Plain governed text.\n'));
|
|
195
|
+
writeTarget(ws);
|
|
196
|
+
const result = runCheck(leg.dir, ws, ['--changed-path', 'docs/a.md']);
|
|
197
|
+
same(result.status, 0, 'check exit (F2 meaning preserved)');
|
|
198
|
+
const out = jsonOut(result);
|
|
199
|
+
const entry = (out.coverage.unsupported || []).find(item => item.path === 'docs/index.md');
|
|
200
|
+
if (!entry) throw new Error(`no coverage.unsupported entry for docs/index.md: ${truncate(JSON.stringify(out.coverage))}`);
|
|
201
|
+
same(entry.state, 'unverified', 'coverage state');
|
|
202
|
+
same(entry.applicability, 'conservative-all-governed', 'coverage applicability');
|
|
203
|
+
if (markerRan(ws)) throw new Error('sentinel generator executed during check');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
assertPerLeg('case-d2:check-marks-affected-view-unverified', leg => caseD2(leg));
|
|
207
|
+
|
|
208
|
+
// D3: equal candidate with reported generation ok → exit 0, result equal (regenerate-equals).
|
|
209
|
+
function caseD3(leg) {
|
|
210
|
+
const { ws } = viewWorkspace();
|
|
211
|
+
writeTarget(ws);
|
|
212
|
+
const dir = candidateDir();
|
|
213
|
+
const candidate = path.join(dir, 'candidate.md');
|
|
214
|
+
fs.writeFileSync(candidate, TARGET_BYTES);
|
|
215
|
+
const result = runCompare(leg.dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'ok' });
|
|
216
|
+
same(result.status, 0, 'exit');
|
|
217
|
+
if (!JSON.stringify(jsonOut(result)).includes('equal')) throw new Error(`result state is not equal: ${truncate(result.stdout)}`);
|
|
218
|
+
}
|
|
219
|
+
assertPerLeg('case-d3:equal-candidate-exit-0-equal', leg => caseD3(leg));
|
|
220
|
+
|
|
221
|
+
// D4: a one-byte difference → exit 1 stale; the checked-in target must be preserved byte for byte.
|
|
222
|
+
function caseD4(leg) {
|
|
223
|
+
const { ws } = viewWorkspace();
|
|
224
|
+
writeTarget(ws);
|
|
225
|
+
const dir = candidateDir();
|
|
226
|
+
const candidate = path.join(dir, 'candidate.md');
|
|
227
|
+
const differing = Buffer.concat([TARGET_BYTES.slice(0, TARGET_BYTES.length - 1), Buffer.from('X\n', 'utf8')]);
|
|
228
|
+
fs.writeFileSync(candidate, differing);
|
|
229
|
+
const before = sha256(path.join(ws, 'docs', 'index.md'));
|
|
230
|
+
const result = runCompare(leg.dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'ok' });
|
|
231
|
+
same(result.status, 1, 'exit');
|
|
232
|
+
if (!JSON.stringify(jsonOut(result)).includes('stale')) throw new Error(`result state is not stale: ${truncate(result.stdout)}`);
|
|
233
|
+
same(sha256(path.join(ws, 'docs', 'index.md')), before, 'target preserved');
|
|
234
|
+
}
|
|
235
|
+
assertPerLeg('case-d4:one-byte-diff-stale-target-preserved', leg => caseD4(leg));
|
|
236
|
+
|
|
237
|
+
// D5: the declared target file is absent → exit 1 missing-view.
|
|
238
|
+
function caseD5(leg) {
|
|
239
|
+
const { ws } = viewWorkspace();
|
|
240
|
+
const dir = candidateDir();
|
|
241
|
+
const candidate = path.join(dir, 'candidate.md');
|
|
242
|
+
fs.writeFileSync(candidate, TARGET_BYTES);
|
|
243
|
+
const result = runCompare(leg.dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'ok' });
|
|
244
|
+
same(result.status, 1, 'exit');
|
|
245
|
+
if (!JSON.stringify(jsonOut(result)).includes('missing-view')) throw new Error(`result state is not missing-view: ${truncate(result.stdout)}`);
|
|
246
|
+
}
|
|
247
|
+
assertPerLeg('case-d5:missing-target-missing-view', leg => caseD5(leg));
|
|
248
|
+
|
|
249
|
+
// D6: reported generator failure with an artifact that happens to equal the target must not pass.
|
|
250
|
+
function caseD6(leg) {
|
|
251
|
+
const { ws } = viewWorkspace();
|
|
252
|
+
writeTarget(ws);
|
|
253
|
+
const dir = candidateDir();
|
|
254
|
+
const candidate = path.join(dir, 'candidate.md');
|
|
255
|
+
fs.writeFileSync(candidate, TARGET_BYTES);
|
|
256
|
+
const result = runCompare(leg.dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'failed' });
|
|
257
|
+
same(result.status, 1, 'exit');
|
|
258
|
+
if (!JSON.stringify(jsonOut(result)).includes('failed')) throw new Error(`result state is not generator-failed: ${truncate(result.stdout)}`);
|
|
259
|
+
}
|
|
260
|
+
assertPerLeg('case-d6:failed-status-equal-candidate-not-equal', leg => caseD6(leg));
|
|
261
|
+
|
|
262
|
+
// D7: reported timeout → exit 2 generation-timeout; the candidate must not be compared (an equal
|
|
263
|
+
// candidate would otherwise return equal on this lane).
|
|
264
|
+
function caseD7(leg) {
|
|
265
|
+
const { ws } = viewWorkspace();
|
|
266
|
+
writeTarget(ws);
|
|
267
|
+
const dir = candidateDir();
|
|
268
|
+
const candidate = path.join(dir, 'candidate.md');
|
|
269
|
+
fs.writeFileSync(candidate, TARGET_BYTES);
|
|
270
|
+
const result = runCompare(leg.dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'timeout' });
|
|
271
|
+
same(result.status, 2, 'exit');
|
|
272
|
+
const text = JSON.stringify(jsonOut(result));
|
|
273
|
+
if (!text.includes('timeout')) throw new Error(`result state is not generation-timeout: ${truncate(result.stdout)}`);
|
|
274
|
+
if (text.includes('"equal"')) throw new Error('candidate compared despite timeout');
|
|
275
|
+
}
|
|
276
|
+
assertPerLeg('case-d7:timeout-status-not-compared', leg => caseD7(leg));
|
|
277
|
+
|
|
278
|
+
// D8: reported unavailable generator → exit 2 generator-unavailable.
|
|
279
|
+
function caseD8(leg) {
|
|
280
|
+
const { ws } = viewWorkspace();
|
|
281
|
+
writeTarget(ws);
|
|
282
|
+
const dir = candidateDir();
|
|
283
|
+
const candidate = path.join(dir, 'candidate.md');
|
|
284
|
+
fs.writeFileSync(candidate, TARGET_BYTES);
|
|
285
|
+
const result = runCompare(leg.dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'unavailable' });
|
|
286
|
+
same(result.status, 2, 'exit');
|
|
287
|
+
if (!JSON.stringify(jsonOut(result)).includes('unavailable')) throw new Error(`result state is not generator-unavailable: ${truncate(result.stdout)}`);
|
|
288
|
+
}
|
|
289
|
+
assertPerLeg('case-d8:unavailable-status-exit-2', leg => caseD8(leg));
|
|
290
|
+
|
|
291
|
+
// D9: a missing or invalid --generation-status → exit 2 on both variants.
|
|
292
|
+
function caseD9(leg) {
|
|
293
|
+
const { ws } = viewWorkspace();
|
|
294
|
+
writeTarget(ws);
|
|
295
|
+
const dir = candidateDir();
|
|
296
|
+
const candidate = path.join(dir, 'candidate.md');
|
|
297
|
+
fs.writeFileSync(candidate, TARGET_BYTES);
|
|
298
|
+
same(runCompare(leg.dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate }).status, 2, 'missing status exit');
|
|
299
|
+
same(runCompare(leg.dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'bogus' }).status, 2, 'invalid status exit');
|
|
300
|
+
}
|
|
301
|
+
assertPerLeg('case-d9:missing-or-invalid-status-exit-2', leg => caseD9(leg));
|
|
302
|
+
|
|
303
|
+
// D10: an undeclared view, and a config declaring the same view twice, both exit 2.
|
|
304
|
+
function caseD10(leg) {
|
|
305
|
+
const plain = viewWorkspace();
|
|
306
|
+
writeTarget(plain.ws);
|
|
307
|
+
const dir = candidateDir();
|
|
308
|
+
const candidate = path.join(dir, 'candidate.md');
|
|
309
|
+
fs.writeFileSync(candidate, TARGET_BYTES);
|
|
310
|
+
same(runCompare(leg.dir, { workspace: plain.ws, config: CONFIG, view: 'docs/other.md', candidate, status: 'ok' }).status, 2, 'undeclared view exit');
|
|
311
|
+
|
|
312
|
+
const duplicate = viewWorkspace([
|
|
313
|
+
{ path: 'docs/index.md', generator: sentinelGenerator(plain.ws) },
|
|
314
|
+
{ path: 'docs/index.md', generator: sentinelGenerator(plain.ws) }
|
|
315
|
+
]);
|
|
316
|
+
writeTarget(duplicate.ws);
|
|
317
|
+
same(runCompare(leg.dir, { workspace: duplicate.ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'ok' }).status, 2, 'duplicate declaration exit');
|
|
318
|
+
}
|
|
319
|
+
assertPerLeg('case-d10:undeclared-or-duplicate-view-exit-2', leg => caseD10(leg));
|
|
320
|
+
|
|
321
|
+
// D11: a candidate that is the target itself — directly or through a symlink (skipped on
|
|
322
|
+
// win32 where symlinking may need privileges) — is rejected with exit 2.
|
|
323
|
+
function caseD11(leg) {
|
|
324
|
+
const { ws } = viewWorkspace();
|
|
325
|
+
writeTarget(ws);
|
|
326
|
+
const direct = runCompare(leg.dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate: path.join(ws, 'docs', 'index.md'), status: 'ok' });
|
|
327
|
+
same(direct.status, 2, 'direct alias exit');
|
|
328
|
+
if (process.platform === 'win32') return;
|
|
329
|
+
const link = path.join(ws, 'docs', 'alias.md');
|
|
330
|
+
fs.symlinkSync(path.join(ws, 'docs', 'index.md'), link);
|
|
331
|
+
const through = runCompare(leg.dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate: link, status: 'ok' });
|
|
332
|
+
same(through.status, 2, 'symlink alias exit');
|
|
333
|
+
}
|
|
334
|
+
assertPerLeg('case-d11:candidate-aliases-target-exit-2', leg => caseD11(leg));
|
|
335
|
+
|
|
336
|
+
// D12: traversal, absolute, and a declared glob are all unsupported selections (exit 2).
|
|
337
|
+
function caseD12(leg) {
|
|
338
|
+
const globbed = viewWorkspace([{ path: 'docs/*.md', generator: 'node generator.js' }]);
|
|
339
|
+
writeTarget(globbed.ws);
|
|
340
|
+
const dir = candidateDir();
|
|
341
|
+
const candidate = path.join(dir, 'candidate.md');
|
|
342
|
+
fs.writeFileSync(candidate, TARGET_BYTES);
|
|
343
|
+
same(runCompare(leg.dir, { workspace: globbed.ws, config: CONFIG, view: '../x', candidate, status: 'ok' }).status, 2, 'traversal exit');
|
|
344
|
+
same(runCompare(leg.dir, { workspace: globbed.ws, config: CONFIG, view: path.join(os.tmpdir(), 'elsewhere.md'), candidate, status: 'ok' }).status, 2, 'absolute view exit');
|
|
345
|
+
const globResult = runCompare(leg.dir, { workspace: globbed.ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'ok' });
|
|
346
|
+
same(globResult.status, 2, 'glob view exit');
|
|
347
|
+
if (!JSON.stringify(jsonOut(globResult)).includes('unsupported')) throw new Error(`glob selection not reported unsupported: ${truncate(globResult.stdout)}`);
|
|
348
|
+
}
|
|
349
|
+
assertPerLeg('case-d12:traversal-absolute-glob-unsupported', leg => caseD12(leg));
|
|
350
|
+
|
|
351
|
+
// D13: CRLF candidate bytes against an LF target are stale — no normalization, no trimming.
|
|
352
|
+
function caseD13(leg) {
|
|
353
|
+
const { ws } = viewWorkspace();
|
|
354
|
+
writeTarget(ws);
|
|
355
|
+
const dir = candidateDir();
|
|
356
|
+
const candidate = path.join(dir, 'candidate.md');
|
|
357
|
+
fs.writeFileSync(candidate, Buffer.from(TARGET_BYTES.toString('utf8').replace(/\n/g, '\r\n'), 'utf8'));
|
|
358
|
+
const result = runCompare(leg.dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'ok' });
|
|
359
|
+
same(result.status, 1, 'exit');
|
|
360
|
+
if (!JSON.stringify(jsonOut(result)).includes('stale')) throw new Error(`CRLF candidate accepted: ${truncate(result.stdout)}`);
|
|
361
|
+
}
|
|
362
|
+
assertPerLeg('case-d13:crlf-candidate-not-normalized', leg => caseD13(leg));
|
|
363
|
+
|
|
364
|
+
// D14: every compare-derived lane above (plus D1) printed exactly one JSON object on stdout.
|
|
365
|
+
{
|
|
366
|
+
const id = 'case-d14:stdout-single-json-object';
|
|
367
|
+
try {
|
|
368
|
+
const bad = compareRuns
|
|
369
|
+
.filter(run => {
|
|
370
|
+
const text = run.output.trim();
|
|
371
|
+
if (!text.startsWith('{') || !text.endsWith('}')) return true;
|
|
372
|
+
try { JSON.parse(text); return false; } catch { return true; }
|
|
373
|
+
}).length;
|
|
374
|
+
if (bad > 0) throw new Error(`${bad} compare-derived lane(s) without one JSON object on stdout`);
|
|
375
|
+
record('PASS', id, 'both');
|
|
376
|
+
} catch (error) {
|
|
377
|
+
record('FAIL', id, 'both', error.message.split('\n')[0]);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// D15: dogfood — the repo declares docs/governed-index.md; the index generator runs through
|
|
382
|
+
// explicit argv with Buffer capture, then compare-derived must see exit 0 equal. RED until the
|
|
383
|
+
// coordinator generates the index (A2 §2).
|
|
384
|
+
{
|
|
385
|
+
const id = 'case-d15:dogfood-index-matches-checkin';
|
|
386
|
+
assertPerLeg(id, leg => {
|
|
387
|
+
const generation = spawnSync(process.execPath, [
|
|
388
|
+
path.join(LEGS[0].dir, 'doc-lifecycle.js'), 'index',
|
|
389
|
+
'--workspace', ROOT,
|
|
390
|
+
'--config', CONFIG,
|
|
391
|
+
'--all'
|
|
392
|
+
], { encoding: 'buffer', cwd: ROOT });
|
|
393
|
+
if (generation.status !== 0) throw new Error(`index generation failed (exit ${generation.status}): ${truncate(generation.stderr.toString())}`);
|
|
394
|
+
const dir = candidateDir();
|
|
395
|
+
const candidate = path.join(dir, 'candidate-index.md');
|
|
396
|
+
fs.writeFileSync(candidate, generation.stdout);
|
|
397
|
+
const result = runCompare(leg.dir, { workspace: ROOT, config: CONFIG, view: 'docs/governed-index.md', candidate, status: 'ok' });
|
|
398
|
+
same(result.status, 0, 'exit');
|
|
399
|
+
if (!JSON.stringify(jsonOut(result)).includes('equal')) throw new Error(`result state is not equal: ${truncate(result.stdout)}`);
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---- P case: byte parity -------------------------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
{
|
|
406
|
+
const id = 'case-p1:doc-check-byte-parity';
|
|
407
|
+
try {
|
|
408
|
+
same(sha256(path.join(LEGS[1].dir, MODULE)), sha256(path.join(LEGS[0].dir, MODULE)), 'sha256 doc-check.js source = packaged');
|
|
409
|
+
record('PASS', id, 'both');
|
|
410
|
+
} catch (error) {
|
|
411
|
+
record('FAIL', id, 'both', error.message.split('\n')[0]);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ---- Mutation probes (run with --mutation-probes once the suite is GREEN) -------------------------------------------
|
|
416
|
+
// Probes mutate a tmp copy of the source scripts and expect the referenced case assertion to
|
|
417
|
+
// throw on the mutant (mutation detected). Each becomes meaningful at GREEN; at RED they may
|
|
418
|
+
// report that they did not apply — sharpen the locator for the implemented shape then.
|
|
419
|
+
|
|
420
|
+
class ProbeApplyError extends Error {}
|
|
421
|
+
|
|
422
|
+
function scriptCopy(prefix) {
|
|
423
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
424
|
+
tmpDirs.push(dir);
|
|
425
|
+
fs.cpSync(LEGS[0].dir, dir, { recursive: true });
|
|
426
|
+
return dir;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function applyMutation(dir, needle, replacement, label) {
|
|
430
|
+
const file = path.join(dir, MODULE);
|
|
431
|
+
const original = fs.readFileSync(file, 'utf8');
|
|
432
|
+
const occurrences = original.split(needle).length - 1;
|
|
433
|
+
if (occurrences !== 1) throw new ProbeApplyError(`locator "${needle}" (${label}) matched ${occurrences} occurrences; probe did not apply`);
|
|
434
|
+
fs.writeFileSync(file, original.replace(needle, replacement));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// mp1: ignore --generation-status (treat every reported status as ok). Shape-independent wiring:
|
|
438
|
+
// rewrite the extra argv so a `failed` report is presented as `ok`; D6 must then fail on the mutant.
|
|
439
|
+
function mp1Apply() {
|
|
440
|
+
const dir = scriptCopy('f3 derived mp1 ');
|
|
441
|
+
const marker = 'if (require.main === module)';
|
|
442
|
+
const injected = 'if (process.env.F3_MP1) { const i = process.argv.indexOf(\'--generation-status\'); if (i >= 0 && process.argv[i + 1] !== undefined) process.argv.splice(i, 2, \'--generation-status\', \'ok\'); }\n';
|
|
443
|
+
applyMutation(dir, marker, `${injected}${marker}`, 'mp1 main gate');
|
|
444
|
+
return dir;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function mp1Assert(dir) {
|
|
448
|
+
const { ws } = viewWorkspace();
|
|
449
|
+
writeTarget(ws);
|
|
450
|
+
const dirC = candidateDir();
|
|
451
|
+
const candidate = path.join(dirC, 'candidate.md');
|
|
452
|
+
fs.writeFileSync(candidate, TARGET_BYTES);
|
|
453
|
+
// The D6 assertion: a failed generation report with an equal artifact must not pass.
|
|
454
|
+
const result = runCompare(dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'failed', extra: { env: { ...process.env, F3_MP1: '1' } } });
|
|
455
|
+
same(result.status, 1, 'exit (mutated ignored status must violate this)');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// mp2: normalize line endings while reading bytes; D13 (CRLF candidate) must then fail on the mutant.
|
|
459
|
+
function mp2Apply() {
|
|
460
|
+
const dir = scriptCopy('f3 derived mp2 ');
|
|
461
|
+
const file = path.join(dir, MODULE);
|
|
462
|
+
const original = fs.readFileSync(file, 'utf8');
|
|
463
|
+
const needle = 'const fs = require(\'fs\');';
|
|
464
|
+
const occurrences = original.split(needle).length - 1;
|
|
465
|
+
if (occurrences !== 1) throw new ProbeApplyError(`fs require hook (${occurrences}); sharpen this probe for the green shape`);
|
|
466
|
+
const injected = `${needle}\n{ const _rf = fs.readFileSync; fs.readFileSync = (p, o) => { const b = _rf(p, o); if (Buffer.isBuffer(b)) return Buffer.from(b.toString('utf8').replace(/\\r\\n/g, '\\n')); return b; }; }`;
|
|
467
|
+
fs.writeFileSync(file, original.replace(needle, injected));
|
|
468
|
+
return dir;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function mp2Assert(dir) {
|
|
472
|
+
const { ws } = viewWorkspace();
|
|
473
|
+
writeTarget(ws);
|
|
474
|
+
const dirC = candidateDir();
|
|
475
|
+
const candidate = path.join(dirC, 'candidate.md');
|
|
476
|
+
fs.writeFileSync(candidate, Buffer.from(TARGET_BYTES.toString('utf8').replace(/\n/g, '\r\n'), 'utf8'));
|
|
477
|
+
// The D13 assertion: CRLF-vs-LF must stay stale under byte comparison.
|
|
478
|
+
const result = runCompare(dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'ok' });
|
|
479
|
+
same(result.status, 1, 'exit (mutated normalization must violate this)');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// mp3: neuter the sameFile identity guard; D11 must then fail on the mutant.
|
|
483
|
+
function mp3Apply() {
|
|
484
|
+
const dir = scriptCopy('f3 derived mp3 ');
|
|
485
|
+
// The guard's only decision line — elsewhere in the mutant the script stays intact, so the
|
|
486
|
+
// aliased run proceeds to a real byte comparison (exit 0, equal) instead of crashing.
|
|
487
|
+
applyMutation(dir, 'return x.ino > 0 && x.ino === y.ino && x.dev === y.dev;', 'return false;', 'mp3 sameFile identity guard');
|
|
488
|
+
return dir;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function mp3Assert(dir) {
|
|
492
|
+
const { ws } = viewWorkspace();
|
|
493
|
+
writeTarget(ws);
|
|
494
|
+
// The D11 assertion: the aliased candidate is rejected with exit 2 AND the identity/alias
|
|
495
|
+
// reason, so the probe cannot pass because a crash or stale result happens to be non-zero.
|
|
496
|
+
const direct = runCompare(dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate: path.join(ws, 'docs', 'index.md'), status: 'ok' });
|
|
497
|
+
const out = jsonOut(direct);
|
|
498
|
+
same(direct.status, 2, 'exit (mutated dropped alias check must violate this)');
|
|
499
|
+
same(out.reason, 'candidate aliases the target', 'reason (mutated dropped alias check must violate this)');
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (process.argv.includes('--mutation-probes')) {
|
|
503
|
+
for (const [id, apply, assertFn] of [
|
|
504
|
+
['mp1:generation-status-ignored-detected-by-d6', mp1Apply, mp1Assert],
|
|
505
|
+
['mp2:line-ending-normalization-detected-by-d13', mp2Apply, mp2Assert],
|
|
506
|
+
['mp3:alias-check-dropped-detected-by-d11', mp3Apply, mp3Assert]
|
|
507
|
+
]) {
|
|
508
|
+
// A probe PASSES when the case assertion throws on the mutant (mutation detected).
|
|
509
|
+
let dir;
|
|
510
|
+
try { dir = apply(); } catch (error) {
|
|
511
|
+
if (error instanceof ProbeApplyError) record('FAIL', id, 'source', `probe did not apply: ${error.message.split('\n')[0]}`);
|
|
512
|
+
else record('FAIL', id, 'source', `apply error: ${error.message.split('\n')[0]}`);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
try { assertFn(dir); record('FAIL', id, 'source', 'mutation survived — the case passed on the mutant'); } catch (error) {
|
|
516
|
+
record('PASS', id, 'source', `mutation detected: ${error.message.split('\n')[0]}`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---- Summary -------------------------------------------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
console.log(`\nF3 derived-views 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)' : ''}`);
|
|
524
|
+
|
|
525
|
+
for (const dir of tmpDirs) {
|
|
526
|
+
try { removeTree(dir); } catch { /* tmp leftovers are harmless */ }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
process.exitCode = failed > 0 ? 1 : 0;
|
|
530
|
+
|
|
531
|
+
// =====================================================================================================================
|
|
532
|
+
// Appended block (packet F3-T3) — D17–D23 and mp4–mp6, appended without touching the frozen cases above.
|
|
533
|
+
// Spec: the F3-W3 packet table plus F3-R report rows F3-01, F3-02, F3-08, F3-11; expected values come from that spec,
|
|
534
|
+
// never from running code. RED is expected until F3-W3 lands (each case must fail at an assertion, never crash).
|
|
535
|
+
// Because this block must be appended strictly after the frozen tail, the frozen mid-file summary is a snapshot;
|
|
536
|
+
// the trailing summary below is the authoritative total for the whole file.
|
|
537
|
+
// =====================================================================================================================
|
|
538
|
+
|
|
539
|
+
function appendMsg() {
|
|
540
|
+
return '[T3 appended] ';
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function runCheckEnv(scriptDir, workspace, args, extraEnv) {
|
|
544
|
+
return spawnIt(scriptDir, 'check', [
|
|
545
|
+
'--workspace', workspace,
|
|
546
|
+
'--config', CONFIG,
|
|
547
|
+
...(args || [])
|
|
548
|
+
], { env: { ...process.env, ...(extraEnv || {}) } });
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function unsupportedEntries(out) {
|
|
552
|
+
return (out && out.coverage && Array.isArray(out.coverage.unsupported)) ? out.coverage.unsupported : [];
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ---- Git fixtures for the --base cases (D17, D18) -------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
function gitRun(ws, args) {
|
|
558
|
+
return spawnSync('git', ['-C', ws, ...args], { encoding: 'utf8' });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function initGitRepo(ws) {
|
|
562
|
+
const r = gitRun(ws, ['init', '-q']);
|
|
563
|
+
if (r.status !== 0) throw new Error(`git init failed: ${truncate(r.stderr)}`);
|
|
564
|
+
const c = gitRun(ws, ['-c', 'user.email=red@example.invalid', '-c', 'user.name=F3 T3', '-c', 'commit.gpgsign=false', 'add', '-A']);
|
|
565
|
+
if (c.status !== 0) throw new Error(`git add failed: ${truncate(c.stderr)}`);
|
|
566
|
+
const d = gitRun(ws, ['-c', 'user.email=red@example.invalid', '-c', 'user.name=F3 T3', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'base']);
|
|
567
|
+
if (d.status !== 0) throw new Error(`git commit failed: ${truncate(d.stderr)}`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/// D17: with the declaration committed at HEAD and then removed in the working tree, `check --base HEAD` (config as a
|
|
571
|
+
/// changed path) must name the removed declaration: an unsupported entry {path:"docs/index.md", state:"unverified",
|
|
572
|
+
/// applicability:"declaration-removed"}. The F2 exit meaning is unchanged: the exit equals the same run with the
|
|
573
|
+
/// declaration still present. (F3-01, first lane.)
|
|
574
|
+
function caseD17Body(dir, extraEnv) {
|
|
575
|
+
const { ws } = viewWorkspace();
|
|
576
|
+
writeDoc(ws, 'docs/a.md', refDoc('Plain governed text.\n'));
|
|
577
|
+
initGitRepo(ws);
|
|
578
|
+
const flags = ['--base', 'HEAD', '--changed-path', CONFIG];
|
|
579
|
+
const control = runCheckEnv(dir, ws, flags, extraEnv);
|
|
580
|
+
same(control.status, 0, 'control exit (declaration present)');
|
|
581
|
+
writeDoc(ws, CONFIG, `${JSON.stringify({ governed: [{ path: 'docs/a.md', role: 'reference' }], derived: [] }, null, 2)}\n`);
|
|
582
|
+
const result = runCheckEnv(dir, ws, flags, extraEnv);
|
|
583
|
+
const out = jsonOut(result);
|
|
584
|
+
const entry = unsupportedEntries(out).find(item => item.path === 'docs/index.md');
|
|
585
|
+
if (!entry) throw new Error(`no unsupported entry naming docs/index.md: ${truncate(JSON.stringify(unsupportedEntries(out)))}`);
|
|
586
|
+
same(entry.state, 'unverified', 'coverage state');
|
|
587
|
+
same(entry.applicability, 'declaration-removed', 'coverage applicability');
|
|
588
|
+
same(result.status, control.status, 'exit unchanged by the added entry');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// D18: a committed declaration renamed to docs/new.md must produce entries for BOTH the old path (declaration-changed
|
|
592
|
+
// or declaration-removed) and the new path (unverified). (F3-01, rename lane.)
|
|
593
|
+
function caseD18Body(dir, extraEnv) {
|
|
594
|
+
const { ws } = viewWorkspace();
|
|
595
|
+
writeDoc(ws, 'docs/a.md', refDoc('Plain governed text.\n'));
|
|
596
|
+
initGitRepo(ws);
|
|
597
|
+
writeDoc(ws, CONFIG, `${JSON.stringify({ governed: [{ path: 'docs/a.md', role: 'reference' }], derived: [{ path: 'docs/new.md', generator: sentinelGenerator(ws) }] }, null, 2)}\n`);
|
|
598
|
+
const result = runCheckEnv(dir, ws, ['--base', 'HEAD', '--changed-path', CONFIG], extraEnv);
|
|
599
|
+
const out = jsonOut(result);
|
|
600
|
+
const oldEntry = unsupportedEntries(out).find(item => item.path === 'docs/index.md');
|
|
601
|
+
if (!oldEntry) throw new Error(`no unsupported entry naming docs/index.md: ${truncate(JSON.stringify(unsupportedEntries(out)))}`);
|
|
602
|
+
same(oldEntry.state, 'unverified', 'old declaration state');
|
|
603
|
+
if (oldEntry.applicability !== 'declaration-changed' && oldEntry.applicability !== 'declaration-removed') {
|
|
604
|
+
throw new Error(`old declaration applicability: got ${JSON.stringify(oldEntry.applicability)} expected declaration-changed or declaration-removed`);
|
|
605
|
+
}
|
|
606
|
+
const newEntry = unsupportedEntries(out).find(item => item.path === 'docs/new.md');
|
|
607
|
+
if (!newEntry) throw new Error(`no unsupported entry naming docs/new.md: ${truncate(JSON.stringify(unsupportedEntries(out)))}`);
|
|
608
|
+
same(newEntry.state, 'unverified', 'new declaration state');
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// D19: config changed with no --base → at least one entry whose reason starts with the exact warning text. (F3-01)
|
|
612
|
+
function caseD19Body(dir) {
|
|
613
|
+
const { ws } = viewWorkspace();
|
|
614
|
+
writeDoc(ws, 'docs/a.md', refDoc('Plain governed text.\n'));
|
|
615
|
+
const result = runCheckEnv(dir, ws, ['--changed-path', CONFIG]);
|
|
616
|
+
const out = jsonOut(result);
|
|
617
|
+
const hit = unsupportedEntries(out).find(item => typeof item.reason === 'string' && item.reason.startsWith('config changed without --base'));
|
|
618
|
+
if (!hit) throw new Error(`no "config changed without --base" entry: ${truncate(JSON.stringify(unsupportedEntries(out)))}`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// D20: a glob declaration is reported as glob-unsupported and never as a concrete unverified view with the literal
|
|
622
|
+
// glob path. (F3-01, glob lane.)
|
|
623
|
+
function caseD20Body(dir, extraEnv) {
|
|
624
|
+
const globbed = viewWorkspace([{ path: 'docs/*.md', generator: 'node generator.js' }]);
|
|
625
|
+
writeDoc(globbed.ws, 'docs/a.md', refDoc('Plain governed text.\n'));
|
|
626
|
+
const result = runCheckEnv(dir, globbed.ws, ['--changed-path', CONFIG], extraEnv);
|
|
627
|
+
const out = jsonOut(result);
|
|
628
|
+
if (!unsupportedEntries(out).some(item => item.applicability === 'glob-unsupported')) {
|
|
629
|
+
throw new Error(`no glob-unsupported entry: ${truncate(JSON.stringify(unsupportedEntries(out)))}`);
|
|
630
|
+
}
|
|
631
|
+
if (unsupportedEntries(out).some(item => item.path === 'docs/*.md' && item.state === 'unverified')) {
|
|
632
|
+
throw new Error(`the literal glob path is still reported as a concrete unverified view: ${truncate(JSON.stringify(unsupportedEntries(out)))}`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// D21: a FIFO candidate must give exit 2 comparison-unavailable well inside a 5 s timeout — the call must never hang.
|
|
637
|
+
// (F3-02; mp5 target. win32 skips: no mkfifo.)
|
|
638
|
+
function caseD21Body(dir, fifo, extraEnv) {
|
|
639
|
+
const { ws } = viewWorkspace();
|
|
640
|
+
writeTarget(ws);
|
|
641
|
+
const start = Date.now();
|
|
642
|
+
const result = spawnSync(process.execPath, [
|
|
643
|
+
path.join(dir, MODULE), 'compare-derived',
|
|
644
|
+
'--workspace', ws, '--config', CONFIG,
|
|
645
|
+
'--view', 'docs/index.md', '--candidate', fifo,
|
|
646
|
+
'--generation-status', 'ok'
|
|
647
|
+
], { encoding: 'utf8', cwd: os.tmpdir(), timeout: 5000, killSignal: 'SIGKILL', env: { ...process.env, ...(extraEnv || {}) } });
|
|
648
|
+
if (result.error) throw new Error(`process killed or failed (${result.error.code || result.error}): this is a hang, not the required exit 2`);
|
|
649
|
+
same(result.status, 2, 'exit');
|
|
650
|
+
const out = jsonOut(result);
|
|
651
|
+
if (!JSON.stringify(out).includes('comparison-unavailable')) throw new Error(`result is not comparison-unavailable: ${truncate(result.stdout)}`);
|
|
652
|
+
const elapsed = Date.now() - start;
|
|
653
|
+
if (elapsed > 4800) throw new Error(`completed after ${elapsed} ms, not well inside the 5 s timeout`);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// D22: the declared target symlinked OUT of the workspace is rejected with exit 2 — the symlink-escape guard must stay
|
|
657
|
+
// reachable by the suite. (F3-08; mp6 target. win32 skips: symlink dev mode.)
|
|
658
|
+
function caseD22Body(dir, extraEnv) {
|
|
659
|
+
const { ws } = viewWorkspace();
|
|
660
|
+
const far = candidateDir();
|
|
661
|
+
const foreign = path.join(far, 'foreign.md');
|
|
662
|
+
fs.writeFileSync(foreign, Buffer.from('# Foreign content outside the workspace\n', 'utf8'));
|
|
663
|
+
fs.mkdirSync(path.join(ws, 'docs'), { recursive: true });
|
|
664
|
+
fs.symlinkSync(foreign, path.join(ws, 'docs', 'index.md'));
|
|
665
|
+
const d2Candidate = candidateDir();
|
|
666
|
+
const candidate = path.join(d2Candidate, 'candidate.md');
|
|
667
|
+
fs.writeFileSync(candidate, TARGET_BYTES);
|
|
668
|
+
const result = runCompare(dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'ok', extra: { env: { ...process.env, ...(extraEnv || {}) } } });
|
|
669
|
+
same(result.status, 2, 'exit');
|
|
670
|
+
const out = jsonOut(result);
|
|
671
|
+
if (!JSON.stringify(out).includes('unsupported')) throw new Error(`escaped target not reported unsupported: ${truncate(result.stdout)}`);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// D23: a declared view that is also governed must be flagged self-governed in unsupported. (F3-11)
|
|
675
|
+
function caseD23Body(dir, extraEnv) {
|
|
676
|
+
const ws = makeWorkspace(null);
|
|
677
|
+
const config = {
|
|
678
|
+
governed: [{ path: 'docs/a.md', role: 'reference' }, { path: 'docs/index.md', role: 'reference' }],
|
|
679
|
+
derived: [{ path: 'docs/index.md', generator: sentinelGenerator(ws) }]
|
|
680
|
+
};
|
|
681
|
+
writeDoc(ws, CONFIG, `${JSON.stringify(config, null, 2)}\n`);
|
|
682
|
+
writeDoc(ws, 'docs/a.md', refDoc('Plain governed text.\n'));
|
|
683
|
+
writeDoc(ws, 'docs/index.md', refDoc('Plain governed text for the self-governed declared view.\n'));
|
|
684
|
+
const result = runCheckEnv(dir, ws, ['--changed-path', 'docs/a.md'], extraEnv);
|
|
685
|
+
const out = jsonOut(result);
|
|
686
|
+
const entry = unsupportedEntries(out).find(item => item.path === 'docs/index.md' && item.applicability === 'self-governed');
|
|
687
|
+
if (!entry) throw new Error(`no self-governed entry for docs/index.md: ${truncate(JSON.stringify(unsupportedEntries(out)))}`);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
{
|
|
691
|
+
const id = appendMsg() + 'case-d17:removed-declaration-reported-exit-unchanged';
|
|
692
|
+
assertPerLeg(id, leg => caseD17Body(leg.dir));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
{
|
|
696
|
+
const id = appendMsg() + 'case-d18:renamed-declaration-reports-both-paths';
|
|
697
|
+
assertPerLeg(id, leg => caseD18Body(leg.dir));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
{
|
|
701
|
+
const id = appendMsg() + 'case-d19:config-change-without-base-flagged';
|
|
702
|
+
assertPerLeg(id, leg => caseD19Body(leg.dir));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
{
|
|
706
|
+
const id = appendMsg() + 'case-d20:glob-declaration-glob-unsupported';
|
|
707
|
+
assertPerLeg(id, leg => caseD20Body(leg.dir));
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
{
|
|
711
|
+
const id = appendMsg() + 'case-d21:fifo-candidate-exit-2-no-hang';
|
|
712
|
+
for (const leg of LEGS) {
|
|
713
|
+
if (!fs.existsSync(path.join(leg.dir, MODULE))) { record('FAIL', id, leg.name, `module-missing: ${path.relative(ROOT, path.join(leg.dir, MODULE))}`); continue; }
|
|
714
|
+
if (process.platform === 'win32') { record('SKIP', id, leg.name, 'win32: mkfifo unavailable'); continue; }
|
|
715
|
+
const dir = candidateDir();
|
|
716
|
+
const fifo = path.join(dir, 'candidate.fifo');
|
|
717
|
+
const mk = spawnSync('mkfifo', [fifo], { encoding: 'utf8' });
|
|
718
|
+
if (mk.status !== 0) { record('SKIP', id, leg.name, `mkfifo unavailable: ${truncate((mk.stderr || '').trim() || mk.error)}`); continue; }
|
|
719
|
+
try { caseD21Body(leg.dir, fifo); record('PASS', id, leg.name); } catch (error) { record('FAIL', id, leg.name, error.message.split('\n')[0]); }
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
{
|
|
724
|
+
const id = appendMsg() + 'case-d22:target-symlink-escape-exit-2';
|
|
725
|
+
for (const leg of LEGS) {
|
|
726
|
+
if (!fs.existsSync(path.join(leg.dir, MODULE))) { record('FAIL', id, leg.name, `module-missing: ${path.relative(ROOT, path.join(leg.dir, MODULE))}`); continue; }
|
|
727
|
+
if (process.platform === 'win32') { record('SKIP', id, leg.name, 'win32: symlink dev mode'); continue; }
|
|
728
|
+
try { caseD22Body(leg.dir); record('PASS', id, leg.name); } catch (error) { record('FAIL', id, leg.name, error.message.split('\n')[0]); }
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
{
|
|
733
|
+
const id = appendMsg() + 'case-d23:declared-and-governed-self-governed';
|
|
734
|
+
assertPerLeg(id, leg => caseD23Body(leg.dir));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ---- F3-T3 mutation probes (also run with --mutation-probes) --------------------------------------------------------
|
|
738
|
+
// Same contract as mp1–mp3 above: a probe PASSES when the case assertion throws on the mutant (mutation detected).
|
|
739
|
+
// The mutants are env-gated hook injections right after `const fs = require('fs');` — before any later destructuring —
|
|
740
|
+
// so they apply regardless of how the F3-W3 fix is shaped. Inert without F3_MP4/F3_MP5/F3_MP6.
|
|
741
|
+
|
|
742
|
+
function injectProbeHooks(dir) {
|
|
743
|
+
const file = path.join(dir, MODULE);
|
|
744
|
+
const original = fs.readFileSync(file, 'utf8');
|
|
745
|
+
const needle = `const fs = require('fs');`;
|
|
746
|
+
const occurrences = original.split(needle).length - 1;
|
|
747
|
+
if (occurrences !== 1) throw new ProbeApplyError(`hook injection point matched ${occurrences} occurrences; probe did not apply`);
|
|
748
|
+
// mp4: every `git show` behaves as if the base config read produced nothing usable — the base-config comparison is
|
|
749
|
+
// effectively dropped. D17 must then fail on the mutant.
|
|
750
|
+
// mp5: every sync stat hook reports a regular file (and never a FIFO), so a FIFO candidate passes any isFile gate;
|
|
751
|
+
// the subsequent unguarded read blocks forever → timeout kill → D21 must fail on the mutant.
|
|
752
|
+
// mp6: realpathSync becomes the identity, so the symlink-escape guard can no longer resolve paths out of the
|
|
753
|
+
// workspace; the escaped target is then compared in place → D22 must fail on the mutant.
|
|
754
|
+
const hooks = `
|
|
755
|
+
// F3-T3 mutation hooks: inert without F3_MP4 / F3_MP5 / F3_MP6.
|
|
756
|
+
if (process.env.F3_MP4) { const cp = require('child_process'); const prev = cp.spawnSync; cp.spawnSync = function () { const cmd = arguments[0]; const args = arguments[1]; if (cmd === 'git' && Array.isArray(args) && args[0] === 'show') return { pid: 0, output: ['', ''], stdout: '', stderr: '', status: 0, signal: null, error: null }; return prev.apply(cp, arguments); }; const prevExec = cp.execFileSync; cp.execFileSync = function () { const cmd = arguments[0]; const args = arguments[1]; if (cmd === 'git' && Array.isArray(args) && args[0] === 'show') return ''; return prevExec.apply(cp, arguments); }; }
|
|
757
|
+
if (process.env.F3_MP5) { ['lstatSync', 'statSync', 'fstatSync'].forEach(function (name) { const orig = fs[name]; if (typeof orig !== 'function') return; fs[name] = function () { const st = orig.apply(fs, arguments); return new Proxy(st, { get: (t, k) => (k === 'isFile' ? () => true : k === 'isFIFO' ? () => false : Reflect.get(t, k, t)) }); }; }); }
|
|
758
|
+
if (process.env.F3_MP6) { fs.realpathSync = (p) => p; fs.realpathSync.native = fs.realpathSync; }
|
|
759
|
+
`;
|
|
760
|
+
fs.writeFileSync(file, original.replace(needle, `${needle}${hooks}`));
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (process.argv.includes('--mutation-probes')) {
|
|
764
|
+
// mp5's FIFO must exist before the case body runs (win32 and non-unix skip the probe).
|
|
765
|
+
let fifo = null;
|
|
766
|
+
if (process.platform !== 'win32') {
|
|
767
|
+
const dir = candidateDir();
|
|
768
|
+
const fifoPath = path.join(dir, 'candidate.fifo');
|
|
769
|
+
if (spawnSync('mkfifo', [fifoPath], { encoding: 'utf8' }).status === 0) fifo = fifoPath;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
for (const [id, envKey, caseFn] of [
|
|
773
|
+
[appendMsg() + 'mp4:base-config-comparison-dropped-detected-by-d17', 'F3_MP4', (dir) => caseD17Body(dir, { F3_MP4: '1' })],
|
|
774
|
+
[appendMsg() + 'mp5:regular-file-check-dropped-detected-by-d21', 'F3_MP5', (dir) => caseD21Body(dir, fifo, { F3_MP5: '1' })],
|
|
775
|
+
[appendMsg() + 'mp6:symlink-escape-guard-dropped-detected-by-d22', 'F3_MP6', (dir) => caseD22Body(dir, { F3_MP6: '1' })]
|
|
776
|
+
]) {
|
|
777
|
+
if (envKey === 'F3_MP5' && !fifo) { record('SKIP', id, 'source', 'mkfifo unavailable'); continue; }
|
|
778
|
+
let dir;
|
|
779
|
+
try { dir = scriptCopy('f3 t3 probes '); injectProbeHooks(dir); } catch (error) {
|
|
780
|
+
if (error instanceof ProbeApplyError) record('FAIL', id, 'source', `probe did not apply: ${error.message.split('\n')[0]}`);
|
|
781
|
+
else record('FAIL', id, 'source', `apply error: ${error.message.split('\n')[0]}`);
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
try { caseFn(dir); record('FAIL', id, 'source', 'mutation survived — the case passed on the mutant'); } catch (error) {
|
|
785
|
+
record('PASS', id, 'source', `mutation detected: ${error.message.split('\n')[0]}`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ---- Final summary (authoritative totals incl. the appended block) ---------------------------------------------------
|
|
791
|
+
|
|
792
|
+
console.log(`\nF3 derived-views 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)' : ''}`);
|
|
793
|
+
|
|
794
|
+
for (const dir of tmpDirs.splice(0)) {
|
|
795
|
+
try { removeTree(dir); } catch { /* tmp leftovers are harmless */ }
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
process.exitCode = failed > 0 ? 1 : 0;
|