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.
- package/CHANGELOG.md +95 -0
- package/README.md +2 -2
- package/bin/ark-check-runtime.mjs +17 -14
- package/bin/ark-layer-match.mjs +115 -13
- package/bin/ark-mcp-runtime.mjs +36 -31
- package/bin/ark-shared.mjs +4 -23
- 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 +4 -4
- package/bin/lib/ambient-state.mjs +9 -30
- package/bin/lib/analysis-engine.mjs +14 -14
- package/bin/lib/architecture-scan.mjs +11 -9
- package/bin/lib/ark-order-doctor.mjs +3 -28
- package/bin/lib/ark-order-facts.mjs +6 -99
- package/bin/lib/ark-order-types.mjs +1 -2
- package/bin/lib/ark-run-doctor.mjs +3 -28
- package/bin/lib/ark-run-facts.mjs +30 -40
- package/bin/lib/ark-run-sensors.mjs +1 -1
- package/bin/lib/arkrule-file-hints.mjs +2 -47
- package/bin/lib/arkrules-contract.mjs +124 -133
- package/bin/lib/arkrules-sensors.mjs +7 -5
- package/bin/lib/check-args.mjs +63 -1
- package/bin/lib/ci-and-commands.mjs +8 -72
- package/bin/lib/ci-merge-boundary.mjs +1 -1
- package/bin/lib/class-source-scan.mjs +5 -5
- package/bin/lib/clone-detection.mjs +474 -0
- package/bin/lib/codex-home.mjs +1 -1
- package/bin/lib/config-contract-slices.mjs +44 -6
- package/bin/lib/config-contract.mjs +40 -117
- package/bin/lib/config-extras.mjs +16 -22
- package/bin/lib/contract-smells.mjs +33 -15
- package/bin/lib/core-ratchet.mjs +0 -2
- package/bin/lib/dashboard-cli.mjs +2 -2
- package/bin/lib/deep-module-coach.mjs +2 -4
- package/bin/lib/deploy-path.mjs +13 -38
- package/bin/lib/design-smells.mjs +2 -2
- package/bin/lib/diagnostic-catalog.mjs +8 -1
- package/bin/lib/doctor-advisories.mjs +55 -9
- package/bin/lib/doctor-green-cite.mjs +1 -1
- package/bin/lib/doctor-human.mjs +1 -1
- package/bin/lib/doctor-next-actions.mjs +2 -2
- package/bin/lib/doctor-plan.mjs +4 -6
- package/bin/lib/duplication-io.mjs +572 -0
- package/bin/lib/effective-contract-load.mjs +181 -44
- 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 +7 -3
- package/bin/lib/flat-parent-importers.mjs +5 -3
- package/bin/lib/gate-files.mjs +30 -89
- package/bin/lib/github-enforcement.mjs +165 -28
- package/bin/lib/golden-pattern.mjs +1 -1
- package/bin/lib/hook-templates.mjs +16 -16
- 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/improvement-compass-doctor.mjs +1 -24
- package/bin/lib/invariant-coverage-io.mjs +4 -3
- package/bin/lib/invariant-coverage.mjs +26 -0
- 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 +4 -5
- package/bin/lib/mcp-hook-payload.mjs +4 -4
- 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 +3 -3
- package/bin/lib/parse-health.mjs +1 -1
- package/bin/lib/persistence-write-hint.mjs +33 -8
- package/bin/lib/pin-dependent-gates.mjs +2 -2
- package/bin/lib/policy-delta-io.mjs +60 -12
- package/bin/lib/post-green-path.mjs +1 -1
- package/bin/lib/prepare-change.mjs +3 -9
- package/bin/lib/prepare-write.mjs +1 -2
- package/bin/lib/presets.mjs +56 -68
- package/bin/lib/product-copy.mjs +1 -6
- package/bin/lib/project-root.mjs +43 -85
- package/bin/lib/resolved-candidate-facts.mjs +90 -62
- package/bin/lib/rules-under-contract.mjs +72 -8
- package/bin/lib/scan-files.mjs +4 -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 +1 -1
- package/bin/lib/start-install-recovery.mjs +1 -1
- package/bin/lib/start-preview.mjs +1 -1
- package/bin/lib/states-transitions-presence.mjs +3 -3
- package/bin/lib/status-command.mjs +2 -18
- package/bin/lib/status-manifest.mjs +18 -26
- package/bin/lib/status-transition-catalog.mjs +0 -6
- package/bin/lib/suggestions.mjs +3 -3
- package/bin/lib/team-parliament-io.mjs +3 -17
- package/bin/lib/team-parliament.mjs +15 -9
- 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 +2 -2
- package/dist/chunk-G3JRBJGH.js +16 -0
- package/dist/chunk-R3AJ5RSZ.cjs +16 -0
- package/dist/{configTypes-BJbaeoj4.d.cts → configTypes-GPvvdleM.d.cts} +12 -1
- package/dist/{configTypes-BJbaeoj4.d.ts → configTypes-GPvvdleM.d.ts} +12 -1
- package/dist/eslint/index.cjs +11 -11
- package/dist/eslint/index.d.cts +10 -1
- package/dist/eslint/index.d.ts +10 -1
- package/dist/eslint/index.js +11 -11
- package/dist/index.cjs +34 -34
- package/dist/index.d.cts +587 -515
- package/dist/index.d.ts +587 -515
- package/dist/index.js +34 -34
- package/dist/nestjs/index.cjs +1 -1
- package/dist/nestjs/index.d.cts +1 -1
- package/dist/nestjs/index.d.ts +1 -1
- package/dist/nestjs/index.js +1 -1
- package/dist/order/index.cjs +1 -1
- package/dist/order/index.js +1 -1
- package/dist/runtime/index.cjs +12 -12
- package/dist/runtime/index.d.cts +174 -136
- package/dist/runtime/index.d.ts +174 -136
- package/dist/runtime/index.js +12 -12
- package/dist/{types-DRinG-vT.d.cts → types-CnauMQQL.d.cts} +33 -25
- package/dist/{types-DRinG-vT.d.ts → types-CnauMQQL.d.ts} +33 -25
- package/docs/README.md +3 -1
- package/docs/agent-guide.md +140 -6
- package/docs/ai-gates.md +15 -1
- package/docs/configuration.md +40 -11
- package/docs/diagnostics.md +119 -3
- package/docs/package-surface.md +4 -1
- package/docs/product-voice.md +7 -1
- package/package.json +11 -2
- package/schemas/ark.config.schema.json +31 -3
- package/schemas/ark.invariant-probe.schema.json +368 -0
- package/server.json +2 -2
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +1 -0
- 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-place/SKILL.md +8 -0
- package/templates/skills/ark-adopt.md +1 -0
- 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-place.md +8 -0
- package/bin/lib/ark-order-error.mjs +0 -18
- package/bin/lib/ark-order-invariants.mjs +0 -388
- package/bin/lib/stable-hash.mjs +0 -90
- package/dist/chunk-MBXCFLO4.js +0 -16
- package/dist/chunk-RNQHRDCQ.cjs +0 -16
package/bin/lib/html-report.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
resolveOperatingMode,
|
|
12
12
|
} from '../ark-shared.mjs';
|
|
13
13
|
import { collectAdoptionGaps, arkCheckCommand } from './agent-gates.mjs';
|
|
14
|
-
import {
|
|
14
|
+
import { LEFTOVER_DESIGN_LABEL } from './product-copy.mjs';
|
|
15
15
|
import { CORE_LAYER_NAMES } from './core-layers.mjs';
|
|
16
16
|
import {
|
|
17
17
|
renderBaselineSignalLegend,
|
|
@@ -572,7 +572,7 @@ export function renderHtmlReport({
|
|
|
572
572
|
const designSmells = Array.isArray(depth.designSmells) ? depth.designSmells : [];
|
|
573
573
|
const designWeakBadge =
|
|
574
574
|
designFitness?.designWeak === true
|
|
575
|
-
? ` <span class="badge design" title="Import rules can be green while leftover design work remains. Not a FAIL.">${
|
|
575
|
+
? ` <span class="badge design" title="Import rules can be green while leftover design work remains. Not a FAIL.">${LEFTOVER_DESIGN_LABEL}</span>`
|
|
576
576
|
: '';
|
|
577
577
|
const designStripHtml =
|
|
578
578
|
renderDesignDepthStrip({
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact importer index for doctor and report (ADR 0037 D1).
|
|
3
|
+
*
|
|
4
|
+
* The scan releases the resolved facts right after the verdict (memory). This
|
|
5
|
+
* keeps only what the files-nothing-imports advisory and the flat-parent
|
|
6
|
+
* suggestion read: governed paths, incoming edge counts, the edge list as
|
|
7
|
+
* typed-array pairs, and which export names each target is imported by. It is
|
|
8
|
+
* a projection of facts that already exist — never an input to the verdict.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Unresolved project-looking specifiers kept for `maybe-unresolved` matching. */
|
|
12
|
+
const MAX_UNRESOLVED_SPECIFIERS = 200;
|
|
13
|
+
/** A bare specifier that looks like a project alias rather than a package. */
|
|
14
|
+
const ALIAS_LIKE = /^(?:[@~#$]\/|[~#$])/;
|
|
15
|
+
|
|
16
|
+
function looksLikeProjectSpecifier(specifier) {
|
|
17
|
+
return typeof specifier === 'string' && (specifier.startsWith('.') || ALIAS_LIKE.test(specifier));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{ files?: Array<{ path: string, parseStatus?: string }>, dependencies?: Array<object>, completeness?: string }} facts
|
|
22
|
+
* @returns {{
|
|
23
|
+
* files: string[],
|
|
24
|
+
* importerCount: Uint32Array,
|
|
25
|
+
* edges: Int32Array,
|
|
26
|
+
* namedUse: Map<number, Set<string> | '*'>,
|
|
27
|
+
* unresolved: Array<{ from: string, specifier: string }>,
|
|
28
|
+
* unresolvedTotal: number,
|
|
29
|
+
* dynamicSites: number,
|
|
30
|
+
* invalidFiles: number,
|
|
31
|
+
* completeness: string,
|
|
32
|
+
* }}
|
|
33
|
+
*/
|
|
34
|
+
export function projectImporterIndex(facts) {
|
|
35
|
+
const files = [];
|
|
36
|
+
const indexOf = new Map();
|
|
37
|
+
let invalidFiles = 0;
|
|
38
|
+
for (const file of Array.isArray(facts?.files) ? facts.files : []) {
|
|
39
|
+
if (typeof file?.path !== 'string' || indexOf.has(file.path)) continue;
|
|
40
|
+
indexOf.set(file.path, files.length);
|
|
41
|
+
files.push(file.path);
|
|
42
|
+
if (file.parseStatus === 'invalid') invalidFiles += 1;
|
|
43
|
+
}
|
|
44
|
+
const importerCount = new Uint32Array(files.length);
|
|
45
|
+
const pairs = [];
|
|
46
|
+
const namedUse = new Map();
|
|
47
|
+
const unresolved = [];
|
|
48
|
+
let unresolvedTotal = 0;
|
|
49
|
+
let dynamicSites = 0;
|
|
50
|
+
for (const dep of Array.isArray(facts?.dependencies) ? facts.dependencies : []) {
|
|
51
|
+
if (dep?.resolution === 'dynamic') {
|
|
52
|
+
dynamicSites += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (dep?.resolution === 'unresolved') {
|
|
56
|
+
if (!looksLikeProjectSpecifier(dep.specifier)) continue;
|
|
57
|
+
unresolvedTotal += 1;
|
|
58
|
+
if (unresolved.length < MAX_UNRESOLVED_SPECIFIERS) {
|
|
59
|
+
unresolved.push({ from: dep.from, specifier: dep.specifier });
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (dep?.resolution !== 'resolved-project') continue;
|
|
64
|
+
const from = indexOf.get(dep.from);
|
|
65
|
+
const to = indexOf.get(dep.target);
|
|
66
|
+
if (from === undefined || to === undefined || from === to) continue;
|
|
67
|
+
pairs.push(from, to);
|
|
68
|
+
importerCount[to] += 1;
|
|
69
|
+
const previous = namedUse.get(to);
|
|
70
|
+
if (previous === '*') continue;
|
|
71
|
+
// Default, namespace, side-effect, dynamic, require and `export *` carry no
|
|
72
|
+
// names: any export may be used through them.
|
|
73
|
+
const named = Array.isArray(dep.namedBindings) && dep.namedBindings.length > 0 ? dep.namedBindings : null;
|
|
74
|
+
if (!named) {
|
|
75
|
+
namedUse.set(to, '*');
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const set = previous ?? new Set();
|
|
79
|
+
for (const name of named) set.add(name);
|
|
80
|
+
namedUse.set(to, set);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
files,
|
|
84
|
+
importerCount,
|
|
85
|
+
edges: Int32Array.from(pairs),
|
|
86
|
+
namedUse,
|
|
87
|
+
unresolved,
|
|
88
|
+
unresolvedTotal,
|
|
89
|
+
dynamicSites,
|
|
90
|
+
invalidFiles,
|
|
91
|
+
completeness: typeof facts?.completeness === 'string' ? facts.completeness : 'unavailable',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* target path → every governed file that imports it (the flat-parent graph).
|
|
97
|
+
* @param {ReturnType<typeof projectImporterIndex>} index
|
|
98
|
+
*/
|
|
99
|
+
export function importerGraphFromIndex(index) {
|
|
100
|
+
const graph = new Map();
|
|
101
|
+
const { files, edges } = index;
|
|
102
|
+
for (let i = 0; i + 1 < edges.length; i += 2) {
|
|
103
|
+
const target = files[edges[i + 1]];
|
|
104
|
+
let set = graph.get(target);
|
|
105
|
+
if (!set) {
|
|
106
|
+
set = new Set();
|
|
107
|
+
graph.set(target, set);
|
|
108
|
+
}
|
|
109
|
+
set.add(files[edges[i]]);
|
|
110
|
+
}
|
|
111
|
+
return graph;
|
|
112
|
+
}
|
|
@@ -2,11 +2,7 @@
|
|
|
2
2
|
* Doctor adapter for the Domain improvement compass (notAScore projection).
|
|
3
3
|
* Keeps doctor-plan.mjs inside its module budget; pure assembly only.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
6
|
-
buildImprovementCompass,
|
|
7
|
-
formatImprovementCompassDoctorLines,
|
|
8
|
-
primaryImprovementCompassNextAction,
|
|
9
|
-
} from './improvement-compass.mjs';
|
|
5
|
+
import { buildImprovementCompass } from './improvement-compass.mjs';
|
|
10
6
|
|
|
11
7
|
/**
|
|
12
8
|
* @param {{
|
|
@@ -92,22 +88,3 @@ export function buildDoctorImprovementCompass(input = {}) {
|
|
|
92
88
|
});
|
|
93
89
|
}
|
|
94
90
|
|
|
95
|
-
export {
|
|
96
|
-
formatImprovementCompassDoctorLines,
|
|
97
|
-
primaryImprovementCompassNextAction,
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Human doctor section (never a score bar).
|
|
102
|
-
* @param {import('./improvement-compass.mjs').ImprovementCompass} compass
|
|
103
|
-
* @param {{ line: Function, warn: string, ok: string, color: { bold: Function } }} io
|
|
104
|
-
*/
|
|
105
|
-
export function printImprovementCompassSection(compass, io) {
|
|
106
|
-
const { line, warn, ok, color } = io;
|
|
107
|
-
console.log('');
|
|
108
|
-
console.log(color.bold('Improvement compass (not a score)'));
|
|
109
|
-
const mark = compass.topResidual.length > 0 ? warn : ok;
|
|
110
|
-
for (const text of formatImprovementCompassDoctorLines(compass)) {
|
|
111
|
-
line(mark, text);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
@@ -10,7 +10,7 @@ const DEFAULT_TEST_NAME_RE =
|
|
|
10
10
|
/\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$|\/__tests__\/|\/tests?\//i;
|
|
11
11
|
|
|
12
12
|
/** Default max files to load for coverage evidence (budget). Config: `coverage.maxFiles`. */
|
|
13
|
-
|
|
13
|
+
const DEFAULT_MAX_COVERAGE_FILES = 400;
|
|
14
14
|
/**
|
|
15
15
|
* Hard ceiling on `coverage.maxFiles`. The config validator implements no
|
|
16
16
|
* `maximum` keyword for integers, so a schema bound would be accepted and then
|
|
@@ -38,11 +38,12 @@ function isPathInsideRoot(root, absolute) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Minimal glob match for testGlobs
|
|
41
|
+
* Minimal glob match for testGlobs and ArkRules `appliesTo` fileHints
|
|
42
|
+
* (double-star slash = zero path segments).
|
|
42
43
|
* @param {string} glob
|
|
43
44
|
* @param {string} file
|
|
44
45
|
*/
|
|
45
|
-
function matchSimpleGlob(glob, file) {
|
|
46
|
+
export function matchSimpleGlob(glob, file) {
|
|
46
47
|
const pattern = String(glob || '').replace(/\\/g, '/');
|
|
47
48
|
const target = String(file || '').replace(/\\/g, '/');
|
|
48
49
|
if (!pattern) return false;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { findClassDeclarations, findMemberContainers, objectLiteralMemberNames, scanClassMembers, } from './class-source-scan.mjs';
|
|
12
|
+
import { probeRefusalReason, verdictRefusesPromotion } from './invariant-probe.mjs';
|
|
12
13
|
/** Adopted + catalogued invariants, but no declared tests path (P2 §10). */
|
|
13
14
|
export const INVARIANT_TESTS_PATH_RULE_ID = 'INVARIANT_TESTS_PATH_MISSING';
|
|
14
15
|
export const INVARIANT_TESTS_PATH_MESSAGE = 'This project is adopted and has domain invariants, but ark.config.json does not name a real tests path. Add coverage.testGlobs or coverage.coverageRoots pointing at the folder where those tests live, then re-run. Without that path, coverage is an empty checkbox.';
|
|
@@ -672,6 +673,15 @@ export function canPromoteInvariant(coverage) {
|
|
|
672
673
|
reason: `Declare coverage.coverageRoots in ark.config.json before promoting ${coverage.invariantId} to enforced. Without that, ArkGate cannot tell whether a covering test is one the runner executes.`,
|
|
673
674
|
};
|
|
674
675
|
}
|
|
676
|
+
// ADR 0039 D2: probe evidence can only subtract. A fresh survived or
|
|
677
|
+
// not-reached row refuses; stale, killed, inconclusive or absent changes nothing.
|
|
678
|
+
if (coverage.probe?.fresh === true && verdictRefusesPromotion(coverage.probe.verdict)) {
|
|
679
|
+
return {
|
|
680
|
+
ok: false,
|
|
681
|
+
reason: probeRefusalReason(coverage.invariantId, coverage.probe),
|
|
682
|
+
blocker: 'probe-survived',
|
|
683
|
+
};
|
|
684
|
+
}
|
|
675
685
|
return { ok: true, reason: `Invariant ${coverage.invariantId} has coverage evidence.` };
|
|
676
686
|
}
|
|
677
687
|
function nonEmptyPathStrings(value) {
|
|
@@ -780,3 +790,19 @@ export function collectMissingCoverageRootsFindings(input) {
|
|
|
780
790
|
},
|
|
781
791
|
];
|
|
782
792
|
}
|
|
793
|
+
/**
|
|
794
|
+
* Test files whose describe/it/test/context title names `invariant.id` — the
|
|
795
|
+
* same title matcher coverage uses. Sorted, deduplicated. The probe (ADR 0039)
|
|
796
|
+
* runs these as covering tests; coverage itself never runs anything.
|
|
797
|
+
*/
|
|
798
|
+
export function testFilesNamingInvariant(invariant, files) {
|
|
799
|
+
const out = new Set();
|
|
800
|
+
for (const file of files.testFiles ?? []) {
|
|
801
|
+
const content = files.fileContents[file];
|
|
802
|
+
if (!content)
|
|
803
|
+
continue;
|
|
804
|
+
if (matchTestTitle(content, invariant.id) !== undefined)
|
|
805
|
+
out.add(normalizePath(file));
|
|
806
|
+
}
|
|
807
|
+
return [...out].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
808
|
+
}
|
|
@@ -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
|
+
}
|