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.
Files changed (54) hide show
  1. package/CHANGELOG.md +242 -0
  2. package/README.md +10 -3
  3. package/bin/ark-check-runtime.mjs +340 -5
  4. package/bin/ark-layer-match.mjs +170 -13
  5. package/bin/ark-mcp-runtime.mjs +9 -2
  6. package/bin/lib/analysis-completeness.mjs +86 -0
  7. package/bin/lib/analysis-engine.mjs +5 -5
  8. package/bin/lib/architecture-scan.mjs +2 -0
  9. package/bin/lib/arkrules-contract.mjs +8 -1
  10. package/bin/lib/check-args.mjs +66 -0
  11. package/bin/lib/config-contract.mjs +26 -0
  12. package/bin/lib/design-smells.mjs +85 -0
  13. package/bin/lib/diagnostic-catalog.mjs +6 -1
  14. package/bin/lib/first-run-help.mjs +12 -0
  15. package/bin/lib/invariant-coverage-io.mjs +175 -19
  16. package/bin/lib/invariant-coverage.mjs +110 -7
  17. package/bin/lib/literal-path-drift-io.mjs +569 -0
  18. package/bin/lib/literal-path-drift.mjs +761 -0
  19. package/bin/lib/policy-delta-io.mjs +5 -0
  20. package/bin/lib/remediation.mjs +15 -0
  21. package/bin/lib/rules-under-contract.mjs +5 -0
  22. package/bin/lib/scan-files.mjs +54 -0
  23. package/bin/lib/sensor-promote-cli.mjs +372 -0
  24. package/bin/lib/sensor-promote-io.mjs +246 -0
  25. package/bin/lib/sensor-promotion.mjs +363 -0
  26. package/dist/{configTypes-dNJ2C0yx.d.ts → configTypes-dy5PfTqS.d.ts} +31 -0
  27. package/dist/{diagnosticCatalog-C5GgeyEE.d.ts → diagnosticCatalog-DgTs0abp.d.ts} +75 -7
  28. package/dist/eslint/index.cjs +6 -6
  29. package/dist/eslint/index.d.ts +34 -1
  30. package/dist/eslint/index.js +6 -6
  31. package/dist/index.cjs +32 -32
  32. package/dist/index.d.ts +65 -4
  33. package/dist/index.js +29 -29
  34. package/dist/nestjs/index.cjs +5 -5
  35. package/dist/nestjs/index.d.ts +3 -3
  36. package/dist/nestjs/index.js +5 -5
  37. package/dist/runtime/index.cjs +15 -15
  38. package/dist/runtime/index.d.ts +6 -6
  39. package/dist/runtime/index.js +15 -15
  40. package/dist/{types-dK24fDZa.d.ts → types-BuM8WNqe.d.ts} +1 -1
  41. package/dist/{types-DeK7SYGC.d.ts → types-D95drJ3_.d.ts} +1 -1
  42. package/docs/README.md +1 -1
  43. package/docs/agent-guide.md +182 -0
  44. package/docs/configuration.md +77 -1
  45. package/docs/develop.md +1 -0
  46. package/docs/diagnostics.md +70 -1
  47. package/docs/package-surface.md +32 -2
  48. package/package.json +2 -2
  49. package/schemas/ark.config.schema.json +63 -0
  50. package/server.json +3 -3
  51. package/templates/agent-skills/ark-adopt/SKILL.md +5 -0
  52. package/templates/agent-skills/ark-coverage/SKILL.md +1 -0
  53. package/templates/skills/ark-adopt.md +5 -0
  54. 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;
@@ -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(
@@ -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
+