arkgate 4.8.23 → 4.8.25

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 (201) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +41 -91
  4. package/bin/ark-dashboard.mjs +56 -53
  5. package/bin/ark-layer-match.mjs +707 -121
  6. package/bin/ark-mcp-runtime.mjs +448 -169
  7. package/bin/ark-mcp.mjs +68 -4
  8. package/bin/ark-shared.mjs +42 -22
  9. package/bin/ark.mjs +65 -97
  10. package/bin/lib/adapter-contract-types.mjs +5 -0
  11. package/bin/lib/adapter-contract.mjs +15 -0
  12. package/bin/lib/adoption-stance.mjs +2 -2
  13. package/bin/lib/adr-path.mjs +3 -3
  14. package/bin/lib/agent-gates.mjs +0 -61
  15. package/bin/lib/agent-homes.mjs +12 -21
  16. package/bin/lib/agent-projection-command.mjs +9 -5
  17. package/bin/lib/ambient-state.mjs +9 -30
  18. package/bin/lib/analysis-engine.mjs +15 -11
  19. package/bin/lib/architecture-scan.mjs +61 -15
  20. package/bin/lib/ark-order-doctor.mjs +4 -29
  21. package/bin/lib/ark-order-facts.mjs +319 -17
  22. package/bin/lib/ark-order-sensors.mjs +28 -101
  23. package/bin/lib/ark-order-types.mjs +3 -2
  24. package/bin/lib/ark-run-doctor.mjs +17 -28
  25. package/bin/lib/ark-run-facts.mjs +461 -48
  26. package/bin/lib/ark-run-sensors.mjs +6 -4
  27. package/bin/lib/arkgate-pins.mjs +91 -0
  28. package/bin/lib/arkrule-file-hints.mjs +2 -47
  29. package/bin/lib/arkrules-contract.mjs +124 -133
  30. package/bin/lib/arkrules-sensors.mjs +55 -252
  31. package/bin/lib/check-args.mjs +85 -1
  32. package/bin/lib/ci-and-commands.mjs +24 -78
  33. package/bin/lib/ci-merge-boundary.mjs +1 -1
  34. package/bin/lib/class-source-scan.mjs +479 -0
  35. package/bin/lib/clone-detection.mjs +474 -0
  36. package/bin/lib/codex-home.mjs +5 -3
  37. package/bin/lib/config-contract-slices.mjs +739 -0
  38. package/bin/lib/config-contract.mjs +42 -607
  39. package/bin/lib/config-extras.mjs +16 -22
  40. package/bin/lib/config-warnings.mjs +32 -0
  41. package/bin/lib/contract-smells.mjs +33 -15
  42. package/bin/lib/core-ratchet.mjs +0 -2
  43. package/bin/lib/dashboard-cli.mjs +62 -0
  44. package/bin/lib/deep-module-coach.mjs +2 -4
  45. package/bin/lib/deploy-path.mjs +13 -38
  46. package/bin/lib/design-delta.mjs +1 -1
  47. package/bin/lib/design-smells.mjs +2 -2
  48. package/bin/lib/diagnostic-catalog.mjs +23 -9
  49. package/bin/lib/doctor-advisories.mjs +78 -8
  50. package/bin/lib/doctor-green-cite.mjs +1 -1
  51. package/bin/lib/doctor-human.mjs +1 -1
  52. package/bin/lib/doctor-next-actions.mjs +19 -4
  53. package/bin/lib/doctor-plan.mjs +9 -13
  54. package/bin/lib/duplication-io.mjs +572 -0
  55. package/bin/lib/effective-contract-load.mjs +282 -83
  56. package/bin/lib/enforcement-profiles.mjs +40 -4
  57. package/bin/lib/entry-points-io.mjs +424 -0
  58. package/bin/lib/extra-merge-teeth.mjs +36 -0
  59. package/bin/lib/field-install.mjs +38 -14
  60. package/bin/lib/first-run-help.mjs +10 -5
  61. package/bin/lib/flat-parent-importers.mjs +139 -0
  62. package/bin/lib/flat-parent-pilot.mjs +40 -100
  63. package/bin/lib/gate-files.mjs +58 -90
  64. package/bin/lib/git-change-scope.mjs +109 -0
  65. package/bin/lib/github-enforcement.mjs +166 -29
  66. package/bin/lib/golden-pattern.mjs +1 -1
  67. package/bin/lib/hook-templates.mjs +17 -15
  68. package/bin/lib/host-support-matrix.mjs +3 -1
  69. package/bin/lib/html-report-advisories.mjs +4 -0
  70. package/bin/lib/html-report-depth.mjs +2 -2
  71. package/bin/lib/html-report.mjs +2 -2
  72. package/bin/lib/import-graph-projection.mjs +112 -0
  73. package/bin/lib/import-resolve.mjs +13 -1
  74. package/bin/lib/improvement-compass-doctor.mjs +1 -24
  75. package/bin/lib/install-migrate.mjs +50 -73
  76. package/bin/lib/invariant-coverage-io.mjs +4 -3
  77. package/bin/lib/invariant-coverage.mjs +85 -11
  78. package/bin/lib/invariant-probe-cli.mjs +347 -0
  79. package/bin/lib/invariant-probe-io.mjs +165 -0
  80. package/bin/lib/invariant-probe-runner.mjs +268 -0
  81. package/bin/lib/invariant-probe-sites.mjs +174 -0
  82. package/bin/lib/invariant-probe-workspace.mjs +183 -0
  83. package/bin/lib/invariant-probe.mjs +461 -0
  84. package/bin/lib/invariant-tests-path.mjs +16 -22
  85. package/bin/lib/literal-path-drift.mjs +4 -2
  86. package/bin/lib/managed-upgrade.mjs +2 -8
  87. package/bin/lib/mcp-adoption.mjs +13 -6
  88. package/bin/lib/mcp-hook-payload.mjs +403 -63
  89. package/bin/lib/orphan-modules-io.mjs +458 -0
  90. package/bin/lib/orphan-modules.mjs +251 -0
  91. package/bin/lib/outside-importers.mjs +197 -0
  92. package/bin/lib/package-manager.mjs +60 -2
  93. package/bin/lib/parse-health.mjs +1 -1
  94. package/bin/lib/passthrough-commands.mjs +83 -0
  95. package/bin/lib/persistence-write-hint.mjs +106 -0
  96. package/bin/lib/pin-dependent-gates.mjs +117 -0
  97. package/bin/lib/policy-delta-io.mjs +219 -14
  98. package/bin/lib/post-green-path.mjs +1 -1
  99. package/bin/lib/prepare-change.mjs +115 -7
  100. package/bin/lib/prepare-write.mjs +2 -2
  101. package/bin/lib/presets.mjs +59 -73
  102. package/bin/lib/product-copy.mjs +1 -6
  103. package/bin/lib/project-root.mjs +31 -62
  104. package/bin/lib/remediation.mjs +23 -4
  105. package/bin/lib/resolved-candidate-facts.mjs +405 -117
  106. package/bin/lib/rules-inventory.mjs +22 -3
  107. package/bin/lib/rules-under-contract.mjs +247 -9
  108. package/bin/lib/scan-files.mjs +36 -4
  109. package/bin/lib/schema-validation.mjs +130 -0
  110. package/bin/lib/sensor-promote-cli.mjs +2 -11
  111. package/bin/lib/sensor-promote-io.mjs +33 -22
  112. package/bin/lib/sensor-promotion.mjs +1 -1
  113. package/bin/lib/skill-catalog-apply.mjs +1 -1
  114. package/bin/lib/skill-install.mjs +42 -38
  115. package/bin/lib/snippet-analysis.mjs +140 -6
  116. package/bin/lib/source-policy.mjs +34 -0
  117. package/bin/lib/start-install-recovery.mjs +72 -2
  118. package/bin/lib/start-preview.mjs +31 -1
  119. package/bin/lib/states-transitions-presence.mjs +3 -3
  120. package/bin/lib/status-command.mjs +35 -22
  121. package/bin/lib/status-manifest.mjs +50 -30
  122. package/bin/lib/status-transition-catalog.mjs +0 -6
  123. package/bin/lib/suggestions.mjs +3 -3
  124. package/bin/lib/team-parliament-io.mjs +76 -88
  125. package/bin/lib/team-parliament.mjs +21 -10
  126. package/bin/lib/upgrade-behavior-changes.mjs +2 -2
  127. package/bin/lib/upgrade-command.mjs +2 -24
  128. package/bin/lib/upgrade-outcome.mjs +1 -1
  129. package/bin/lib/upstream-report.mjs +21 -10
  130. package/bin/lib/violations.mjs +10 -9
  131. package/bin/lib/weakest-link.mjs +13 -0
  132. package/bin/lib/write-path-capabilities.mjs +6 -4
  133. package/dist/chunk-A5EIY3HI.cjs +5 -0
  134. package/dist/chunk-AHACEKSV.js +16 -0
  135. package/dist/chunk-SVSQVGFJ.js +5 -0
  136. package/dist/chunk-UN5EH4QV.cjs +16 -0
  137. package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-GPvvdleM.d.cts} +30 -4
  138. package/dist/configTypes-GPvvdleM.d.ts +257 -0
  139. package/dist/eslint/index.cjs +18 -5
  140. package/dist/eslint/index.d.cts +368 -0
  141. package/dist/eslint/index.d.ts +139 -156
  142. package/dist/eslint/index.js +11 -5
  143. package/dist/index.cjs +36 -35
  144. package/dist/index.d.cts +5547 -0
  145. package/dist/index.d.ts +3283 -198
  146. package/dist/index.js +36 -35
  147. package/dist/inspectorListen-6DQVJ4DC.js +1 -0
  148. package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
  149. package/dist/nestjs/index.cjs +1 -18
  150. package/dist/nestjs/index.d.cts +22 -0
  151. package/dist/nestjs/index.d.ts +1 -3
  152. package/dist/nestjs/index.js +1 -18
  153. package/dist/order/index.cjs +1 -1
  154. package/dist/order/index.d.cts +155 -0
  155. package/dist/order/index.d.ts +15 -3
  156. package/dist/order/index.js +1 -1
  157. package/dist/runtime/index.cjs +13 -30
  158. package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +828 -276
  159. package/dist/runtime/index.d.ts +2741 -133
  160. package/dist/runtime/index.js +13 -30
  161. package/dist/{types-Bdj32QwA.d.ts → types-CnauMQQL.d.cts} +607 -163
  162. package/dist/types-CnauMQQL.d.ts +1800 -0
  163. package/docs/README.md +3 -1
  164. package/docs/agent-guide.md +217 -16
  165. package/docs/ai-gates.md +174 -26
  166. package/docs/arkorder.md +32 -15
  167. package/docs/brownfield-adoption.md +5 -1
  168. package/docs/configuration.md +230 -31
  169. package/docs/develop.md +2 -1
  170. package/docs/diagnostics.md +212 -19
  171. package/docs/package-surface.md +35 -9
  172. package/docs/product-voice.md +7 -1
  173. package/package.json +69 -19
  174. package/schemas/ark.analysis-result.schema.json +15 -0
  175. package/schemas/ark.config.schema.json +77 -12
  176. package/schemas/ark.invariant-probe.schema.json +368 -0
  177. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  178. package/schemas/ark.status-manifest.schema.json +22 -0
  179. package/server.json +3 -9
  180. package/templates/agent-skills/README.md +1 -1
  181. package/templates/agent-skills/ark-adopt/SKILL.md +5 -1
  182. package/templates/agent-skills/ark-contract/SKILL.md +17 -0
  183. package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
  184. package/templates/agent-skills/ark-explore/SKILL.md +7 -0
  185. package/templates/agent-skills/ark-fix/SKILL.md +20 -0
  186. package/templates/agent-skills/ark-order/SKILL.md +4 -2
  187. package/templates/agent-skills/ark-place/SKILL.md +8 -0
  188. package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
  189. package/templates/arkrules/PersistenceAdapters.json +1 -1
  190. package/templates/arkrules/PresentationAdapters.json +1 -1
  191. package/templates/skills/ark-adopt.md +5 -1
  192. package/templates/skills/ark-contract.md +17 -0
  193. package/templates/skills/ark-coverage.md +2 -0
  194. package/templates/skills/ark-explore.md +7 -0
  195. package/templates/skills/ark-fix.md +20 -0
  196. package/templates/skills/ark-order.md +4 -2
  197. package/templates/skills/ark-place.md +8 -0
  198. package/templates/skills/ark-runtime.md +6 -1
  199. package/bin/lib/ark-order-error.mjs +0 -18
  200. package/bin/lib/ark-order-invariants.mjs +0 -346
  201. package/dist/types-Ik_OakcI.d.ts +0 -183
@@ -9,6 +9,11 @@
9
9
  */
10
10
 
11
11
  import { DOMAIN_EVENTS_PUSH_RE, DOMAIN_INVARIANT_WORD_RE, expectedDomainInvariantWordsPhrase, isIdiomaticEventsReset, } from './arkrules-sensors.mjs';
12
+ /** Baseline key belongs to the ArkRules plane (structure sensors or invariants). */
13
+ export function isArkRulesFrozenKey(key) {
14
+ const ruleId = key.split('|', 1)[0] ?? '';
15
+ return ruleId.startsWith('ARKRULE_') || ruleId.startsWith('INVARIANT_');
16
+ }
12
17
  function lineOf(content, index) {
13
18
  return content.slice(0, index).split('\n').length;
14
19
  }
@@ -274,13 +279,27 @@ export function buildRulesInventory(input) {
274
279
  a.line - b.line ||
275
280
  a.kind.localeCompare(b.kind) ||
276
281
  a.id.localeCompare(b.id));
277
- const underContract = candidates.filter((c) => (c.suggestedArkRule?.invariantId && contracted.has(c.suggestedArkRule.invariantId)) ||
278
- (c.suggestedArkRule?.structureId && contracted.has(c.suggestedArkRule.structureId))).length;
282
+ const contractedSensors = new Set((input.contractedStructure ?? []).map((rule) => `${rule.layer}\u0000${rule.sensor}`));
283
+ const underContract = candidates.filter((c) => {
284
+ const suggestion = c.suggestedArkRule;
285
+ if (!suggestion)
286
+ return false;
287
+ if (suggestion.invariantId && contracted.has(suggestion.invariantId))
288
+ return true;
289
+ if (!suggestion.structureId)
290
+ return false;
291
+ // Structure suggestions: the sensor on that layer is what enforces, not the id string.
292
+ if (suggestion.sensor &&
293
+ contractedSensors.has(`${suggestion.layer}\u0000${suggestion.sensor}`)) {
294
+ return true;
295
+ }
296
+ return contracted.has(suggestion.structureId);
297
+ }).length;
279
298
  return {
280
299
  candidates,
281
300
  inventoried: candidates.length,
282
301
  underContract,
283
- frozen: (input.frozenKeys ?? []).length,
302
+ frozen: (input.frozenKeys ?? []).filter(isArkRulesFrozenKey).length,
284
303
  notAScore: true,
285
304
  };
286
305
  }
@@ -4,21 +4,25 @@
4
4
  * Summary includes per-layer + structure/invariant detail so showcase HTML /ark-explain
5
5
  * can teach what is under contract, not only aggregate numbers.
6
6
  */
7
+ import fs from 'node:fs';
7
8
  import path from 'node:path';
8
9
  import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
10
+ import { buildRulesInventory, inventoryToExtractionCard } from './rules-inventory.mjs';
11
+ import { collectGovernedFiles } from './scan-files.mjs';
12
+ import { readBaseline } from './violations.mjs';
9
13
  import { evaluateInvariantCoverage, formatCoverageDiscards } from './invariant-coverage.mjs';
10
14
  import {
11
15
  coverageOptionsFromConfig,
12
16
  invariantIdsFromCatalog,
13
17
  loadInvariantCoverageInputs,
14
18
  } from './invariant-coverage-io.mjs';
19
+ import { formatProbeStatusLines, probeStatusSection } from './invariant-probe-io.mjs';
15
20
  import {
16
- EXTRA_MERGE_TEETH_GOVERNED_FLOOR,
17
21
  composeMergePlanesHonesty,
18
22
  demoteExtraPlaneTeethUnderClassificationFloor,
19
23
  } from './extra-merge-teeth.mjs';
20
24
  import { collectEmptyInvariantCatalogFindings } from './arkrules-sensors.mjs';
21
- import { layerForRelativePath } from '../ark-layer-match.mjs';
25
+ import { layerForFile, layerForRelativePath } from '../ark-layer-match.mjs';
22
26
  import {
23
27
  ARKRULES_EMPTY_CATALOG_NEXT,
24
28
  ARKRULES_FIRST_CONTACT_NEXT,
@@ -35,8 +39,6 @@ const COVERED_SAMPLE_MAX = 24;
35
39
  const STRUCTURE_CATALOG_MAX = 40;
36
40
  const UNCOVERED_CATALOG_MAX = 30;
37
41
 
38
- export { EXTRA_MERGE_TEETH_GOVERNED_FLOOR };
39
-
40
42
  /** Doctor walk often hands absolute paths; layer globs are project-relative. */
41
43
  function projectRelativePath(root, filePath) {
42
44
  const posix = String(filePath).replace(/\\/g, '/');
@@ -96,9 +98,80 @@ function arkOrderMergeInput(config, residualCount = 0) {
96
98
  };
97
99
  }
98
100
 
99
- export function summarizeRulesUnderContract(root, config, facts, classification) {
101
+ function rulesBySlice(arkRules) {
102
+ const groups = new Map();
103
+ const rows = [...(arkRules?.structure ?? []), ...(arkRules?.invariants ?? [])];
104
+ for (const entry of rows) {
105
+ const childId = entry?.provenance?.childId;
106
+ const sourceFile = entry?.provenance?.sourceFile;
107
+ if (typeof childId !== 'string' || typeof sourceFile !== 'string') continue;
108
+ const key = `${childId}\0${sourceFile}`;
109
+ let group = groups.get(key);
110
+ if (!group) {
111
+ group = {
112
+ slice: childId,
113
+ layer: entry.provenance?.layer ?? null,
114
+ sourceFile,
115
+ ids: [],
116
+ appliesTo: [],
117
+ };
118
+ groups.set(key, group);
119
+ }
120
+ if (typeof entry.id === 'string' && !group.ids.includes(entry.id)) group.ids.push(entry.id);
121
+ for (const pattern of entry.appliesTo ?? []) {
122
+ if (typeof pattern === 'string' && !group.appliesTo.includes(pattern)) group.appliesTo.push(pattern);
123
+ }
124
+ }
125
+ return [...groups.values()]
126
+ .map((group) => ({
127
+ ...group,
128
+ ids: [...group.ids].sort(),
129
+ appliesTo: [...group.appliesTo].sort(),
130
+ }))
131
+ .sort((left, right) => left.slice.localeCompare(right.slice) || left.sourceFile.localeCompare(right.sourceFile));
132
+ }
133
+
134
+ /** ADR 0012 D2 drift: arkrules/*.json no arkRules entry references (advisory). */
135
+ function unreferencedArkRulesFiles(loaded) {
136
+ const files = (loaded?.warnings ?? []).map((w) => w.path).filter(Boolean);
137
+ return files.length > 0 ? { unreferencedFiles: files } : {};
138
+ }
139
+
140
+ /**
141
+ * @param {{ rulesMigration?: boolean }} [options] rulesMigration:false skips the
142
+ * migration counts (the inventory payload computes them itself).
143
+ */
144
+ function governedPathsFromFacts(root, facts) {
145
+ const rows = facts?.files;
146
+ if (!Array.isArray(rows) || rows.length === 0) return undefined;
147
+ const base = String(root).replace(/\\/g, '/').replace(/\/+$/, '');
148
+ const prefix = `${base}/`;
149
+ const out = [];
150
+ for (const entry of rows) {
151
+ const raw = typeof entry === 'string' ? entry : entry?.path;
152
+ if (typeof raw !== 'string' || raw.length === 0) continue;
153
+ const norm = raw.replace(/\\/g, '/');
154
+ const rel = norm.startsWith(prefix)
155
+ ? norm.slice(prefix.length)
156
+ : norm.replace(/^\.\//, '');
157
+ if (!rel || rel.startsWith('..') || rel.startsWith('/')) continue;
158
+ out.push(rel);
159
+ }
160
+ return out.length > 0 ? out : undefined;
161
+ }
162
+
163
+ export function summarizeRulesUnderContract(root, config, facts, classification, options = {}) {
164
+ const governedFiles = governedPathsFromFacts(root, facts);
165
+ const loadOpts = governedFiles ? { files: governedFiles } : {};
100
166
  if (!config?.arkRules || Object.keys(config.arkRules).length === 0) {
167
+ let drift = {};
168
+ try {
169
+ drift = unreferencedArkRulesFiles(loadEffectiveArkRulesFromDisk(root, config, loadOpts));
170
+ } catch {
171
+ drift = {};
172
+ }
101
173
  return {
174
+ ...drift,
102
175
  active: false,
103
176
  structureRules: 0,
104
177
  invariants: 0,
@@ -115,7 +188,7 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
115
188
  };
116
189
  }
117
190
  try {
118
- const loaded = loadEffectiveArkRulesFromDisk(root, config);
191
+ const loaded = loadEffectiveArkRulesFromDisk(root, config, loadOpts);
119
192
  if (loaded.errors?.length) {
120
193
  return {
121
194
  active: true,
@@ -146,6 +219,7 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
146
219
  const covById = new Map(
147
220
  (coverage.coverage ?? []).map((row) => [row.invariantId, row])
148
221
  );
222
+ const probeSection = probeStatusSection(root, loaded.arkRules.invariants);
149
223
  const byLayer = loaded.arkRules.byLayer ?? {};
150
224
  const layers = Object.keys(byLayer)
151
225
  .sort((a, b) => a.localeCompare(b))
@@ -159,6 +233,7 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
159
233
  return {
160
234
  name,
161
235
  sourceFile: part.sourceFile ?? null,
236
+ ...(Array.isArray(part.sourceFiles) ? { sourceFiles: [...part.sourceFiles] } : {}),
162
237
  structureRules: (part.structure ?? []).length,
163
238
  invariants: layerInvariants.length,
164
239
  coveredInvariants: covered,
@@ -191,7 +266,7 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
191
266
 
192
267
  const symbolEvidence = (coverage.coverage ?? [])
193
268
  .filter((row) => typeof row.symbolEvidenceFile === 'string' && row.symbolEvidenceFile.length > 0)
194
- .map((row) => ({ id: row.invariantId, file: row.symbolEvidenceFile }));
269
+ .map((row) => ({ id: row.invariantId, file: projectRelativePath(root, row.symbolEvidenceFile) }));
195
270
  const coveredAll = (coverage.coverage ?? [])
196
271
  .filter((row) => row.covered)
197
272
  .map((row) => ({
@@ -200,7 +275,7 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
200
275
  mode: row.mode ?? null,
201
276
  description: row.description ?? null,
202
277
  ...(typeof row.symbolEvidenceFile === 'string' && row.symbolEvidenceFile.length > 0
203
- ? { symbolEvidenceFile: row.symbolEvidenceFile }
278
+ ? { symbolEvidenceFile: projectRelativePath(root, row.symbolEvidenceFile) }
204
279
  : {}),
205
280
  }));
206
281
  const coveredTruncated = Math.max(0, coveredAll.length - COVERED_SAMPLE_MAX);
@@ -259,6 +334,7 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
259
334
  partialCoverage: coverage.partial,
260
335
  testFilesScanned: coverageInputs.testFiles.length,
261
336
  layers,
337
+ ...(rulesBySlice(loaded.arkRules).length > 0 ? { bySlice: rulesBySlice(loaded.arkRules) } : {}),
262
338
  structure,
263
339
  structureTruncated,
264
340
  uncovered,
@@ -266,7 +342,17 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
266
342
  coveredSample,
267
343
  coveredTruncated,
268
344
  symbolEvidence,
345
+ ...unreferencedArkRulesFiles(loaded),
346
+ // AR15: inventoried / under contract / frozen — same helper as --rules-inventory.
347
+ ...(options.rulesMigration === false
348
+ ? {}
349
+ : {
350
+ rulesMigration: buildRulesMigration({ root, config, arkRules: loaded.arkRules })
351
+ .rulesMigration,
352
+ }),
269
353
  ...(coverageInputs.stats ? { coverageStats: coverageInputs.stats } : {}),
354
+ // ADR 0039: only when a probe artifact exists — absence stays silent.
355
+ ...(probeSection ? { probe: probeSection } : {}),
270
356
  mergePlanes,
271
357
  notAScore: true,
272
358
  note: 'ArkRules plane (intra-layer) — counts and catalog, never a score. Green with uncovered residual must say so. Structure sensors are heuristics; invariants are catalog+coverage evidence, not a business runtime.',
@@ -306,6 +392,7 @@ export function formatArkRulesEvidenceLines(section) {
306
392
  }
307
393
  const discard = formatCoverageDiscards(section.coverageStats);
308
394
  if (discard) lines.push(`ArkRules:${discard}`);
395
+ lines.push(...formatProbeStatusLines(section.probe));
309
396
  return lines;
310
397
  }
311
398
 
@@ -314,9 +401,19 @@ export function formatArkRulesEvidenceLines(section) {
314
401
  * @param {ReturnType<typeof summarizeRulesUnderContract>|null|undefined} section
315
402
  * @returns {string[]}
316
403
  */
404
+ function unreferencedFilesLine(section) {
405
+ const files = Array.isArray(section?.unreferencedFiles) ? section.unreferencedFiles : [];
406
+ if (files.length === 0) return [];
407
+ const shown = files.slice(0, 3).join(', ');
408
+ const more = files.length > 3 ? ` (+${files.length - 3} more)` : '';
409
+ return [
410
+ `ArkRules: ${shown}${more} not referenced by arkRules — nothing in it is enforced (ARKRULE_FILE_UNREFERENCED, advisory).`,
411
+ ];
412
+ }
413
+
317
414
  export function formatArkRulesDoctorLines(section) {
318
415
  if (!section || typeof section !== 'object') return [];
319
- if (section.active !== true) return [];
416
+ if (section.active !== true) return unreferencedFilesLine(section);
320
417
 
321
418
  if (Array.isArray(section.loadErrors) && section.loadErrors.length > 0) {
322
419
  const first = section.loadErrors[0];
@@ -362,6 +459,10 @@ export function formatArkRulesDoctorLines(section) {
362
459
  lines.push(`ArkRules: ${section.note}`);
363
460
  }
364
461
  lines.push(...formatArkRulesEvidenceLines(section));
462
+ if (section.rulesMigration) {
463
+ lines.push(`ArkRules migration: ${formatRulesMigrationCounts(section.rulesMigration)}.`);
464
+ }
465
+ lines.push(...unreferencedFilesLine(section));
365
466
  return lines;
366
467
  }
367
468
 
@@ -559,6 +660,11 @@ export function formatRulesUnderContractHtml(section, esc) {
559
660
  }
560
661
  ${uncoveredBlock}
561
662
  ${coveredBlock}
663
+ ${
664
+ section.rulesMigration
665
+ ? `<p class="muted" style="margin-top:.35rem;font-size:.84rem">Rules migration: ${escape(formatRulesMigrationCounts(section.rulesMigration))}</p>`
666
+ : ''
667
+ }
562
668
  ${
563
669
  formatArkRulesEvidenceLines(section)
564
670
  .map((line) => `<p class="muted" style="margin-top:.35rem;font-size:.84rem">${escape(line)}</p>`)
@@ -572,3 +678,135 @@ export function formatRulesUnderContractHtml(section, esc) {
572
678
  ${note}
573
679
  </section>`;
574
680
  }
681
+
682
+ const RULES_INVENTORY_MAX_FILES = 400;
683
+
684
+ /**
685
+ * Brownfield rules migration (AR13/AR15): one code path for `--rules-inventory`,
686
+ * MCP `ark_rules_inventory`, doctor, and the HTML report. Honest counts, never a score:
687
+ * - inventoried: candidates the heuristic inventory found (first 400 governed files);
688
+ * - underContract: candidates whose suggested ArkRule is declared — structure
689
+ * suggestions by (layer, sensor), invariant suggestions by id;
690
+ * - frozen: ArkRules-plane keys (`ARKRULE_*` / `INVARIANT_*`) in .ark-baseline.json.
691
+ * @param {{ root: string, config: Record<string, any>, files?: string[], arkRules?: any }} input
692
+ * `files` defaults to the governed walk; `arkRules` to the catalog on disk.
693
+ */
694
+ function buildRulesMigration({ root, config, files, arkRules }) {
695
+ const governed = Array.isArray(files) ? files : collectGovernedFiles(root, config);
696
+ const fileContents = {};
697
+ const fileLayers = {};
698
+ for (const file of governed.slice(0, RULES_INVENTORY_MAX_FILES)) {
699
+ const absolute = path.isAbsolute(file) ? file : path.resolve(root, file);
700
+ const rel = path.relative(root, absolute).split(path.sep).join('/');
701
+ try {
702
+ fileContents[rel] = fs.readFileSync(absolute, 'utf8');
703
+ const layer = layerForFile(root, absolute, config.layers);
704
+ if (layer) fileLayers[rel] = layer;
705
+ } catch {
706
+ /* skip unreadable */
707
+ }
708
+ }
709
+ let catalog = arkRules;
710
+ if (catalog === undefined && config?.arkRules) {
711
+ try {
712
+ const loaded = loadEffectiveArkRulesFromDisk(root, config, {
713
+ files: governed.map((file) => {
714
+ const absolute = path.isAbsolute(file) ? file : path.resolve(root, file);
715
+ return path.relative(root, absolute).split(path.sep).join('/');
716
+ }),
717
+ });
718
+ catalog = loaded.errors.length > 0 ? null : loaded.arkRules;
719
+ } catch {
720
+ catalog = null; // the advisory inventory is still useful
721
+ }
722
+ }
723
+ const contractedRuleIds = [];
724
+ const contractedStructure = [];
725
+ for (const rule of catalog?.structure ?? []) {
726
+ contractedRuleIds.push(rule.id);
727
+ const layer = rule.provenance?.layer ?? rule.layer;
728
+ if (layer && rule.sensor) contractedStructure.push({ layer, sensor: rule.sensor });
729
+ }
730
+ for (const inv of catalog?.invariants ?? []) contractedRuleIds.push(inv.id);
731
+ let frozenKeys = [];
732
+ try {
733
+ frozenKeys = [...readBaseline(root, '.ark-baseline.json').keys];
734
+ } catch {
735
+ frozenKeys = []; // an unreadable baseline freezes nothing
736
+ }
737
+ const inventory = buildRulesInventory({
738
+ fileContents,
739
+ fileLayers,
740
+ layerContexts: (config.layers ?? []).map((layer) => ({
741
+ name: layer.name,
742
+ intentPrefixes: layer.intentPrefixes ?? [],
743
+ })),
744
+ contractedRuleIds,
745
+ contractedStructure,
746
+ frozenKeys,
747
+ });
748
+ return {
749
+ inventory,
750
+ rulesMigration: {
751
+ inventoried: inventory.inventoried,
752
+ underContract: inventory.underContract,
753
+ frozen: inventory.frozen,
754
+ notAScore: true,
755
+ },
756
+ nextPilot:
757
+ inventory.candidates[0] != null ? inventoryToExtractionCard(inventory.candidates[0]) : null,
758
+ };
759
+ }
760
+
761
+ /** `N inventoried, N under contract, N frozen (not a score)` — shared CLI/doctor wording. */
762
+ export function formatRulesMigrationCounts(rulesMigration) {
763
+ if (!rulesMigration || typeof rulesMigration !== 'object') return '';
764
+ return `${rulesMigration.inventoried} inventoried, ${rulesMigration.underContract} under contract, ${rulesMigration.frozen} frozen (not a score)`;
765
+ }
766
+
767
+ /**
768
+ * Full `--rules-inventory` / `ark_rules_inventory` payload (same keys on both surfaces).
769
+ * @param {string} root
770
+ * @param {Record<string, any>} config
771
+ * @param {string[]} [files] governed files (absolute or root-relative)
772
+ */
773
+ export function buildRulesInventoryPayload(root, config, files) {
774
+ const governed = Array.isArray(files) ? files : collectGovernedFiles(root, config);
775
+ const migration = buildRulesMigration({ root, config, files: governed });
776
+ let evidenceLines = [];
777
+ let coverageEvidence = null;
778
+ if (config?.arkRules && Object.keys(config.arkRules).length > 0) {
779
+ try {
780
+ const section = summarizeRulesUnderContract(
781
+ root,
782
+ config,
783
+ {
784
+ files: governed.map((file) => ({
785
+ path: path.relative(root, path.resolve(root, file)).split(path.sep).join('/'),
786
+ })),
787
+ },
788
+ undefined,
789
+ { rulesMigration: false }
790
+ );
791
+ evidenceLines = formatArkRulesEvidenceLines(section);
792
+ if (section.active === true) {
793
+ coverageEvidence = {
794
+ symbolEvidence: section.symbolEvidence ?? [],
795
+ discarded: section.coverageStats?.discarded ?? null,
796
+ ...(section.probe ? { probe: section.probe } : {}),
797
+ };
798
+ }
799
+ } catch {
800
+ /* inventory still useful when coverage cannot be read */
801
+ }
802
+ }
803
+ return {
804
+ payload: {
805
+ rulesInventory: migration.inventory,
806
+ rulesMigration: migration.rulesMigration,
807
+ nextPilot: migration.nextPilot,
808
+ ...(coverageEvidence ? { coverageEvidence } : {}),
809
+ },
810
+ evidenceLines,
811
+ };
812
+ }
@@ -8,12 +8,12 @@ import { isScanExcludedRelative, isSkippedSourceDir } from '../ark-shared.mjs';
8
8
 
9
9
  export { isSkippedSourceDir };
10
10
 
11
- export const SOURCE_FILE_NAME = /\.[cm]?[tj]sx?$/;
11
+ const SOURCE_FILE_NAME = /\.[cm]?[tj]sx?$/;
12
12
 
13
13
  /** Unit/e2e test files are not architecture surface — agents and Nest put them next
14
14
  * to production code (*.spec.ts). Counting them as ungoverned forces false
15
15
  * CONFIG_UNCLASSIFIED_FILES under --strict-config on every starter. */
16
- export const TEST_FILE_NAME =
16
+ const TEST_FILE_NAME =
17
17
  /(^test(?:[-_.]).*|\.(spec|test)(?:-d)?\.)(tsx?|jsx?|mjsx?|cjsx?|mts|cts)$/i;
18
18
 
19
19
  export function isGovernableSourceFile(name) {
@@ -32,6 +32,17 @@ function isInsideRoot(root, target) {
32
32
  * Internal symlink directories are followed once (TypeScript follows them too),
33
33
  * while escaping links fail closed instead of reading arbitrary filesystem paths.
34
34
  */
35
+ /**
36
+ * Return `value` flattened. V8 keeps path.join / concatenation results as rope
37
+ * strings (a tree of segment objects); the per-file syscalls of the generic walk
38
+ * branch flattened them as a side effect. Without it, every retained path kept its
39
+ * rope alive and a 20k-file walk peaked ~70 MB higher. ToNumber flattens in place.
40
+ */
41
+ function flatString(value) {
42
+ void Number(value);
43
+ return value;
44
+ }
45
+
35
46
  export function walk(dir, files = [], options = {}) {
36
47
  let state = options.state;
37
48
  if (!state) {
@@ -67,6 +78,7 @@ export function walk(dir, files = [], options = {}) {
67
78
  !state.visitedFiles.has(resolved)
68
79
  ) {
69
80
  state.visitedFiles.add(resolved);
81
+ state.onFile?.(dir, resolved);
70
82
  files.push(dir);
71
83
  }
72
84
  return files;
@@ -84,6 +96,25 @@ export function walk(dir, files = [], options = {}) {
84
96
  } else if (entry.isSymbolicLink()) {
85
97
  if (isSkippedSourceDir(entry.name)) continue;
86
98
  walk(full, files, { state });
99
+ } else if (entry.isFile()) {
100
+ // A regular (non-link) file inside an already-resolved directory: its real
101
+ // path is that directory's real path plus its name. Same result as the
102
+ // per-file lstat + realpath of the generic branch, without two syscalls
103
+ // and two Stats objects for every source file.
104
+ if (!isGovernableSourceFile(entry.name)) continue;
105
+ state.observeInput?.(path.resolve(full), 'lstat');
106
+ state.observeInput?.(path.resolve(full), 'realpath');
107
+ // `resolved` is already a normalized real path and an entry name has no
108
+ // separator, so plain concatenation equals path.join(resolved, name).
109
+ const real = flatString(
110
+ resolved.endsWith(path.sep)
111
+ ? `${resolved}${entry.name}`
112
+ : `${resolved}${path.sep}${entry.name}`
113
+ );
114
+ if (state.visitedFiles.has(real)) continue;
115
+ state.visitedFiles.add(real);
116
+ state.onFile?.(full, real);
117
+ files.push(flatString(full));
87
118
  } else if (isGovernableSourceFile(entry.name)) {
88
119
  walk(full, files, { state });
89
120
  }
@@ -92,7 +123,7 @@ export function walk(dir, files = [], options = {}) {
92
123
  }
93
124
 
94
125
  /** Walk include roots then drop codegen / config.exclude (universal scan filter). */
95
- export function isIncludeMatch(relativePath, include) {
126
+ function isIncludeMatch(relativePath, include) {
96
127
  return (include ?? []).some((entry) => {
97
128
  const includeRoot = String(entry)
98
129
  .replace(/\\/g, '/')
@@ -138,6 +169,7 @@ export function collectGovernedFiles(root, config, options = {}) {
138
169
  visitedDirectories: new Set(),
139
170
  visitedFiles: new Set(),
140
171
  onDirectory: options.onDirectory,
172
+ onFile: options.onFile,
141
173
  observeInput: options.observeInput,
142
174
  };
143
175
  const raw = (config.include ?? []).flatMap((entry) =>
@@ -160,7 +192,7 @@ export function normalize(value) {
160
192
  export const UNGOVERNED_PROBE_CAP = 200;
161
193
 
162
194
  /** Tooling configs (vite.config.ts, eslint.config.js …) are not the product source a contract governs. */
163
- export const TOOLING_CONFIG_FILE_NAME = /\.config\.[cm]?[jt]sx?$/i;
195
+ const TOOLING_CONFIG_FILE_NAME = /\.config\.[cm]?[jt]sx?$/i;
164
196
 
165
197
  /**
166
198
  * Count governable source files under `root` that the contract's own scope cannot hide.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/schemaValidation.ts
5
+ * Regenerate: node scripts/generate-cli-pure.mjs
6
+ * Drift check: node scripts/generate-cli-pure.mjs --check
7
+ *
8
+ * Pure CLI helper (bin/lib/schema-validation.mjs). Zero Node I/O.
9
+ */
10
+
11
+ export function isObject(value) {
12
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
13
+ }
14
+ export function propertyPath(parent, key) {
15
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
16
+ ? `${parent}.${key}`
17
+ : `${parent}[${JSON.stringify(key)}]`;
18
+ }
19
+ export function valueType(value) {
20
+ if (value === null)
21
+ return 'null';
22
+ if (Array.isArray(value))
23
+ return 'array';
24
+ return typeof value;
25
+ }
26
+ function resolveSchemaRef(ref, root) {
27
+ const prefix = '#/$defs/';
28
+ if (!ref.startsWith(prefix))
29
+ return undefined;
30
+ return root.$defs[ref.slice(prefix.length)];
31
+ }
32
+ /** Walk `value` against `schema`, appending every finding to `issues`. */
33
+ export function validateSchemaNode(value, schema, path, root, issues) {
34
+ if (schema.$ref) {
35
+ const referenced = resolveSchemaRef(schema.$ref, root);
36
+ if (!referenced) {
37
+ issues.push({ path, message: `schema reference ${schema.$ref} cannot be resolved` });
38
+ return;
39
+ }
40
+ validateSchemaNode(value, referenced, path, root, issues);
41
+ return;
42
+ }
43
+ if (schema.const !== undefined && !Object.is(value, schema.const)) {
44
+ issues.push({ path, message: `must equal ${JSON.stringify(schema.const)}` });
45
+ return;
46
+ }
47
+ if (schema.enum && !schema.enum.some((candidate) => Object.is(candidate, value))) {
48
+ issues.push({ path, message: `must be one of ${schema.enum.map(String).join(', ')}` });
49
+ return;
50
+ }
51
+ if (schema.type === 'object') {
52
+ if (!isObject(value)) {
53
+ issues.push({ path, message: `must be an object; received ${valueType(value)}` });
54
+ return;
55
+ }
56
+ const properties = schema.properties ?? {};
57
+ for (const key of schema.required ?? []) {
58
+ if (value[key] === undefined) {
59
+ issues.push({ path: propertyPath(path, key), message: 'is required' });
60
+ }
61
+ }
62
+ if (schema.additionalProperties === false) {
63
+ for (const key of Object.keys(value)) {
64
+ if (!(key in properties)) {
65
+ issues.push({ path: propertyPath(path, key), message: 'unknown field' });
66
+ }
67
+ }
68
+ }
69
+ else if (schema.additionalProperties !== undefined &&
70
+ schema.additionalProperties !== true &&
71
+ typeof schema.additionalProperties === 'object') {
72
+ const additional = schema.additionalProperties;
73
+ for (const key of Object.keys(value)) {
74
+ if (!(key in properties)) {
75
+ validateSchemaNode(value[key], additional, propertyPath(path, key), root, issues);
76
+ }
77
+ }
78
+ }
79
+ for (const [key, childSchema] of Object.entries(properties)) {
80
+ if (value[key] !== undefined) {
81
+ validateSchemaNode(value[key], childSchema, propertyPath(path, key), root, issues);
82
+ }
83
+ }
84
+ return;
85
+ }
86
+ if (schema.type === 'array') {
87
+ if (!Array.isArray(value)) {
88
+ issues.push({ path, message: `must be an array; received ${valueType(value)}` });
89
+ return;
90
+ }
91
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
92
+ issues.push({ path, message: `must contain at least ${schema.minItems} item(s)` });
93
+ }
94
+ if (schema.uniqueItems) {
95
+ const serialized = value.map((entry) => JSON.stringify(entry));
96
+ if (new Set(serialized).size !== serialized.length) {
97
+ issues.push({ path, message: 'must not contain duplicate items' });
98
+ }
99
+ }
100
+ if (schema.items) {
101
+ value.forEach((entry, index) => validateSchemaNode(entry, schema.items, `${path}[${index}]`, root, issues));
102
+ }
103
+ return;
104
+ }
105
+ if (schema.type === 'string') {
106
+ if (typeof value !== 'string') {
107
+ issues.push({ path, message: `must be a string; received ${valueType(value)}` });
108
+ return;
109
+ }
110
+ if (schema.minLength !== undefined && value.length < schema.minLength) {
111
+ issues.push({ path, message: `must contain at least ${schema.minLength} character(s)` });
112
+ }
113
+ return;
114
+ }
115
+ if (schema.type === 'boolean') {
116
+ if (typeof value !== 'boolean') {
117
+ issues.push({ path, message: `must be a boolean; received ${valueType(value)}` });
118
+ }
119
+ return;
120
+ }
121
+ if (schema.type === 'integer') {
122
+ if (!Number.isInteger(value)) {
123
+ issues.push({ path, message: `must be an integer; received ${valueType(value)}` });
124
+ return;
125
+ }
126
+ if (schema.minimum !== undefined && value < schema.minimum) {
127
+ issues.push({ path, message: `must be at least ${schema.minimum}` });
128
+ }
129
+ }
130
+ }
@@ -11,18 +11,9 @@ import path from 'node:path';
11
11
 
12
12
  import { arkCommand } from '../ark-shared.mjs';
13
13
  import { collectGovernedFiles, normalize } from './scan-files.mjs';
14
+ // Same palette and TTY test as the entry's stderr output.
15
+ import { color } from './violations.mjs';
14
16
 
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
17
 
27
18
  /**
28
19
  * A rule id or a source path comes out of the project's own ArkRules JSON and