harness-everything 0.13.0 → 0.14.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +16 -0
- package/ci/mechanism-2ak-contract-probe-adapter.test.js +207 -0
- package/ci/openai-plugin-package.test.js +10 -0
- package/contract-integrity/ADAPTERS.md +37 -0
- package/contract-integrity/fixtures/node-probe-project/package.json +8 -0
- package/contract-integrity/fixtures/node-probe-project/probe-plan.json +27 -0
- package/contract-integrity/fixtures/node-probe-project/src/limit.js +8 -0
- package/contract-integrity/fixtures/node-probe-project/test/contract.test.js +14 -0
- package/contract-integrity/scripts/node-probe-adapter.js +390 -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/contract-integrity/ADAPTERS.md +37 -0
- package/plugins/harness-everything/contract-integrity/scripts/node-probe-adapter.js +390 -0
- package/plugins/harness-everything/plugin.json +1 -1
- package/scripts/sync-openai-plugin.js +4 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "harness-everything",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
|
|
13
|
-
"version": "0.
|
|
13
|
+
"version": "0.14.0",
|
|
14
14
|
"author": {
|
|
15
15
|
"name": "Miya Daniel"
|
|
16
16
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "harness-everything",
|
|
3
3
|
"description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.14.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Miya Daniel"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.14.0](https://github.com/dyphn1/Harness-everything/compare/v0.13.0...v0.14.0) (2026-09-20)
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
* **contract:** add node npm probe adapter ([02f4044](https://github.com/dyphn1/Harness-everything/commit/02f4044c82a6cb082d4b8e4fe810071afe064c21))
|
|
10
|
+
* **contract:** add packaged node probe adapter ([de72904](https://github.com/dyphn1/Harness-everything/commit/de7290434346cdc358ea119845c72ddca3ea0090))
|
|
11
|
+
* **contract:** package node probe adapter runtime ([783dc8f](https://github.com/dyphn1/Harness-everything/commit/783dc8f891a3244461f3dfe154542bad15125f15))
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **contract:** close linked-workspace isolation gap ([574ea2f](https://github.com/dyphn1/Harness-everything/commit/574ea2f299ae02aa93785cca7aa5b4a829e9f961))
|
|
16
|
+
* **contract:** launch npm probes cross-platform ([b39b2c0](https://github.com/dyphn1/Harness-everything/commit/b39b2c09a223c64d3665d383852a6313fff888fc))
|
|
17
|
+
* **contract:** launch npm probes cross-platform ([723bae1](https://github.com/dyphn1/Harness-everything/commit/723bae19ac4a05bfefe44ad0d61bd5dfb8b0ba50))
|
|
18
|
+
* **contract:** reject symlink probe escapes ([bf577a5](https://github.com/dyphn1/Harness-everything/commit/bf577a5b58dec2a4779709402146073f0fd0ce29))
|
|
19
|
+
* **contract:** reject symlink probe escapes ([5e3c569](https://github.com/dyphn1/Harness-everything/commit/5e3c569b1b7b883490e40fd66a64385f2d24d717))
|
|
20
|
+
|
|
5
21
|
## [0.13.0](https://github.com/dyphn1/Harness-everything/compare/v0.12.3...v0.13.0) (2026-09-19)
|
|
6
22
|
|
|
7
23
|
### Features
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const assert = require('assert');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
11
|
+
const FIXTURE = path.join(ROOT, 'contract-integrity', 'fixtures', 'node-probe-project');
|
|
12
|
+
const ADAPTER = path.join(ROOT, 'contract-integrity', 'scripts', 'node-probe-adapter.js');
|
|
13
|
+
const { applyProbe, executePlan, validatePlan } = require(ADAPTER);
|
|
14
|
+
const { evaluateTrace } = require(path.join(ROOT, 'contract-integrity', 'scripts', 'audit.js'));
|
|
15
|
+
|
|
16
|
+
let failed = 0;
|
|
17
|
+
function check(condition, message, detail = '') {
|
|
18
|
+
if (condition) console.log(' PASS ' + message);
|
|
19
|
+
else {
|
|
20
|
+
console.error(' FAIL ' + message + (detail ? ' — ' + detail : ''));
|
|
21
|
+
failed++;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function clone(value) {
|
|
25
|
+
return JSON.parse(JSON.stringify(value));
|
|
26
|
+
}
|
|
27
|
+
function json(file) {
|
|
28
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
function digest(file) {
|
|
31
|
+
return require('crypto').createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log('=== Contract Integrity Phase 3 Node Probe Adapter (#84) ===');
|
|
35
|
+
|
|
36
|
+
const plan = json(path.join(FIXTURE, 'probe-plan.json'));
|
|
37
|
+
check(validatePlan(plan).length === 0, 'declared node-npm-v1 probe plan validates');
|
|
38
|
+
|
|
39
|
+
const beforeSource = digest(path.join(FIXTURE, 'src', 'limit.js'));
|
|
40
|
+
const beforeTest = digest(path.join(FIXTURE, 'test', 'contract.test.js'));
|
|
41
|
+
const protectedReport = executePlan(FIXTURE, plan);
|
|
42
|
+
check(protectedReport.result === 'PROTECTED', 'green baseline plus both explicit probes produces PROTECTED evidence');
|
|
43
|
+
check(protectedReport.probes.length === 2 && protectedReport.probes.every(probe =>
|
|
44
|
+
probe.status === 'KILLED' && probe.failureClass === 'contract' && probe.workspaceIsolation === 'isolated'),
|
|
45
|
+
'env and exact-replace probes are killed only by expected contract failures');
|
|
46
|
+
check(digest(path.join(FIXTURE, 'src', 'limit.js')) === beforeSource &&
|
|
47
|
+
digest(path.join(FIXTURE, 'test', 'contract.test.js')) === beforeTest,
|
|
48
|
+
'adapter never mutates the source workspace');
|
|
49
|
+
check(!JSON.stringify(protectedReport).includes('out-of-range input must be rejected'),
|
|
50
|
+
'probe report retains output hashes/evidence refs instead of project test output');
|
|
51
|
+
|
|
52
|
+
const trace = json(path.join(ROOT, 'contract-integrity', 'fixtures', 'consistent-trace.json'));
|
|
53
|
+
trace.probes = protectedReport.probes.filter(probe => probe.probeId === 'PROBE-001').map(probe => ({
|
|
54
|
+
probeId: probe.probeId,
|
|
55
|
+
requirementId: probe.requirementId,
|
|
56
|
+
status: probe.status,
|
|
57
|
+
sourceSection: probe.sourceSection,
|
|
58
|
+
evidenceRef: probe.evidenceRef,
|
|
59
|
+
workspaceIsolation: probe.workspaceIsolation,
|
|
60
|
+
failureClass: probe.failureClass,
|
|
61
|
+
}));
|
|
62
|
+
const tdd = json(path.join(ROOT, 'tdd', 'fixtures', 'pass-evidence.json'));
|
|
63
|
+
tdd.requirements[0].source.path = 'docs/specs/api.md';
|
|
64
|
+
tdd.requirements[0].source.section = 'REQ-001';
|
|
65
|
+
const integrated = evaluateTrace(trace, { tddEvidence: tdd });
|
|
66
|
+
check(integrated.result === 'PASS' && integrated.scores.protectionScore === 100,
|
|
67
|
+
'derived adapter evidence feeds the existing #84/#58 strict integrity gate');
|
|
68
|
+
|
|
69
|
+
const survivingPlan = clone(plan);
|
|
70
|
+
survivingPlan.probes = [clone(plan.probes[0])];
|
|
71
|
+
survivingPlan.probes[0].env.HARNESS_CONTRACT_PROBE = 'noop';
|
|
72
|
+
const surviving = executePlan(FIXTURE, survivingPlan);
|
|
73
|
+
check(surviving.result === 'SURVIVED' && surviving.probes[0].status === 'SURVIVED',
|
|
74
|
+
'green test run under a required probe is explicitly SURVIVED, never PASS');
|
|
75
|
+
const weakTrace = clone(trace);
|
|
76
|
+
weakTrace.probes[0] = {
|
|
77
|
+
probeId: surviving.probes[0].probeId,
|
|
78
|
+
requirementId: surviving.probes[0].requirementId,
|
|
79
|
+
status: surviving.probes[0].status,
|
|
80
|
+
sourceSection: surviving.probes[0].sourceSection,
|
|
81
|
+
evidenceRef: surviving.probes[0].evidenceRef,
|
|
82
|
+
workspaceIsolation: surviving.probes[0].workspaceIsolation,
|
|
83
|
+
failureClass: surviving.probes[0].failureClass,
|
|
84
|
+
};
|
|
85
|
+
const weakAudit = evaluateTrace(weakTrace, { tddEvidence: tdd });
|
|
86
|
+
check(weakAudit.result === 'FAIL' && weakAudit.scores.completenessScore === 100 &&
|
|
87
|
+
weakAudit.scores.protectionScore === 0,
|
|
88
|
+
'green but weak suite cannot compensate 100 completeness with a surviving contract probe');
|
|
89
|
+
|
|
90
|
+
const unrelatedPlan = clone(plan);
|
|
91
|
+
unrelatedPlan.probes = [clone(plan.probes[0])];
|
|
92
|
+
unrelatedPlan.probes[0].expectedFailureContains = 'THIS-MARKER-DOES-NOT-EXIST';
|
|
93
|
+
const unrelated = executePlan(FIXTURE, unrelatedPlan);
|
|
94
|
+
check(unrelated.probes[0].status === 'INVALID' && unrelated.probes[0].failureClass === 'unknown',
|
|
95
|
+
'unrelated failing test output is INVALID and cannot be credited as a kill');
|
|
96
|
+
|
|
97
|
+
const ambiguousReplace = clone(plan);
|
|
98
|
+
ambiguousReplace.probes = [clone(plan.probes[1])];
|
|
99
|
+
ambiguousReplace.probes[0].find = 'return';
|
|
100
|
+
const ambiguous = executePlan(FIXTURE, ambiguousReplace);
|
|
101
|
+
check(ambiguous.probes[0].status === 'NOT_EVALUATED' &&
|
|
102
|
+
/exactly once/.test(ambiguous.probes[0].adapterReason),
|
|
103
|
+
'ambiguous exact-replace fallback fails closed without mutating source');
|
|
104
|
+
|
|
105
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-contract-adapter-test-'));
|
|
106
|
+
try {
|
|
107
|
+
const symlinkWorkspace = path.join(temp, 'symlink-workspace');
|
|
108
|
+
fs.mkdirSync(symlinkWorkspace, { recursive: true });
|
|
109
|
+
const outsideDir = path.join(temp, 'outside-dir');
|
|
110
|
+
fs.mkdirSync(outsideDir, { recursive: true });
|
|
111
|
+
const outsideTarget = path.join(outsideDir, 'outside.txt');
|
|
112
|
+
fs.writeFileSync(outsideTarget, 'SAFE', 'utf8');
|
|
113
|
+
fs.symlinkSync(outsideDir, path.join(symlinkWorkspace, 'linked-dir'),
|
|
114
|
+
process.platform === 'win32' ? 'junction' : 'dir');
|
|
115
|
+
let symlinkRejected = false;
|
|
116
|
+
try {
|
|
117
|
+
applyProbe(symlinkWorkspace, {
|
|
118
|
+
strategy: 'replace',
|
|
119
|
+
target: path.join('linked-dir', 'outside.txt'),
|
|
120
|
+
find: 'SAFE',
|
|
121
|
+
replace: 'MUTATED',
|
|
122
|
+
});
|
|
123
|
+
} catch (error) {
|
|
124
|
+
symlinkRejected = /symbolic link|reparse-point|outside isolated workspace/.test(String(error.message || error));
|
|
125
|
+
}
|
|
126
|
+
check(symlinkRejected && fs.readFileSync(outsideTarget, 'utf8') === 'SAFE',
|
|
127
|
+
'replace probe rejects symlink/junction escape without mutating outside the isolated workspace');
|
|
128
|
+
|
|
129
|
+
const linkedProject = path.join(temp, 'linked-project');
|
|
130
|
+
fs.mkdirSync(linkedProject, { recursive: true });
|
|
131
|
+
fs.writeFileSync(path.join(linkedProject, 'package.json'), JSON.stringify({
|
|
132
|
+
name: 'linked-project',
|
|
133
|
+
scripts: { contract: 'node test.js' },
|
|
134
|
+
}), 'utf8');
|
|
135
|
+
fs.writeFileSync(path.join(linkedProject, 'test.js'), [
|
|
136
|
+
"const fs = require('fs');",
|
|
137
|
+
"const path = require('path');",
|
|
138
|
+
"fs.writeFileSync(path.join(__dirname, 'linked-dir', 'outside.txt'), 'MUTATED', 'utf8');",
|
|
139
|
+
].join('\n'), 'utf8');
|
|
140
|
+
fs.symlinkSync(outsideDir, path.join(linkedProject, 'linked-dir'),
|
|
141
|
+
process.platform === 'win32' ? 'junction' : 'dir');
|
|
142
|
+
const linkedPlan = {
|
|
143
|
+
schemaVersion: '1.0.0',
|
|
144
|
+
adapter: 'node-npm-v1',
|
|
145
|
+
script: 'contract',
|
|
146
|
+
probes: [{
|
|
147
|
+
probeId: 'PROBE-LINK',
|
|
148
|
+
requirementId: 'REQ-LINK',
|
|
149
|
+
sourceSection: 'Linked workspace',
|
|
150
|
+
strategy: 'env',
|
|
151
|
+
expectedFailureContains: 'unused',
|
|
152
|
+
env: { HARNESS_CONTRACT_PROBE: 'link' },
|
|
153
|
+
}],
|
|
154
|
+
};
|
|
155
|
+
const linkedReport = executePlan(linkedProject, linkedPlan);
|
|
156
|
+
check(linkedReport.result === 'NOT_EVALUATED' &&
|
|
157
|
+
linkedReport.baseline.workspaceIsolation === 'not-isolated' &&
|
|
158
|
+
linkedReport.probes.every(probe => probe.workspaceIsolation === 'not-isolated') &&
|
|
159
|
+
fs.readFileSync(outsideTarget, 'utf8') === 'SAFE',
|
|
160
|
+
'workspace copy rejects source symlinks/junctions before project code can mutate an outside target');
|
|
161
|
+
|
|
162
|
+
const dependent = path.join(temp, 'dependent');
|
|
163
|
+
fs.cpSync(FIXTURE, dependent, { recursive: true });
|
|
164
|
+
const pkg = json(path.join(dependent, 'package.json'));
|
|
165
|
+
pkg.dependencies = { leftpad: '1.0.0' };
|
|
166
|
+
fs.writeFileSync(path.join(dependent, 'package.json'), JSON.stringify(pkg, null, 2), 'utf8');
|
|
167
|
+
const dependencyReport = executePlan(dependent, plan);
|
|
168
|
+
check(dependencyReport.result === 'NOT_EVALUATED' &&
|
|
169
|
+
dependencyReport.discovery.reason === 'external-dependencies-not-supported-v1',
|
|
170
|
+
'v1 adapter refuses to infer/install external dependencies');
|
|
171
|
+
|
|
172
|
+
const broken = path.join(temp, 'broken');
|
|
173
|
+
fs.cpSync(FIXTURE, broken, { recursive: true });
|
|
174
|
+
fs.appendFileSync(path.join(broken, 'test', 'contract.test.js'), "\nthrow new Error('baseline fixture failure');\n", 'utf8');
|
|
175
|
+
const baselineFailure = executePlan(broken, plan);
|
|
176
|
+
check(baselineFailure.result === 'NOT_EVALUATED' &&
|
|
177
|
+
baselineFailure.baseline.status === 'FAIL' &&
|
|
178
|
+
baselineFailure.probes.every(probe => probe.status === 'NOT_EVALUATED'),
|
|
179
|
+
'non-green baseline blocks all protection claims');
|
|
180
|
+
} finally {
|
|
181
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const invalidPlan = clone(plan);
|
|
185
|
+
invalidPlan.probes[0].target = '../escape.js';
|
|
186
|
+
invalidPlan.probes[0].strategy = 'replace';
|
|
187
|
+
invalidPlan.probes[0].find = 'x';
|
|
188
|
+
invalidPlan.probes[0].replace = 'y';
|
|
189
|
+
delete invalidPlan.probes[0].env;
|
|
190
|
+
check(validatePlan(invalidPlan).some(error => /repo-relative/.test(error)),
|
|
191
|
+
'probe plan rejects replace targets that escape the isolated workspace');
|
|
192
|
+
|
|
193
|
+
const protectedCli = spawnSync(process.execPath, [
|
|
194
|
+
ADAPTER, '--workspace', FIXTURE, '--plan', path.join(FIXTURE, 'probe-plan.json'),
|
|
195
|
+
], { encoding: 'utf8' });
|
|
196
|
+
check(protectedCli.status === 0 && /"result": "PROTECTED"/.test(protectedCli.stdout),
|
|
197
|
+
'CLI returns exit 0 only for fully killed declared probes', protectedCli.stderr);
|
|
198
|
+
|
|
199
|
+
const docs = fs.readFileSync(path.join(ROOT, 'contract-integrity', 'ADAPTERS.md'), 'utf8');
|
|
200
|
+
check(/does \*\*not\*\* claim generic mutation-testing support/.test(docs) &&
|
|
201
|
+
/zero external npm dependencies/.test(docs) &&
|
|
202
|
+
/Not yet supported/.test(docs),
|
|
203
|
+
'adapter documentation states exact supported scope and explicit non-support');
|
|
204
|
+
|
|
205
|
+
console.log('\n' + (failed === 0 ? 'PASS' : 'FAIL') +
|
|
206
|
+
': #84 contract protection node adapter (' + failed + ' failure' + (failed === 1 ? '' : 's') + ')');
|
|
207
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -120,6 +120,16 @@ assert.doesNotMatch(packagedAuditSource, /\.\.\/\.\.\/tdd\/scripts\/quality-gate
|
|
|
120
120
|
const packagedAudit = require(packagedContractAudit);
|
|
121
121
|
assert.strictEqual(typeof packagedAudit.runCli, 'function', 'packaged contract audit runtime must load and export runCli');
|
|
122
122
|
|
|
123
|
+
const packagedProbeAdapter = path.join(PLUGIN, 'contract-integrity', 'scripts', 'node-probe-adapter.js');
|
|
124
|
+
const packagedAdapterDocs = path.join(PLUGIN, 'contract-integrity', 'ADAPTERS.md');
|
|
125
|
+
assert.ok(fs.existsSync(packagedProbeAdapter) && fs.existsSync(packagedAdapterDocs),
|
|
126
|
+
'OpenAI package must include shared contract-probe adapter runtime and support boundary docs');
|
|
127
|
+
const packagedAdapter = require(packagedProbeAdapter);
|
|
128
|
+
assert.strictEqual(packagedAdapter.ADAPTER, 'node-npm-v1',
|
|
129
|
+
'packaged contract-probe adapter must load without becoming a routed skill');
|
|
130
|
+
assert.match(fs.readFileSync(packagedAdapterDocs, 'utf8'), /does \*\*not\*\* claim generic mutation-testing support/,
|
|
131
|
+
'packaged adapter docs must preserve the narrow support boundary');
|
|
132
|
+
|
|
123
133
|
const verificationContractWrapper = path.join(PLUGIN, 'skills', 'verification-loop', 'scripts', 'contract-integrity-audit.js');
|
|
124
134
|
assert.ok(fs.existsSync(verificationContractWrapper), 'verification-loop must package its contract audit wrapper');
|
|
125
135
|
const verificationWrapper = require(verificationContractWrapper);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Contract Protection Adapters
|
|
2
|
+
|
|
3
|
+
This directory does **not** claim generic mutation-testing support.
|
|
4
|
+
|
|
5
|
+
## Supported in Phase 3
|
|
6
|
+
|
|
7
|
+
### `node-npm-v1`
|
|
8
|
+
|
|
9
|
+
Supported project shape:
|
|
10
|
+
|
|
11
|
+
- Node.js project with a `package.json`;
|
|
12
|
+
- one explicitly selected npm script used as the contract-test command;
|
|
13
|
+
- **zero external npm dependencies** for this first adapter revision;
|
|
14
|
+
- probes are predeclared in a local JSON plan and tied to stable `REQ-*` IDs and authoritative source sections;
|
|
15
|
+
- every baseline and probe run executes in a fresh temporary copy; the source workspace is never mutated.
|
|
16
|
+
|
|
17
|
+
Source workspaces containing symbolic-link or junction entries are rejected before project code runs. The v1 adapter does not claim isolation when a copied path could retain an external filesystem target.
|
|
18
|
+
|
|
19
|
+
Supported probe strategies:
|
|
20
|
+
|
|
21
|
+
1. **`env`** — explicit project-owned fault injection via environment variables.
|
|
22
|
+
2. **`replace`** — exact one-occurrence text replacement in the isolated copy only. This is the fallback when the project has no built-in fault-injection seam.
|
|
23
|
+
|
|
24
|
+
A probe is `KILLED` only when the selected npm script fails **and** its output contains the predeclared requirement-specific observable marker. A green run is `SURVIVED`. Timeout/spawn failures, baseline failures, unsafe/ambiguous replacement, missing dependencies, or unrelated failures are `NOT_EVALUATED` / `INVALID`; none of those earn protection credit.
|
|
25
|
+
|
|
26
|
+
The adapter does not install dependencies, access the network, infer mutation locations, rewrite source documents, or commit generated probes.
|
|
27
|
+
|
|
28
|
+
## Not yet supported
|
|
29
|
+
|
|
30
|
+
- arbitrary Jest/Vitest/Mocha/Pytest/Go/Rust/.NET adapter discovery;
|
|
31
|
+
- projects that require dependency installation inside the isolated copy;
|
|
32
|
+
- automatic source-derived mutation synthesis;
|
|
33
|
+
- compiler-failure-as-kill;
|
|
34
|
+
- flaky-test attribution;
|
|
35
|
+
- dirty-worktree mutation or in-place production changes.
|
|
36
|
+
|
|
37
|
+
Those remain future adapters/slices. Phase 3 intentionally proves one narrow real runner plus an explicit isolated fault-injection fallback.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "1.0.0",
|
|
3
|
+
"adapter": "node-npm-v1",
|
|
4
|
+
"script": "test:contract",
|
|
5
|
+
"probes": [
|
|
6
|
+
{
|
|
7
|
+
"probeId": "PROBE-001",
|
|
8
|
+
"requirementId": "REQ-001",
|
|
9
|
+
"sourceSection": "docs/specs/api.md#REQ-001",
|
|
10
|
+
"strategy": "env",
|
|
11
|
+
"env": {
|
|
12
|
+
"HARNESS_CONTRACT_PROBE": "accept-invalid"
|
|
13
|
+
},
|
|
14
|
+
"expectedFailureContains": "[REQ-001]"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"probeId": "PROBE-REPLACE",
|
|
18
|
+
"requirementId": "REQ-001",
|
|
19
|
+
"sourceSection": "docs/specs/api.md#REQ-001",
|
|
20
|
+
"strategy": "replace",
|
|
21
|
+
"target": "src/limit.js",
|
|
22
|
+
"find": "return Number.isInteger(value) && value >= 1 && value <= 10;",
|
|
23
|
+
"replace": "return true;",
|
|
24
|
+
"expectedFailureContains": "[REQ-001]"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { acceptsLimit } = require('../src/limit');
|
|
6
|
+
|
|
7
|
+
test('[REQ-001] accepts supported boundary', () => {
|
|
8
|
+
assert.equal(acceptsLimit(1), true);
|
|
9
|
+
assert.equal(acceptsLimit(10), true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('[REQ-001] rejects out-of-range input', () => {
|
|
13
|
+
assert.equal(acceptsLimit(0), false, '[REQ-001] out-of-range input must be rejected');
|
|
14
|
+
});
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
const VERSION = '1.0.0';
|
|
11
|
+
const ADAPTER = 'node-npm-v1';
|
|
12
|
+
const STRATEGIES = new Set(['env', 'replace']);
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
14
|
+
const MAX_TIMEOUT_MS = 120000;
|
|
15
|
+
|
|
16
|
+
function isObject(value) {
|
|
17
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function safeRelative(value) {
|
|
21
|
+
if (typeof value !== 'string' || !value.trim() || path.isAbsolute(value)) return false;
|
|
22
|
+
const normalized = value.replace(/\\/g, '/');
|
|
23
|
+
return normalized !== '..' && !normalized.startsWith('../') && !normalized.includes('/../') && !normalized.includes('\0');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sha256(value) {
|
|
27
|
+
return crypto.createHash('sha256').update(String(value || ''), 'utf8').digest('hex');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validatePlan(plan) {
|
|
31
|
+
const errors = [];
|
|
32
|
+
if (!isObject(plan)) return ['probe plan must be an object'];
|
|
33
|
+
if (plan.schemaVersion !== VERSION) errors.push(`schemaVersion must be ${VERSION}`);
|
|
34
|
+
if (plan.adapter !== ADAPTER) errors.push(`adapter must be ${ADAPTER}`);
|
|
35
|
+
if (typeof plan.script !== 'string' || !/^[A-Za-z0-9:_-]+$/.test(plan.script)) errors.push('script must be an npm script name');
|
|
36
|
+
if (!Array.isArray(plan.probes) || plan.probes.length === 0) errors.push('probes must be a non-empty array');
|
|
37
|
+
const ids = new Set();
|
|
38
|
+
for (const [index, probe] of (Array.isArray(plan.probes) ? plan.probes : []).entries()) {
|
|
39
|
+
const p = `probes[${index}]`;
|
|
40
|
+
if (!isObject(probe)) { errors.push(`${p} must be an object`); continue; }
|
|
41
|
+
for (const field of ['probeId', 'requirementId', 'sourceSection', 'strategy', 'expectedFailureContains']) {
|
|
42
|
+
if (typeof probe[field] !== 'string' || !probe[field].trim()) errors.push(`${p}.${field} is required`);
|
|
43
|
+
}
|
|
44
|
+
if (probe.probeId && ids.has(probe.probeId)) errors.push(`${p}.probeId is duplicated: ${probe.probeId}`);
|
|
45
|
+
if (probe.probeId) ids.add(probe.probeId);
|
|
46
|
+
if (!STRATEGIES.has(probe.strategy)) errors.push(`${p}.strategy must be env or replace`);
|
|
47
|
+
if (probe.strategy === 'env') {
|
|
48
|
+
if (!isObject(probe.env) || Object.keys(probe.env).length === 0) errors.push(`${p}.env is required for env strategy`);
|
|
49
|
+
for (const [key, value] of Object.entries(isObject(probe.env) ? probe.env : {})) {
|
|
50
|
+
if (!/^[A-Z][A-Z0-9_]{0,79}$/.test(key)) errors.push(`${p}.env contains invalid key: ${key}`);
|
|
51
|
+
if (typeof value !== 'string' || value.length > 200) errors.push(`${p}.env.${key} must be a string <= 200 chars`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (probe.strategy === 'replace') {
|
|
55
|
+
if (!safeRelative(probe.target)) errors.push(`${p}.target must be a repo-relative path`);
|
|
56
|
+
if (typeof probe.find !== 'string' || probe.find.length === 0 || probe.find.length > 4000) errors.push(`${p}.find must be 1..4000 chars`);
|
|
57
|
+
if (typeof probe.replace !== 'string' || probe.replace.length > 4000) errors.push(`${p}.replace must be <= 4000 chars`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (plan.timeoutMs !== undefined && (!Number.isInteger(plan.timeoutMs) || plan.timeoutMs < 100 || plan.timeoutMs > MAX_TIMEOUT_MS)) {
|
|
61
|
+
errors.push(`timeoutMs must be an integer between 100 and ${MAX_TIMEOUT_MS}`);
|
|
62
|
+
}
|
|
63
|
+
return errors;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function discoverProject(workspace, plan) {
|
|
67
|
+
const packageFile = path.join(workspace, 'package.json');
|
|
68
|
+
if (!fs.existsSync(packageFile)) return { ok: false, reason: 'package-json-missing' };
|
|
69
|
+
let pkg;
|
|
70
|
+
try { pkg = JSON.parse(fs.readFileSync(packageFile, 'utf8')); }
|
|
71
|
+
catch (_) { return { ok: false, reason: 'package-json-invalid' }; }
|
|
72
|
+
if (!isObject(pkg.scripts) || typeof pkg.scripts[plan.script] !== 'string') {
|
|
73
|
+
return { ok: false, reason: 'npm-script-missing' };
|
|
74
|
+
}
|
|
75
|
+
const dependencyCount =
|
|
76
|
+
Object.keys(isObject(pkg.dependencies) ? pkg.dependencies : {}).length +
|
|
77
|
+
Object.keys(isObject(pkg.devDependencies) ? pkg.devDependencies : {}).length +
|
|
78
|
+
Object.keys(isObject(pkg.optionalDependencies) ? pkg.optionalDependencies : {}).length;
|
|
79
|
+
if (dependencyCount > 0) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
reason: 'external-dependencies-not-supported-v1',
|
|
83
|
+
detail: 'node-npm-v1 intentionally supports zero-external-dependency projects only; dependency installation is never inferred or networked',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
packageName: typeof pkg.name === 'string' ? pkg.name : null,
|
|
89
|
+
script: plan.script,
|
|
90
|
+
command: `npm run ${plan.script}`,
|
|
91
|
+
dependencyMode: 'zero-external-dependencies',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function copyWorkspace(source) {
|
|
96
|
+
const sourceRoot = path.resolve(source);
|
|
97
|
+
if (fs.lstatSync(sourceRoot).isSymbolicLink()) {
|
|
98
|
+
throw new Error('source workspace contains a symbolic link/reparse-point root');
|
|
99
|
+
}
|
|
100
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-contract-probe-'));
|
|
101
|
+
const destination = path.join(root, 'workspace');
|
|
102
|
+
try {
|
|
103
|
+
fs.cpSync(sourceRoot, destination, {
|
|
104
|
+
recursive: true,
|
|
105
|
+
filter: src => {
|
|
106
|
+
if (fs.lstatSync(src).isSymbolicLink()) {
|
|
107
|
+
throw new Error('source workspace contains a symbolic link/reparse-point component');
|
|
108
|
+
}
|
|
109
|
+
const relative = path.relative(sourceRoot, src).replace(/\\/g, '/');
|
|
110
|
+
if (!relative) return true;
|
|
111
|
+
const head = relative.split('/')[0];
|
|
112
|
+
return !['.git', 'node_modules', '.harness'].includes(head);
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
return { root, workspace: destination };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function runScript(workspace, plan, extraEnv = {}) {
|
|
123
|
+
const timeout = plan.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
124
|
+
// On Windows npm is normally a .cmd shim, which child_process cannot
|
|
125
|
+
// execute directly without a command shell. The script name is restricted
|
|
126
|
+
// to a safe identifier by validatePlan(), and no user-provided shell text is
|
|
127
|
+
// interpolated into the command.
|
|
128
|
+
const result = spawnSync('npm', ['run', plan.script], {
|
|
129
|
+
cwd: workspace,
|
|
130
|
+
encoding: 'utf8',
|
|
131
|
+
timeout,
|
|
132
|
+
windowsHide: true,
|
|
133
|
+
shell: process.platform === 'win32',
|
|
134
|
+
env: { ...process.env, CI: '1', ...extraEnv },
|
|
135
|
+
});
|
|
136
|
+
const stdout = String(result.stdout || '');
|
|
137
|
+
const stderr = String(result.stderr || '');
|
|
138
|
+
const combined = stdout + '\n' + stderr;
|
|
139
|
+
if (result.error) {
|
|
140
|
+
const timedOut = result.error.code === 'ETIMEDOUT';
|
|
141
|
+
return {
|
|
142
|
+
kind: timedOut ? 'timeout' : 'spawn-error',
|
|
143
|
+
exitCode: null,
|
|
144
|
+
signal: result.signal || null,
|
|
145
|
+
outputHash: sha256(combined + String(result.error.message || '')),
|
|
146
|
+
combined,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
kind: 'exit',
|
|
151
|
+
exitCode: Number.isInteger(result.status) ? result.status : null,
|
|
152
|
+
signal: result.signal || null,
|
|
153
|
+
outputHash: sha256(combined),
|
|
154
|
+
combined,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isolatedRegularFile(workspace, relativeTarget) {
|
|
159
|
+
const root = path.resolve(workspace);
|
|
160
|
+
const target = path.resolve(root, relativeTarget);
|
|
161
|
+
const lexical = path.relative(root, target);
|
|
162
|
+
if (lexical === '..' || lexical.startsWith('..' + path.sep) || path.isAbsolute(lexical)) {
|
|
163
|
+
throw new Error('replace target escaped isolated workspace');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let current = root;
|
|
167
|
+
for (const segment of lexical.split(path.sep).filter(Boolean)) {
|
|
168
|
+
current = path.join(current, segment);
|
|
169
|
+
if (!fs.existsSync(current)) throw new Error('replace target does not exist');
|
|
170
|
+
const stat = fs.lstatSync(current);
|
|
171
|
+
if (stat.isSymbolicLink()) {
|
|
172
|
+
throw new Error('replace target contains symbolic link/reparse-point component');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const realRoot = fs.realpathSync(root);
|
|
177
|
+
const realTarget = fs.realpathSync(target);
|
|
178
|
+
const resolved = path.relative(realRoot, realTarget);
|
|
179
|
+
if (resolved === '..' || resolved.startsWith('..' + path.sep) || path.isAbsolute(resolved)) {
|
|
180
|
+
throw new Error('replace target resolved outside isolated workspace');
|
|
181
|
+
}
|
|
182
|
+
if (!fs.lstatSync(realTarget).isFile()) throw new Error('replace target does not exist');
|
|
183
|
+
return realTarget;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function applyProbe(workspace, probe) {
|
|
187
|
+
if (probe.strategy === 'env') return { env: { ...probe.env }, mutation: 'environment-only' };
|
|
188
|
+
|
|
189
|
+
const target = isolatedRegularFile(workspace, probe.target);
|
|
190
|
+
const source = fs.readFileSync(target, 'utf8');
|
|
191
|
+
const first = source.indexOf(probe.find);
|
|
192
|
+
const last = source.lastIndexOf(probe.find);
|
|
193
|
+
if (first < 0) throw new Error('replace find text was not present');
|
|
194
|
+
if (first !== last) throw new Error('replace find text must match exactly once');
|
|
195
|
+
fs.writeFileSync(target, source.slice(0, first) + probe.replace + source.slice(first + probe.find.length), 'utf8');
|
|
196
|
+
return { env: {}, mutation: `replace:${probe.target}` };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function evidenceRef(probe, run) {
|
|
200
|
+
return `probe:${probe.probeId}:output-sha256:${run.outputHash}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function notEvaluatedProbe(probe, reason, failureClass = 'infrastructure', workspaceIsolation = 'isolated') {
|
|
204
|
+
return {
|
|
205
|
+
probeId: probe.probeId,
|
|
206
|
+
requirementId: probe.requirementId,
|
|
207
|
+
status: 'NOT_EVALUATED',
|
|
208
|
+
sourceSection: probe.sourceSection,
|
|
209
|
+
evidenceRef: null,
|
|
210
|
+
workspaceIsolation,
|
|
211
|
+
failureClass,
|
|
212
|
+
adapterReason: reason,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function executePlan(workspace, plan) {
|
|
217
|
+
const errors = validatePlan(plan);
|
|
218
|
+
if (errors.length) return { schemaVersion: VERSION, adapter: ADAPTER, result: 'INVALID_PLAN', errors, probes: [] };
|
|
219
|
+
|
|
220
|
+
const discovered = discoverProject(workspace, plan);
|
|
221
|
+
if (!discovered.ok) {
|
|
222
|
+
return {
|
|
223
|
+
schemaVersion: VERSION,
|
|
224
|
+
adapter: ADAPTER,
|
|
225
|
+
result: 'NOT_EVALUATED',
|
|
226
|
+
discovery: discovered,
|
|
227
|
+
baseline: null,
|
|
228
|
+
probes: plan.probes.map(probe => notEvaluatedProbe(probe, discovered.reason)),
|
|
229
|
+
errors: [],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let baselineIsolation;
|
|
234
|
+
try {
|
|
235
|
+
baselineIsolation = copyWorkspace(workspace);
|
|
236
|
+
const baselineRun = runScript(baselineIsolation.workspace, plan);
|
|
237
|
+
const baseline = {
|
|
238
|
+
status: baselineRun.kind === 'exit' && baselineRun.exitCode === 0 ? 'PASS' : 'FAIL',
|
|
239
|
+
exitCode: baselineRun.exitCode,
|
|
240
|
+
failureClass: baselineRun.kind === 'exit' ? (baselineRun.exitCode === 0 ? 'none' : 'baseline-test') : 'infrastructure',
|
|
241
|
+
evidenceRef: `baseline:output-sha256:${baselineRun.outputHash}`,
|
|
242
|
+
workspaceIsolation: 'isolated',
|
|
243
|
+
};
|
|
244
|
+
if (baseline.status !== 'PASS') {
|
|
245
|
+
return {
|
|
246
|
+
schemaVersion: VERSION,
|
|
247
|
+
adapter: ADAPTER,
|
|
248
|
+
result: 'NOT_EVALUATED',
|
|
249
|
+
discovery: discovered,
|
|
250
|
+
baseline,
|
|
251
|
+
probes: plan.probes.map(probe => notEvaluatedProbe(probe, 'baseline-not-green')),
|
|
252
|
+
errors: [],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
const reason = String(error.message || error);
|
|
257
|
+
return {
|
|
258
|
+
schemaVersion: VERSION,
|
|
259
|
+
adapter: ADAPTER,
|
|
260
|
+
result: 'NOT_EVALUATED',
|
|
261
|
+
discovery: discovered,
|
|
262
|
+
baseline: {
|
|
263
|
+
status: 'NOT_EVALUATED',
|
|
264
|
+
evidenceRef: null,
|
|
265
|
+
workspaceIsolation: 'not-isolated',
|
|
266
|
+
failureClass: 'infrastructure',
|
|
267
|
+
adapterReason: reason,
|
|
268
|
+
},
|
|
269
|
+
probes: plan.probes.map(probe =>
|
|
270
|
+
notEvaluatedProbe(probe, reason, 'infrastructure', 'not-isolated')),
|
|
271
|
+
errors: [],
|
|
272
|
+
};
|
|
273
|
+
} finally {
|
|
274
|
+
if (baselineIsolation?.root) fs.rmSync(baselineIsolation.root, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const results = [];
|
|
278
|
+
for (const probe of plan.probes) {
|
|
279
|
+
let isolated;
|
|
280
|
+
try {
|
|
281
|
+
isolated = copyWorkspace(workspace);
|
|
282
|
+
const applied = applyProbe(isolated.workspace, probe);
|
|
283
|
+
const run = runScript(isolated.workspace, plan, applied.env);
|
|
284
|
+
if (run.kind !== 'exit') {
|
|
285
|
+
results.push(notEvaluatedProbe(probe, run.kind));
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (run.exitCode === 0) {
|
|
289
|
+
results.push({
|
|
290
|
+
probeId: probe.probeId,
|
|
291
|
+
requirementId: probe.requirementId,
|
|
292
|
+
status: 'SURVIVED',
|
|
293
|
+
sourceSection: probe.sourceSection,
|
|
294
|
+
evidenceRef: evidenceRef(probe, run),
|
|
295
|
+
workspaceIsolation: 'isolated',
|
|
296
|
+
failureClass: 'none',
|
|
297
|
+
adapterReason: 'project-test-suite-remained-green',
|
|
298
|
+
});
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (run.combined.includes(probe.expectedFailureContains)) {
|
|
302
|
+
results.push({
|
|
303
|
+
probeId: probe.probeId,
|
|
304
|
+
requirementId: probe.requirementId,
|
|
305
|
+
status: 'KILLED',
|
|
306
|
+
sourceSection: probe.sourceSection,
|
|
307
|
+
evidenceRef: evidenceRef(probe, run),
|
|
308
|
+
workspaceIsolation: 'isolated',
|
|
309
|
+
failureClass: 'contract',
|
|
310
|
+
adapterReason: 'expected-observable-contract-failure',
|
|
311
|
+
});
|
|
312
|
+
} else {
|
|
313
|
+
results.push({
|
|
314
|
+
probeId: probe.probeId,
|
|
315
|
+
requirementId: probe.requirementId,
|
|
316
|
+
status: 'INVALID',
|
|
317
|
+
sourceSection: probe.sourceSection,
|
|
318
|
+
evidenceRef: evidenceRef(probe, run),
|
|
319
|
+
workspaceIsolation: 'isolated',
|
|
320
|
+
failureClass: 'unknown',
|
|
321
|
+
adapterReason: 'test-failed-without-expected-contract-observable',
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
} catch (error) {
|
|
325
|
+
results.push(notEvaluatedProbe(
|
|
326
|
+
probe,
|
|
327
|
+
String(error.message || error),
|
|
328
|
+
'infrastructure',
|
|
329
|
+
isolated ? 'isolated' : 'not-isolated',
|
|
330
|
+
));
|
|
331
|
+
} finally {
|
|
332
|
+
if (isolated?.root) fs.rmSync(isolated.root, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
schemaVersion: VERSION,
|
|
338
|
+
adapter: ADAPTER,
|
|
339
|
+
result: results.every(probe => probe.status === 'KILLED') ? 'PROTECTED' :
|
|
340
|
+
results.some(probe => probe.status === 'SURVIVED') ? 'SURVIVED' : 'NOT_EVALUATED',
|
|
341
|
+
discovery: discovered,
|
|
342
|
+
baseline: { status: 'PASS', workspaceIsolation: 'isolated' },
|
|
343
|
+
probes: results,
|
|
344
|
+
errors: [],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function parseArgs(argv) {
|
|
349
|
+
const args = { workspace: null, plan: null, output: null };
|
|
350
|
+
for (let i = 0; i < argv.length; i++) {
|
|
351
|
+
if (argv[i] === '--workspace') args.workspace = argv[++i];
|
|
352
|
+
else if (argv[i] === '--plan') args.plan = argv[++i];
|
|
353
|
+
else if (argv[i] === '--output') args.output = argv[++i];
|
|
354
|
+
else throw new Error('unknown argument: ' + argv[i]);
|
|
355
|
+
}
|
|
356
|
+
if (!args.workspace || !args.plan) throw new Error('Usage: node node-probe-adapter.js --workspace <project> --plan <probe-plan.json> [--output report.json]');
|
|
357
|
+
return args;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function main() {
|
|
361
|
+
try {
|
|
362
|
+
const args = parseArgs(process.argv.slice(2));
|
|
363
|
+
const workspace = path.resolve(args.workspace);
|
|
364
|
+
const plan = JSON.parse(fs.readFileSync(path.resolve(args.plan), 'utf8'));
|
|
365
|
+
const report = executePlan(workspace, plan);
|
|
366
|
+
const json = JSON.stringify(report, null, 2) + '\n';
|
|
367
|
+
if (args.output) {
|
|
368
|
+
fs.mkdirSync(path.dirname(path.resolve(args.output)), { recursive: true });
|
|
369
|
+
fs.writeFileSync(path.resolve(args.output), json, 'utf8');
|
|
370
|
+
}
|
|
371
|
+
process.stdout.write(json);
|
|
372
|
+
process.exitCode = report.result === 'PROTECTED' ? 0 : report.result === 'SURVIVED' ? 1 : 2;
|
|
373
|
+
} catch (error) {
|
|
374
|
+
console.error('[Contract Probe Adapter] ' + error.message);
|
|
375
|
+
process.exitCode = 2;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (require.main === module) main();
|
|
380
|
+
|
|
381
|
+
module.exports = {
|
|
382
|
+
ADAPTER,
|
|
383
|
+
VERSION,
|
|
384
|
+
applyProbe,
|
|
385
|
+
discoverProject,
|
|
386
|
+
executePlan,
|
|
387
|
+
runScript,
|
|
388
|
+
safeRelative,
|
|
389
|
+
validatePlan,
|
|
390
|
+
};
|
package/package.json
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Contract Protection Adapters
|
|
2
|
+
|
|
3
|
+
This directory does **not** claim generic mutation-testing support.
|
|
4
|
+
|
|
5
|
+
## Supported in Phase 3
|
|
6
|
+
|
|
7
|
+
### `node-npm-v1`
|
|
8
|
+
|
|
9
|
+
Supported project shape:
|
|
10
|
+
|
|
11
|
+
- Node.js project with a `package.json`;
|
|
12
|
+
- one explicitly selected npm script used as the contract-test command;
|
|
13
|
+
- **zero external npm dependencies** for this first adapter revision;
|
|
14
|
+
- probes are predeclared in a local JSON plan and tied to stable `REQ-*` IDs and authoritative source sections;
|
|
15
|
+
- every baseline and probe run executes in a fresh temporary copy; the source workspace is never mutated.
|
|
16
|
+
|
|
17
|
+
Source workspaces containing symbolic-link or junction entries are rejected before project code runs. The v1 adapter does not claim isolation when a copied path could retain an external filesystem target.
|
|
18
|
+
|
|
19
|
+
Supported probe strategies:
|
|
20
|
+
|
|
21
|
+
1. **`env`** — explicit project-owned fault injection via environment variables.
|
|
22
|
+
2. **`replace`** — exact one-occurrence text replacement in the isolated copy only. This is the fallback when the project has no built-in fault-injection seam.
|
|
23
|
+
|
|
24
|
+
A probe is `KILLED` only when the selected npm script fails **and** its output contains the predeclared requirement-specific observable marker. A green run is `SURVIVED`. Timeout/spawn failures, baseline failures, unsafe/ambiguous replacement, missing dependencies, or unrelated failures are `NOT_EVALUATED` / `INVALID`; none of those earn protection credit.
|
|
25
|
+
|
|
26
|
+
The adapter does not install dependencies, access the network, infer mutation locations, rewrite source documents, or commit generated probes.
|
|
27
|
+
|
|
28
|
+
## Not yet supported
|
|
29
|
+
|
|
30
|
+
- arbitrary Jest/Vitest/Mocha/Pytest/Go/Rust/.NET adapter discovery;
|
|
31
|
+
- projects that require dependency installation inside the isolated copy;
|
|
32
|
+
- automatic source-derived mutation synthesis;
|
|
33
|
+
- compiler-failure-as-kill;
|
|
34
|
+
- flaky-test attribution;
|
|
35
|
+
- dirty-worktree mutation or in-place production changes.
|
|
36
|
+
|
|
37
|
+
Those remain future adapters/slices. Phase 3 intentionally proves one narrow real runner plus an explicit isolated fault-injection fallback.
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
const VERSION = '1.0.0';
|
|
11
|
+
const ADAPTER = 'node-npm-v1';
|
|
12
|
+
const STRATEGIES = new Set(['env', 'replace']);
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
14
|
+
const MAX_TIMEOUT_MS = 120000;
|
|
15
|
+
|
|
16
|
+
function isObject(value) {
|
|
17
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function safeRelative(value) {
|
|
21
|
+
if (typeof value !== 'string' || !value.trim() || path.isAbsolute(value)) return false;
|
|
22
|
+
const normalized = value.replace(/\\/g, '/');
|
|
23
|
+
return normalized !== '..' && !normalized.startsWith('../') && !normalized.includes('/../') && !normalized.includes('\0');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sha256(value) {
|
|
27
|
+
return crypto.createHash('sha256').update(String(value || ''), 'utf8').digest('hex');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validatePlan(plan) {
|
|
31
|
+
const errors = [];
|
|
32
|
+
if (!isObject(plan)) return ['probe plan must be an object'];
|
|
33
|
+
if (plan.schemaVersion !== VERSION) errors.push(`schemaVersion must be ${VERSION}`);
|
|
34
|
+
if (plan.adapter !== ADAPTER) errors.push(`adapter must be ${ADAPTER}`);
|
|
35
|
+
if (typeof plan.script !== 'string' || !/^[A-Za-z0-9:_-]+$/.test(plan.script)) errors.push('script must be an npm script name');
|
|
36
|
+
if (!Array.isArray(plan.probes) || plan.probes.length === 0) errors.push('probes must be a non-empty array');
|
|
37
|
+
const ids = new Set();
|
|
38
|
+
for (const [index, probe] of (Array.isArray(plan.probes) ? plan.probes : []).entries()) {
|
|
39
|
+
const p = `probes[${index}]`;
|
|
40
|
+
if (!isObject(probe)) { errors.push(`${p} must be an object`); continue; }
|
|
41
|
+
for (const field of ['probeId', 'requirementId', 'sourceSection', 'strategy', 'expectedFailureContains']) {
|
|
42
|
+
if (typeof probe[field] !== 'string' || !probe[field].trim()) errors.push(`${p}.${field} is required`);
|
|
43
|
+
}
|
|
44
|
+
if (probe.probeId && ids.has(probe.probeId)) errors.push(`${p}.probeId is duplicated: ${probe.probeId}`);
|
|
45
|
+
if (probe.probeId) ids.add(probe.probeId);
|
|
46
|
+
if (!STRATEGIES.has(probe.strategy)) errors.push(`${p}.strategy must be env or replace`);
|
|
47
|
+
if (probe.strategy === 'env') {
|
|
48
|
+
if (!isObject(probe.env) || Object.keys(probe.env).length === 0) errors.push(`${p}.env is required for env strategy`);
|
|
49
|
+
for (const [key, value] of Object.entries(isObject(probe.env) ? probe.env : {})) {
|
|
50
|
+
if (!/^[A-Z][A-Z0-9_]{0,79}$/.test(key)) errors.push(`${p}.env contains invalid key: ${key}`);
|
|
51
|
+
if (typeof value !== 'string' || value.length > 200) errors.push(`${p}.env.${key} must be a string <= 200 chars`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (probe.strategy === 'replace') {
|
|
55
|
+
if (!safeRelative(probe.target)) errors.push(`${p}.target must be a repo-relative path`);
|
|
56
|
+
if (typeof probe.find !== 'string' || probe.find.length === 0 || probe.find.length > 4000) errors.push(`${p}.find must be 1..4000 chars`);
|
|
57
|
+
if (typeof probe.replace !== 'string' || probe.replace.length > 4000) errors.push(`${p}.replace must be <= 4000 chars`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (plan.timeoutMs !== undefined && (!Number.isInteger(plan.timeoutMs) || plan.timeoutMs < 100 || plan.timeoutMs > MAX_TIMEOUT_MS)) {
|
|
61
|
+
errors.push(`timeoutMs must be an integer between 100 and ${MAX_TIMEOUT_MS}`);
|
|
62
|
+
}
|
|
63
|
+
return errors;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function discoverProject(workspace, plan) {
|
|
67
|
+
const packageFile = path.join(workspace, 'package.json');
|
|
68
|
+
if (!fs.existsSync(packageFile)) return { ok: false, reason: 'package-json-missing' };
|
|
69
|
+
let pkg;
|
|
70
|
+
try { pkg = JSON.parse(fs.readFileSync(packageFile, 'utf8')); }
|
|
71
|
+
catch (_) { return { ok: false, reason: 'package-json-invalid' }; }
|
|
72
|
+
if (!isObject(pkg.scripts) || typeof pkg.scripts[plan.script] !== 'string') {
|
|
73
|
+
return { ok: false, reason: 'npm-script-missing' };
|
|
74
|
+
}
|
|
75
|
+
const dependencyCount =
|
|
76
|
+
Object.keys(isObject(pkg.dependencies) ? pkg.dependencies : {}).length +
|
|
77
|
+
Object.keys(isObject(pkg.devDependencies) ? pkg.devDependencies : {}).length +
|
|
78
|
+
Object.keys(isObject(pkg.optionalDependencies) ? pkg.optionalDependencies : {}).length;
|
|
79
|
+
if (dependencyCount > 0) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
reason: 'external-dependencies-not-supported-v1',
|
|
83
|
+
detail: 'node-npm-v1 intentionally supports zero-external-dependency projects only; dependency installation is never inferred or networked',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
packageName: typeof pkg.name === 'string' ? pkg.name : null,
|
|
89
|
+
script: plan.script,
|
|
90
|
+
command: `npm run ${plan.script}`,
|
|
91
|
+
dependencyMode: 'zero-external-dependencies',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function copyWorkspace(source) {
|
|
96
|
+
const sourceRoot = path.resolve(source);
|
|
97
|
+
if (fs.lstatSync(sourceRoot).isSymbolicLink()) {
|
|
98
|
+
throw new Error('source workspace contains a symbolic link/reparse-point root');
|
|
99
|
+
}
|
|
100
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-contract-probe-'));
|
|
101
|
+
const destination = path.join(root, 'workspace');
|
|
102
|
+
try {
|
|
103
|
+
fs.cpSync(sourceRoot, destination, {
|
|
104
|
+
recursive: true,
|
|
105
|
+
filter: src => {
|
|
106
|
+
if (fs.lstatSync(src).isSymbolicLink()) {
|
|
107
|
+
throw new Error('source workspace contains a symbolic link/reparse-point component');
|
|
108
|
+
}
|
|
109
|
+
const relative = path.relative(sourceRoot, src).replace(/\\/g, '/');
|
|
110
|
+
if (!relative) return true;
|
|
111
|
+
const head = relative.split('/')[0];
|
|
112
|
+
return !['.git', 'node_modules', '.harness'].includes(head);
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
return { root, workspace: destination };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function runScript(workspace, plan, extraEnv = {}) {
|
|
123
|
+
const timeout = plan.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
124
|
+
// On Windows npm is normally a .cmd shim, which child_process cannot
|
|
125
|
+
// execute directly without a command shell. The script name is restricted
|
|
126
|
+
// to a safe identifier by validatePlan(), and no user-provided shell text is
|
|
127
|
+
// interpolated into the command.
|
|
128
|
+
const result = spawnSync('npm', ['run', plan.script], {
|
|
129
|
+
cwd: workspace,
|
|
130
|
+
encoding: 'utf8',
|
|
131
|
+
timeout,
|
|
132
|
+
windowsHide: true,
|
|
133
|
+
shell: process.platform === 'win32',
|
|
134
|
+
env: { ...process.env, CI: '1', ...extraEnv },
|
|
135
|
+
});
|
|
136
|
+
const stdout = String(result.stdout || '');
|
|
137
|
+
const stderr = String(result.stderr || '');
|
|
138
|
+
const combined = stdout + '\n' + stderr;
|
|
139
|
+
if (result.error) {
|
|
140
|
+
const timedOut = result.error.code === 'ETIMEDOUT';
|
|
141
|
+
return {
|
|
142
|
+
kind: timedOut ? 'timeout' : 'spawn-error',
|
|
143
|
+
exitCode: null,
|
|
144
|
+
signal: result.signal || null,
|
|
145
|
+
outputHash: sha256(combined + String(result.error.message || '')),
|
|
146
|
+
combined,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
kind: 'exit',
|
|
151
|
+
exitCode: Number.isInteger(result.status) ? result.status : null,
|
|
152
|
+
signal: result.signal || null,
|
|
153
|
+
outputHash: sha256(combined),
|
|
154
|
+
combined,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isolatedRegularFile(workspace, relativeTarget) {
|
|
159
|
+
const root = path.resolve(workspace);
|
|
160
|
+
const target = path.resolve(root, relativeTarget);
|
|
161
|
+
const lexical = path.relative(root, target);
|
|
162
|
+
if (lexical === '..' || lexical.startsWith('..' + path.sep) || path.isAbsolute(lexical)) {
|
|
163
|
+
throw new Error('replace target escaped isolated workspace');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let current = root;
|
|
167
|
+
for (const segment of lexical.split(path.sep).filter(Boolean)) {
|
|
168
|
+
current = path.join(current, segment);
|
|
169
|
+
if (!fs.existsSync(current)) throw new Error('replace target does not exist');
|
|
170
|
+
const stat = fs.lstatSync(current);
|
|
171
|
+
if (stat.isSymbolicLink()) {
|
|
172
|
+
throw new Error('replace target contains symbolic link/reparse-point component');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const realRoot = fs.realpathSync(root);
|
|
177
|
+
const realTarget = fs.realpathSync(target);
|
|
178
|
+
const resolved = path.relative(realRoot, realTarget);
|
|
179
|
+
if (resolved === '..' || resolved.startsWith('..' + path.sep) || path.isAbsolute(resolved)) {
|
|
180
|
+
throw new Error('replace target resolved outside isolated workspace');
|
|
181
|
+
}
|
|
182
|
+
if (!fs.lstatSync(realTarget).isFile()) throw new Error('replace target does not exist');
|
|
183
|
+
return realTarget;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function applyProbe(workspace, probe) {
|
|
187
|
+
if (probe.strategy === 'env') return { env: { ...probe.env }, mutation: 'environment-only' };
|
|
188
|
+
|
|
189
|
+
const target = isolatedRegularFile(workspace, probe.target);
|
|
190
|
+
const source = fs.readFileSync(target, 'utf8');
|
|
191
|
+
const first = source.indexOf(probe.find);
|
|
192
|
+
const last = source.lastIndexOf(probe.find);
|
|
193
|
+
if (first < 0) throw new Error('replace find text was not present');
|
|
194
|
+
if (first !== last) throw new Error('replace find text must match exactly once');
|
|
195
|
+
fs.writeFileSync(target, source.slice(0, first) + probe.replace + source.slice(first + probe.find.length), 'utf8');
|
|
196
|
+
return { env: {}, mutation: `replace:${probe.target}` };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function evidenceRef(probe, run) {
|
|
200
|
+
return `probe:${probe.probeId}:output-sha256:${run.outputHash}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function notEvaluatedProbe(probe, reason, failureClass = 'infrastructure', workspaceIsolation = 'isolated') {
|
|
204
|
+
return {
|
|
205
|
+
probeId: probe.probeId,
|
|
206
|
+
requirementId: probe.requirementId,
|
|
207
|
+
status: 'NOT_EVALUATED',
|
|
208
|
+
sourceSection: probe.sourceSection,
|
|
209
|
+
evidenceRef: null,
|
|
210
|
+
workspaceIsolation,
|
|
211
|
+
failureClass,
|
|
212
|
+
adapterReason: reason,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function executePlan(workspace, plan) {
|
|
217
|
+
const errors = validatePlan(plan);
|
|
218
|
+
if (errors.length) return { schemaVersion: VERSION, adapter: ADAPTER, result: 'INVALID_PLAN', errors, probes: [] };
|
|
219
|
+
|
|
220
|
+
const discovered = discoverProject(workspace, plan);
|
|
221
|
+
if (!discovered.ok) {
|
|
222
|
+
return {
|
|
223
|
+
schemaVersion: VERSION,
|
|
224
|
+
adapter: ADAPTER,
|
|
225
|
+
result: 'NOT_EVALUATED',
|
|
226
|
+
discovery: discovered,
|
|
227
|
+
baseline: null,
|
|
228
|
+
probes: plan.probes.map(probe => notEvaluatedProbe(probe, discovered.reason)),
|
|
229
|
+
errors: [],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let baselineIsolation;
|
|
234
|
+
try {
|
|
235
|
+
baselineIsolation = copyWorkspace(workspace);
|
|
236
|
+
const baselineRun = runScript(baselineIsolation.workspace, plan);
|
|
237
|
+
const baseline = {
|
|
238
|
+
status: baselineRun.kind === 'exit' && baselineRun.exitCode === 0 ? 'PASS' : 'FAIL',
|
|
239
|
+
exitCode: baselineRun.exitCode,
|
|
240
|
+
failureClass: baselineRun.kind === 'exit' ? (baselineRun.exitCode === 0 ? 'none' : 'baseline-test') : 'infrastructure',
|
|
241
|
+
evidenceRef: `baseline:output-sha256:${baselineRun.outputHash}`,
|
|
242
|
+
workspaceIsolation: 'isolated',
|
|
243
|
+
};
|
|
244
|
+
if (baseline.status !== 'PASS') {
|
|
245
|
+
return {
|
|
246
|
+
schemaVersion: VERSION,
|
|
247
|
+
adapter: ADAPTER,
|
|
248
|
+
result: 'NOT_EVALUATED',
|
|
249
|
+
discovery: discovered,
|
|
250
|
+
baseline,
|
|
251
|
+
probes: plan.probes.map(probe => notEvaluatedProbe(probe, 'baseline-not-green')),
|
|
252
|
+
errors: [],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
const reason = String(error.message || error);
|
|
257
|
+
return {
|
|
258
|
+
schemaVersion: VERSION,
|
|
259
|
+
adapter: ADAPTER,
|
|
260
|
+
result: 'NOT_EVALUATED',
|
|
261
|
+
discovery: discovered,
|
|
262
|
+
baseline: {
|
|
263
|
+
status: 'NOT_EVALUATED',
|
|
264
|
+
evidenceRef: null,
|
|
265
|
+
workspaceIsolation: 'not-isolated',
|
|
266
|
+
failureClass: 'infrastructure',
|
|
267
|
+
adapterReason: reason,
|
|
268
|
+
},
|
|
269
|
+
probes: plan.probes.map(probe =>
|
|
270
|
+
notEvaluatedProbe(probe, reason, 'infrastructure', 'not-isolated')),
|
|
271
|
+
errors: [],
|
|
272
|
+
};
|
|
273
|
+
} finally {
|
|
274
|
+
if (baselineIsolation?.root) fs.rmSync(baselineIsolation.root, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const results = [];
|
|
278
|
+
for (const probe of plan.probes) {
|
|
279
|
+
let isolated;
|
|
280
|
+
try {
|
|
281
|
+
isolated = copyWorkspace(workspace);
|
|
282
|
+
const applied = applyProbe(isolated.workspace, probe);
|
|
283
|
+
const run = runScript(isolated.workspace, plan, applied.env);
|
|
284
|
+
if (run.kind !== 'exit') {
|
|
285
|
+
results.push(notEvaluatedProbe(probe, run.kind));
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (run.exitCode === 0) {
|
|
289
|
+
results.push({
|
|
290
|
+
probeId: probe.probeId,
|
|
291
|
+
requirementId: probe.requirementId,
|
|
292
|
+
status: 'SURVIVED',
|
|
293
|
+
sourceSection: probe.sourceSection,
|
|
294
|
+
evidenceRef: evidenceRef(probe, run),
|
|
295
|
+
workspaceIsolation: 'isolated',
|
|
296
|
+
failureClass: 'none',
|
|
297
|
+
adapterReason: 'project-test-suite-remained-green',
|
|
298
|
+
});
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (run.combined.includes(probe.expectedFailureContains)) {
|
|
302
|
+
results.push({
|
|
303
|
+
probeId: probe.probeId,
|
|
304
|
+
requirementId: probe.requirementId,
|
|
305
|
+
status: 'KILLED',
|
|
306
|
+
sourceSection: probe.sourceSection,
|
|
307
|
+
evidenceRef: evidenceRef(probe, run),
|
|
308
|
+
workspaceIsolation: 'isolated',
|
|
309
|
+
failureClass: 'contract',
|
|
310
|
+
adapterReason: 'expected-observable-contract-failure',
|
|
311
|
+
});
|
|
312
|
+
} else {
|
|
313
|
+
results.push({
|
|
314
|
+
probeId: probe.probeId,
|
|
315
|
+
requirementId: probe.requirementId,
|
|
316
|
+
status: 'INVALID',
|
|
317
|
+
sourceSection: probe.sourceSection,
|
|
318
|
+
evidenceRef: evidenceRef(probe, run),
|
|
319
|
+
workspaceIsolation: 'isolated',
|
|
320
|
+
failureClass: 'unknown',
|
|
321
|
+
adapterReason: 'test-failed-without-expected-contract-observable',
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
} catch (error) {
|
|
325
|
+
results.push(notEvaluatedProbe(
|
|
326
|
+
probe,
|
|
327
|
+
String(error.message || error),
|
|
328
|
+
'infrastructure',
|
|
329
|
+
isolated ? 'isolated' : 'not-isolated',
|
|
330
|
+
));
|
|
331
|
+
} finally {
|
|
332
|
+
if (isolated?.root) fs.rmSync(isolated.root, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
schemaVersion: VERSION,
|
|
338
|
+
adapter: ADAPTER,
|
|
339
|
+
result: results.every(probe => probe.status === 'KILLED') ? 'PROTECTED' :
|
|
340
|
+
results.some(probe => probe.status === 'SURVIVED') ? 'SURVIVED' : 'NOT_EVALUATED',
|
|
341
|
+
discovery: discovered,
|
|
342
|
+
baseline: { status: 'PASS', workspaceIsolation: 'isolated' },
|
|
343
|
+
probes: results,
|
|
344
|
+
errors: [],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function parseArgs(argv) {
|
|
349
|
+
const args = { workspace: null, plan: null, output: null };
|
|
350
|
+
for (let i = 0; i < argv.length; i++) {
|
|
351
|
+
if (argv[i] === '--workspace') args.workspace = argv[++i];
|
|
352
|
+
else if (argv[i] === '--plan') args.plan = argv[++i];
|
|
353
|
+
else if (argv[i] === '--output') args.output = argv[++i];
|
|
354
|
+
else throw new Error('unknown argument: ' + argv[i]);
|
|
355
|
+
}
|
|
356
|
+
if (!args.workspace || !args.plan) throw new Error('Usage: node node-probe-adapter.js --workspace <project> --plan <probe-plan.json> [--output report.json]');
|
|
357
|
+
return args;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function main() {
|
|
361
|
+
try {
|
|
362
|
+
const args = parseArgs(process.argv.slice(2));
|
|
363
|
+
const workspace = path.resolve(args.workspace);
|
|
364
|
+
const plan = JSON.parse(fs.readFileSync(path.resolve(args.plan), 'utf8'));
|
|
365
|
+
const report = executePlan(workspace, plan);
|
|
366
|
+
const json = JSON.stringify(report, null, 2) + '\n';
|
|
367
|
+
if (args.output) {
|
|
368
|
+
fs.mkdirSync(path.dirname(path.resolve(args.output)), { recursive: true });
|
|
369
|
+
fs.writeFileSync(path.resolve(args.output), json, 'utf8');
|
|
370
|
+
}
|
|
371
|
+
process.stdout.write(json);
|
|
372
|
+
process.exitCode = report.result === 'PROTECTED' ? 0 : report.result === 'SURVIVED' ? 1 : 2;
|
|
373
|
+
} catch (error) {
|
|
374
|
+
console.error('[Contract Probe Adapter] ' + error.message);
|
|
375
|
+
process.exitCode = 2;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (require.main === module) main();
|
|
380
|
+
|
|
381
|
+
module.exports = {
|
|
382
|
+
ADAPTER,
|
|
383
|
+
VERSION,
|
|
384
|
+
applyProbe,
|
|
385
|
+
discoverProject,
|
|
386
|
+
executePlan,
|
|
387
|
+
runScript,
|
|
388
|
+
safeRelative,
|
|
389
|
+
validatePlan,
|
|
390
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "harness-everything",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.14.0",
|
|
5
5
|
"description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Miya Daniel",
|
|
@@ -71,8 +71,11 @@ syncTree(path.join(ROOT, 'scripts', 'lib', 'advisory-text.js'), packageAdvisoryT
|
|
|
71
71
|
// Contract-integrity is shared runtime, not a directly-routed 27th skill.
|
|
72
72
|
// Package it under the plugin root and rewrite its one canonical TDD import
|
|
73
73
|
// to the packaged skill tree.
|
|
74
|
-
const
|
|
74
|
+
const packageContractRoot = path.join(pluginRoot, 'contract-integrity');
|
|
75
|
+
const packageContractAudit = path.join(packageContractRoot, 'scripts', 'audit.js');
|
|
75
76
|
syncTree(path.join(ROOT, 'contract-integrity', 'scripts', 'audit.js'), packageContractAudit);
|
|
77
|
+
syncTree(path.join(ROOT, 'contract-integrity', 'scripts', 'node-probe-adapter.js'), path.join(packageContractRoot, 'scripts', 'node-probe-adapter.js'));
|
|
78
|
+
syncTree(path.join(ROOT, 'contract-integrity', 'ADAPTERS.md'), path.join(packageContractRoot, 'ADAPTERS.md'));
|
|
76
79
|
const contractAuditSource = fs.readFileSync(packageContractAudit, 'utf8');
|
|
77
80
|
fs.writeFileSync(
|
|
78
81
|
packageContractAudit,
|