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.
- package/CHANGELOG.md +153 -0
- package/README.md +7 -5
- package/bin/ark-check-runtime.mjs +41 -91
- package/bin/ark-dashboard.mjs +56 -53
- package/bin/ark-layer-match.mjs +707 -121
- package/bin/ark-mcp-runtime.mjs +448 -169
- package/bin/ark-mcp.mjs +68 -4
- package/bin/ark-shared.mjs +42 -22
- package/bin/ark.mjs +65 -97
- package/bin/lib/adapter-contract-types.mjs +5 -0
- package/bin/lib/adapter-contract.mjs +15 -0
- package/bin/lib/adoption-stance.mjs +2 -2
- package/bin/lib/adr-path.mjs +3 -3
- package/bin/lib/agent-gates.mjs +0 -61
- package/bin/lib/agent-homes.mjs +12 -21
- package/bin/lib/agent-projection-command.mjs +9 -5
- package/bin/lib/ambient-state.mjs +9 -30
- package/bin/lib/analysis-engine.mjs +15 -11
- package/bin/lib/architecture-scan.mjs +61 -15
- package/bin/lib/ark-order-doctor.mjs +4 -29
- package/bin/lib/ark-order-facts.mjs +319 -17
- package/bin/lib/ark-order-sensors.mjs +28 -101
- package/bin/lib/ark-order-types.mjs +3 -2
- package/bin/lib/ark-run-doctor.mjs +17 -28
- package/bin/lib/ark-run-facts.mjs +461 -48
- package/bin/lib/ark-run-sensors.mjs +6 -4
- package/bin/lib/arkgate-pins.mjs +91 -0
- package/bin/lib/arkrule-file-hints.mjs +2 -47
- package/bin/lib/arkrules-contract.mjs +124 -133
- package/bin/lib/arkrules-sensors.mjs +55 -252
- package/bin/lib/check-args.mjs +85 -1
- package/bin/lib/ci-and-commands.mjs +24 -78
- package/bin/lib/ci-merge-boundary.mjs +1 -1
- package/bin/lib/class-source-scan.mjs +479 -0
- package/bin/lib/clone-detection.mjs +474 -0
- package/bin/lib/codex-home.mjs +5 -3
- package/bin/lib/config-contract-slices.mjs +739 -0
- package/bin/lib/config-contract.mjs +42 -607
- package/bin/lib/config-extras.mjs +16 -22
- package/bin/lib/config-warnings.mjs +32 -0
- package/bin/lib/contract-smells.mjs +33 -15
- package/bin/lib/core-ratchet.mjs +0 -2
- package/bin/lib/dashboard-cli.mjs +62 -0
- package/bin/lib/deep-module-coach.mjs +2 -4
- package/bin/lib/deploy-path.mjs +13 -38
- package/bin/lib/design-delta.mjs +1 -1
- package/bin/lib/design-smells.mjs +2 -2
- package/bin/lib/diagnostic-catalog.mjs +23 -9
- package/bin/lib/doctor-advisories.mjs +78 -8
- package/bin/lib/doctor-green-cite.mjs +1 -1
- package/bin/lib/doctor-human.mjs +1 -1
- package/bin/lib/doctor-next-actions.mjs +19 -4
- package/bin/lib/doctor-plan.mjs +9 -13
- package/bin/lib/duplication-io.mjs +572 -0
- package/bin/lib/effective-contract-load.mjs +282 -83
- package/bin/lib/enforcement-profiles.mjs +40 -4
- package/bin/lib/entry-points-io.mjs +424 -0
- package/bin/lib/extra-merge-teeth.mjs +36 -0
- package/bin/lib/field-install.mjs +38 -14
- package/bin/lib/first-run-help.mjs +10 -5
- package/bin/lib/flat-parent-importers.mjs +139 -0
- package/bin/lib/flat-parent-pilot.mjs +40 -100
- package/bin/lib/gate-files.mjs +58 -90
- package/bin/lib/git-change-scope.mjs +109 -0
- package/bin/lib/github-enforcement.mjs +166 -29
- package/bin/lib/golden-pattern.mjs +1 -1
- package/bin/lib/hook-templates.mjs +17 -15
- package/bin/lib/host-support-matrix.mjs +3 -1
- package/bin/lib/html-report-advisories.mjs +4 -0
- package/bin/lib/html-report-depth.mjs +2 -2
- package/bin/lib/html-report.mjs +2 -2
- package/bin/lib/import-graph-projection.mjs +112 -0
- package/bin/lib/import-resolve.mjs +13 -1
- package/bin/lib/improvement-compass-doctor.mjs +1 -24
- package/bin/lib/install-migrate.mjs +50 -73
- package/bin/lib/invariant-coverage-io.mjs +4 -3
- package/bin/lib/invariant-coverage.mjs +85 -11
- package/bin/lib/invariant-probe-cli.mjs +347 -0
- package/bin/lib/invariant-probe-io.mjs +165 -0
- package/bin/lib/invariant-probe-runner.mjs +268 -0
- package/bin/lib/invariant-probe-sites.mjs +174 -0
- package/bin/lib/invariant-probe-workspace.mjs +183 -0
- package/bin/lib/invariant-probe.mjs +461 -0
- package/bin/lib/invariant-tests-path.mjs +16 -22
- package/bin/lib/literal-path-drift.mjs +4 -2
- package/bin/lib/managed-upgrade.mjs +2 -8
- package/bin/lib/mcp-adoption.mjs +13 -6
- package/bin/lib/mcp-hook-payload.mjs +403 -63
- package/bin/lib/orphan-modules-io.mjs +458 -0
- package/bin/lib/orphan-modules.mjs +251 -0
- package/bin/lib/outside-importers.mjs +197 -0
- package/bin/lib/package-manager.mjs +60 -2
- package/bin/lib/parse-health.mjs +1 -1
- package/bin/lib/passthrough-commands.mjs +83 -0
- package/bin/lib/persistence-write-hint.mjs +106 -0
- package/bin/lib/pin-dependent-gates.mjs +117 -0
- package/bin/lib/policy-delta-io.mjs +219 -14
- package/bin/lib/post-green-path.mjs +1 -1
- package/bin/lib/prepare-change.mjs +115 -7
- package/bin/lib/prepare-write.mjs +2 -2
- package/bin/lib/presets.mjs +59 -73
- package/bin/lib/product-copy.mjs +1 -6
- package/bin/lib/project-root.mjs +31 -62
- package/bin/lib/remediation.mjs +23 -4
- package/bin/lib/resolved-candidate-facts.mjs +405 -117
- package/bin/lib/rules-inventory.mjs +22 -3
- package/bin/lib/rules-under-contract.mjs +247 -9
- package/bin/lib/scan-files.mjs +36 -4
- package/bin/lib/schema-validation.mjs +130 -0
- package/bin/lib/sensor-promote-cli.mjs +2 -11
- package/bin/lib/sensor-promote-io.mjs +33 -22
- package/bin/lib/sensor-promotion.mjs +1 -1
- package/bin/lib/skill-catalog-apply.mjs +1 -1
- package/bin/lib/skill-install.mjs +42 -38
- package/bin/lib/snippet-analysis.mjs +140 -6
- package/bin/lib/source-policy.mjs +34 -0
- package/bin/lib/start-install-recovery.mjs +72 -2
- package/bin/lib/start-preview.mjs +31 -1
- package/bin/lib/states-transitions-presence.mjs +3 -3
- package/bin/lib/status-command.mjs +35 -22
- package/bin/lib/status-manifest.mjs +50 -30
- package/bin/lib/status-transition-catalog.mjs +0 -6
- package/bin/lib/suggestions.mjs +3 -3
- package/bin/lib/team-parliament-io.mjs +76 -88
- package/bin/lib/team-parliament.mjs +21 -10
- package/bin/lib/upgrade-behavior-changes.mjs +2 -2
- package/bin/lib/upgrade-command.mjs +2 -24
- package/bin/lib/upgrade-outcome.mjs +1 -1
- package/bin/lib/upstream-report.mjs +21 -10
- package/bin/lib/violations.mjs +10 -9
- package/bin/lib/weakest-link.mjs +13 -0
- package/bin/lib/write-path-capabilities.mjs +6 -4
- package/dist/chunk-A5EIY3HI.cjs +5 -0
- package/dist/chunk-AHACEKSV.js +16 -0
- package/dist/chunk-SVSQVGFJ.js +5 -0
- package/dist/chunk-UN5EH4QV.cjs +16 -0
- package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-GPvvdleM.d.cts} +30 -4
- package/dist/configTypes-GPvvdleM.d.ts +257 -0
- package/dist/eslint/index.cjs +18 -5
- package/dist/eslint/index.d.cts +368 -0
- package/dist/eslint/index.d.ts +139 -156
- package/dist/eslint/index.js +11 -5
- package/dist/index.cjs +36 -35
- package/dist/index.d.cts +5547 -0
- package/dist/index.d.ts +3283 -198
- package/dist/index.js +36 -35
- package/dist/inspectorListen-6DQVJ4DC.js +1 -0
- package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
- package/dist/nestjs/index.cjs +1 -18
- package/dist/nestjs/index.d.cts +22 -0
- package/dist/nestjs/index.d.ts +1 -3
- package/dist/nestjs/index.js +1 -18
- package/dist/order/index.cjs +1 -1
- package/dist/order/index.d.cts +155 -0
- package/dist/order/index.d.ts +15 -3
- package/dist/order/index.js +1 -1
- package/dist/runtime/index.cjs +13 -30
- package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +828 -276
- package/dist/runtime/index.d.ts +2741 -133
- package/dist/runtime/index.js +13 -30
- package/dist/{types-Bdj32QwA.d.ts → types-CnauMQQL.d.cts} +607 -163
- package/dist/types-CnauMQQL.d.ts +1800 -0
- package/docs/README.md +3 -1
- package/docs/agent-guide.md +217 -16
- package/docs/ai-gates.md +174 -26
- package/docs/arkorder.md +32 -15
- package/docs/brownfield-adoption.md +5 -1
- package/docs/configuration.md +230 -31
- package/docs/develop.md +2 -1
- package/docs/diagnostics.md +212 -19
- package/docs/package-surface.md +35 -9
- package/docs/product-voice.md +7 -1
- package/package.json +69 -19
- package/schemas/ark.analysis-result.schema.json +15 -0
- package/schemas/ark.config.schema.json +77 -12
- package/schemas/ark.invariant-probe.schema.json +368 -0
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/schemas/ark.status-manifest.schema.json +22 -0
- package/server.json +3 -9
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +5 -1
- package/templates/agent-skills/ark-contract/SKILL.md +17 -0
- package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
- package/templates/agent-skills/ark-explore/SKILL.md +7 -0
- package/templates/agent-skills/ark-fix/SKILL.md +20 -0
- package/templates/agent-skills/ark-order/SKILL.md +4 -2
- package/templates/agent-skills/ark-place/SKILL.md +8 -0
- package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
- package/templates/arkrules/PersistenceAdapters.json +1 -1
- package/templates/arkrules/PresentationAdapters.json +1 -1
- package/templates/skills/ark-adopt.md +5 -1
- package/templates/skills/ark-contract.md +17 -0
- package/templates/skills/ark-coverage.md +2 -0
- package/templates/skills/ark-explore.md +7 -0
- package/templates/skills/ark-fix.md +20 -0
- package/templates/skills/ark-order.md +4 -2
- package/templates/skills/ark-place.md +8 -0
- package/templates/skills/ark-runtime.md +6 -1
- package/bin/lib/ark-order-error.mjs +0 -18
- package/bin/lib/ark-order-invariants.mjs +0 -346
- package/dist/types-Ik_OakcI.d.ts +0 -183
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `arkgate-check --probe-invariants[=<id>] [--write] [--json] [--runner <id>]`
|
|
3
|
+
* — orchestration and output (ADR 0039).
|
|
4
|
+
*
|
|
5
|
+
* Owner-invoked only. Loaded through a dynamic import from the CLI entry when
|
|
6
|
+
* the flag is set; the write hook, MCP, ESLint, the action and --strict-merge
|
|
7
|
+
* never import this module (a static test pins it).
|
|
8
|
+
*
|
|
9
|
+
* Per target: baseline green → load canary caught → reach canary caught → at
|
|
10
|
+
* most three mutants. Everything runs in a temporary copy; the user's tree is
|
|
11
|
+
* only read, except `.ark/invariant-probe.json` under --write.
|
|
12
|
+
*
|
|
13
|
+
* Exit codes: 0 no survivor and nothing unreached · 1 at least one survived or
|
|
14
|
+
* not-reached · 2 could not run.
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { arkCommand } from '../ark-shared.mjs';
|
|
19
|
+
import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
|
|
20
|
+
import { testFilesNamingInvariant } from './invariant-coverage.mjs';
|
|
21
|
+
import {
|
|
22
|
+
INVARIANT_PROBE_ARTIFACT_PATH,
|
|
23
|
+
INVARIANT_PROBE_MAX_TARGETS,
|
|
24
|
+
INVARIANT_PROBE_MAX_TESTS,
|
|
25
|
+
applyEdit,
|
|
26
|
+
buildInvariantProbeArtifact,
|
|
27
|
+
canaryEdits,
|
|
28
|
+
foldVerdict,
|
|
29
|
+
planMutants,
|
|
30
|
+
probeExitCode,
|
|
31
|
+
probeRowLine,
|
|
32
|
+
readInvariantProbeArtifact,
|
|
33
|
+
} from './invariant-probe.mjs';
|
|
34
|
+
import { hashProjectFile, invariantIdentityHash, sha256Text } from './invariant-probe-io.mjs';
|
|
35
|
+
import {
|
|
36
|
+
PROBE_TIMEOUT_CEILING_MS,
|
|
37
|
+
buildProbeEnv,
|
|
38
|
+
detectRunner,
|
|
39
|
+
probeTimeoutMs,
|
|
40
|
+
runCoveringTests,
|
|
41
|
+
} from './invariant-probe-runner.mjs';
|
|
42
|
+
import { findProbeSites, parsesCleanly } from './invariant-probe-sites.mjs';
|
|
43
|
+
import { createProbeWorkspace, sweepStaleProbeWorkspaces } from './invariant-probe-workspace.mjs';
|
|
44
|
+
import { testImportersOf } from './outside-importers.mjs';
|
|
45
|
+
import { collectGovernedFiles, normalize } from './scan-files.mjs';
|
|
46
|
+
import { evaluateCatalogCoverage } from './sensor-promote-io.mjs';
|
|
47
|
+
import { arkPackageVersion } from './skill-install.mjs';
|
|
48
|
+
import { loadTypeScript } from './typescript-host.mjs';
|
|
49
|
+
import { color } from './violations.mjs';
|
|
50
|
+
|
|
51
|
+
const DECLARATION_ONLY_SHAPES = new Set(['type', 'interface', 'enum', 'class']);
|
|
52
|
+
|
|
53
|
+
function unprobeable(root, inv, reason, symbolFile = null) {
|
|
54
|
+
return {
|
|
55
|
+
invariantId: inv.id,
|
|
56
|
+
invariantHash: invariantIdentityHash(inv),
|
|
57
|
+
layer: inv.provenance?.layer ?? null,
|
|
58
|
+
sourceFile: inv.provenance?.sourceFile ?? null,
|
|
59
|
+
mode: inv.mode ?? 'advisory',
|
|
60
|
+
symbol: inv.coverage?.symbol ?? null,
|
|
61
|
+
symbolFile,
|
|
62
|
+
// Bound like any row: when the file changes, the reason may no longer hold.
|
|
63
|
+
symbolFileHash: symbolFile ? hashProjectFile(root, symbolFile) : null,
|
|
64
|
+
tests: [],
|
|
65
|
+
baseline: null,
|
|
66
|
+
wiring: null,
|
|
67
|
+
mutants: [],
|
|
68
|
+
verdict: 'unprobeable',
|
|
69
|
+
reason,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Resolve what to probe for one invariant, or the unprobeable row that says why not. */
|
|
74
|
+
function planTarget({ root, ts, inv, coverageRow, inputs, importers }) {
|
|
75
|
+
const symbol = inv.coverage?.symbol;
|
|
76
|
+
if (!symbol) return { row: unprobeable(root, inv, 'no-symbol') };
|
|
77
|
+
const symbolFile = coverageRow?.symbolEvidenceFile ?? null;
|
|
78
|
+
if (coverageRow?.shape && DECLARATION_ONLY_SHAPES.has(coverageRow.shape)) {
|
|
79
|
+
return { row: unprobeable(root, inv, 'declaration-only', symbolFile) };
|
|
80
|
+
}
|
|
81
|
+
if (!symbolFile) return { row: unprobeable(root, inv, 'symbol-not-found') };
|
|
82
|
+
let text;
|
|
83
|
+
try {
|
|
84
|
+
text = fs.readFileSync(path.join(root, symbolFile), 'utf8');
|
|
85
|
+
} catch {
|
|
86
|
+
return { row: unprobeable(root, inv, 'symbol-not-found', symbolFile) };
|
|
87
|
+
}
|
|
88
|
+
const found = findProbeSites(ts, symbolFile, text, symbol);
|
|
89
|
+
if (!found.ok) return { row: unprobeable(root, inv, found.reason, symbolFile) };
|
|
90
|
+
const named = inputs ? testFilesNamingInvariant(inv, inputs) : [];
|
|
91
|
+
const tests = [...new Set([...named, ...(importers.get(symbolFile) ?? [])])].slice(0, INVARIANT_PROBE_MAX_TESTS);
|
|
92
|
+
if (tests.length === 0) return { row: unprobeable(root, inv, 'no-covering-test', symbolFile) };
|
|
93
|
+
return { target: { inv, symbol, symbolFile, text, found, tests } };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function outcomeToRun(outcome) {
|
|
97
|
+
if (outcome === 'failed') return 'killed';
|
|
98
|
+
if (outcome === 'passed') return 'survived';
|
|
99
|
+
return outcome;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function outcomeToBaseline(outcome) {
|
|
103
|
+
if (outcome === 'passed') return 'green';
|
|
104
|
+
if (outcome === 'failed') return 'red';
|
|
105
|
+
return outcome;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Run one target in the workspace. The copy's symbol file is restored after every edit. */
|
|
109
|
+
async function probeTarget(root, workspace, runner, target) {
|
|
110
|
+
const cwd = path.join(workspace.project, path.relative(root, runner.pkgRoot));
|
|
111
|
+
const files = target.tests.map((test) => normalize(path.relative(runner.pkgRoot, path.join(root, test))));
|
|
112
|
+
const copyFile = path.join(workspace.project, target.symbolFile);
|
|
113
|
+
const env = buildProbeEnv({ home: workspace.home, tmp: workspace.tmp });
|
|
114
|
+
const run = (timeoutMs) => runCoveringTests({ runner, cwd, files, timeoutMs, env, scratch: workspace.tmp });
|
|
115
|
+
const withText = async (text, timeoutMs) => {
|
|
116
|
+
fs.writeFileSync(copyFile, text);
|
|
117
|
+
try {
|
|
118
|
+
return await run(timeoutMs);
|
|
119
|
+
} finally {
|
|
120
|
+
fs.writeFileSync(copyFile, target.text);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const baseline = await run(PROBE_TIMEOUT_CEILING_MS);
|
|
124
|
+
const wiring = { baseline: outcomeToBaseline(baseline.outcome) };
|
|
125
|
+
const mutants = [];
|
|
126
|
+
if (wiring.baseline === 'green') {
|
|
127
|
+
const timeout = probeTimeoutMs(baseline.durationMs);
|
|
128
|
+
const canary = canaryEdits(target.text, target.found.body);
|
|
129
|
+
wiring.load = outcomeToRun((await withText(applyEdit(target.text, canary.load), timeout)).outcome);
|
|
130
|
+
if (wiring.load === 'killed' || wiring.load === 'timeout') {
|
|
131
|
+
wiring.reach = outcomeToRun((await withText(applyEdit(target.text, canary.reach), timeout)).outcome);
|
|
132
|
+
}
|
|
133
|
+
if (wiring.reach === 'killed' || wiring.reach === 'timeout') {
|
|
134
|
+
for (const mutant of planMutants(target.found.sites)) {
|
|
135
|
+
const mutated = applyEdit(target.text, mutant);
|
|
136
|
+
const record = {
|
|
137
|
+
id: mutant.id,
|
|
138
|
+
operator: mutant.operator,
|
|
139
|
+
line: mutant.line,
|
|
140
|
+
column: mutant.column,
|
|
141
|
+
original: mutant.original,
|
|
142
|
+
replacement: mutant.replacement,
|
|
143
|
+
};
|
|
144
|
+
if (!parsesCleanly(workspace.ts, target.symbolFile, mutated)) {
|
|
145
|
+
mutants.push({ ...record, status: 'invalid', durationMs: 0 });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const result = await withText(mutated, timeout);
|
|
149
|
+
mutants.push({ ...record, status: outcomeToRun(result.outcome), durationMs: result.durationMs });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const { verdict, reason } = foldVerdict(wiring, mutants);
|
|
154
|
+
return {
|
|
155
|
+
invariantId: target.inv.id,
|
|
156
|
+
invariantHash: invariantIdentityHash(target.inv),
|
|
157
|
+
layer: target.inv.provenance?.layer ?? null,
|
|
158
|
+
sourceFile: target.inv.provenance?.sourceFile ?? null,
|
|
159
|
+
mode: target.inv.mode ?? 'advisory',
|
|
160
|
+
symbol: target.symbol,
|
|
161
|
+
symbolFile: target.symbolFile,
|
|
162
|
+
symbolFileHash: sha256Text(target.text),
|
|
163
|
+
tests: target.tests.map((test) => ({ path: test, contentHash: hashProjectFile(root, test) ?? 'sha256:missing' })),
|
|
164
|
+
baseline: { status: wiring.baseline, durationMs: baseline.durationMs },
|
|
165
|
+
wiring: { load: wiring.load ?? null, reach: wiring.reach ?? null },
|
|
166
|
+
mutants,
|
|
167
|
+
verdict,
|
|
168
|
+
reason,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Persist under `.ark/`, merging rows for invariants this run did not touch. */
|
|
173
|
+
function writeArtifact(root, artifact, focus) {
|
|
174
|
+
const absolute = path.join(root, INVARIANT_PROBE_ARTIFACT_PATH);
|
|
175
|
+
const dir = path.dirname(absolute);
|
|
176
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
177
|
+
const rootReal = fs.realpathSync(root);
|
|
178
|
+
const dirReal = fs.realpathSync(dir);
|
|
179
|
+
if (dirReal !== rootReal && !dirReal.startsWith(`${rootReal}${path.sep}`)) {
|
|
180
|
+
throw new Error(`${path.dirname(INVARIANT_PROBE_ARTIFACT_PATH)} resolves outside the project; refusing to write.`);
|
|
181
|
+
}
|
|
182
|
+
let rows = artifact.invariants;
|
|
183
|
+
if (focus) {
|
|
184
|
+
try {
|
|
185
|
+
const previous = readInvariantProbeArtifact(JSON.parse(fs.readFileSync(absolute, 'utf8')));
|
|
186
|
+
if (previous.ok) {
|
|
187
|
+
const ids = new Set(rows.map((row) => row.invariantId));
|
|
188
|
+
rows = [...previous.artifact.invariants.filter((row) => !ids.has(row.invariantId)), ...rows];
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
/* no previous artifact, or unreadable: this run's rows only */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const merged = buildInvariantProbeArtifact({
|
|
195
|
+
arkgateVersion: artifact.arkgateVersion,
|
|
196
|
+
runner: artifact.runner,
|
|
197
|
+
probedOn: artifact.probedOn,
|
|
198
|
+
rows,
|
|
199
|
+
});
|
|
200
|
+
const temp = path.join(dir, `.invariant-probe.${process.pid}.tmp`);
|
|
201
|
+
fs.writeFileSync(temp, `${JSON.stringify(merged, null, 2)}\n`);
|
|
202
|
+
fs.renameSync(temp, absolute);
|
|
203
|
+
return INVARIANT_PROBE_ARTIFACT_PATH;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function refuse(args, reasonCode, reason) {
|
|
207
|
+
if (args.json) {
|
|
208
|
+
console.log(JSON.stringify({ probeInvariants: { notAScore: true, status: 'refused', reasonCode, reason } }, null, 2));
|
|
209
|
+
} else {
|
|
210
|
+
console.error(`${reason}`);
|
|
211
|
+
}
|
|
212
|
+
process.exitCode = 2;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function printReport(root, payload) {
|
|
216
|
+
console.log(color.bold('Invariant probe (not a score)'));
|
|
217
|
+
console.log(color.dim(' Ran the covering tests in a temporary copy. Your files were not changed.'));
|
|
218
|
+
for (const row of payload.rows) {
|
|
219
|
+
const mark =
|
|
220
|
+
row.verdict === 'killed'
|
|
221
|
+
? color.green('caught')
|
|
222
|
+
: row.verdict === 'survived' || row.verdict === 'not-reached'
|
|
223
|
+
? color.red(row.verdict)
|
|
224
|
+
: color.dim(row.verdict);
|
|
225
|
+
console.log(` ${mark} ${row.line}`);
|
|
226
|
+
}
|
|
227
|
+
if (payload.truncated > 0) {
|
|
228
|
+
console.log(color.dim(` ${payload.truncated} more invariant(s) past the ${INVARIANT_PROBE_MAX_TARGETS}-target cap were not probed. Probe them one at a time with --probe-invariants=<id>.`));
|
|
229
|
+
}
|
|
230
|
+
if (payload.written) {
|
|
231
|
+
console.log(color.green(` wrote ${payload.written}. Commit it so CI sees the same evidence.`));
|
|
232
|
+
} else {
|
|
233
|
+
console.log(color.dim(` Report only. ${arkCommand(root, 'ark-check', '--probe-invariants --write')} saves ${INVARIANT_PROBE_ARTIFACT_PATH}; a fresh survived or not-reached row refuses promotion.`));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function runProbeInvariants(args, readConfig) {
|
|
238
|
+
const root = args.root;
|
|
239
|
+
let config;
|
|
240
|
+
try {
|
|
241
|
+
config = readConfig(root, args.config);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
refuse(args, 'PROBE_CONFIG_INVALID', error instanceof Error ? error.message : String(error));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const loaded = loadEffectiveArkRulesFromDisk(root, config);
|
|
247
|
+
if (loaded.errors?.length) {
|
|
248
|
+
refuse(args, 'PROBE_CONFIG_INVALID', 'ArkRules references failed to load, so no invariant can be probed.');
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const focus = typeof args.probeInvariants === 'string' ? args.probeInvariants : null;
|
|
252
|
+
const catalog = (loaded.arkRules.invariants ?? []).filter((inv) => !focus || inv.id === focus);
|
|
253
|
+
if (catalog.length === 0) {
|
|
254
|
+
refuse(
|
|
255
|
+
args,
|
|
256
|
+
'PROBE_NO_TARGETS',
|
|
257
|
+
focus
|
|
258
|
+
? `No declared invariant with id ${JSON.stringify(focus)}.`
|
|
259
|
+
: 'No ArkRules invariants are declared, so there is nothing to probe.'
|
|
260
|
+
);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const loadedTs = await loadTypeScript(root);
|
|
264
|
+
const ts = loadedTs?.ts;
|
|
265
|
+
if (!ts || typeof ts.createSourceFile !== 'function') {
|
|
266
|
+
refuse(args, 'PROBE_RUNNER_UNSUPPORTED', 'TypeScript could not be loaded, so the symbol cannot be located.');
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const governedRel = collectGovernedFiles(root, config).map((file) => normalize(path.relative(root, file)));
|
|
270
|
+
const evaluated = evaluateCatalogCoverage(root, config, { files: governedRel.map((p) => ({ path: p })) }, {
|
|
271
|
+
...loaded.arkRules,
|
|
272
|
+
invariants: catalog,
|
|
273
|
+
});
|
|
274
|
+
const coverageById = new Map(evaluated.rows.map((row) => [row.invariantId, row]));
|
|
275
|
+
const selected = catalog.slice(0, INVARIANT_PROBE_MAX_TARGETS);
|
|
276
|
+
const truncated = catalog.length - selected.length;
|
|
277
|
+
const symbolFiles = [...new Set(selected.map((inv) => coverageById.get(inv.id)?.symbolEvidenceFile).filter(Boolean))];
|
|
278
|
+
const importers = testImportersOf({ root, ts, config, governed: new Set(governedRel), targets: symbolFiles }).byTarget;
|
|
279
|
+
|
|
280
|
+
const rows = [];
|
|
281
|
+
const targets = [];
|
|
282
|
+
for (const inv of selected) {
|
|
283
|
+
const planned = planTarget({ root, ts, inv, coverageRow: coverageById.get(inv.id), inputs: evaluated.inputs, importers });
|
|
284
|
+
if (planned.row) rows.push(planned.row);
|
|
285
|
+
else targets.push(planned.target);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let runnerInfo = null;
|
|
289
|
+
if (targets.length > 0) {
|
|
290
|
+
const runners = [];
|
|
291
|
+
for (const target of targets) {
|
|
292
|
+
const runner = detectRunner({ root, tests: target.tests, override: args.runner });
|
|
293
|
+
if (!runner.ok) {
|
|
294
|
+
refuse(args, runner.reasonCode, `${target.inv.id}: ${runner.reason}`);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
runners.push(runner);
|
|
298
|
+
}
|
|
299
|
+
sweepStaleProbeWorkspaces();
|
|
300
|
+
const workspace = createProbeWorkspace(root);
|
|
301
|
+
if (!workspace.ok) {
|
|
302
|
+
refuse(
|
|
303
|
+
args,
|
|
304
|
+
workspace.reasonCode,
|
|
305
|
+
`The project is too large to copy for the probe (${workspace.files} files, ${Math.round(workspace.bytes / 1048576)} MiB; the limits are 20000 files and 256 MiB).`
|
|
306
|
+
);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
workspace.ts = ts;
|
|
310
|
+
try {
|
|
311
|
+
for (let index = 0; index < targets.length; index += 1) {
|
|
312
|
+
rows.push(await probeTarget(root, workspace, runners[index], targets[index]));
|
|
313
|
+
}
|
|
314
|
+
} finally {
|
|
315
|
+
workspace.cleanup();
|
|
316
|
+
}
|
|
317
|
+
runnerInfo = { id: runners[0].id, version: runners[0].version ?? null };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const artifact = buildInvariantProbeArtifact({
|
|
321
|
+
arkgateVersion: arkPackageVersion() ?? 'unknown',
|
|
322
|
+
runner: runnerInfo,
|
|
323
|
+
probedOn: new Date().toISOString().slice(0, 10),
|
|
324
|
+
rows,
|
|
325
|
+
});
|
|
326
|
+
let written = null;
|
|
327
|
+
if (args.write) {
|
|
328
|
+
try {
|
|
329
|
+
written = writeArtifact(root, artifact, focus);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
refuse(args, 'PROBE_WRITE_FAILED', error instanceof Error ? error.message : String(error));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const payload = {
|
|
336
|
+
notAScore: true,
|
|
337
|
+
status: 'complete',
|
|
338
|
+
runner: artifact.runner,
|
|
339
|
+
rows: artifact.invariants.map((row) => ({ ...row, line: probeRowLine(row) })),
|
|
340
|
+
totals: artifact.totals,
|
|
341
|
+
truncated,
|
|
342
|
+
...(written ? { written } : {}),
|
|
343
|
+
};
|
|
344
|
+
if (args.json) console.log(JSON.stringify({ probeInvariants: payload }, null, 2));
|
|
345
|
+
else printReport(root, payload);
|
|
346
|
+
process.exitCode = probeExitCode(artifact.invariants);
|
|
347
|
+
}
|
|
@@ -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
|
+
}
|