arkgate 4.8.2 → 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 +257 -3
- package/README.md +47 -9
- 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 +6 -6
- package/bin/lib/architecture-scan.mjs +2 -0
- package/bin/lib/ark-order-facts.mjs +59 -0
- package/bin/lib/ark-order-sensors.mjs +31 -2
- package/bin/lib/arkrule-file-hints.mjs +6 -2
- package/bin/lib/arkrules-contract.mjs +9 -1
- package/bin/lib/arkrules-sensors.mjs +22 -2
- package/bin/lib/check-args.mjs +66 -0
- package/bin/lib/config-contract.mjs +26 -0
- package/bin/lib/config-extras.mjs +2 -0
- package/bin/lib/design-smells.mjs +85 -0
- package/bin/lib/diagnostic-catalog.mjs +8 -2
- 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 +24 -1
- package/bin/lib/resolved-candidate-facts.mjs +31 -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-BdCe_gvv.d.ts → configTypes-dy5PfTqS.d.ts} +36 -0
- package/dist/{diagnosticCatalog-CPzH-MLN.d.ts → diagnosticCatalog-DgTs0abp.d.ts} +169 -11
- package/dist/eslint/index.cjs +5 -5
- package/dist/eslint/index.d.ts +34 -1
- package/dist/eslint/index.js +5 -5
- package/dist/index.cjs +31 -31
- package/dist/index.d.ts +85 -7
- package/dist/index.js +31 -31
- 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 +13 -13
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +13 -13
- package/dist/{types-DCSlrRnV.d.ts → types-BuM8WNqe.d.ts} +1 -1
- package/dist/{types-C9KApBzX.d.ts → types-D95drJ3_.d.ts} +1 -1
- package/docs/README.md +4 -4
- package/docs/agent-guide.md +182 -0
- package/docs/configuration.md +89 -9
- package/docs/develop.md +24 -2
- package/docs/diagnostics.md +79 -1
- package/docs/enthusiast/README.md +6 -4
- package/docs/package-surface.md +36 -4
- package/docs/product-voice.md +15 -5
- package/docs/use.md +8 -5
- package/package.json +2 -2
- package/schemas/ark.arkrules.schema.json +1 -0
- package/schemas/ark.config.schema.json +72 -0
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/server.json +3 -3
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +13 -3
- package/templates/agent-skills/ark-autopilot/SKILL.md +1 -1
- package/templates/agent-skills/ark-contract/SKILL.md +4 -0
- package/templates/agent-skills/ark-coverage/SKILL.md +1 -0
- package/templates/agent-skills/ark-place/SKILL.md +6 -2
- package/templates/arkrules/ApplicationOrchestration.json +6 -0
- package/templates/skills/ark-adopt.md +13 -3
- package/templates/skills/ark-autopilot.md +1 -1
- package/templates/skills/ark-contract.md +4 -0
- package/templates/skills/ark-coverage.md +1 -0
- package/templates/skills/ark-place.md +6 -2
|
@@ -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
|
@@ -106,6 +106,7 @@ const ARKORDER_JUDGMENT_RULE_IDS = new Set([
|
|
|
106
106
|
'ARKORDER_GENERIC_UPDATE',
|
|
107
107
|
'ARKORDER_TOO_MANY_PARAMS',
|
|
108
108
|
'ARKORDER_INGEST_WRITES_XI',
|
|
109
|
+
'ARKORDER_XI_FIELD_WRITE',
|
|
109
110
|
]);
|
|
110
111
|
function arkRunCallSiteName(violation) {
|
|
111
112
|
return typeof violation.target === 'string' && violation.target.trim().length > 0
|
|
@@ -204,8 +205,16 @@ export function deterministicNextAction(violation) {
|
|
|
204
205
|
return 'Extract the shared dependency into a third module, test at the public interface, then preflight again.';
|
|
205
206
|
case 'RAW_EVENT_PUBLISH':
|
|
206
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.';
|
|
207
214
|
case 'PUBLISH_MISSING_SOURCE':
|
|
208
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.`;
|
|
209
218
|
case 'ARKRULE_STRUCTURE':
|
|
210
219
|
case 'ARKRULE_INVARIANT':
|
|
211
220
|
case 'INVARIANT_UNCOVERED':
|
|
@@ -234,6 +243,10 @@ export function deterministicNextAction(violation) {
|
|
|
234
243
|
return 'Cut ξ to the slow keys that actually slave the rest, then preflight again. Never mechanical-safe.';
|
|
235
244
|
case 'ARKORDER_INGEST_WRITES_XI':
|
|
236
245
|
return 'Keep ingest results as absorb/escalate only. Change ξ with proposeRelease + release. Never mechanical-safe.';
|
|
246
|
+
case 'ARKORDER_XI_FIELD_WRITE':
|
|
247
|
+
return typeof violation.target === 'string' && violation.target.length > 0
|
|
248
|
+
? `Do not persist slow key ${violation.target} from a use-case. Absorb the field with ingest() or change the pattern with proposeRelease(), then preflight again.`
|
|
249
|
+
: 'Do not persist a declared slow key from a use-case. Absorb the field with ingest() or change the pattern with proposeRelease(), then preflight again. Never mechanical-safe.';
|
|
237
250
|
default:
|
|
238
251
|
if (typeof violation.ruleId === 'string' && violation.ruleId.startsWith('ARKRULE_')) {
|
|
239
252
|
return `Fix the ArkRule ${typeof violation.arkruleId === 'string' ? violation.arkruleId : violation.ruleId}, then preflight again.`;
|
|
@@ -343,6 +356,7 @@ export function classifyRemediation(violation) {
|
|
|
343
356
|
if (ruleId === 'ARKRULE_STRUCTURE' ||
|
|
344
357
|
ruleId === 'ARKRULE_INVARIANT' ||
|
|
345
358
|
ruleId === 'INVARIANT_UNCOVERED' ||
|
|
359
|
+
ruleId === 'INVARIANT_COVERAGE_OUTSIDE_ROOTS' ||
|
|
346
360
|
(typeof ruleId === 'string' && ruleId.startsWith('ARKRULE_'))) {
|
|
347
361
|
return {
|
|
348
362
|
class: 'judgment',
|
|
@@ -467,6 +481,12 @@ export function enrichViolationWithFixClass(violation) {
|
|
|
467
481
|
enriched.enthusiastHint =
|
|
468
482
|
'Reference that intent from a layer allowed to know about it — usually an adapter or application layer, not the domain core.';
|
|
469
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;
|
|
470
490
|
case 'ARKRULE_STRUCTURE':
|
|
471
491
|
case 'ARKRULE_INVARIANT':
|
|
472
492
|
case 'INVARIANT_UNCOVERED':
|
|
@@ -496,6 +516,7 @@ export function enrichViolationWithFixClass(violation) {
|
|
|
496
516
|
case 'ARKORDER_GENERIC_UPDATE':
|
|
497
517
|
case 'ARKORDER_TOO_MANY_PARAMS':
|
|
498
518
|
case 'ARKORDER_INGEST_WRITES_XI':
|
|
519
|
+
case 'ARKORDER_XI_FIELD_WRITE':
|
|
499
520
|
enriched.fixClass = 'arkorder-usage';
|
|
500
521
|
enriched.effort = 'medium';
|
|
501
522
|
enriched.enthusiastHint =
|
|
@@ -507,7 +528,9 @@ export function enrichViolationWithFixClass(violation) {
|
|
|
507
528
|
? 'Too many slow keys. Keep ξ small — the rest is derived noise.'
|
|
508
529
|
: violation.ruleId === 'ARKORDER_INGEST_WRITES_XI'
|
|
509
530
|
? 'ingest can absorb or escalate. It never writes a new house.'
|
|
510
|
-
:
|
|
531
|
+
: violation.ruleId === 'ARKORDER_XI_FIELD_WRITE'
|
|
532
|
+
? 'Name the slow keys in arkOrder.xiKeys. Invoices and seats still flow; changing the plan is a new release, not prisma.update.'
|
|
533
|
+
: 'Call createOrderPlane from arkgate/order in a listed plane root so the app actually freezes a pattern.';
|
|
511
534
|
break;
|
|
512
535
|
default:
|
|
513
536
|
enriched.fixClass = 'review-contract';
|
|
@@ -42,7 +42,10 @@ import {
|
|
|
42
42
|
} from './ark-run-facts.mjs';
|
|
43
43
|
import {
|
|
44
44
|
extractArkOrderGenericUpdatesFromSource,
|
|
45
|
+
extractArkOrderIngestWritesXiFromSource,
|
|
45
46
|
extractArkOrderPlaneCallsFromSource,
|
|
47
|
+
extractArkOrderReleaseKeyCountsFromSource,
|
|
48
|
+
extractArkOrderXiFieldWritesFromSource,
|
|
46
49
|
} from './ark-order-facts.mjs';
|
|
47
50
|
import {
|
|
48
51
|
collectGovernedFiles,
|
|
@@ -1015,6 +1018,10 @@ export function resolveCandidateFacts({
|
|
|
1015
1018
|
const arkOrderPlaneCalls = [];
|
|
1016
1019
|
const arkOrderGenericUpdates = [];
|
|
1017
1020
|
const arkOrderRootHits = [];
|
|
1021
|
+
const arkOrderXiFieldWrites = [];
|
|
1022
|
+
const arkOrderIngestWritesXi = [];
|
|
1023
|
+
const arkOrderReleaseKeyCounts = [];
|
|
1024
|
+
const xiKeys = [...(config.arkOrder?.xiKeys ?? [])];
|
|
1018
1025
|
const compositionRootPatterns = [...(config.arkRun?.compositionRoots ?? [])];
|
|
1019
1026
|
const planeRootPatterns = [...(config.arkOrder?.planeRoots ?? [])];
|
|
1020
1027
|
|
|
@@ -1134,6 +1141,27 @@ export function resolveCandidateFacts({
|
|
|
1134
1141
|
} catch {
|
|
1135
1142
|
// Never fail the resolver for ArkOrder generic-update extraction.
|
|
1136
1143
|
}
|
|
1144
|
+
try {
|
|
1145
|
+
arkOrderXiFieldWrites.push(
|
|
1146
|
+
...extractArkOrderXiFieldWritesFromSource(candidate.path, candidate.content, xiKeys)
|
|
1147
|
+
);
|
|
1148
|
+
} catch {
|
|
1149
|
+
// Never fail the resolver for ArkOrder xi-field-write extraction.
|
|
1150
|
+
}
|
|
1151
|
+
try {
|
|
1152
|
+
arkOrderIngestWritesXi.push(
|
|
1153
|
+
...extractArkOrderIngestWritesXiFromSource(candidate.path, candidate.content)
|
|
1154
|
+
);
|
|
1155
|
+
} catch {
|
|
1156
|
+
// Never fail the resolver for ArkOrder ingest-writes-ξ extraction.
|
|
1157
|
+
}
|
|
1158
|
+
try {
|
|
1159
|
+
arkOrderReleaseKeyCounts.push(
|
|
1160
|
+
...extractArkOrderReleaseKeyCountsFromSource(candidate.path, candidate.content)
|
|
1161
|
+
);
|
|
1162
|
+
} catch {
|
|
1163
|
+
// Never fail the resolver for ArkOrder release key-count extraction.
|
|
1164
|
+
}
|
|
1137
1165
|
}
|
|
1138
1166
|
}
|
|
1139
1167
|
|
|
@@ -1277,5 +1305,8 @@ export function resolveCandidateFacts({
|
|
|
1277
1305
|
arkOrderPlaneCalls,
|
|
1278
1306
|
arkOrderGenericUpdates,
|
|
1279
1307
|
arkOrderRootHits,
|
|
1308
|
+
arkOrderXiFieldWrites,
|
|
1309
|
+
arkOrderIngestWritesXi,
|
|
1310
|
+
arkOrderReleaseKeyCounts,
|
|
1280
1311
|
});
|
|
1281
1312
|
}
|
|
@@ -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
|
+
|