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,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
+ }
@@ -0,0 +1,461 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/invariantProbe.ts
5
+ * Regenerate: node scripts/generate-cli-pure.mjs
6
+ * Drift check: node scripts/generate-cli-pure.mjs --check
7
+ *
8
+ * Pure CLI helper (bin/lib/invariant-probe.mjs). Zero Node I/O.
9
+ */
10
+
11
+ /** Versioned, closed operator set. Changing an operator changes this id. */
12
+ export const INVARIANT_PROBE_OPERATOR_SET = 'ip-ops@1';
13
+ const INVARIANT_PROBE_SCHEMA_VERSION = '1.0';
14
+ const INVARIANT_PROBE_KIND = 'arkgate-invariant-probe';
15
+ /**
16
+ * Project-relative path of the persisted artifact (`--write`).
17
+ */
18
+ export const INVARIANT_PROBE_ARTIFACT_PATH = '.ark/invariant-probe.json';
19
+ const INVARIANT_PROBE_MAX_MUTANTS = 3;
20
+ /**
21
+ * Targets per run; truncation is stated.
22
+ */
23
+ export const INVARIANT_PROBE_MAX_TARGETS = 25;
24
+ /**
25
+ * Covering tests per target.
26
+ */
27
+ export const INVARIANT_PROBE_MAX_TESTS = 8;
28
+ /** Reader bounds for the committed artifact. */
29
+ const INVARIANT_PROBE_MAX_ROWS = 500;
30
+ /**
31
+ * Largest artifact the reader opens.
32
+ */
33
+ export const INVARIANT_PROBE_MAX_BYTES = 1024 * 1024;
34
+ const INVARIANT_PROBE_LOAD_MARKER = 'ARK_PROBE_LOAD';
35
+ const INVARIANT_PROBE_REACH_MARKER = 'ARK_PROBE_REACH';
36
+ /** Priority order: guard-family operators first, constants last. */
37
+ export const INVARIANT_PROBE_OPERATORS = [
38
+ 'negate-guard',
39
+ 'drop-throw',
40
+ 'flip-comparison',
41
+ 'boundary-shift',
42
+ 'const-shift',
43
+ ];
44
+ export const INVARIANT_PROBE_VERDICTS = [
45
+ 'killed',
46
+ 'survived',
47
+ 'not-reached',
48
+ 'inconclusive',
49
+ 'unprobeable',
50
+ ];
51
+ export const INVARIANT_PROBE_REASONS = [
52
+ 'all-killed',
53
+ 'mutant-survived',
54
+ 'reach-canary-survived',
55
+ 'baseline-red',
56
+ 'baseline-timeout',
57
+ 'runner-error',
58
+ 'file-not-loaded',
59
+ 'mutant-runtime-error',
60
+ 'no-symbol',
61
+ 'declaration-only',
62
+ 'symbol-not-found',
63
+ 'no-covering-test',
64
+ 'no-site',
65
+ ];
66
+ /** What a single run of the covering tests showed. */
67
+ export const INVARIANT_PROBE_RUN_STATUSES = [
68
+ 'killed',
69
+ 'survived',
70
+ 'timeout',
71
+ 'runtime-error',
72
+ 'invalid',
73
+ ];
74
+ export const INVARIANT_PROBE_BASELINE_STATUSES = ['green', 'red', 'timeout', 'runtime-error'];
75
+ const FLIP = {
76
+ '<': '>=',
77
+ '>=': '<',
78
+ '>': '<=',
79
+ '<=': '>',
80
+ '===': '!==',
81
+ '!==': '===',
82
+ '==': '!=',
83
+ '!=': '==',
84
+ };
85
+ const BOUNDARY = {
86
+ '<': '<=',
87
+ '<=': '<',
88
+ '>': '>=',
89
+ '>=': '>',
90
+ };
91
+ const PLAIN_NUMBER = /^[0-9][0-9_]*(?:\.[0-9_]+)?$/;
92
+ function shiftedNumber(text) {
93
+ if (!PLAIN_NUMBER.test(text))
94
+ return null;
95
+ const value = Number(text.replace(/_/g, ''));
96
+ if (!Number.isFinite(value))
97
+ return null;
98
+ return String(value + 1);
99
+ }
100
+ /** Every operator that applies to one site, in priority order. */
101
+ export function mutantsForSite(site) {
102
+ const make = (operator, replacement) => ({
103
+ id: `${operator}@${site.line}:${site.column}`,
104
+ operator,
105
+ start: site.start,
106
+ end: site.end,
107
+ line: site.line,
108
+ column: site.column,
109
+ original: site.text,
110
+ replacement,
111
+ });
112
+ switch (site.kind) {
113
+ case 'guard':
114
+ return [make('negate-guard', `!(${site.text})`)];
115
+ case 'throw':
116
+ return [make('drop-throw', ';')];
117
+ case 'comparison': {
118
+ const out = [];
119
+ const flip = FLIP[site.text];
120
+ if (flip)
121
+ out.push(make('flip-comparison', flip));
122
+ const boundary = BOUNDARY[site.text];
123
+ if (boundary)
124
+ out.push(make('boundary-shift', boundary));
125
+ return out;
126
+ }
127
+ case 'numeric': {
128
+ const shifted = shiftedNumber(site.text);
129
+ return shifted === null ? [] : [make('const-shift', shifted)];
130
+ }
131
+ case 'boolean':
132
+ if (site.text === 'true')
133
+ return [make('const-shift', 'false')];
134
+ if (site.text === 'false')
135
+ return [make('const-shift', 'true')];
136
+ return [];
137
+ }
138
+ }
139
+ /**
140
+ * Deterministic selection: for each operator in priority order, the first site
141
+ * (by source position) that yields it, up to `max` mutants. Input order does
142
+ * not matter.
143
+ */
144
+ export function planMutants(sites, max = INVARIANT_PROBE_MAX_MUTANTS) {
145
+ const ordered = [...sites].sort((a, b) => a.start - b.start || a.end - b.end);
146
+ const candidates = ordered.flatMap((site) => mutantsForSite(site));
147
+ const planned = [];
148
+ for (const operator of INVARIANT_PROBE_OPERATORS) {
149
+ if (planned.length >= max)
150
+ break;
151
+ const first = candidates.find((mutant) => mutant.operator === operator);
152
+ if (first)
153
+ planned.push(first);
154
+ }
155
+ return planned;
156
+ }
157
+ /** Pure text splice. */
158
+ export function applyEdit(text, edit) {
159
+ return `${text.slice(0, edit.start)}${edit.replacement}${text.slice(edit.end)}`;
160
+ }
161
+ /**
162
+ * The two wiring canaries (ADR 0039 D4). The load canary throws at the top of
163
+ * the file (after a shebang). The reach canary throws as the first statement of
164
+ * the symbol; for a `const`, the initializer becomes a throwing call, so for a
165
+ * constant "reached" and "loaded" are the same event.
166
+ */
167
+ export function canaryEdits(text, body) {
168
+ const shebangEnd = text.startsWith('#!') ? text.indexOf('\n') + 1 || text.length : 0;
169
+ const load = {
170
+ start: shebangEnd,
171
+ end: shebangEnd,
172
+ replacement: `throw new Error('${INVARIANT_PROBE_LOAD_MARKER}');\n`,
173
+ };
174
+ const reachThrow = `throw new Error('${INVARIANT_PROBE_REACH_MARKER}');`;
175
+ let reach;
176
+ switch (body.kind) {
177
+ case 'block':
178
+ reach = { start: body.insertAt, end: body.insertAt, replacement: ` ${reachThrow}` };
179
+ break;
180
+ case 'expression':
181
+ reach = { start: body.start, end: body.end, replacement: `{ ${reachThrow} }` };
182
+ break;
183
+ case 'initializer':
184
+ reach = { start: body.start, end: body.end, replacement: `(() => { ${reachThrow} })()` };
185
+ break;
186
+ }
187
+ return { load, reach };
188
+ }
189
+ /**
190
+ * One verdict from the wiring checks and the mutant runs (ADR 0039 D4/D5).
191
+ * A timeout on a mutant counts as caught (the change was detected); a runner
192
+ * crash with no reported failure is never counted as caught.
193
+ */
194
+ export function foldVerdict(wiring, mutants) {
195
+ if (wiring.baseline === 'timeout')
196
+ return { verdict: 'inconclusive', reason: 'baseline-timeout' };
197
+ if (wiring.baseline === 'runtime-error')
198
+ return { verdict: 'inconclusive', reason: 'runner-error' };
199
+ if (wiring.baseline !== 'green')
200
+ return { verdict: 'inconclusive', reason: 'baseline-red' };
201
+ if (wiring.load === 'survived')
202
+ return { verdict: 'inconclusive', reason: 'file-not-loaded' };
203
+ if (wiring.load !== 'killed' && wiring.load !== 'timeout') {
204
+ return { verdict: 'inconclusive', reason: 'runner-error' };
205
+ }
206
+ if (wiring.reach === 'survived')
207
+ return { verdict: 'not-reached', reason: 'reach-canary-survived' };
208
+ if (wiring.reach !== 'killed' && wiring.reach !== 'timeout') {
209
+ return { verdict: 'inconclusive', reason: 'runner-error' };
210
+ }
211
+ const valid = mutants.filter((mutant) => mutant.status !== 'invalid');
212
+ if (valid.length === 0)
213
+ return { verdict: 'unprobeable', reason: 'no-site' };
214
+ if (valid.some((mutant) => mutant.status === 'survived')) {
215
+ return { verdict: 'survived', reason: 'mutant-survived' };
216
+ }
217
+ if (valid.some((mutant) => mutant.status === 'runtime-error')) {
218
+ return { verdict: 'inconclusive', reason: 'mutant-runtime-error' };
219
+ }
220
+ return { verdict: 'killed', reason: 'all-killed' };
221
+ }
222
+ /**
223
+ * Canonical identity text for an invariant. `mode` is left out on purpose:
224
+ * promoting the rule must not make its own evidence stale (ADR 0039 D3).
225
+ * Tooling hashes this text.
226
+ */
227
+ export function invariantProbeIdentity(invariant) {
228
+ return JSON.stringify([
229
+ invariant.id,
230
+ invariant.coverage?.symbol ?? null,
231
+ invariant.coverage?.test ?? null,
232
+ ]);
233
+ }
234
+ function probeTotals(rows) {
235
+ const count = (verdict) => rows.filter((row) => row.verdict === verdict).length;
236
+ return {
237
+ probed: rows.filter((row) => row.verdict !== 'unprobeable').length,
238
+ killed: count('killed'),
239
+ survived: count('survived'),
240
+ notReached: count('not-reached'),
241
+ inconclusive: count('inconclusive'),
242
+ unprobeable: count('unprobeable'),
243
+ };
244
+ }
245
+ /** True when a fresh row of this verdict refuses promotion (ADR 0039 D2). */
246
+ export function verdictRefusesPromotion(verdict) {
247
+ return verdict === 'survived' || verdict === 'not-reached';
248
+ }
249
+ /** Command exit code for a finished run: 1 when any row would refuse promotion. */
250
+ export function probeExitCode(rows) {
251
+ return rows.some((row) => verdictRefusesPromotion(row.verdict)) ? 1 : 0;
252
+ }
253
+ export function buildInvariantProbeArtifact(input) {
254
+ const invariants = [...input.rows].sort((a, b) => a.invariantId < b.invariantId ? -1 : a.invariantId > b.invariantId ? 1 : 0);
255
+ return {
256
+ schemaVersion: INVARIANT_PROBE_SCHEMA_VERSION,
257
+ kind: INVARIANT_PROBE_KIND,
258
+ notAScore: true,
259
+ arkgateVersion: input.arkgateVersion,
260
+ operatorSet: INVARIANT_PROBE_OPERATOR_SET,
261
+ runner: input.runner,
262
+ probedOn: input.probedOn,
263
+ invariants,
264
+ totals: probeTotals(invariants),
265
+ };
266
+ }
267
+ /** Why a recorded row no longer describes the tree. Empty: fresh. */
268
+ export function probeRowStaleness(row, current, operatorSet = INVARIANT_PROBE_OPERATOR_SET) {
269
+ const reasons = [];
270
+ if (operatorSet !== INVARIANT_PROBE_OPERATOR_SET)
271
+ reasons.push('the operator set changed');
272
+ if (current.invariantHash === null)
273
+ reasons.push('the invariant is no longer declared');
274
+ else if (current.invariantHash !== row.invariantHash)
275
+ reasons.push('the invariant changed');
276
+ if (row.symbolFile !== null && row.symbolFileHash !== null && current.symbolFileHash !== row.symbolFileHash) {
277
+ reasons.push(`${row.symbolFile} changed`);
278
+ }
279
+ for (const test of row.tests) {
280
+ if (current.testHashes[test.path] !== test.contentHash)
281
+ reasons.push(`${test.path} changed`);
282
+ }
283
+ return reasons;
284
+ }
285
+ export function summarizeProbeForCoverage(row, staleBecause, probedOn) {
286
+ return {
287
+ verdict: row.verdict,
288
+ reason: row.reason,
289
+ fresh: staleBecause.length === 0,
290
+ survivors: row.mutants
291
+ .filter((mutant) => mutant.status === 'survived')
292
+ .map((mutant) => ({
293
+ operator: mutant.operator,
294
+ line: mutant.line,
295
+ original: mutant.original,
296
+ replacement: mutant.replacement,
297
+ })),
298
+ ...(staleBecause.length > 0 ? { staleBecause: [...staleBecause] } : {}),
299
+ ...(row.symbolFile ? { symbolFile: row.symbolFile } : {}),
300
+ ...(probedOn ? { probedOn } : {}),
301
+ };
302
+ }
303
+ /** Short state for status and inventory lines. */
304
+ export function probeStatusLabel(summary) {
305
+ if (!summary)
306
+ return 'not run';
307
+ if (!summary.fresh)
308
+ return 'stale';
309
+ return summary.verdict;
310
+ }
311
+ function changeText(mutant) {
312
+ switch (mutant.operator) {
313
+ case 'negate-guard':
314
+ return 'the guard is negated';
315
+ case 'drop-throw':
316
+ return 'the throw is removed';
317
+ case 'flip-comparison':
318
+ return `\`${mutant.original}\` becomes \`${mutant.replacement}\``;
319
+ case 'boundary-shift':
320
+ return `the boundary moves (\`${mutant.original}\` becomes \`${mutant.replacement}\`)`;
321
+ case 'const-shift':
322
+ return `\`${mutant.original}\` becomes \`${mutant.replacement}\``;
323
+ }
324
+ }
325
+ /** One plain sentence per row (product voice; no score). */
326
+ export function probeRowLine(row) {
327
+ const id = row.invariantId;
328
+ const symbol = row.symbol ?? 'the symbol';
329
+ const file = row.symbolFile ?? row.sourceFile ?? 'its file';
330
+ switch (row.reason) {
331
+ case 'all-killed':
332
+ return `${id}: a covering test fails for every change made to ${symbol} (${file}).`;
333
+ case 'mutant-survived': {
334
+ const survivor = row.mutants.find((mutant) => mutant.status === 'survived');
335
+ const where = survivor ? `${file}:${survivor.line}` : file;
336
+ return `${id}: the tests still pass when ${survivor ? changeText(survivor) : `${symbol} is changed`} (${where}). The test names the rule but does not pin it. Next: add a case that fails when the rule is broken, then re-run with --write.`;
337
+ }
338
+ case 'reach-canary-survived':
339
+ return `${id}: the tests load ${file} but never call ${symbol}. Next: add a test that calls it, then re-run with --write.`;
340
+ case 'baseline-red':
341
+ return `${id}: the covering tests fail before any change, so nothing was probed. Next: make them pass first.`;
342
+ case 'baseline-timeout':
343
+ return `${id}: the covering tests did not finish in time, so nothing was probed.`;
344
+ case 'runner-error':
345
+ return `${id}: the test runner crashed instead of reporting results, so nothing is claimed.`;
346
+ case 'file-not-loaded':
347
+ return `${id}: the tests never load this copy of ${file} (it may come from node_modules), so nothing was probed.`;
348
+ case 'mutant-runtime-error':
349
+ return `${id}: the runner crashed on a change instead of reporting a failure, so nothing is claimed.`;
350
+ case 'no-symbol':
351
+ return `${id}: declares no coverage.symbol, so there is nothing to change.`;
352
+ case 'declaration-only':
353
+ return `${id}: ${symbol} is a declaration with no behavior to change.`;
354
+ case 'symbol-not-found':
355
+ return `${id}: ${symbol} was not found as a function, const or method in ${file}.`;
356
+ case 'no-covering-test':
357
+ return `${id}: no test names it or imports ${file}. A declaration is not a test.`;
358
+ case 'no-site':
359
+ return `${id}: ${symbol} has no guard, throw, comparison or constant to change.`;
360
+ }
361
+ }
362
+ /** Promotion refusal sentence for a fresh survived / not-reached summary. */
363
+ export function probeRefusalReason(invariantId, summary) {
364
+ if (summary.verdict === 'not-reached') {
365
+ return `Invariant ${invariantId}: the covering tests load the file but never call the declared symbol (mutation probe: not reached); add a test that calls it and re-run --probe-invariants --write before promoting.`;
366
+ }
367
+ const survivor = summary.survivors[0];
368
+ const detail = survivor ? ` (${changeText(survivor)} at line ${survivor.line})` : '';
369
+ return `Invariant ${invariantId}: the mutation probe shows the covering tests do not pin it${detail}; strengthen the test and re-run --probe-invariants --write before promoting.`;
370
+ }
371
+ function isRecord(value) {
372
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
373
+ }
374
+ function isText(value) {
375
+ return typeof value === 'string' && value.length > 0;
376
+ }
377
+ function oneOf(values, value) {
378
+ return typeof value === 'string' && values.includes(value);
379
+ }
380
+ function isNullableText(value) {
381
+ return value === null || isText(value);
382
+ }
383
+ function isRunStatusOrNull(value) {
384
+ return value === null || oneOf(INVARIANT_PROBE_RUN_STATUSES, value);
385
+ }
386
+ function isMutantRecord(value) {
387
+ return (isRecord(value) &&
388
+ isText(value.id) &&
389
+ oneOf(INVARIANT_PROBE_OPERATORS, value.operator) &&
390
+ Number.isInteger(value.line) &&
391
+ Number.isInteger(value.column) &&
392
+ typeof value.original === 'string' &&
393
+ typeof value.replacement === 'string' &&
394
+ oneOf(INVARIANT_PROBE_RUN_STATUSES, value.status) &&
395
+ typeof value.durationMs === 'number');
396
+ }
397
+ function isProbeRow(value) {
398
+ if (!isRecord(value))
399
+ return false;
400
+ const baselineOk = value.baseline === null ||
401
+ (isRecord(value.baseline) &&
402
+ oneOf(INVARIANT_PROBE_BASELINE_STATUSES, value.baseline.status) &&
403
+ typeof value.baseline.durationMs === 'number');
404
+ const wiringOk = value.wiring === null ||
405
+ (isRecord(value.wiring) && isRunStatusOrNull(value.wiring.load) && isRunStatusOrNull(value.wiring.reach));
406
+ return (isText(value.invariantId) &&
407
+ isText(value.invariantHash) &&
408
+ isNullableText(value.layer) &&
409
+ isNullableText(value.sourceFile) &&
410
+ (value.mode === 'advisory' || value.mode === 'enforced') &&
411
+ isNullableText(value.symbol) &&
412
+ isNullableText(value.symbolFile) &&
413
+ isNullableText(value.symbolFileHash) &&
414
+ Array.isArray(value.tests) &&
415
+ value.tests.every((test) => isRecord(test) && isText(test.path) && isText(test.contentHash)) &&
416
+ baselineOk &&
417
+ wiringOk &&
418
+ Array.isArray(value.mutants) &&
419
+ value.mutants.every(isMutantRecord) &&
420
+ oneOf(INVARIANT_PROBE_VERDICTS, value.verdict) &&
421
+ oneOf(INVARIANT_PROBE_REASONS, value.reason));
422
+ }
423
+ /**
424
+ * Read a parsed artifact. A malformed envelope is refused whole; a malformed
425
+ * row is dropped and counted. Either way a malformed artifact can only change
426
+ * nothing — it never grants anything (ADR 0039 D2).
427
+ */
428
+ export function readInvariantProbeArtifact(value) {
429
+ if (!isRecord(value))
430
+ return { ok: false, reason: 'not a JSON object' };
431
+ if (value.kind !== INVARIANT_PROBE_KIND)
432
+ return { ok: false, reason: `kind is not ${INVARIANT_PROBE_KIND}` };
433
+ if (value.schemaVersion !== INVARIANT_PROBE_SCHEMA_VERSION) {
434
+ return { ok: false, reason: `schemaVersion is not ${INVARIANT_PROBE_SCHEMA_VERSION}` };
435
+ }
436
+ if (!Array.isArray(value.invariants))
437
+ return { ok: false, reason: 'invariants is not a list' };
438
+ if (!isText(value.operatorSet))
439
+ return { ok: false, reason: 'operatorSet is missing' };
440
+ const kept = value.invariants.slice(0, INVARIANT_PROBE_MAX_ROWS);
441
+ const rows = kept.filter(isProbeRow);
442
+ const runner = isRecord(value.runner) && isText(value.runner.id)
443
+ ? { id: value.runner.id, version: isText(value.runner.version) ? value.runner.version : null }
444
+ : null;
445
+ return {
446
+ ok: true,
447
+ artifact: {
448
+ schemaVersion: INVARIANT_PROBE_SCHEMA_VERSION,
449
+ kind: INVARIANT_PROBE_KIND,
450
+ notAScore: true,
451
+ arkgateVersion: isText(value.arkgateVersion) ? value.arkgateVersion : 'unknown',
452
+ operatorSet: value.operatorSet,
453
+ runner,
454
+ probedOn: isText(value.probedOn) ? value.probedOn : 'unknown',
455
+ invariants: rows,
456
+ totals: probeTotals(rows),
457
+ },
458
+ droppedRows: kept.length - rows.length,
459
+ truncatedRows: value.invariants.length - kept.length,
460
+ };
461
+ }