arkgate 4.8.24 → 4.8.26

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 (166) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +2 -2
  3. package/bin/ark-check-runtime.mjs +17 -14
  4. package/bin/ark-layer-match.mjs +115 -13
  5. package/bin/ark-mcp-runtime.mjs +36 -31
  6. package/bin/ark-shared.mjs +4 -23
  7. package/bin/lib/adoption-stance.mjs +2 -2
  8. package/bin/lib/adr-path.mjs +3 -3
  9. package/bin/lib/agent-gates.mjs +0 -61
  10. package/bin/lib/agent-homes.mjs +12 -21
  11. package/bin/lib/agent-projection-command.mjs +4 -4
  12. package/bin/lib/ambient-state.mjs +9 -30
  13. package/bin/lib/analysis-engine.mjs +14 -14
  14. package/bin/lib/architecture-scan.mjs +11 -9
  15. package/bin/lib/ark-order-doctor.mjs +3 -28
  16. package/bin/lib/ark-order-facts.mjs +6 -99
  17. package/bin/lib/ark-order-types.mjs +1 -2
  18. package/bin/lib/ark-run-doctor.mjs +3 -28
  19. package/bin/lib/ark-run-facts.mjs +30 -40
  20. package/bin/lib/ark-run-sensors.mjs +1 -1
  21. package/bin/lib/arkrule-file-hints.mjs +2 -47
  22. package/bin/lib/arkrules-contract.mjs +124 -133
  23. package/bin/lib/arkrules-sensors.mjs +7 -5
  24. package/bin/lib/check-args.mjs +63 -1
  25. package/bin/lib/ci-and-commands.mjs +8 -72
  26. package/bin/lib/ci-merge-boundary.mjs +1 -1
  27. package/bin/lib/class-source-scan.mjs +5 -5
  28. package/bin/lib/clone-detection.mjs +474 -0
  29. package/bin/lib/codex-home.mjs +1 -1
  30. package/bin/lib/config-contract-slices.mjs +44 -6
  31. package/bin/lib/config-contract.mjs +40 -117
  32. package/bin/lib/config-extras.mjs +16 -22
  33. package/bin/lib/contract-smells.mjs +33 -15
  34. package/bin/lib/core-ratchet.mjs +0 -2
  35. package/bin/lib/dashboard-cli.mjs +2 -2
  36. package/bin/lib/deep-module-coach.mjs +2 -4
  37. package/bin/lib/deploy-path.mjs +13 -38
  38. package/bin/lib/design-smells.mjs +2 -2
  39. package/bin/lib/diagnostic-catalog.mjs +8 -1
  40. package/bin/lib/doctor-advisories.mjs +55 -9
  41. package/bin/lib/doctor-green-cite.mjs +1 -1
  42. package/bin/lib/doctor-human.mjs +1 -1
  43. package/bin/lib/doctor-next-actions.mjs +2 -2
  44. package/bin/lib/doctor-plan.mjs +4 -6
  45. package/bin/lib/duplication-io.mjs +572 -0
  46. package/bin/lib/effective-contract-load.mjs +181 -44
  47. package/bin/lib/entry-points-io.mjs +424 -0
  48. package/bin/lib/extra-merge-teeth.mjs +36 -0
  49. package/bin/lib/field-install.mjs +38 -14
  50. package/bin/lib/first-run-help.mjs +7 -3
  51. package/bin/lib/flat-parent-importers.mjs +5 -3
  52. package/bin/lib/gate-files.mjs +30 -89
  53. package/bin/lib/github-enforcement.mjs +165 -28
  54. package/bin/lib/golden-pattern.mjs +1 -1
  55. package/bin/lib/hook-templates.mjs +16 -16
  56. package/bin/lib/html-report-advisories.mjs +4 -0
  57. package/bin/lib/html-report-depth.mjs +2 -2
  58. package/bin/lib/html-report.mjs +2 -2
  59. package/bin/lib/import-graph-projection.mjs +112 -0
  60. package/bin/lib/improvement-compass-doctor.mjs +1 -24
  61. package/bin/lib/invariant-coverage-io.mjs +4 -3
  62. package/bin/lib/invariant-coverage.mjs +26 -0
  63. package/bin/lib/invariant-probe-cli.mjs +347 -0
  64. package/bin/lib/invariant-probe-io.mjs +165 -0
  65. package/bin/lib/invariant-probe-runner.mjs +268 -0
  66. package/bin/lib/invariant-probe-sites.mjs +174 -0
  67. package/bin/lib/invariant-probe-workspace.mjs +183 -0
  68. package/bin/lib/invariant-probe.mjs +461 -0
  69. package/bin/lib/invariant-tests-path.mjs +16 -22
  70. package/bin/lib/literal-path-drift.mjs +4 -2
  71. package/bin/lib/managed-upgrade.mjs +2 -8
  72. package/bin/lib/mcp-adoption.mjs +4 -5
  73. package/bin/lib/mcp-hook-payload.mjs +4 -4
  74. package/bin/lib/orphan-modules-io.mjs +458 -0
  75. package/bin/lib/orphan-modules.mjs +251 -0
  76. package/bin/lib/outside-importers.mjs +197 -0
  77. package/bin/lib/package-manager.mjs +3 -3
  78. package/bin/lib/parse-health.mjs +1 -1
  79. package/bin/lib/persistence-write-hint.mjs +33 -8
  80. package/bin/lib/pin-dependent-gates.mjs +2 -2
  81. package/bin/lib/policy-delta-io.mjs +60 -12
  82. package/bin/lib/post-green-path.mjs +1 -1
  83. package/bin/lib/prepare-change.mjs +3 -9
  84. package/bin/lib/prepare-write.mjs +1 -2
  85. package/bin/lib/presets.mjs +56 -68
  86. package/bin/lib/product-copy.mjs +1 -6
  87. package/bin/lib/project-root.mjs +43 -85
  88. package/bin/lib/resolved-candidate-facts.mjs +90 -62
  89. package/bin/lib/rules-under-contract.mjs +72 -8
  90. package/bin/lib/scan-files.mjs +4 -4
  91. package/bin/lib/schema-validation.mjs +130 -0
  92. package/bin/lib/sensor-promote-cli.mjs +2 -11
  93. package/bin/lib/sensor-promote-io.mjs +33 -22
  94. package/bin/lib/sensor-promotion.mjs +1 -1
  95. package/bin/lib/skill-catalog-apply.mjs +1 -1
  96. package/bin/lib/skill-install.mjs +42 -38
  97. package/bin/lib/snippet-analysis.mjs +1 -1
  98. package/bin/lib/start-install-recovery.mjs +1 -1
  99. package/bin/lib/start-preview.mjs +1 -1
  100. package/bin/lib/states-transitions-presence.mjs +3 -3
  101. package/bin/lib/status-command.mjs +2 -18
  102. package/bin/lib/status-manifest.mjs +18 -26
  103. package/bin/lib/status-transition-catalog.mjs +0 -6
  104. package/bin/lib/suggestions.mjs +3 -3
  105. package/bin/lib/team-parliament-io.mjs +3 -17
  106. package/bin/lib/team-parliament.mjs +15 -9
  107. package/bin/lib/upgrade-behavior-changes.mjs +2 -2
  108. package/bin/lib/upgrade-command.mjs +2 -24
  109. package/bin/lib/upgrade-outcome.mjs +1 -1
  110. package/bin/lib/upstream-report.mjs +21 -10
  111. package/bin/lib/violations.mjs +10 -9
  112. package/bin/lib/weakest-link.mjs +13 -0
  113. package/bin/lib/write-path-capabilities.mjs +2 -2
  114. package/dist/chunk-G3JRBJGH.js +16 -0
  115. package/dist/chunk-R3AJ5RSZ.cjs +16 -0
  116. package/dist/{configTypes-BJbaeoj4.d.cts → configTypes-GPvvdleM.d.cts} +12 -1
  117. package/dist/{configTypes-BJbaeoj4.d.ts → configTypes-GPvvdleM.d.ts} +12 -1
  118. package/dist/eslint/index.cjs +11 -11
  119. package/dist/eslint/index.d.cts +10 -1
  120. package/dist/eslint/index.d.ts +10 -1
  121. package/dist/eslint/index.js +11 -11
  122. package/dist/index.cjs +34 -34
  123. package/dist/index.d.cts +587 -515
  124. package/dist/index.d.ts +587 -515
  125. package/dist/index.js +34 -34
  126. package/dist/nestjs/index.cjs +1 -1
  127. package/dist/nestjs/index.d.cts +1 -1
  128. package/dist/nestjs/index.d.ts +1 -1
  129. package/dist/nestjs/index.js +1 -1
  130. package/dist/order/index.cjs +1 -1
  131. package/dist/order/index.js +1 -1
  132. package/dist/runtime/index.cjs +12 -12
  133. package/dist/runtime/index.d.cts +174 -136
  134. package/dist/runtime/index.d.ts +174 -136
  135. package/dist/runtime/index.js +12 -12
  136. package/dist/{types-DRinG-vT.d.cts → types-CnauMQQL.d.cts} +33 -25
  137. package/dist/{types-DRinG-vT.d.ts → types-CnauMQQL.d.ts} +33 -25
  138. package/docs/README.md +3 -1
  139. package/docs/agent-guide.md +140 -6
  140. package/docs/ai-gates.md +15 -1
  141. package/docs/configuration.md +40 -11
  142. package/docs/diagnostics.md +119 -3
  143. package/docs/package-surface.md +4 -1
  144. package/docs/product-voice.md +7 -1
  145. package/package.json +11 -2
  146. package/schemas/ark.config.schema.json +31 -3
  147. package/schemas/ark.invariant-probe.schema.json +368 -0
  148. package/server.json +2 -2
  149. package/templates/agent-skills/README.md +1 -1
  150. package/templates/agent-skills/ark-adopt/SKILL.md +1 -0
  151. package/templates/agent-skills/ark-contract/SKILL.md +17 -0
  152. package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
  153. package/templates/agent-skills/ark-explore/SKILL.md +7 -0
  154. package/templates/agent-skills/ark-fix/SKILL.md +20 -0
  155. package/templates/agent-skills/ark-place/SKILL.md +8 -0
  156. package/templates/skills/ark-adopt.md +1 -0
  157. package/templates/skills/ark-contract.md +17 -0
  158. package/templates/skills/ark-coverage.md +2 -0
  159. package/templates/skills/ark-explore.md +7 -0
  160. package/templates/skills/ark-fix.md +20 -0
  161. package/templates/skills/ark-place.md +8 -0
  162. package/bin/lib/ark-order-error.mjs +0 -18
  163. package/bin/lib/ark-order-invariants.mjs +0 -388
  164. package/bin/lib/stable-hash.mjs +0 -90
  165. package/dist/chunk-MBXCFLO4.js +0 -16
  166. package/dist/chunk-RNQHRDCQ.cjs +0 -16
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Invariant probe evidence reader — Tooling (ADR 0039).
3
+ *
4
+ * Reads the committed `.ark/invariant-probe.json` (at most 1 MiB, 500 rows),
5
+ * hashes the inputs each row was bound to as they stand now, and attaches a
6
+ * `probe` summary to coverage rows. Read-only: this module never runs a test
7
+ * and never imports the runner or the workspace, so the gate entry points that
8
+ * load it (--promote, the policy delta, status, rules inventory) can never
9
+ * spawn anything. A malformed or stale artifact changes nothing.
10
+ */
11
+ import crypto from 'node:crypto';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import {
15
+ INVARIANT_PROBE_ARTIFACT_PATH,
16
+ INVARIANT_PROBE_MAX_BYTES,
17
+ invariantProbeIdentity,
18
+ probeRowLine,
19
+ probeRowStaleness,
20
+ probeStatusLabel,
21
+ readInvariantProbeArtifact,
22
+ summarizeProbeForCoverage,
23
+ verdictRefusesPromotion,
24
+ } from './invariant-probe.mjs';
25
+
26
+ export function sha256Text(text) {
27
+ return `sha256:${crypto.createHash('sha256').update(text).digest('hex')}`;
28
+ }
29
+
30
+ /** Content hash of a project file, or null when it is gone or outside the root. */
31
+ export function hashProjectFile(root, rel) {
32
+ if (typeof rel !== 'string' || rel.length === 0) return null;
33
+ const rootResolved = path.resolve(root);
34
+ const absolute = path.resolve(rootResolved, rel);
35
+ if (absolute !== rootResolved && !absolute.startsWith(`${rootResolved}${path.sep}`)) return null;
36
+ try {
37
+ return `sha256:${crypto.createHash('sha256').update(fs.readFileSync(absolute)).digest('hex')}`;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ export function invariantIdentityHash(invariant) {
44
+ return sha256Text(invariantProbeIdentity(invariant));
45
+ }
46
+
47
+ /**
48
+ * @returns {{ present: false }
49
+ * | { present: true, ok: false, note: string }
50
+ * | { present: true, ok: true, artifact: object, note?: string }}
51
+ */
52
+ function loadInvariantProbeArtifact(root) {
53
+ const absolute = path.join(root, INVARIANT_PROBE_ARTIFACT_PATH);
54
+ let stat;
55
+ try {
56
+ stat = fs.lstatSync(absolute);
57
+ } catch {
58
+ return { present: false };
59
+ }
60
+ const ignored = (why) => ({
61
+ present: true,
62
+ ok: false,
63
+ note: `${INVARIANT_PROBE_ARTIFACT_PATH} was ignored (${why}), so it changes no promotion.`,
64
+ });
65
+ if (!stat.isFile()) return ignored('not a regular file');
66
+ if (stat.size > INVARIANT_PROBE_MAX_BYTES) return ignored('larger than 1 MiB');
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(fs.readFileSync(absolute, 'utf8'));
70
+ } catch {
71
+ return ignored('not valid JSON');
72
+ }
73
+ const read = readInvariantProbeArtifact(parsed);
74
+ if (!read.ok) return ignored(read.reason);
75
+ const skipped = read.droppedRows + read.truncatedRows;
76
+ return {
77
+ present: true,
78
+ ok: true,
79
+ artifact: read.artifact,
80
+ ...(skipped > 0
81
+ ? { note: `${skipped} row(s) in ${INVARIANT_PROBE_ARTIFACT_PATH} were malformed or past the 500-row cap and were ignored.` }
82
+ : {}),
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Probe summary per catalogued invariant id, each judged fresh or stale
88
+ * against the tree as it stands now. Rows for ids no longer declared are ignored.
89
+ */
90
+ function probeSummariesFor(root, catalogInvariants, loaded = loadInvariantProbeArtifact(root)) {
91
+ const byId = new Map();
92
+ if (!loaded.present || !loaded.ok) return byId;
93
+ const catalog = new Map((catalogInvariants ?? []).map((inv) => [inv.id, inv]));
94
+ const fileHashes = new Map();
95
+ const hashOf = (rel) => {
96
+ if (!fileHashes.has(rel)) fileHashes.set(rel, hashProjectFile(root, rel));
97
+ return fileHashes.get(rel);
98
+ };
99
+ for (const row of loaded.artifact.invariants) {
100
+ const inv = catalog.get(row.invariantId);
101
+ if (!inv || byId.has(row.invariantId)) continue;
102
+ const current = {
103
+ invariantHash: invariantIdentityHash(inv),
104
+ symbolFileHash: row.symbolFile ? hashOf(row.symbolFile) : null,
105
+ testHashes: Object.fromEntries(row.tests.map((test) => [test.path, hashOf(test.path)])),
106
+ };
107
+ const stale = probeRowStaleness(row, current, loaded.artifact.operatorSet);
108
+ byId.set(row.invariantId, { summary: summarizeProbeForCoverage(row, stale, loaded.artifact.probedOn), row });
109
+ }
110
+ return byId;
111
+ }
112
+
113
+ /** Coverage rows with `probe` attached where the artifact has a row. Absent artifact: rows unchanged. */
114
+ export function attachProbeEvidence(root, coverageRows, catalogInvariants) {
115
+ const loaded = loadInvariantProbeArtifact(root);
116
+ if (!loaded.present) return { rows: coverageRows, loaded };
117
+ const summaries = probeSummariesFor(root, catalogInvariants, loaded);
118
+ const rows = (coverageRows ?? []).map((row) => {
119
+ const hit = summaries.get(row.invariantId);
120
+ return hit ? { ...row, probe: hit.summary } : row;
121
+ });
122
+ return { rows, loaded };
123
+ }
124
+
125
+ /**
126
+ * Status / inventory projection. Null when there is no artifact: absence is
127
+ * silent, so a project that never ran the probe sees nothing new.
128
+ */
129
+ export function probeStatusSection(root, catalogInvariants) {
130
+ const loaded = loadInvariantProbeArtifact(root);
131
+ if (!loaded.present) return null;
132
+ if (!loaded.ok) return { notAScore: true, artifact: INVARIANT_PROBE_ARTIFACT_PATH, note: loaded.note, rows: [] };
133
+ const summaries = probeSummariesFor(root, catalogInvariants, loaded);
134
+ const rows = (catalogInvariants ?? []).map((inv) => {
135
+ const hit = summaries.get(inv.id);
136
+ const status = probeStatusLabel(hit?.summary);
137
+ const refuses = Boolean(hit?.summary.fresh && verdictRefusesPromotion(hit.summary.verdict));
138
+ return {
139
+ id: inv.id,
140
+ status,
141
+ ...(refuses ? { ruleId: 'INVARIANT_PROBE_SURVIVED', line: probeRowLine(hit.row) } : {}),
142
+ ...(hit?.summary.staleBecause ? { staleBecause: hit.summary.staleBecause } : {}),
143
+ };
144
+ });
145
+ return {
146
+ notAScore: true,
147
+ artifact: INVARIANT_PROBE_ARTIFACT_PATH,
148
+ probedOn: loaded.artifact.probedOn,
149
+ rows,
150
+ ...(loaded.note ? { note: loaded.note } : {}),
151
+ };
152
+ }
153
+
154
+ /** One status line per probed invariant, plus the artifact note. Empty without an artifact. */
155
+ export function formatProbeStatusLines(section) {
156
+ if (!section) return [];
157
+ const lines = [];
158
+ if (section.note) lines.push(`ArkRules: ${section.note}`);
159
+ for (const row of section.rows ?? []) {
160
+ if (row.status === 'not run') continue;
161
+ if (row.ruleId) lines.push(`ArkRules: ${row.line} (${row.ruleId}, advisory; promotion refuses)`);
162
+ else lines.push(`ArkRules: ${row.id} probe: ${row.status}${row.staleBecause ? ` (${row.staleBecause[0]})` : ''}`);
163
+ }
164
+ return lines;
165
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Runner adapters for the invariant probe — Tooling (ADR 0039).
3
+ *
4
+ * Detects the project's own runner (vitest, jest, node:test) from the nearest
5
+ * package.json, resolves its binary from the project, and runs it with
6
+ * `process.execPath`, an argv array and no shell. A timeout kills the whole
7
+ * process group. The environment is an allowlist: no tokens, no cloud
8
+ * credentials, proxies pointed at a dead port, HOME and TMPDIR inside the
9
+ * probe workspace. Best effort — not a sandbox.
10
+ */
11
+ import { spawn, spawnSync } from 'node:child_process';
12
+ import fs from 'node:fs';
13
+ import { createRequire } from 'node:module';
14
+ import path from 'node:path';
15
+
16
+ const PROBE_RUNNERS = ['vitest', 'jest', 'node'];
17
+ const TS_TEST = /\.[cm]?tsx?$/;
18
+ const MAX_CAPTURE = 4 * 1024 * 1024;
19
+ const PROBE_TIMEOUT_FLOOR_MS = 10_000;
20
+ export const PROBE_TIMEOUT_CEILING_MS = 120_000;
21
+
22
+ function readJson(file) {
23
+ try {
24
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /** Nearest directory at or above `fromDir` (inside `root`) holding a package.json. */
31
+ function nearestPackageRoot(root, fromDir) {
32
+ let dir = path.resolve(fromDir);
33
+ const top = path.resolve(root);
34
+ while (dir.startsWith(top)) {
35
+ if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
36
+ if (dir === top) break;
37
+ dir = path.dirname(dir);
38
+ }
39
+ return top;
40
+ }
41
+
42
+ function hasConfig(pkgRoot, stem) {
43
+ try {
44
+ return fs.readdirSync(pkgRoot).some((name) => name.startsWith(`${stem}.config.`));
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ /** Runner ids the package declares, from `scripts.test` first, then dependency + config. */
51
+ function runnerEvidence(pkgRoot) {
52
+ const pkg = readJson(path.join(pkgRoot, 'package.json')) ?? {};
53
+ const script = typeof pkg.scripts?.test === 'string' ? pkg.scripts.test : '';
54
+ const fromScript = [];
55
+ if (/\bvitest\b/.test(script)) fromScript.push('vitest');
56
+ if (/\bjest\b/.test(script)) fromScript.push('jest');
57
+ if (/\bnode\b[^&|;]*\s--test\b/.test(script)) fromScript.push('node');
58
+ if (fromScript.length > 0) return fromScript;
59
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
60
+ const fromDeps = [];
61
+ if (deps.vitest && hasConfig(pkgRoot, 'vitest')) fromDeps.push('vitest');
62
+ if (deps.jest && hasConfig(pkgRoot, 'jest')) fromDeps.push('jest');
63
+ return fromDeps;
64
+ }
65
+
66
+ function resolveBin(pkgRoot, id) {
67
+ if (id === 'node') return { bin: null, version: process.versions.node };
68
+ try {
69
+ const require = createRequire(path.join(pkgRoot, 'package.json'));
70
+ const manifestPath = require.resolve(`${id}/package.json`);
71
+ const manifest = readJson(manifestPath) ?? {};
72
+ const binField = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[id];
73
+ if (typeof binField !== 'string') return null;
74
+ return { bin: path.resolve(path.dirname(manifestPath), binField), version: manifest.version ?? null };
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Pick the runner for a set of covering tests.
82
+ * @param {{ root: string, tests: string[], override?: string }} input tests are root-relative
83
+ */
84
+ export function detectRunner({ root, tests, override }) {
85
+ const first = tests[0];
86
+ const pkgRoot = nearestPackageRoot(root, path.dirname(path.join(root, first ?? '.')));
87
+ const evidence = runnerEvidence(pkgRoot);
88
+ let id;
89
+ if (evidence.length === 1) {
90
+ id = evidence[0];
91
+ if (override && override !== id) {
92
+ return {
93
+ ok: false,
94
+ reasonCode: 'PROBE_RUNNER_UNKNOWN',
95
+ reason: `--runner ${override} does not match the runner this package declares (${id}). --runner only settles an unclear detection.`,
96
+ };
97
+ }
98
+ } else if (override && PROBE_RUNNERS.includes(override)) {
99
+ id = override;
100
+ } else {
101
+ return {
102
+ ok: false,
103
+ reasonCode: 'PROBE_RUNNER_UNKNOWN',
104
+ reason:
105
+ evidence.length > 1
106
+ ? `The package declares more than one test runner (${evidence.join(', ')}). Next: pass --runner ${evidence.join('|')}.`
107
+ : 'No test runner was found in package.json (scripts.test, or a vitest / jest dependency with its config file). Next: pass --runner vitest|jest|node.',
108
+ };
109
+ }
110
+ if (id === 'node' && tests.some((test) => TS_TEST.test(test))) {
111
+ return {
112
+ ok: false,
113
+ reasonCode: 'PROBE_RUNNER_UNSUPPORTED',
114
+ reason: 'node:test cannot run TypeScript test files without a loader, so the probe does not run them.',
115
+ };
116
+ }
117
+ const resolved = resolveBin(pkgRoot, id);
118
+ if (!resolved) {
119
+ return {
120
+ ok: false,
121
+ reasonCode: 'PROBE_RUNNER_UNKNOWN',
122
+ reason: `${id} is not installed in ${path.relative(root, pkgRoot) || '.'}, so the probe cannot run it.`,
123
+ };
124
+ }
125
+ return { ok: true, id, bin: resolved.bin, version: resolved.version, pkgRoot };
126
+ }
127
+
128
+ /** Allowlisted environment for the runner. Nothing else from the parent passes. */
129
+ export function buildProbeEnv({ home, tmp, parentEnv = process.env }) {
130
+ const env = {
131
+ PATH: parentEnv.PATH ?? parentEnv.Path ?? '',
132
+ LANG: parentEnv.LANG ?? 'C.UTF-8',
133
+ TZ: 'UTC',
134
+ CI: '1',
135
+ NODE_ENV: 'test',
136
+ FORCE_COLOR: '0',
137
+ NO_COLOR: '1',
138
+ HOME: home,
139
+ USERPROFILE: home,
140
+ TMPDIR: tmp,
141
+ TMP: tmp,
142
+ TEMP: tmp,
143
+ HTTP_PROXY: 'http://127.0.0.1:9',
144
+ HTTPS_PROXY: 'http://127.0.0.1:9',
145
+ http_proxy: 'http://127.0.0.1:9',
146
+ https_proxy: 'http://127.0.0.1:9',
147
+ NO_PROXY: '',
148
+ no_proxy: '',
149
+ npm_config_offline: 'true',
150
+ };
151
+ if (process.platform === 'win32' && parentEnv.SystemRoot) env.SystemRoot = parentEnv.SystemRoot;
152
+ return env;
153
+ }
154
+
155
+ /** argv for one run. `files` are relative to the package root in the copy. */
156
+ export function runnerArgv(runner, files, { outputFile, cacheDir }) {
157
+ switch (runner.id) {
158
+ case 'vitest':
159
+ return [runner.bin, 'run', '--no-coverage', '--reporter=json', `--outputFile=${outputFile}`, ...files];
160
+ case 'jest':
161
+ return [
162
+ runner.bin,
163
+ '--ci',
164
+ '--json',
165
+ `--outputFile=${outputFile}`,
166
+ '--coverage=false',
167
+ '--watchman=false',
168
+ `--cacheDirectory=${cacheDir}`,
169
+ '--runTestsByPath',
170
+ ...files,
171
+ ];
172
+ case 'node':
173
+ return ['--test', '--test-reporter=tap', ...files];
174
+ default:
175
+ throw new Error(`unknown runner ${runner.id}`);
176
+ }
177
+ }
178
+
179
+ /** clamp(3 × baseline + 5 s, 10 s, 120 s). */
180
+ export function probeTimeoutMs(baselineMs) {
181
+ const wanted = 3 * Math.max(0, baselineMs) + 5000;
182
+ return Math.min(PROBE_TIMEOUT_CEILING_MS, Math.max(PROBE_TIMEOUT_FLOOR_MS, wanted));
183
+ }
184
+
185
+ function killGroup(child) {
186
+ if (!child.pid) return;
187
+ try {
188
+ if (process.platform === 'win32') {
189
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
190
+ } else {
191
+ process.kill(-child.pid, 'SIGKILL');
192
+ }
193
+ } catch {
194
+ try {
195
+ child.kill('SIGKILL');
196
+ } catch {
197
+ /* already gone */
198
+ }
199
+ }
200
+ }
201
+
202
+ /** Failure counts from the runner's own report. Null: no report to read. */
203
+ function readFailures(runner, outputFile, stdout) {
204
+ if (runner.id === 'node') {
205
+ const fail = /^# fail (\d+)/m.exec(stdout);
206
+ const tests = /^# tests (\d+)/m.exec(stdout);
207
+ if (!fail) return null;
208
+ return { failed: Number(fail[1]), total: tests ? Number(tests[1]) : null };
209
+ }
210
+ const report = readJson(outputFile);
211
+ if (!report || typeof report !== 'object') return null;
212
+ const failed = (Number(report.numFailedTests) || 0) + (Number(report.numFailedTestSuites) || 0);
213
+ return { failed, total: Number(report.numTotalTests) || 0 };
214
+ }
215
+
216
+ /**
217
+ * Run the covering tests once.
218
+ * @returns {Promise<{ outcome: 'passed' | 'failed' | 'timeout' | 'runtime-error', durationMs: number, detail?: string }>}
219
+ */
220
+ export function runCoveringTests({ runner, cwd, files, timeoutMs, env, scratch }) {
221
+ const outputFile = path.join(scratch, `runner-report-${process.hrtime.bigint()}.json`);
222
+ const argv = runnerArgv(runner, files, { outputFile, cacheDir: path.join(scratch, 'jest-cache') });
223
+ const started = process.hrtime.bigint();
224
+ return new Promise((resolve) => {
225
+ let stdout = '';
226
+ let stderr = '';
227
+ let timedOut = false;
228
+ const child = spawn(process.execPath, argv, {
229
+ cwd,
230
+ env,
231
+ shell: false,
232
+ detached: process.platform !== 'win32',
233
+ stdio: ['ignore', 'pipe', 'pipe'],
234
+ windowsHide: true,
235
+ });
236
+ child.stdout.on('data', (chunk) => {
237
+ stdout = stdout.length < MAX_CAPTURE ? stdout + chunk.toString('utf8') : stdout;
238
+ });
239
+ child.stderr.on('data', (chunk) => {
240
+ stderr = stderr.length < MAX_CAPTURE ? stderr + chunk.toString('utf8') : stderr;
241
+ });
242
+ const timer = setTimeout(() => {
243
+ timedOut = true;
244
+ killGroup(child);
245
+ }, timeoutMs);
246
+ let done = false;
247
+ const finish = (code, spawnError) => {
248
+ if (done) return;
249
+ done = true;
250
+ clearTimeout(timer);
251
+ const durationMs = Math.round(Number(process.hrtime.bigint() - started) / 1e6);
252
+ if (timedOut) return resolve({ outcome: 'timeout', durationMs });
253
+ if (spawnError) return resolve({ outcome: 'runtime-error', durationMs, detail: spawnError.message });
254
+ const failures = readFailures(runner, outputFile, stdout);
255
+ try {
256
+ fs.rmSync(outputFile, { force: true });
257
+ } catch {
258
+ /* scratch is removed with the workspace */
259
+ }
260
+ if (failures && failures.failed > 0) return resolve({ outcome: 'failed', durationMs });
261
+ if (code === 0) return resolve({ outcome: 'passed', durationMs });
262
+ // A non-zero exit with no reported failure is a crash, never a caught change.
263
+ return resolve({ outcome: 'runtime-error', durationMs, detail: stderr.slice(-2000) });
264
+ };
265
+ child.on('error', (error) => finish(null, error));
266
+ child.on('close', (code) => finish(code, null));
267
+ });
268
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Where the invariant probe may change code — Tooling (ADR 0039).
3
+ *
4
+ * Parses the symbol file with the TypeScript the CLI already loaded, locates
5
+ * the declaration `coverage.symbol` names (the same shapes coverage accepts:
6
+ * function, `const x = () => {}` / function expression / literal, class or
7
+ * object-literal method, `Class.member`), and emits the sites inside that
8
+ * declaration only. Which sites become mutants is decided in Domain
9
+ * (`planMutants` in invariant-probe.mjs).
10
+ */
11
+
12
+ const COMPARISON_TOKENS = new Set(['<', '>', '<=', '>=', '===', '!==', '==', '!=']);
13
+ const DECLARATION_ONLY = new Map([
14
+ ['ClassDeclaration', 'class'],
15
+ ['InterfaceDeclaration', 'interface'],
16
+ ['TypeAliasDeclaration', 'type'],
17
+ ['EnumDeclaration', 'enum'],
18
+ ]);
19
+
20
+ function nameOf(ts, node) {
21
+ return node.name && (ts.isIdentifier(node.name) || ts.isPrivateIdentifier?.(node.name)) ? node.name.text : null;
22
+ }
23
+
24
+ function isFunctionLike(ts, node) {
25
+ return Boolean(node) && (ts.isArrowFunction(node) || ts.isFunctionExpression(node));
26
+ }
27
+
28
+ /** Body shape of a function-like node: a block to insert into, or an arrow expression. */
29
+ function bodyOf(ts, sourceFile, fn) {
30
+ if (!fn.body) return null;
31
+ if (ts.isBlock(fn.body)) return { node: fn.body, body: { kind: 'block', insertAt: fn.body.getStart(sourceFile) + 1 } };
32
+ return {
33
+ node: fn.body,
34
+ body: { kind: 'expression', start: fn.body.getStart(sourceFile), end: fn.body.end },
35
+ };
36
+ }
37
+
38
+ /** Members of a class body or object literal named `member`. */
39
+ function memberTarget(ts, sourceFile, members, member) {
40
+ for (const node of members) {
41
+ if (nameOf(ts, node) !== member) continue;
42
+ if ((ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node)) && node.body) {
43
+ return { shape: 'method', ...bodyOf(ts, sourceFile, node) };
44
+ }
45
+ const init = node.initializer;
46
+ if ((ts.isPropertyDeclaration(node) || ts.isPropertyAssignment(node)) && isFunctionLike(ts, init)) {
47
+ return { shape: 'method', ...bodyOf(ts, sourceFile, init) };
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Find the declaration for `symbol` (`name` or `Container.member`).
55
+ * @returns {{ found: true, shape: string, node: object, body: object } | { found: false, declarationOnly?: string }}
56
+ */
57
+ function locate(ts, sourceFile, symbol) {
58
+ const parts = String(symbol).split('.');
59
+ const name = parts[parts.length - 1];
60
+ const container = parts.length > 1 ? parts[parts.length - 2] : null;
61
+ let found = null;
62
+ let declarationOnly = null;
63
+ const visit = (node) => {
64
+ if (found) return;
65
+ const kindName = ts.SyntaxKind[node.kind];
66
+ if (container === null) {
67
+ if (ts.isFunctionDeclaration(node) && nameOf(ts, node) === name && node.body) {
68
+ found = { shape: 'function', ...bodyOf(ts, sourceFile, node) };
69
+ return;
70
+ }
71
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && node.initializer) {
72
+ const init = node.initializer;
73
+ found = isFunctionLike(ts, init)
74
+ ? { shape: 'const', ...bodyOf(ts, sourceFile, init) }
75
+ : {
76
+ shape: 'const',
77
+ node: init,
78
+ body: { kind: 'initializer', start: init.getStart(sourceFile), end: init.end },
79
+ };
80
+ return;
81
+ }
82
+ if (DECLARATION_ONLY.has(kindName) && nameOf(ts, node) === name) {
83
+ declarationOnly ??= DECLARATION_ONLY.get(kindName);
84
+ }
85
+ } else {
86
+ if ((ts.isClassDeclaration(node) || ts.isClassExpression(node)) && nameOf(ts, node) === container) {
87
+ found = memberTarget(ts, sourceFile, node.members, name);
88
+ } else if (
89
+ ts.isVariableDeclaration(node) &&
90
+ ts.isIdentifier(node.name) &&
91
+ node.name.text === container &&
92
+ node.initializer
93
+ ) {
94
+ const init = node.initializer;
95
+ if (ts.isObjectLiteralExpression(init)) found = memberTarget(ts, sourceFile, init.properties, name);
96
+ else if (ts.isClassExpression(init)) found = memberTarget(ts, sourceFile, init.members, name);
97
+ } else if (ts.isModuleDeclaration(node) && nameOf(ts, node) === container && node.body) {
98
+ const fn = (node.body.statements ?? []).find(
99
+ (statement) => ts.isFunctionDeclaration(statement) && nameOf(ts, statement) === name && statement.body
100
+ );
101
+ if (fn) found = { shape: 'function', ...bodyOf(ts, sourceFile, fn) };
102
+ }
103
+ if (found && !found.node) found = null;
104
+ }
105
+ if (!found) ts.forEachChild(node, visit);
106
+ };
107
+ visit(sourceFile);
108
+ if (found) return { found: true, ...found };
109
+ return { found: false, ...(declarationOnly ? { declarationOnly } : {}) };
110
+ }
111
+
112
+ function thenExits(ts, statement) {
113
+ if (!statement) return false;
114
+ if (ts.isThrowStatement(statement) || ts.isReturnStatement(statement)) return true;
115
+ return ts.isBlock(statement) && statement.statements.some((s) => ts.isThrowStatement(s) || ts.isReturnStatement(s));
116
+ }
117
+
118
+ function siteAt(sourceFile, text, kind, start, end) {
119
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(start);
120
+ return { kind, start, end, line: line + 1, column: character + 1, text: text.slice(start, end) };
121
+ }
122
+
123
+ /**
124
+ * Sites and body shape for the probe.
125
+ * @param {object} ts the loaded TypeScript module
126
+ * @param {string} fileName project-relative path (decides JS vs TS parsing)
127
+ * @param {string} text file contents
128
+ * @param {string} symbol `coverage.symbol`
129
+ * @returns {{ ok: true, shape: string, body: object, line: number, sites: object[] }
130
+ * | { ok: false, reason: 'symbol-not-found' | 'declaration-only', shape?: string }}
131
+ */
132
+ export function findProbeSites(ts, fileName, text, symbol) {
133
+ const sourceFile = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true);
134
+ const located = locate(ts, sourceFile, symbol);
135
+ if (!located.found) {
136
+ return located.declarationOnly
137
+ ? { ok: false, reason: 'declaration-only', shape: located.declarationOnly }
138
+ : { ok: false, reason: 'symbol-not-found' };
139
+ }
140
+ const sites = [];
141
+ const walk = (node) => {
142
+ if (ts.isIfStatement(node) && thenExits(ts, node.thenStatement)) {
143
+ sites.push(siteAt(sourceFile, text, 'guard', node.expression.getStart(sourceFile), node.expression.end));
144
+ } else if (ts.isThrowStatement(node)) {
145
+ sites.push(siteAt(sourceFile, text, 'throw', node.getStart(sourceFile), node.end));
146
+ } else if (ts.isBinaryExpression(node) && COMPARISON_TOKENS.has(node.operatorToken.getText(sourceFile))) {
147
+ sites.push(
148
+ siteAt(sourceFile, text, 'comparison', node.operatorToken.getStart(sourceFile), node.operatorToken.end)
149
+ );
150
+ } else if (ts.isNumericLiteral(node)) {
151
+ sites.push(siteAt(sourceFile, text, 'numeric', node.getStart(sourceFile), node.end));
152
+ } else if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) {
153
+ sites.push(siteAt(sourceFile, text, 'boolean', node.getStart(sourceFile), node.end));
154
+ }
155
+ ts.forEachChild(node, walk);
156
+ };
157
+ walk(located.node);
158
+ const { line } = sourceFile.getLineAndCharacterOfPosition(located.node.getStart(sourceFile));
159
+ return { ok: true, shape: located.shape, body: located.body, line: line + 1, sites };
160
+ }
161
+
162
+ /** True when `text` has no syntax errors (a mutant that does not parse is `invalid`). */
163
+ export function parsesCleanly(ts, fileName, text) {
164
+ try {
165
+ const out = ts.transpileModule(text, {
166
+ fileName,
167
+ reportDiagnostics: true,
168
+ compilerOptions: { allowJs: true, module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.Latest },
169
+ });
170
+ return (out.diagnostics ?? []).length === 0;
171
+ } catch {
172
+ return false;
173
+ }
174
+ }