harness-everything 0.13.0 → 0.15.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.
Files changed (25) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +33 -0
  4. package/ci/contract-integrity.test.js +155 -1
  5. package/ci/mechanism-2ak-contract-probe-adapter.test.js +207 -0
  6. package/ci/openai-plugin-package.test.js +10 -0
  7. package/contract-integrity/ADAPTERS.md +37 -0
  8. package/contract-integrity/fixtures/node-probe-project/package.json +8 -0
  9. package/contract-integrity/fixtures/node-probe-project/probe-plan.json +27 -0
  10. package/contract-integrity/fixtures/node-probe-project/src/limit.js +8 -0
  11. package/contract-integrity/fixtures/node-probe-project/test/contract.test.js +14 -0
  12. package/contract-integrity/scripts/audit.js +225 -2
  13. package/contract-integrity/scripts/node-probe-adapter.js +390 -0
  14. package/opencode-plugin/plugin.json +1 -1
  15. package/package.json +1 -1
  16. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  17. package/plugins/harness-everything/contract-integrity/ADAPTERS.md +37 -0
  18. package/plugins/harness-everything/contract-integrity/scripts/audit.js +225 -2
  19. package/plugins/harness-everything/contract-integrity/scripts/node-probe-adapter.js +390 -0
  20. package/plugins/harness-everything/plugin.json +1 -1
  21. package/plugins/harness-everything/skills/verification-loop/SKILL.md +2 -2
  22. package/plugins/harness-everything/skills/verification-loop/references/verification-phases.md +9 -1
  23. package/scripts/sync-openai-plugin.js +4 -1
  24. package/verification-loop/SKILL.md +2 -2
  25. package/verification-loop/references/verification-phases.md +9 -1
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
+ const crypto = require('crypto');
4
5
  const fs = require('fs');
5
6
  const path = require('path');
7
+ const { spawnSync } = require('child_process');
6
8
  const { evaluateEvidence } = require('../../tdd/scripts/quality-gate');
7
9
 
8
10
  const VERSION = '1.0.0';
@@ -57,6 +59,181 @@ function stable(value) {
57
59
  return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
58
60
  }
59
61
 
62
+ function sha256File(file) {
63
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
64
+ }
65
+
66
+ function realpath(file) {
67
+ return fs.realpathSync.native ? fs.realpathSync.native(file) : fs.realpathSync(file);
68
+ }
69
+
70
+ function containedPath(root, target) {
71
+ const relative = path.relative(root, target);
72
+ return relative === '' ||
73
+ (relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative));
74
+ }
75
+
76
+ function gitRevision(workspace) {
77
+ if (!workspace) return null;
78
+ const result = spawnSync('git', ['rev-parse', 'HEAD'], {
79
+ cwd: workspace,
80
+ encoding: 'utf8',
81
+ windowsHide: true,
82
+ });
83
+ return result.status === 0 ? String(result.stdout || '').trim() || null : null;
84
+ }
85
+
86
+ function artifactFingerprints(trace, workspace) {
87
+ if (!workspace) return [];
88
+ const root = path.resolve(workspace);
89
+ let realRoot;
90
+ try {
91
+ if (fs.lstatSync(root).isSymbolicLink()) throw new Error('linked-root');
92
+ realRoot = realpath(root);
93
+ } catch (_) {
94
+ return (trace?.artifacts || []).map(artifact => ({
95
+ id: artifact.id, path: artifact.path, sha256: null, missing: true, reason: 'workspace-unavailable',
96
+ }));
97
+ }
98
+ return (trace?.artifacts || []).map(artifact => {
99
+ const full = path.resolve(root, artifact.path);
100
+ if (!containedPath(root, full)) {
101
+ return { id: artifact.id, path: artifact.path, sha256: null, missing: true, reason: 'escaped-path' };
102
+ }
103
+ let current = root;
104
+ for (const segment of path.relative(root, full).split(path.sep).filter(Boolean)) {
105
+ current = path.join(current, segment);
106
+ if (!fs.existsSync(current)) {
107
+ return { id: artifact.id, path: artifact.path, sha256: null, missing: true, reason: 'missing' };
108
+ }
109
+ if (fs.lstatSync(current).isSymbolicLink()) {
110
+ return { id: artifact.id, path: artifact.path, sha256: null, missing: true, reason: 'linked-path' };
111
+ }
112
+ }
113
+ let physical;
114
+ try { physical = realpath(full); }
115
+ catch (_) {
116
+ return { id: artifact.id, path: artifact.path, sha256: null, missing: true, reason: 'missing' };
117
+ }
118
+ if (!containedPath(realRoot, physical) || !fs.lstatSync(physical).isFile()) {
119
+ return { id: artifact.id, path: artifact.path, sha256: null, missing: true, reason: 'escaped-path' };
120
+ }
121
+ return { id: artifact.id, path: artifact.path, sha256: sha256File(physical), missing: false };
122
+ });
123
+ }
124
+
125
+ function buildProvenance(args, trace) {
126
+ const workspace = args.workspace ? path.resolve(args.workspace) : null;
127
+ return {
128
+ schemaVersion: 1,
129
+ generatedAt: new Date().toISOString(),
130
+ auditVersion: VERSION,
131
+ nodeVersion: process.version,
132
+ command: 'node contract-integrity/scripts/audit.js <trace> [--tdd-evidence <evidence>] [--workspace <workspace>]',
133
+ gitRevision: gitRevision(workspace || process.cwd()),
134
+ workspaceIsolation: unique((trace?.probes || []).map(probe => probe.workspaceIsolation).filter(Boolean)),
135
+ workspace: workspace ? { root: workspace } : null,
136
+ inputs: {
137
+ trace: { sha256: sha256File(path.resolve(args.input)) },
138
+ tddEvidence: args.tddEvidence ? { sha256: sha256File(path.resolve(args.tddEvidence)) } : null,
139
+ },
140
+ artifacts: artifactFingerprints(trace, workspace),
141
+ };
142
+ }
143
+
144
+ function verifyFreshReport(report, args, trace, currentAudit = null) {
145
+ const reasons = [];
146
+ if (!isObject(report?.provenance) || !isObject(report.provenance.inputs)) {
147
+ return { fresh: false, authorizesCompletion: false, reasons: ['report-provenance-missing'] };
148
+ }
149
+ const currentTraceHash = sha256File(path.resolve(args.input));
150
+ if (report.provenance.inputs.trace?.sha256 !== currentTraceHash) reasons.push('trace-changed-since-audit');
151
+ const currentTddHash = args.tddEvidence ? sha256File(path.resolve(args.tddEvidence)) : null;
152
+ const reportedTddHash = report.provenance.inputs.tddEvidence?.sha256 || null;
153
+ if (currentTddHash !== reportedTddHash) reasons.push('tdd-evidence-changed-since-audit');
154
+ if (!currentAudit || currentAudit.result !== 'PASS' || currentAudit.completionGate !== 'PASS' || currentAudit.errors?.length) {
155
+ reasons.push('current-audit-not-pass');
156
+ }
157
+
158
+ const tracedArtifacts = Array.isArray(trace?.artifacts) ? trace.artifacts : [];
159
+ if (tracedArtifacts.length > 0) {
160
+ if (!args.workspace) {
161
+ reasons.push('workspace-required-for-artifact-freshness');
162
+ } else {
163
+ const reportedRoot = report.provenance.workspace?.root;
164
+ if (!reportedRoot) {
165
+ reasons.push('report-workspace-unbound');
166
+ } else if (path.resolve(reportedRoot) !== path.resolve(args.workspace)) {
167
+ reasons.push('workspace-changed-since-audit');
168
+ }
169
+
170
+ const priorArtifacts = Array.isArray(report.provenance.artifacts) ? report.provenance.artifacts : [];
171
+ const prior = new Map();
172
+ for (const item of priorArtifacts) {
173
+ if (item?.id && !prior.has(item.id)) prior.set(item.id, item);
174
+ else if (item?.id) reasons.push(`artifact-fingerprint-duplicate:${item.id}`);
175
+ }
176
+ const current = new Map(artifactFingerprints(trace, args.workspace).map(item => [item.id, item]));
177
+ for (const artifact of tracedArtifacts) {
178
+ const before = prior.get(artifact.id);
179
+ const now = current.get(artifact.id);
180
+ if (!before || before.path !== artifact.path || before.missing || !before.sha256) {
181
+ reasons.push(`artifact-fingerprint-missing:${artifact.id}`);
182
+ continue;
183
+ }
184
+ if (!now || now.missing || now.sha256 !== before.sha256) {
185
+ reasons.push(`artifact-changed-since-audit:${artifact.id}`);
186
+ }
187
+ }
188
+ if (priorArtifacts.length !== tracedArtifacts.length) {
189
+ reasons.push('artifact-fingerprint-set-incomplete');
190
+ }
191
+ }
192
+ }
193
+ return {
194
+ fresh: reasons.length === 0,
195
+ authorizesCompletion: reasons.length === 0 && report.completionGate === 'PASS' && report.result === 'PASS',
196
+ reasons: unique(reasons),
197
+ };
198
+ }
199
+
200
+ function repairGuidance(requirements) {
201
+ const priorityOrder = { BLOCKER: 0, HIGH: 1, MEDIUM: 2 };
202
+ const recommendations = [];
203
+ const add = (priority, req, code, action) => recommendations.push({
204
+ priority,
205
+ requirementId: req.requirementId,
206
+ sourceRef: req.sourceRef || null,
207
+ code,
208
+ action,
209
+ });
210
+
211
+ for (const req of requirements) {
212
+ if (req.lifecycleStatus !== 'CURRENT') continue;
213
+ if (req.drift.includes('SOURCE_CONFLICT')) add('BLOCKER', req, 'resolve-source-conflict', 'Resolve the authoritative source conflict before changing tests or implementation.');
214
+ if (req.drift.includes('SOURCE_DEFECT')) add('BLOCKER', req, 'repair-source-defect', 'Repair or explicitly supersede the defective authoritative source before completion.');
215
+ if (req.protection.status === 'SURVIVED') add('BLOCKER', req, 'strengthen-weak-oracle', 'Strengthen the requirement-linked observable assertion so every required contract probe is killed for the expected reason.');
216
+ if (req.drift.includes('STALE_SPEC')) add('HIGH', req, 'update-living-spec', 'Update the living specification and requirement revision before accepting behavior that moved ahead of the contract.');
217
+ if (req.drift.includes('STALE_TEST')) add('HIGH', req, 'refresh-requirement-tests', 'Update requirement-linked tests and RED evidence against the current living specification.');
218
+ if (req.drift.includes('STALE_IMPLEMENTATION')) add('HIGH', req, 'refresh-implementation-evidence', 'Attach implementation evidence for the current requirement revision and rerun verification.');
219
+ if (req.drift.includes('UNTRACED_CHANGE')) add('HIGH', req, 'repair-contract-lineage', 'Restore ADR/spec/ticket/test/implementation lineage for this requirement before completion.');
220
+ if (req.protection.status === 'NOT_EVALUATED') add('HIGH', req, 'execute-required-probes', 'Execute every predeclared required contract probe in an isolated supported adapter or retain NOT_EVALUATED.');
221
+ if (req.completeness.result !== 'PASS' || req.completeness.score < 100) add('HIGH', req, 'complete-tdd-evidence', 'Repair the existing #58 completeness evidence; do not compensate with protection score.');
222
+ }
223
+
224
+ const seen = new Set();
225
+ return recommendations
226
+ .filter(item => {
227
+ const key = `${item.requirementId}:${item.code}`;
228
+ if (seen.has(key)) return false;
229
+ seen.add(key);
230
+ return true;
231
+ })
232
+ .sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority] ||
233
+ String(a.requirementId).localeCompare(String(b.requirementId)) ||
234
+ a.code.localeCompare(b.code));
235
+ }
236
+
60
237
  function validateTrace(input) {
61
238
  const errors = [];
62
239
  if (!isObject(input)) return ['trace root must be an object'];
@@ -385,6 +562,7 @@ function evaluateTrace(trace, options = {}) {
385
562
  requirementId: requirement.requirementId,
386
563
  revision: requirement.revision,
387
564
  lifecycleStatus: requirement.status,
565
+ sourceRef: currentSpec ? `${currentSpec.path}#${requirement.requirementId}` : null,
388
566
  drift: unique(statuses),
389
567
  reasonCodes: lineage.reasons,
390
568
  completeness: quality,
@@ -429,6 +607,7 @@ function evaluateTrace(trace, options = {}) {
429
607
  },
430
608
  driftCounts,
431
609
  requirements: reportRequirements,
610
+ repairGuidance: repairGuidance(reportRequirements),
432
611
  graph: {
433
612
  nodes: [
434
613
  ...(trace?.artifacts || []).map(artifact => ({ id: artifact.id, kind: artifact.kind, status: artifact.status, path: artifact.path })),
@@ -458,9 +637,22 @@ function markdown(report) {
458
637
  for (const req of report.requirements) {
459
638
  lines.push(`| ${req.requirementId} | ${req.revision} | ${req.drift.join(', ')} | ${req.completeness.score} | ${req.protection.status} | ${req.gateEligible ? 'PASS' : 'FAIL'} |`);
460
639
  }
640
+ if (report.repairGuidance?.length) {
641
+ lines.push('', '## Repair Guidance', '');
642
+ for (const item of report.repairGuidance) {
643
+ lines.push(`- **${item.priority}** ${item.requirementId}${item.sourceRef ? ` (${item.sourceRef})` : ''}: ${item.action} [${item.code}]`);
644
+ }
645
+ }
461
646
  if (report.errors.length) {
462
647
  lines.push('', '## Errors', '', ...report.errors.map(error => `- ${error}`));
463
648
  }
649
+ if (report.provenance) {
650
+ lines.push('', '## Provenance', '');
651
+ lines.push(`- Audit version: ${report.provenance.auditVersion}`);
652
+ lines.push(`- Git revision: ${report.provenance.gitRevision || 'unknown'}`);
653
+ lines.push(`- Trace SHA-256: ${report.provenance.inputs?.trace?.sha256 || 'unknown'}`);
654
+ lines.push(`- TDD evidence SHA-256: ${report.provenance.inputs?.tddEvidence?.sha256 || 'none'}`);
655
+ }
464
656
  lines.push('', '## Evidence boundary', '');
465
657
  lines.push('- Completeness is derived by the existing #58 evaluator; aggregate scores supplied by agents are ignored.');
466
658
  lines.push('- Protection counts only predeclared required probes. Extra/padded probes do not raise a requirement score.');
@@ -470,13 +662,15 @@ function markdown(report) {
470
662
  }
471
663
 
472
664
  function parseArgs(argv) {
473
- const args = { input: null, tddEvidence: null, output: null, markdown: null };
665
+ const args = { input: null, tddEvidence: null, output: null, markdown: null, workspace: null, verifyFresh: null };
474
666
  for (let i = 0; i < argv.length; i++) {
475
667
  const token = argv[i];
476
668
  if (!args.input && !token.startsWith('--')) args.input = token;
477
669
  else if (token === '--tdd-evidence') args.tddEvidence = argv[++i];
478
670
  else if (token === '--output') args.output = argv[++i];
479
671
  else if (token === '--markdown') args.markdown = argv[++i];
672
+ else if (token === '--workspace') args.workspace = argv[++i];
673
+ else if (token === '--verify-fresh') args.verifyFresh = argv[++i];
480
674
  else throw new Error(`unknown argument: ${token}`);
481
675
  }
482
676
  return args;
@@ -487,7 +681,7 @@ function runCli(argv) {
487
681
  try { args = parseArgs(argv.slice(2)); }
488
682
  catch (error) { console.error(`Contract Integrity: FAIL\n- ${error.message}`); return 2; }
489
683
  if (!args.input) {
490
- console.error('Usage: node contract-integrity/scripts/audit.js <trace.json> [--tdd-evidence <#58-evidence.json>] [--output report.json] [--markdown report.md]');
684
+ console.error('Usage: node contract-integrity/scripts/audit.js <trace.json> [--tdd-evidence <#58-evidence.json>] [--workspace <root>] [--output report.json] [--markdown report.md] [--verify-fresh prior-report.json]');
491
685
  return 2;
492
686
  }
493
687
 
@@ -501,7 +695,33 @@ function runCli(argv) {
501
695
  return 2;
502
696
  }
503
697
 
698
+ if (args.verifyFresh) {
699
+ let prior;
700
+ try { prior = readJson(args.verifyFresh); }
701
+ catch (error) {
702
+ console.error(`Contract Integrity Freshness: FAIL\n- ${error.message}`);
703
+ return 2;
704
+ }
705
+ const currentAudit = evaluateTrace(trace, { tddEvidence });
706
+ const freshness = verifyFreshReport(prior, args, trace, currentAudit);
707
+ console.log(`Contract Integrity Freshness: ${freshness.authorizesCompletion ? 'PASS' : 'FAIL'}`);
708
+ for (const reason of freshness.reasons) console.error(`- ${reason}`);
709
+ return freshness.authorizesCompletion ? 0 : 1;
710
+ }
711
+
504
712
  const report = evaluateTrace(trace, { tddEvidence });
713
+ report.provenance = buildProvenance(args, trace);
714
+ if (args.workspace) {
715
+ const missing = report.provenance.artifacts.filter(item => item.missing);
716
+ if (trace.mode === 'strict' && missing.length) {
717
+ report.result = 'FAIL';
718
+ report.completionGate = 'FAIL';
719
+ report.gateEligible = false;
720
+ report.errors.push(...missing.map(item => item.reason === 'linked-path'
721
+ ? `provenance artifact linked-path: ${item.id} -> ${item.path}`
722
+ : `provenance artifact missing from workspace: ${item.id} -> ${item.path}`));
723
+ }
724
+ }
505
725
  console.log(`Contract Integrity: ${report.result}`);
506
726
  console.log(`Scores: completeness=${report.scores.completenessScore} protection=${report.scores.protectionScore} integrity=${report.scores.contractIntegrityScore}`);
507
727
  console.log(`Gate: ${report.completionGate}`);
@@ -527,10 +747,13 @@ if (require.main === module) process.exit(runCli(process.argv));
527
747
  module.exports = {
528
748
  DRIFT,
529
749
  VERSION,
750
+ buildProvenance,
530
751
  classifyLineage,
531
752
  evaluateTrace,
532
753
  markdown,
754
+ repairGuidance,
533
755
  protectionForRequirement,
534
756
  runCli,
535
757
  validateTrace,
758
+ verifyFreshReport,
536
759
  };
@@ -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
+ };