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.
Files changed (73) hide show
  1. package/CHANGELOG.md +257 -3
  2. package/README.md +47 -9
  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 +6 -6
  8. package/bin/lib/architecture-scan.mjs +2 -0
  9. package/bin/lib/ark-order-facts.mjs +59 -0
  10. package/bin/lib/ark-order-sensors.mjs +31 -2
  11. package/bin/lib/arkrule-file-hints.mjs +6 -2
  12. package/bin/lib/arkrules-contract.mjs +9 -1
  13. package/bin/lib/arkrules-sensors.mjs +22 -2
  14. package/bin/lib/check-args.mjs +66 -0
  15. package/bin/lib/config-contract.mjs +26 -0
  16. package/bin/lib/config-extras.mjs +2 -0
  17. package/bin/lib/design-smells.mjs +85 -0
  18. package/bin/lib/diagnostic-catalog.mjs +8 -2
  19. package/bin/lib/first-run-help.mjs +12 -0
  20. package/bin/lib/invariant-coverage-io.mjs +175 -19
  21. package/bin/lib/invariant-coverage.mjs +110 -7
  22. package/bin/lib/literal-path-drift-io.mjs +569 -0
  23. package/bin/lib/literal-path-drift.mjs +761 -0
  24. package/bin/lib/policy-delta-io.mjs +5 -0
  25. package/bin/lib/remediation.mjs +24 -1
  26. package/bin/lib/resolved-candidate-facts.mjs +31 -0
  27. package/bin/lib/rules-under-contract.mjs +5 -0
  28. package/bin/lib/scan-files.mjs +54 -0
  29. package/bin/lib/sensor-promote-cli.mjs +372 -0
  30. package/bin/lib/sensor-promote-io.mjs +246 -0
  31. package/bin/lib/sensor-promotion.mjs +363 -0
  32. package/dist/{configTypes-BdCe_gvv.d.ts → configTypes-dy5PfTqS.d.ts} +36 -0
  33. package/dist/{diagnosticCatalog-CPzH-MLN.d.ts → diagnosticCatalog-DgTs0abp.d.ts} +169 -11
  34. package/dist/eslint/index.cjs +5 -5
  35. package/dist/eslint/index.d.ts +34 -1
  36. package/dist/eslint/index.js +5 -5
  37. package/dist/index.cjs +31 -31
  38. package/dist/index.d.ts +85 -7
  39. package/dist/index.js +31 -31
  40. package/dist/nestjs/index.cjs +5 -5
  41. package/dist/nestjs/index.d.ts +3 -3
  42. package/dist/nestjs/index.js +5 -5
  43. package/dist/runtime/index.cjs +13 -13
  44. package/dist/runtime/index.d.ts +6 -6
  45. package/dist/runtime/index.js +13 -13
  46. package/dist/{types-DCSlrRnV.d.ts → types-BuM8WNqe.d.ts} +1 -1
  47. package/dist/{types-C9KApBzX.d.ts → types-D95drJ3_.d.ts} +1 -1
  48. package/docs/README.md +4 -4
  49. package/docs/agent-guide.md +182 -0
  50. package/docs/configuration.md +89 -9
  51. package/docs/develop.md +24 -2
  52. package/docs/diagnostics.md +79 -1
  53. package/docs/enthusiast/README.md +6 -4
  54. package/docs/package-surface.md +36 -4
  55. package/docs/product-voice.md +15 -5
  56. package/docs/use.md +8 -5
  57. package/package.json +2 -2
  58. package/schemas/ark.arkrules.schema.json +1 -0
  59. package/schemas/ark.config.schema.json +72 -0
  60. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  61. package/server.json +3 -3
  62. package/templates/agent-skills/README.md +1 -1
  63. package/templates/agent-skills/ark-adopt/SKILL.md +13 -3
  64. package/templates/agent-skills/ark-autopilot/SKILL.md +1 -1
  65. package/templates/agent-skills/ark-contract/SKILL.md +4 -0
  66. package/templates/agent-skills/ark-coverage/SKILL.md +1 -0
  67. package/templates/agent-skills/ark-place/SKILL.md +6 -2
  68. package/templates/arkrules/ApplicationOrchestration.json +6 -0
  69. package/templates/skills/ark-adopt.md +13 -3
  70. package/templates/skills/ark-autopilot.md +1 -1
  71. package/templates/skills/ark-contract.md +4 -0
  72. package/templates/skills/ark-coverage.md +1 -0
  73. 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;
@@ -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
- : 'Call createOrderPlane from arkgate/order in a listed plane root so the app actually freezes a pattern.';
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(
@@ -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
+