arkgate 4.8.3 → 4.8.4
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 +242 -0
- package/README.md +10 -3
- package/bin/ark-check-runtime.mjs +340 -5
- package/bin/ark-layer-match.mjs +170 -13
- package/bin/ark-mcp-runtime.mjs +9 -2
- package/bin/lib/analysis-completeness.mjs +86 -0
- package/bin/lib/analysis-engine.mjs +5 -5
- package/bin/lib/architecture-scan.mjs +2 -0
- package/bin/lib/arkrules-contract.mjs +8 -1
- package/bin/lib/check-args.mjs +66 -0
- package/bin/lib/config-contract.mjs +26 -0
- package/bin/lib/design-smells.mjs +85 -0
- package/bin/lib/diagnostic-catalog.mjs +6 -1
- package/bin/lib/first-run-help.mjs +12 -0
- package/bin/lib/invariant-coverage-io.mjs +175 -19
- package/bin/lib/invariant-coverage.mjs +110 -7
- package/bin/lib/literal-path-drift-io.mjs +569 -0
- package/bin/lib/literal-path-drift.mjs +761 -0
- package/bin/lib/policy-delta-io.mjs +5 -0
- package/bin/lib/remediation.mjs +15 -0
- package/bin/lib/rules-under-contract.mjs +5 -0
- package/bin/lib/scan-files.mjs +54 -0
- package/bin/lib/sensor-promote-cli.mjs +372 -0
- package/bin/lib/sensor-promote-io.mjs +246 -0
- package/bin/lib/sensor-promotion.mjs +363 -0
- package/dist/{configTypes-dNJ2C0yx.d.ts → configTypes-dy5PfTqS.d.ts} +31 -0
- package/dist/{diagnosticCatalog-C5GgeyEE.d.ts → diagnosticCatalog-DgTs0abp.d.ts} +75 -7
- package/dist/eslint/index.cjs +6 -6
- package/dist/eslint/index.d.ts +34 -1
- package/dist/eslint/index.js +6 -6
- package/dist/index.cjs +32 -32
- package/dist/index.d.ts +65 -4
- package/dist/index.js +29 -29
- package/dist/nestjs/index.cjs +5 -5
- package/dist/nestjs/index.d.ts +3 -3
- package/dist/nestjs/index.js +5 -5
- package/dist/runtime/index.cjs +15 -15
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +15 -15
- package/dist/{types-dK24fDZa.d.ts → types-BuM8WNqe.d.ts} +1 -1
- package/dist/{types-DeK7SYGC.d.ts → types-D95drJ3_.d.ts} +1 -1
- package/docs/README.md +1 -1
- package/docs/agent-guide.md +182 -0
- package/docs/configuration.md +77 -1
- package/docs/develop.md +1 -0
- package/docs/diagnostics.md +70 -1
- package/docs/package-surface.md +32 -2
- package/package.json +2 -2
- package/schemas/ark.config.schema.json +63 -0
- package/server.json +3 -3
- package/templates/agent-skills/ark-adopt/SKILL.md +5 -0
- package/templates/agent-skills/ark-coverage/SKILL.md +1 -0
- package/templates/skills/ark-adopt.md +5 -0
- package/templates/skills/ark-coverage.md +1 -0
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { analyzePolicyDelta } from './analysis-engine.mjs';
|
|
5
5
|
import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
|
|
6
6
|
import {
|
|
7
|
+
coverageOptionsFromConfig,
|
|
7
8
|
invariantIdsFromCatalog,
|
|
8
9
|
loadInvariantCoverageInputs,
|
|
9
10
|
} from './invariant-coverage-io.mjs';
|
|
@@ -180,12 +181,16 @@ export function analyzePolicyTransition({
|
|
|
180
181
|
if ((candidateArkRules?.invariants?.length ?? 0) > 0) {
|
|
181
182
|
const coverageInputs = loadInvariantCoverageInputs(root, { files: [] }, {
|
|
182
183
|
invariantIds: invariantIdsFromCatalog(candidateArkRules),
|
|
184
|
+
...coverageOptionsFromConfig(candidateConfig),
|
|
183
185
|
});
|
|
184
186
|
const evaluated = evaluateInvariantCoverage({
|
|
185
187
|
arkRules: candidateArkRules,
|
|
186
188
|
fileContents: coverageInputs.fileContents,
|
|
187
189
|
testFiles: coverageInputs.testFiles,
|
|
188
190
|
testGlobsMissing: coverageInputs.testGlobsMissing,
|
|
191
|
+
// No coverageStats / coverageRoots: this caller reads coverage ROWS and
|
|
192
|
+
// drops the violations, and both only shape violation messages. Passing
|
|
193
|
+
// them would look like wiring while changing nothing observable here.
|
|
189
194
|
coverageBudgetExhausted: coverageInputs.coverageBudgetExhausted === true,
|
|
190
195
|
});
|
|
191
196
|
candidateInvariantCoverage = evaluated.coverage;
|
package/bin/lib/remediation.mjs
CHANGED
|
@@ -205,8 +205,16 @@ export function deterministicNextAction(violation) {
|
|
|
205
205
|
return 'Extract the shared dependency into a third module, test at the public interface, then preflight again.';
|
|
206
206
|
case 'RAW_EVENT_PUBLISH':
|
|
207
207
|
return 'Publish through a registered intent creator, then run Ark again.';
|
|
208
|
+
case 'LITERAL_PATH_DRIFT':
|
|
209
|
+
return typeof violation.target === 'string' && violation.target.length > 0
|
|
210
|
+
? `Rewrite the literal to ${violation.target}, or run \`arkgate-check --path-drift --base-ref <ref> --write\` to apply every anchored replacement.`
|
|
211
|
+
: 'Rewrite the literal to the rename destination, or run `arkgate-check --path-drift --base-ref <ref> --write` to apply every anchored replacement.';
|
|
212
|
+
case 'LITERAL_PATH_UNRESOLVED':
|
|
213
|
+
return 'Read the candidate and decide: fix the path, or leave it. Advisory — with no rename to anchor it there is no destination to propose, so --write never touches it.';
|
|
208
214
|
case 'PUBLISH_MISSING_SOURCE':
|
|
209
215
|
return 'Add metadata.source to the publish call, then run Ark again.';
|
|
216
|
+
case 'INVARIANT_COVERAGE_OUTSIDE_ROOTS':
|
|
217
|
+
return `Move the covering test under a declared coverage root, or add its root to coverage.coverageRoots in ark.config.json, then run Ark again.`;
|
|
210
218
|
case 'ARKRULE_STRUCTURE':
|
|
211
219
|
case 'ARKRULE_INVARIANT':
|
|
212
220
|
case 'INVARIANT_UNCOVERED':
|
|
@@ -348,6 +356,7 @@ export function classifyRemediation(violation) {
|
|
|
348
356
|
if (ruleId === 'ARKRULE_STRUCTURE' ||
|
|
349
357
|
ruleId === 'ARKRULE_INVARIANT' ||
|
|
350
358
|
ruleId === 'INVARIANT_UNCOVERED' ||
|
|
359
|
+
ruleId === 'INVARIANT_COVERAGE_OUTSIDE_ROOTS' ||
|
|
351
360
|
(typeof ruleId === 'string' && ruleId.startsWith('ARKRULE_'))) {
|
|
352
361
|
return {
|
|
353
362
|
class: 'judgment',
|
|
@@ -472,6 +481,12 @@ export function enrichViolationWithFixClass(violation) {
|
|
|
472
481
|
enriched.enthusiastHint =
|
|
473
482
|
'Reference that intent from a layer allowed to know about it — usually an adapter or application layer, not the domain core.';
|
|
474
483
|
break;
|
|
484
|
+
case 'INVARIANT_COVERAGE_OUTSIDE_ROOTS':
|
|
485
|
+
enriched.fixClass = 'review-contract';
|
|
486
|
+
enriched.effort = 'small';
|
|
487
|
+
enriched.enthusiastHint =
|
|
488
|
+
'The covering test lives where the project says its runner does not go. Move it, or declare that root in coverage.coverageRoots.';
|
|
489
|
+
break;
|
|
475
490
|
case 'ARKRULE_STRUCTURE':
|
|
476
491
|
case 'ARKRULE_INVARIANT':
|
|
477
492
|
case 'INVARIANT_UNCOVERED':
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
|
|
8
8
|
import { evaluateInvariantCoverage } from './invariant-coverage.mjs';
|
|
9
9
|
import {
|
|
10
|
+
coverageOptionsFromConfig,
|
|
10
11
|
invariantIdsFromCatalog,
|
|
11
12
|
loadInvariantCoverageInputs,
|
|
12
13
|
} from './invariant-coverage-io.mjs';
|
|
@@ -94,6 +95,7 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
|
|
|
94
95
|
invariants > 0
|
|
95
96
|
? loadInvariantCoverageInputs(root, facts ?? { files: [] }, {
|
|
96
97
|
invariantIds: invariantIdsFromCatalog(loaded.arkRules),
|
|
98
|
+
...coverageOptionsFromConfig(config),
|
|
97
99
|
})
|
|
98
100
|
: { fileContents: {}, testFiles: [], testGlobsMissing: false };
|
|
99
101
|
const coverage = evaluateInvariantCoverage({
|
|
@@ -101,6 +103,9 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
|
|
|
101
103
|
fileContents: coverageInputs.fileContents,
|
|
102
104
|
testFiles: coverageInputs.testFiles,
|
|
103
105
|
testGlobsMissing: coverageInputs.testGlobsMissing,
|
|
106
|
+
// No coverageStats / coverageRoots: this caller reads coverage ROWS and
|
|
107
|
+
// drops the violations, and both only shape violation messages. Passing
|
|
108
|
+
// them would look like wiring while changing nothing observable here.
|
|
104
109
|
coverageBudgetExhausted: coverageInputs.coverageBudgetExhausted === true,
|
|
105
110
|
});
|
|
106
111
|
const covById = new Map(
|
package/bin/lib/scan-files.mjs
CHANGED
|
@@ -136,3 +136,57 @@ export function collectGovernedFiles(root, config, options = {}) {
|
|
|
136
136
|
export function normalize(value) {
|
|
137
137
|
return value.split(path.sep).join('/');
|
|
138
138
|
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Default ceiling for `countUngovernedSourceFiles`. The caller needs "does this tree
|
|
142
|
+
* hold source at all", not a census, so stopping early keeps the probe off the hot path.
|
|
143
|
+
*/
|
|
144
|
+
export const UNGOVERNED_PROBE_CAP = 200;
|
|
145
|
+
|
|
146
|
+
/** Tooling configs (vite.config.ts, eslint.config.js …) are not the product source a contract governs. */
|
|
147
|
+
export const TOOLING_CONFIG_FILE_NAME = /\.config\.[cm]?[jt]sx?$/i;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Count governable source files under `root` that the contract's own scope cannot hide.
|
|
151
|
+
*
|
|
152
|
+
* Deliberately NOT `collectGovernedFiles(root, { ...config, include: ['.'] })`. That
|
|
153
|
+
* variant keeps `config.exclude`, so `exclude: ["**"]` makes the tree look empty — the
|
|
154
|
+
* contract under suspicion would get to answer the question about itself, and a green
|
|
155
|
+
* over zero governed files comes back through the side door.
|
|
156
|
+
*
|
|
157
|
+
* Also deliberately narrow, so the count is evidence and not noise:
|
|
158
|
+
* - dot-directories are skipped (`.git` fan-out is not source, and walking it is expensive);
|
|
159
|
+
* - `isSkippedSourceDir` names are skipped, matching the governed walk;
|
|
160
|
+
* - symlinks are never followed — a link is not proof this tree holds source, and following
|
|
161
|
+
* one can escape the root and turn a diagnostic into a crash;
|
|
162
|
+
* - `*.config.*` files are skipped: a polyglot or TS-less repo whose only TS/JS is
|
|
163
|
+
* `vite.config.ts` has no product source here, and must not be told otherwise;
|
|
164
|
+
* - unreadable directories are skipped rather than thrown: this is a probe, not a gate.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} root
|
|
167
|
+
* @param {number} [cap]
|
|
168
|
+
* @returns {number} source files found, capped at `cap`
|
|
169
|
+
*/
|
|
170
|
+
export function countUngovernedSourceFiles(root, cap = UNGOVERNED_PROBE_CAP) {
|
|
171
|
+
let count = 0;
|
|
172
|
+
const stack = [root];
|
|
173
|
+
while (stack.length > 0 && count < cap) {
|
|
174
|
+
const dir = stack.pop();
|
|
175
|
+
let entries;
|
|
176
|
+
try {
|
|
177
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
178
|
+
} catch {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
for (const entry of entries) {
|
|
182
|
+
if (count >= cap) break;
|
|
183
|
+
if (entry.name.startsWith('.')) continue;
|
|
184
|
+
if (entry.isDirectory()) {
|
|
185
|
+
if (!isSkippedSourceDir(entry.name)) stack.push(path.join(dir, entry.name));
|
|
186
|
+
} else if (entry.isFile() && isGovernableSourceFile(entry.name)) {
|
|
187
|
+
if (!TOOLING_CONFIG_FILE_NAME.test(entry.name)) count += 1;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return count;
|
|
192
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation and orchestration for `ark-check --sensors` and `--promote`.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the entry so the one-shot runtime stays orchestration-only
|
|
5
|
+
* (arkCheckEntrySlim). The pure projection lives in `src/domain/sensorPromotion.ts`
|
|
6
|
+
* (generated to `./sensor-promotion.mjs`) and the filesystem work in the
|
|
7
|
+
* hand-written `./sensor-promote-io.mjs`; this file owns only what the terminal
|
|
8
|
+
* and the exit code need.
|
|
9
|
+
*/
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { arkCommand } from '../ark-shared.mjs';
|
|
13
|
+
import { collectGovernedFiles, normalize } from './scan-files.mjs';
|
|
14
|
+
|
|
15
|
+
// Same shape and the same TTY test as the entry's own `color`: this module is
|
|
16
|
+
// the only other writer to that terminal, and a second copy is cheaper than
|
|
17
|
+
// exporting the entry's internals into a cycle.
|
|
18
|
+
const useColor = process.stderr.isTTY && !process.env.NO_COLOR;
|
|
19
|
+
const color = {
|
|
20
|
+
red: (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
|
|
21
|
+
yellow: (s) => (useColor ? `\x1b[33m${s}\x1b[0m` : s),
|
|
22
|
+
green: (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
|
|
23
|
+
dim: (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
|
|
24
|
+
bold: (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A rule id or a source path comes out of the project's own ArkRules JSON and
|
|
29
|
+
* is about to be printed to a terminal. A control character there can repaint
|
|
30
|
+
* or erase the lines above it — on a fork PR, the branch under analysis would
|
|
31
|
+
* then control what the promotability report appears to say.
|
|
32
|
+
*/
|
|
33
|
+
function renderPath(value) {
|
|
34
|
+
return String(value).replace(/[\u0000-\u001f\u007f]/g, (ch) =>
|
|
35
|
+
`\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Promotion preview — what enforcing a rule would actually cost.
|
|
41
|
+
*
|
|
42
|
+
* The old loop was: edit the ArkRules JSON, wait ~160s for a full run, read the
|
|
43
|
+
* result, `git checkout` it back. One attempt, one run. This runs ONCE and
|
|
44
|
+
* answers for every declared rule at the same time, because advisory rules are
|
|
45
|
+
* already evaluated on every run: the findings are sitting in the analysis the
|
|
46
|
+
* caller just paid for, stamped with the rule id that produced them. Nothing is
|
|
47
|
+
* re-evaluated and no second opinion is invented, so the count cannot disagree
|
|
48
|
+
* with the gate that would enforce it.
|
|
49
|
+
*
|
|
50
|
+
* Plan by default, `--apply` to write, per the house convention. There is no
|
|
51
|
+
* `--dry-run`.
|
|
52
|
+
*/
|
|
53
|
+
export async function runPromote(root, config, args, run) {
|
|
54
|
+
const { loadSensorMap, countFindingsByRule, writeRulePromotion } = await import(
|
|
55
|
+
'./sensor-promote-io.mjs'
|
|
56
|
+
);
|
|
57
|
+
const { buildPromotionPreview } = await import('./sensor-promotion.mjs');
|
|
58
|
+
const loaded = loadSensorMap(root, config, {
|
|
59
|
+
files: run.files.map((file) => ({ path: normalize(path.relative(root, file)) })),
|
|
60
|
+
});
|
|
61
|
+
if (!loaded.ok) {
|
|
62
|
+
console.error(loaded.reason);
|
|
63
|
+
for (const issue of loaded.issues ?? []) console.error(` - ${issue.path}: ${issue.message}`);
|
|
64
|
+
process.exitCode = 2;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const focus = typeof args.promote === 'string' ? args.promote : null;
|
|
68
|
+
const preview = buildPromotionPreview({
|
|
69
|
+
map: loaded.map,
|
|
70
|
+
countsByRuleKey: countFindingsByRule(run.all),
|
|
71
|
+
focus,
|
|
72
|
+
// What the run could and could not see travels WITH the numbers. A price
|
|
73
|
+
// computed from a partial analysis, or one the classification floor will
|
|
74
|
+
// demote, is not a price — and printing it bare is ArkGate's own limitation
|
|
75
|
+
// reported as a fact about the user's code.
|
|
76
|
+
analysis: {
|
|
77
|
+
completeness: run.completeness,
|
|
78
|
+
completenessReasons: run.completenessReasons ?? [],
|
|
79
|
+
teethDemotedByFloor: run.teethDemotedByFloor === true,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (preview.unknownFocus) {
|
|
84
|
+
const message = `No declared rule with id ${JSON.stringify(focus)}.`;
|
|
85
|
+
if (args.json) {
|
|
86
|
+
console.log(JSON.stringify({ promote: preview, error: message }, null, 2));
|
|
87
|
+
} else {
|
|
88
|
+
console.error(message);
|
|
89
|
+
if (preview.suggestions.length > 0) {
|
|
90
|
+
console.error(` Declared ids: ${preview.suggestions.join(', ')}`);
|
|
91
|
+
}
|
|
92
|
+
console.error(` ${arkCommand(root, 'ark-check', '--sensors')} lists them all.`);
|
|
93
|
+
}
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let applied = null;
|
|
99
|
+
if (args.apply) {
|
|
100
|
+
applied = applyPromotion(root, preview, focus, writeRulePromotion);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (args.json) {
|
|
104
|
+
console.log(JSON.stringify({ promote: preview, ...(applied ? { applied } : {}) }, null, 2));
|
|
105
|
+
} else {
|
|
106
|
+
printPromote(root, preview, applied);
|
|
107
|
+
}
|
|
108
|
+
if (applied && !applied.ok) process.exitCode = 1;
|
|
109
|
+
else process.exitCode = 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A write needs one named rule. Applying "everything promotable" from a preview
|
|
114
|
+
* would turn a report into a bulk contract rewrite behind a single flag, and
|
|
115
|
+
* the whole point of the preview is that the cost per rule is now visible
|
|
116
|
+
* before the decision.
|
|
117
|
+
*/
|
|
118
|
+
export function applyPromotion(root, preview, focus, writeRulePromotion) {
|
|
119
|
+
if (!focus) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
reason:
|
|
123
|
+
'--apply needs one rule id: `--promote <ruleId> --apply`. A bare --promote is the preview.',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Rule ids are unique per DOCUMENT, not across them, so a focus id can match
|
|
127
|
+
// two declarations. Taking rows[0] wrote the alphabetically-first layer,
|
|
128
|
+
// reported plain success, and left the other one advisory and unmentioned.
|
|
129
|
+
// The in-file duplicate is already refused rather than guessed; this is the
|
|
130
|
+
// same refusal at the altitude where the ambiguity actually lives.
|
|
131
|
+
if (preview.rows.length > 1) {
|
|
132
|
+
const where = preview.rows
|
|
133
|
+
.map((row) => `${row.sourceFile ?? '?'} (${row.kind})`)
|
|
134
|
+
.join(', ');
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
reason: `Rule ${JSON.stringify(focus)} is declared ${preview.rows.length} times, in ${where}. Rule ids are unique per ArkRules document, not across them — rename one, or promote it by editing that file directly.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const row = preview.rows[0];
|
|
141
|
+
if (!row) return { ok: false, reason: `No declared rule with id ${JSON.stringify(focus)}.` };
|
|
142
|
+
if (row.mode === 'enforced') {
|
|
143
|
+
return { ok: false, reason: `Rule ${JSON.stringify(focus)} is already enforced.` };
|
|
144
|
+
}
|
|
145
|
+
if (!row.promotable) return { ok: false, reason: row.reason };
|
|
146
|
+
// Promotion is a contract change. Making one on evidence the run itself says
|
|
147
|
+
// is incomplete is the false green this surface exists to remove, one level
|
|
148
|
+
// down: the cost that justified it may simply not have been measured.
|
|
149
|
+
if (!preview.countsTrustworthy) {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
reason: `${analysisCaveat(preview.analysis) ?? 'This run could not price the promotion.'} A contract change on a cost this run did not measure is exactly the false green --promote exists to prevent; fix the run first, then --apply.`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// writeRulePromotion re-reads the file, so bind the write to the rule that
|
|
156
|
+
// was priced: an edit landing in between could have made it Tier-2, and
|
|
157
|
+
// "enforced" on that is a contract the loader then refuses.
|
|
158
|
+
return writeRulePromotion(root, row.sourceFile, row.id, row.kind === 'structure' ? row.sensor : undefined);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* One line naming why the numbers above are not the whole answer, or null when
|
|
163
|
+
* they are. Never silence: a qualified number the reader can see is honest, an
|
|
164
|
+
* unqualified one is a claim we cannot support.
|
|
165
|
+
*/
|
|
166
|
+
export function analysisCaveat(analysis) {
|
|
167
|
+
if (!analysis) return null;
|
|
168
|
+
if (analysis.teethDemotedByFloor === true) {
|
|
169
|
+
return 'The classification floor is demoting every enforced ArkRules finding to a warning on this repo, so promoting buys a label, not a tooth: the gate would still pass. Classify more of the tree first (ark-check --coverage).';
|
|
170
|
+
}
|
|
171
|
+
if (analysis.completeness !== undefined && analysis.completeness !== 'complete') {
|
|
172
|
+
const reasons = (analysis.completenessReasons ?? []).slice(0, 3).join('; ');
|
|
173
|
+
return `Analysis was ${analysis.completeness}${reasons ? ` (${reasons})` : ''}, so findings are missing and every count below is a FLOOR, not the price.`;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function printPromote(root, preview, applied) {
|
|
179
|
+
console.log(color.bold('Promotion preview (one run, every declared rule)'));
|
|
180
|
+
const caveat = analysisCaveat(preview.analysis);
|
|
181
|
+
// Above the numbers, not below them: a reader who stops at the first green
|
|
182
|
+
// line must not have already been misled.
|
|
183
|
+
if (caveat) console.log(color.yellow(` ${caveat}`));
|
|
184
|
+
if (preview.rows.length === 0) {
|
|
185
|
+
console.log(color.dim(' No ArkRules declared — intra-layer ArkRules are opt-in.'));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
for (const row of preview.rows) {
|
|
189
|
+
const head = ` ${renderPath(row.id)} ${color.dim(
|
|
190
|
+
`${row.kind === 'structure' ? `sensor=${renderPath(row.sensor)}` : 'invariant'} ${renderPath(row.sourceFile ?? '?')}`
|
|
191
|
+
)}${row.ambiguousId ? color.yellow(` [id declared ${row.declarationsWithThisId}x]`) : ''}`;
|
|
192
|
+
if (row.mode === 'enforced') {
|
|
193
|
+
const label =
|
|
194
|
+
preview.analysis?.teethDemotedByFloor === true
|
|
195
|
+
? `${row.currentFindings} finding(s), demoted by the classification floor`
|
|
196
|
+
: `${row.currentFindings} blocking finding(s)`;
|
|
197
|
+
console.log(`${head} ${color.green('enforced already')} — ${label}`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!row.promotable) {
|
|
201
|
+
console.log(`${head} ${color.dim('cannot be promoted')}`);
|
|
202
|
+
console.log(color.dim(` ${row.reason}`));
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
// A zero that came out of a run which could not see everything is not
|
|
206
|
+
// "costs nothing"; it is "we did not measure it".
|
|
207
|
+
const cost =
|
|
208
|
+
row.wouldBlock === 0 && row.countIsUnreliable
|
|
209
|
+
? color.yellow(`${row.currentFindings} finding(s) seen — not the price (see above)`)
|
|
210
|
+
: row.wouldBlock === 0 && preview.analysis?.teethDemotedByFloor === true
|
|
211
|
+
? color.dim(`${row.currentFindings} finding(s), but the floor demotes them — no teeth yet`)
|
|
212
|
+
: row.wouldBlock === 0
|
|
213
|
+
? color.green('0 findings — promoting costs nothing today')
|
|
214
|
+
: color.yellow(`${row.wouldBlock} advisory finding(s) would start failing the gate`);
|
|
215
|
+
console.log(`${head} ${cost}`);
|
|
216
|
+
}
|
|
217
|
+
console.log('');
|
|
218
|
+
console.log(
|
|
219
|
+
` ${preview.totals.rules} rule(s) · ${preview.totals.cleanPromotions} promotable with zero cost · ` +
|
|
220
|
+
`${preview.totals.wouldBlock} finding(s) would become blocking in total`
|
|
221
|
+
);
|
|
222
|
+
if (applied) {
|
|
223
|
+
if (applied.ok) {
|
|
224
|
+
console.log(color.green(` wrote ${applied.file} — ${applied.reason}`));
|
|
225
|
+
console.log(color.dim(' Re-run the gate to see the rule bite.'));
|
|
226
|
+
} else {
|
|
227
|
+
console.log(color.red(` not applied: ${applied.reason}`));
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
console.log(
|
|
231
|
+
color.dim(
|
|
232
|
+
` Preview only. ${arkCommand(root, 'ark-check', '--promote <ruleId> --apply')} writes mode "enforced" into the rule's own ArkRules file.`
|
|
233
|
+
)
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
console.log(
|
|
237
|
+
color.dim(` Which sensors can ever be enforced: ${arkCommand(root, 'ark-check', '--sensors')}`)
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Sensor promotability, before you pay for a run.
|
|
243
|
+
*
|
|
244
|
+
* Field measurement: `ark-check` takes ~160s on a real repository, and the only
|
|
245
|
+
* way to learn whether a rule could be enforced was to edit the ArkRules JSON,
|
|
246
|
+
* wait, read the result and `git checkout` it back — four times before the map
|
|
247
|
+
* was clear. Everything this prints is a declaration: the closed sensor
|
|
248
|
+
* vocabulary, the project's own ArkRules, and the coverage evidence scan (a
|
|
249
|
+
* filesystem walk plus a text match — ArkGate never executes a test). No
|
|
250
|
+
* TypeScript resolver, so it answers in the time it takes to read the files.
|
|
251
|
+
*/
|
|
252
|
+
export async function runSensors(args, readConfig) {
|
|
253
|
+
const root = args.root;
|
|
254
|
+
const { loadSensorMap } = await import('./sensor-promote-io.mjs');
|
|
255
|
+
let config;
|
|
256
|
+
try {
|
|
257
|
+
config = readConfig(root, args.config);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
260
|
+
process.exitCode = 2;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
let files;
|
|
264
|
+
try {
|
|
265
|
+
files = collectGovernedFiles(root, config).map((file) => ({
|
|
266
|
+
path: normalize(path.relative(root, file)),
|
|
267
|
+
}));
|
|
268
|
+
} catch (error) {
|
|
269
|
+
// Swallowing this and walking an EMPTY file set would report every
|
|
270
|
+
// invariant as "no coverage evidence" — ArkGate's failure to collect the
|
|
271
|
+
// inputs, printed as a fact about the user's tests, with exit 0 on top.
|
|
272
|
+
const message = `Could not collect the governed files, so invariant coverage cannot be evaluated: ${
|
|
273
|
+
error instanceof Error ? error.message : String(error)
|
|
274
|
+
}`;
|
|
275
|
+
if (args.json) {
|
|
276
|
+
console.log(JSON.stringify({ sensors: { ok: false, reason: message } }, null, 2));
|
|
277
|
+
} else {
|
|
278
|
+
console.error(message);
|
|
279
|
+
}
|
|
280
|
+
process.exitCode = 2;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const loaded = loadSensorMap(root, config, { files });
|
|
284
|
+
if (!loaded.ok) {
|
|
285
|
+
if (args.json) {
|
|
286
|
+
console.log(JSON.stringify({ sensors: { ok: false, reason: loaded.reason, issues: loaded.issues } }, null, 2));
|
|
287
|
+
} else {
|
|
288
|
+
console.error(loaded.reason);
|
|
289
|
+
for (const issue of loaded.issues ?? []) console.error(` - ${issue.path}: ${issue.message}`);
|
|
290
|
+
}
|
|
291
|
+
process.exitCode = 2;
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (args.json) {
|
|
295
|
+
console.log(
|
|
296
|
+
JSON.stringify(
|
|
297
|
+
{ sensors: { ...loaded.map, coverage: loaded.coverage, arkRulesActive: loaded.arkRulesActive } },
|
|
298
|
+
null,
|
|
299
|
+
2
|
|
300
|
+
)
|
|
301
|
+
);
|
|
302
|
+
} else {
|
|
303
|
+
printSensors(root, loaded);
|
|
304
|
+
}
|
|
305
|
+
// A map is a report, never a verdict: it exits 0 whatever it found, exactly
|
|
306
|
+
// like --coverage and --rules-inventory.
|
|
307
|
+
process.exitCode = 0;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function modeMark(mode) {
|
|
311
|
+
return mode === 'enforced' ? color.green('enforced') : color.yellow('advisory');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function printSensors(root, loaded) {
|
|
315
|
+
const map = loaded.map;
|
|
316
|
+
console.log(color.bold('Sensor vocabulary (every sensor ArkGate ships)'));
|
|
317
|
+
for (const entry of map.vocabulary) {
|
|
318
|
+
const verdict = entry.promotable
|
|
319
|
+
? entry.plane === 'arkrules'
|
|
320
|
+
? color.green('promotable per rule')
|
|
321
|
+
: color.green(`promotable via ${entry.plane}.mode`)
|
|
322
|
+
: color.dim(`not promotable — ${entry.blocker}`);
|
|
323
|
+
console.log(` ${entry.sensor} ${color.dim(`[${entry.plane} · tier ${entry.tier}]`)} ${verdict}`);
|
|
324
|
+
}
|
|
325
|
+
const blocked = map.vocabulary.filter((entry) => !entry.promotable);
|
|
326
|
+
for (const entry of blocked) console.log(color.dim(` · ${entry.reason}`));
|
|
327
|
+
|
|
328
|
+
console.log('');
|
|
329
|
+
if (!loaded.arkRulesActive) {
|
|
330
|
+
console.log(
|
|
331
|
+
color.dim(
|
|
332
|
+
'No arkRules map in the contract — intra-layer ArkRules are opt-in, so no rule is declared yet.'
|
|
333
|
+
)
|
|
334
|
+
);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
console.log(color.bold('Declared rules'));
|
|
338
|
+
if (map.structure.length === 0 && map.invariants.length === 0) {
|
|
339
|
+
console.log(color.dim(' (none)'));
|
|
340
|
+
}
|
|
341
|
+
for (const row of map.structure) {
|
|
342
|
+
console.log(
|
|
343
|
+
` ${renderPath(row.id)} ${color.dim(`sensor=${renderPath(row.sensor)} layer=${renderPath(row.layer ?? '?')} ${renderPath(row.sourceFile ?? '?')}`)} ${modeMark(row.mode)}${row.ambiguousId ? color.yellow(` [id declared ${row.declarationsWithThisId}x]`) : ''}`
|
|
344
|
+
);
|
|
345
|
+
if (row.mode === 'advisory') console.log(color.dim(` ${row.reason}`));
|
|
346
|
+
}
|
|
347
|
+
for (const row of map.invariants) {
|
|
348
|
+
console.log(
|
|
349
|
+
` ${renderPath(row.id)} ${color.dim(`invariant layer=${renderPath(row.layer ?? '?')} ${renderPath(row.sourceFile ?? '?')}`)} ${modeMark(row.mode)}${row.ambiguousId ? color.yellow(` [id declared ${row.declarationsWithThisId}x]`) : ''}`
|
|
350
|
+
);
|
|
351
|
+
if (row.mode === 'advisory') console.log(color.dim(` ${row.reason}`));
|
|
352
|
+
}
|
|
353
|
+
console.log('');
|
|
354
|
+
console.log(
|
|
355
|
+
` ${map.totals.declared} declared · ${map.totals.enforced} enforced · ` +
|
|
356
|
+
`${map.totals.advisoryPromotable} advisory and promotable now · ` +
|
|
357
|
+
`${map.totals.advisoryBlocked} advisory and blocked`
|
|
358
|
+
);
|
|
359
|
+
if (loaded.coverage.evaluated && loaded.coverage.partial) {
|
|
360
|
+
console.log(
|
|
361
|
+
color.yellow(
|
|
362
|
+
' Coverage evidence is partial, so no invariant can be promoted on it. Partial has more than one cause (no test files matched, or the scan budget was exhausted) — `ark-check --doctor` names which one.'
|
|
363
|
+
)
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
console.log(
|
|
367
|
+
color.dim(
|
|
368
|
+
` What promoting would cost: ${arkCommand(root, 'ark-check', '--promote')} (one run, every rule).`
|
|
369
|
+
)
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|