arkgate 4.8.23 → 4.8.25

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 (201) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +41 -91
  4. package/bin/ark-dashboard.mjs +56 -53
  5. package/bin/ark-layer-match.mjs +707 -121
  6. package/bin/ark-mcp-runtime.mjs +448 -169
  7. package/bin/ark-mcp.mjs +68 -4
  8. package/bin/ark-shared.mjs +42 -22
  9. package/bin/ark.mjs +65 -97
  10. package/bin/lib/adapter-contract-types.mjs +5 -0
  11. package/bin/lib/adapter-contract.mjs +15 -0
  12. package/bin/lib/adoption-stance.mjs +2 -2
  13. package/bin/lib/adr-path.mjs +3 -3
  14. package/bin/lib/agent-gates.mjs +0 -61
  15. package/bin/lib/agent-homes.mjs +12 -21
  16. package/bin/lib/agent-projection-command.mjs +9 -5
  17. package/bin/lib/ambient-state.mjs +9 -30
  18. package/bin/lib/analysis-engine.mjs +15 -11
  19. package/bin/lib/architecture-scan.mjs +61 -15
  20. package/bin/lib/ark-order-doctor.mjs +4 -29
  21. package/bin/lib/ark-order-facts.mjs +319 -17
  22. package/bin/lib/ark-order-sensors.mjs +28 -101
  23. package/bin/lib/ark-order-types.mjs +3 -2
  24. package/bin/lib/ark-run-doctor.mjs +17 -28
  25. package/bin/lib/ark-run-facts.mjs +461 -48
  26. package/bin/lib/ark-run-sensors.mjs +6 -4
  27. package/bin/lib/arkgate-pins.mjs +91 -0
  28. package/bin/lib/arkrule-file-hints.mjs +2 -47
  29. package/bin/lib/arkrules-contract.mjs +124 -133
  30. package/bin/lib/arkrules-sensors.mjs +55 -252
  31. package/bin/lib/check-args.mjs +85 -1
  32. package/bin/lib/ci-and-commands.mjs +24 -78
  33. package/bin/lib/ci-merge-boundary.mjs +1 -1
  34. package/bin/lib/class-source-scan.mjs +479 -0
  35. package/bin/lib/clone-detection.mjs +474 -0
  36. package/bin/lib/codex-home.mjs +5 -3
  37. package/bin/lib/config-contract-slices.mjs +739 -0
  38. package/bin/lib/config-contract.mjs +42 -607
  39. package/bin/lib/config-extras.mjs +16 -22
  40. package/bin/lib/config-warnings.mjs +32 -0
  41. package/bin/lib/contract-smells.mjs +33 -15
  42. package/bin/lib/core-ratchet.mjs +0 -2
  43. package/bin/lib/dashboard-cli.mjs +62 -0
  44. package/bin/lib/deep-module-coach.mjs +2 -4
  45. package/bin/lib/deploy-path.mjs +13 -38
  46. package/bin/lib/design-delta.mjs +1 -1
  47. package/bin/lib/design-smells.mjs +2 -2
  48. package/bin/lib/diagnostic-catalog.mjs +23 -9
  49. package/bin/lib/doctor-advisories.mjs +78 -8
  50. package/bin/lib/doctor-green-cite.mjs +1 -1
  51. package/bin/lib/doctor-human.mjs +1 -1
  52. package/bin/lib/doctor-next-actions.mjs +19 -4
  53. package/bin/lib/doctor-plan.mjs +9 -13
  54. package/bin/lib/duplication-io.mjs +572 -0
  55. package/bin/lib/effective-contract-load.mjs +282 -83
  56. package/bin/lib/enforcement-profiles.mjs +40 -4
  57. package/bin/lib/entry-points-io.mjs +424 -0
  58. package/bin/lib/extra-merge-teeth.mjs +36 -0
  59. package/bin/lib/field-install.mjs +38 -14
  60. package/bin/lib/first-run-help.mjs +10 -5
  61. package/bin/lib/flat-parent-importers.mjs +139 -0
  62. package/bin/lib/flat-parent-pilot.mjs +40 -100
  63. package/bin/lib/gate-files.mjs +58 -90
  64. package/bin/lib/git-change-scope.mjs +109 -0
  65. package/bin/lib/github-enforcement.mjs +166 -29
  66. package/bin/lib/golden-pattern.mjs +1 -1
  67. package/bin/lib/hook-templates.mjs +17 -15
  68. package/bin/lib/host-support-matrix.mjs +3 -1
  69. package/bin/lib/html-report-advisories.mjs +4 -0
  70. package/bin/lib/html-report-depth.mjs +2 -2
  71. package/bin/lib/html-report.mjs +2 -2
  72. package/bin/lib/import-graph-projection.mjs +112 -0
  73. package/bin/lib/import-resolve.mjs +13 -1
  74. package/bin/lib/improvement-compass-doctor.mjs +1 -24
  75. package/bin/lib/install-migrate.mjs +50 -73
  76. package/bin/lib/invariant-coverage-io.mjs +4 -3
  77. package/bin/lib/invariant-coverage.mjs +85 -11
  78. package/bin/lib/invariant-probe-cli.mjs +347 -0
  79. package/bin/lib/invariant-probe-io.mjs +165 -0
  80. package/bin/lib/invariant-probe-runner.mjs +268 -0
  81. package/bin/lib/invariant-probe-sites.mjs +174 -0
  82. package/bin/lib/invariant-probe-workspace.mjs +183 -0
  83. package/bin/lib/invariant-probe.mjs +461 -0
  84. package/bin/lib/invariant-tests-path.mjs +16 -22
  85. package/bin/lib/literal-path-drift.mjs +4 -2
  86. package/bin/lib/managed-upgrade.mjs +2 -8
  87. package/bin/lib/mcp-adoption.mjs +13 -6
  88. package/bin/lib/mcp-hook-payload.mjs +403 -63
  89. package/bin/lib/orphan-modules-io.mjs +458 -0
  90. package/bin/lib/orphan-modules.mjs +251 -0
  91. package/bin/lib/outside-importers.mjs +197 -0
  92. package/bin/lib/package-manager.mjs +60 -2
  93. package/bin/lib/parse-health.mjs +1 -1
  94. package/bin/lib/passthrough-commands.mjs +83 -0
  95. package/bin/lib/persistence-write-hint.mjs +106 -0
  96. package/bin/lib/pin-dependent-gates.mjs +117 -0
  97. package/bin/lib/policy-delta-io.mjs +219 -14
  98. package/bin/lib/post-green-path.mjs +1 -1
  99. package/bin/lib/prepare-change.mjs +115 -7
  100. package/bin/lib/prepare-write.mjs +2 -2
  101. package/bin/lib/presets.mjs +59 -73
  102. package/bin/lib/product-copy.mjs +1 -6
  103. package/bin/lib/project-root.mjs +31 -62
  104. package/bin/lib/remediation.mjs +23 -4
  105. package/bin/lib/resolved-candidate-facts.mjs +405 -117
  106. package/bin/lib/rules-inventory.mjs +22 -3
  107. package/bin/lib/rules-under-contract.mjs +247 -9
  108. package/bin/lib/scan-files.mjs +36 -4
  109. package/bin/lib/schema-validation.mjs +130 -0
  110. package/bin/lib/sensor-promote-cli.mjs +2 -11
  111. package/bin/lib/sensor-promote-io.mjs +33 -22
  112. package/bin/lib/sensor-promotion.mjs +1 -1
  113. package/bin/lib/skill-catalog-apply.mjs +1 -1
  114. package/bin/lib/skill-install.mjs +42 -38
  115. package/bin/lib/snippet-analysis.mjs +140 -6
  116. package/bin/lib/source-policy.mjs +34 -0
  117. package/bin/lib/start-install-recovery.mjs +72 -2
  118. package/bin/lib/start-preview.mjs +31 -1
  119. package/bin/lib/states-transitions-presence.mjs +3 -3
  120. package/bin/lib/status-command.mjs +35 -22
  121. package/bin/lib/status-manifest.mjs +50 -30
  122. package/bin/lib/status-transition-catalog.mjs +0 -6
  123. package/bin/lib/suggestions.mjs +3 -3
  124. package/bin/lib/team-parliament-io.mjs +76 -88
  125. package/bin/lib/team-parliament.mjs +21 -10
  126. package/bin/lib/upgrade-behavior-changes.mjs +2 -2
  127. package/bin/lib/upgrade-command.mjs +2 -24
  128. package/bin/lib/upgrade-outcome.mjs +1 -1
  129. package/bin/lib/upstream-report.mjs +21 -10
  130. package/bin/lib/violations.mjs +10 -9
  131. package/bin/lib/weakest-link.mjs +13 -0
  132. package/bin/lib/write-path-capabilities.mjs +6 -4
  133. package/dist/chunk-A5EIY3HI.cjs +5 -0
  134. package/dist/chunk-AHACEKSV.js +16 -0
  135. package/dist/chunk-SVSQVGFJ.js +5 -0
  136. package/dist/chunk-UN5EH4QV.cjs +16 -0
  137. package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-GPvvdleM.d.cts} +30 -4
  138. package/dist/configTypes-GPvvdleM.d.ts +257 -0
  139. package/dist/eslint/index.cjs +18 -5
  140. package/dist/eslint/index.d.cts +368 -0
  141. package/dist/eslint/index.d.ts +139 -156
  142. package/dist/eslint/index.js +11 -5
  143. package/dist/index.cjs +36 -35
  144. package/dist/index.d.cts +5547 -0
  145. package/dist/index.d.ts +3283 -198
  146. package/dist/index.js +36 -35
  147. package/dist/inspectorListen-6DQVJ4DC.js +1 -0
  148. package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
  149. package/dist/nestjs/index.cjs +1 -18
  150. package/dist/nestjs/index.d.cts +22 -0
  151. package/dist/nestjs/index.d.ts +1 -3
  152. package/dist/nestjs/index.js +1 -18
  153. package/dist/order/index.cjs +1 -1
  154. package/dist/order/index.d.cts +155 -0
  155. package/dist/order/index.d.ts +15 -3
  156. package/dist/order/index.js +1 -1
  157. package/dist/runtime/index.cjs +13 -30
  158. package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +828 -276
  159. package/dist/runtime/index.d.ts +2741 -133
  160. package/dist/runtime/index.js +13 -30
  161. package/dist/{types-Bdj32QwA.d.ts → types-CnauMQQL.d.cts} +607 -163
  162. package/dist/types-CnauMQQL.d.ts +1800 -0
  163. package/docs/README.md +3 -1
  164. package/docs/agent-guide.md +217 -16
  165. package/docs/ai-gates.md +174 -26
  166. package/docs/arkorder.md +32 -15
  167. package/docs/brownfield-adoption.md +5 -1
  168. package/docs/configuration.md +230 -31
  169. package/docs/develop.md +2 -1
  170. package/docs/diagnostics.md +212 -19
  171. package/docs/package-surface.md +35 -9
  172. package/docs/product-voice.md +7 -1
  173. package/package.json +69 -19
  174. package/schemas/ark.analysis-result.schema.json +15 -0
  175. package/schemas/ark.config.schema.json +77 -12
  176. package/schemas/ark.invariant-probe.schema.json +368 -0
  177. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  178. package/schemas/ark.status-manifest.schema.json +22 -0
  179. package/server.json +3 -9
  180. package/templates/agent-skills/README.md +1 -1
  181. package/templates/agent-skills/ark-adopt/SKILL.md +5 -1
  182. package/templates/agent-skills/ark-contract/SKILL.md +17 -0
  183. package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
  184. package/templates/agent-skills/ark-explore/SKILL.md +7 -0
  185. package/templates/agent-skills/ark-fix/SKILL.md +20 -0
  186. package/templates/agent-skills/ark-order/SKILL.md +4 -2
  187. package/templates/agent-skills/ark-place/SKILL.md +8 -0
  188. package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
  189. package/templates/arkrules/PersistenceAdapters.json +1 -1
  190. package/templates/arkrules/PresentationAdapters.json +1 -1
  191. package/templates/skills/ark-adopt.md +5 -1
  192. package/templates/skills/ark-contract.md +17 -0
  193. package/templates/skills/ark-coverage.md +2 -0
  194. package/templates/skills/ark-explore.md +7 -0
  195. package/templates/skills/ark-fix.md +20 -0
  196. package/templates/skills/ark-order.md +4 -2
  197. package/templates/skills/ark-place.md +8 -0
  198. package/templates/skills/ark-runtime.md +6 -1
  199. package/bin/lib/ark-order-error.mjs +0 -18
  200. package/bin/lib/ark-order-invariants.mjs +0 -346
  201. package/dist/types-Ik_OakcI.d.ts +0 -183
@@ -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
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Temporary copy for the invariant probe — Tooling (ADR 0039).
3
+ *
4
+ * Every mutant is applied to a copy under the OS temp directory, never to the
5
+ * user's tree. Layout:
6
+ *
7
+ * <tmp>/arkgate-probe-XXXXXX/
8
+ * .arkgate-probe-owner ownership marker (the stale sweep only touches these)
9
+ * project/ the copy (node_modules is a real dir of links)
10
+ * home/ tmp/ HOME and TMPDIR for the runner
11
+ *
12
+ * Cleanup runs in `finally` and on SIGINT / SIGTERM. The next run removes
13
+ * stale `arkgate-probe-*` directories older than 24 hours, but only when the
14
+ * owner marker is a regular file inside a real directory.
15
+ */
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+
20
+ const PROBE_DIR_PREFIX = 'arkgate-probe-';
21
+ export const PROBE_OWNER_MARKER = '.arkgate-probe-owner';
22
+ const PROBE_MAX_FILES = 20_000;
23
+ const PROBE_MAX_BYTES = 256 * 1024 * 1024;
24
+ const STALE_AFTER_MS = 24 * 60 * 60 * 1000;
25
+ /** Never copied. `node_modules` is linked instead (see linkFarm). */
26
+ const EXCLUDED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', '.turbo', 'out']);
27
+ const EXCLUDED_PATHS = new Set(['.ark/reports']);
28
+ /** Runner caches inside node_modules: never linked, so a cache write lands in the copy. */
29
+ const UNLINKED_MODULE_ENTRIES = new Set(['.vite', '.vitest', '.cache']);
30
+
31
+ /** Walk the tree the copy would hold, without copying. */
32
+ function planCopy(root, maxFiles, maxBytes) {
33
+ const entries = [];
34
+ let bytes = 0;
35
+ let files = 0;
36
+ const over = () => files > maxFiles || bytes > maxBytes;
37
+ const visit = (rel) => {
38
+ if (over()) return;
39
+ const abs = rel ? path.join(root, rel) : root;
40
+ let children;
41
+ try {
42
+ children = fs.readdirSync(abs, { withFileTypes: true });
43
+ } catch {
44
+ return;
45
+ }
46
+ children.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
47
+ for (const child of children) {
48
+ const childRel = rel ? `${rel}/${child.name}` : child.name;
49
+ if (EXCLUDED_PATHS.has(childRel)) continue;
50
+ if (child.isDirectory()) {
51
+ if (child.name === 'node_modules') {
52
+ entries.push({ rel: childRel, kind: 'modules' });
53
+ continue;
54
+ }
55
+ if (EXCLUDED_DIRS.has(child.name)) continue;
56
+ entries.push({ rel: childRel, kind: 'dir' });
57
+ visit(childRel);
58
+ } else if (child.isSymbolicLink()) {
59
+ if (child.name === 'node_modules') entries.push({ rel: childRel, kind: 'modules' });
60
+ else entries.push({ rel: childRel, kind: 'link' });
61
+ } else if (child.isFile()) {
62
+ files += 1;
63
+ try {
64
+ bytes += fs.statSync(path.join(root, childRel)).size;
65
+ } catch {
66
+ /* vanished mid-walk: not copied */
67
+ }
68
+ entries.push({ rel: childRel, kind: 'file' });
69
+ }
70
+ if (over()) return;
71
+ }
72
+ };
73
+ visit('');
74
+ return { entries, files, bytes };
75
+ }
76
+
77
+ /** `node_modules` in the copy: a real directory holding one link per top-level entry. */
78
+ function linkFarm(sourceModules, targetModules) {
79
+ fs.mkdirSync(targetModules, { recursive: true });
80
+ let names = [];
81
+ try {
82
+ names = fs.readdirSync(sourceModules);
83
+ } catch {
84
+ return;
85
+ }
86
+ for (const name of names) {
87
+ if (UNLINKED_MODULE_ENTRIES.has(name)) continue;
88
+ let real;
89
+ try {
90
+ real = fs.realpathSync(path.join(sourceModules, name));
91
+ } catch {
92
+ continue;
93
+ }
94
+ try {
95
+ fs.symlinkSync(real, path.join(targetModules, name), process.platform === 'win32' ? 'junction' : undefined);
96
+ } catch {
97
+ /* a name that cannot be linked is left out; the runner reports what is missing */
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Remove stale probe directories: older than 24 hours AND carrying the owner
104
+ * marker. Anything else under the temp root is never touched.
105
+ */
106
+ export function sweepStaleProbeWorkspaces({ tmpRoot = os.tmpdir(), now = Date.now(), maxAgeMs = STALE_AFTER_MS } = {}) {
107
+ const removed = [];
108
+ let names = [];
109
+ try {
110
+ names = fs.readdirSync(tmpRoot);
111
+ } catch {
112
+ return { removed };
113
+ }
114
+ for (const name of names) {
115
+ if (!name.startsWith(PROBE_DIR_PREFIX)) continue;
116
+ const dir = path.join(tmpRoot, name);
117
+ try {
118
+ const dirStat = fs.lstatSync(dir);
119
+ if (!dirStat.isDirectory()) continue;
120
+ const markerStat = fs.lstatSync(path.join(dir, PROBE_OWNER_MARKER));
121
+ if (!markerStat.isFile()) continue;
122
+ if (now - markerStat.mtimeMs < maxAgeMs) continue;
123
+ fs.rmSync(dir, { recursive: true, force: true });
124
+ removed.push(dir);
125
+ } catch {
126
+ /* no marker, or unreadable: not ours to remove */
127
+ }
128
+ }
129
+ return { removed };
130
+ }
131
+
132
+ /**
133
+ * Copy `root` into a fresh marked workspace.
134
+ * @returns {{ ok: true, dir: string, project: string, home: string, tmp: string, cleanup: () => void,
135
+ * files: number, bytes: number }
136
+ * | { ok: false, reasonCode: 'PROBE_TREE_TOO_LARGE', files: number, bytes: number }}
137
+ */
138
+ export function createProbeWorkspace(
139
+ root,
140
+ { tmpRoot = os.tmpdir(), onSignalCleanup, maxFiles = PROBE_MAX_FILES, maxBytes = PROBE_MAX_BYTES } = {}
141
+ ) {
142
+ const plan = planCopy(root, maxFiles, maxBytes);
143
+ if (plan.files > maxFiles || plan.bytes > maxBytes) {
144
+ return { ok: false, reasonCode: 'PROBE_TREE_TOO_LARGE', files: plan.files, bytes: plan.bytes };
145
+ }
146
+ const dir = fs.mkdtempSync(path.join(tmpRoot, PROBE_DIR_PREFIX));
147
+ fs.writeFileSync(
148
+ path.join(dir, PROBE_OWNER_MARKER),
149
+ `${JSON.stringify({ owner: 'arkgate-probe', pid: process.pid })}\n`
150
+ );
151
+ const project = path.join(dir, 'project');
152
+ const home = path.join(dir, 'home');
153
+ const tmp = path.join(dir, 'tmp');
154
+ for (const sub of [project, home, tmp]) fs.mkdirSync(sub);
155
+ let cleaned = false;
156
+ const signals = ['SIGINT', 'SIGTERM'];
157
+ const onSignal = (signal) => {
158
+ cleanup();
159
+ onSignalCleanup?.(signal);
160
+ process.kill(process.pid, signal);
161
+ };
162
+ function cleanup() {
163
+ if (cleaned) return;
164
+ cleaned = true;
165
+ for (const signal of signals) process.removeListener(signal, onSignal);
166
+ fs.rmSync(dir, { recursive: true, force: true });
167
+ }
168
+ for (const signal of signals) process.once(signal, onSignal);
169
+ try {
170
+ for (const entry of plan.entries) {
171
+ const source = path.join(root, entry.rel);
172
+ const target = path.join(project, entry.rel);
173
+ if (entry.kind === 'dir') fs.mkdirSync(target, { recursive: true });
174
+ else if (entry.kind === 'modules') linkFarm(source, target);
175
+ else if (entry.kind === 'link') fs.symlinkSync(fs.readlinkSync(source), target);
176
+ else fs.copyFileSync(source, target);
177
+ }
178
+ } catch (error) {
179
+ cleanup();
180
+ throw error;
181
+ }
182
+ return { ok: true, dir, project, home, tmp, cleanup, files: plan.files, bytes: plan.bytes };
183
+ }