arkgate 4.8.23 → 4.8.24

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 (131) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +24 -77
  4. package/bin/ark-dashboard.mjs +56 -53
  5. package/bin/ark-layer-match.mjs +596 -112
  6. package/bin/ark-mcp-runtime.mjs +412 -138
  7. package/bin/ark-mcp.mjs +68 -4
  8. package/bin/ark-shared.mjs +39 -0
  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/agent-projection-command.mjs +5 -1
  13. package/bin/lib/analysis-engine.mjs +15 -11
  14. package/bin/lib/architecture-scan.mjs +50 -6
  15. package/bin/lib/ark-order-doctor.mjs +1 -1
  16. package/bin/lib/ark-order-facts.mjs +410 -15
  17. package/bin/lib/ark-order-invariants.mjs +51 -9
  18. package/bin/lib/ark-order-sensors.mjs +28 -101
  19. package/bin/lib/ark-order-types.mjs +2 -0
  20. package/bin/lib/ark-run-doctor.mjs +14 -0
  21. package/bin/lib/ark-run-facts.mjs +434 -11
  22. package/bin/lib/ark-run-sensors.mjs +5 -3
  23. package/bin/lib/arkgate-pins.mjs +91 -0
  24. package/bin/lib/arkrules-sensors.mjs +50 -249
  25. package/bin/lib/check-args.mjs +23 -1
  26. package/bin/lib/ci-and-commands.mjs +16 -6
  27. package/bin/lib/class-source-scan.mjs +479 -0
  28. package/bin/lib/codex-home.mjs +4 -2
  29. package/bin/lib/config-contract-slices.mjs +701 -0
  30. package/bin/lib/config-contract.mjs +5 -493
  31. package/bin/lib/config-warnings.mjs +32 -0
  32. package/bin/lib/dashboard-cli.mjs +62 -0
  33. package/bin/lib/design-delta.mjs +1 -1
  34. package/bin/lib/diagnostic-catalog.mjs +16 -9
  35. package/bin/lib/doctor-advisories.mjs +25 -1
  36. package/bin/lib/doctor-next-actions.mjs +17 -2
  37. package/bin/lib/doctor-plan.mjs +6 -8
  38. package/bin/lib/effective-contract-load.mjs +121 -59
  39. package/bin/lib/enforcement-profiles.mjs +40 -4
  40. package/bin/lib/first-run-help.mjs +3 -2
  41. package/bin/lib/flat-parent-importers.mjs +137 -0
  42. package/bin/lib/flat-parent-pilot.mjs +40 -100
  43. package/bin/lib/gate-files.mjs +29 -2
  44. package/bin/lib/git-change-scope.mjs +109 -0
  45. package/bin/lib/github-enforcement.mjs +1 -1
  46. package/bin/lib/hook-templates.mjs +18 -16
  47. package/bin/lib/host-support-matrix.mjs +3 -1
  48. package/bin/lib/import-resolve.mjs +13 -1
  49. package/bin/lib/install-migrate.mjs +50 -73
  50. package/bin/lib/invariant-coverage.mjs +59 -11
  51. package/bin/lib/mcp-adoption.mjs +9 -1
  52. package/bin/lib/mcp-hook-payload.mjs +401 -61
  53. package/bin/lib/package-manager.mjs +59 -1
  54. package/bin/lib/passthrough-commands.mjs +83 -0
  55. package/bin/lib/persistence-write-hint.mjs +81 -0
  56. package/bin/lib/pin-dependent-gates.mjs +117 -0
  57. package/bin/lib/policy-delta-io.mjs +168 -10
  58. package/bin/lib/prepare-change.mjs +120 -6
  59. package/bin/lib/prepare-write.mjs +1 -0
  60. package/bin/lib/presets.mjs +3 -5
  61. package/bin/lib/remediation.mjs +23 -4
  62. package/bin/lib/resolved-candidate-facts.mjs +315 -55
  63. package/bin/lib/rules-inventory.mjs +22 -3
  64. package/bin/lib/rules-under-contract.mjs +179 -5
  65. package/bin/lib/scan-files.mjs +32 -0
  66. package/bin/lib/snippet-analysis.mjs +140 -6
  67. package/bin/lib/source-policy.mjs +34 -0
  68. package/bin/lib/stable-hash.mjs +90 -0
  69. package/bin/lib/start-install-recovery.mjs +71 -1
  70. package/bin/lib/start-preview.mjs +30 -0
  71. package/bin/lib/status-command.mjs +33 -4
  72. package/bin/lib/status-manifest.mjs +32 -4
  73. package/bin/lib/team-parliament-io.mjs +77 -75
  74. package/bin/lib/team-parliament.mjs +6 -1
  75. package/bin/lib/write-path-capabilities.mjs +4 -2
  76. package/dist/chunk-A5EIY3HI.cjs +5 -0
  77. package/dist/chunk-MBXCFLO4.js +16 -0
  78. package/dist/chunk-RNQHRDCQ.cjs +16 -0
  79. package/dist/chunk-SVSQVGFJ.js +5 -0
  80. package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-BJbaeoj4.d.cts} +18 -3
  81. package/dist/configTypes-BJbaeoj4.d.ts +246 -0
  82. package/dist/eslint/index.cjs +17 -4
  83. package/dist/eslint/index.d.cts +359 -0
  84. package/dist/eslint/index.d.ts +130 -156
  85. package/dist/eslint/index.js +10 -4
  86. package/dist/index.cjs +35 -34
  87. package/dist/index.d.cts +5475 -0
  88. package/dist/index.d.ts +3312 -299
  89. package/dist/index.js +35 -34
  90. package/dist/inspectorListen-6DQVJ4DC.js +1 -0
  91. package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
  92. package/dist/nestjs/index.cjs +1 -18
  93. package/dist/nestjs/index.d.cts +22 -0
  94. package/dist/nestjs/index.d.ts +1 -3
  95. package/dist/nestjs/index.js +1 -18
  96. package/dist/order/index.cjs +1 -1
  97. package/dist/order/index.d.cts +155 -0
  98. package/dist/order/index.d.ts +15 -3
  99. package/dist/order/index.js +1 -1
  100. package/dist/runtime/index.cjs +13 -30
  101. package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +804 -290
  102. package/dist/runtime/index.d.ts +2678 -108
  103. package/dist/runtime/index.js +13 -30
  104. package/dist/{types-Bdj32QwA.d.ts → types-DRinG-vT.d.cts} +576 -140
  105. package/dist/types-DRinG-vT.d.ts +1792 -0
  106. package/docs/README.md +2 -1
  107. package/docs/agent-guide.md +84 -15
  108. package/docs/ai-gates.md +160 -26
  109. package/docs/arkorder.md +32 -15
  110. package/docs/brownfield-adoption.md +5 -1
  111. package/docs/configuration.md +196 -26
  112. package/docs/develop.md +2 -1
  113. package/docs/diagnostics.md +96 -19
  114. package/docs/package-surface.md +31 -8
  115. package/docs/product-voice.md +1 -1
  116. package/package.json +59 -18
  117. package/schemas/ark.analysis-result.schema.json +15 -0
  118. package/schemas/ark.config.schema.json +47 -10
  119. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  120. package/schemas/ark.status-manifest.schema.json +22 -0
  121. package/server.json +3 -9
  122. package/templates/agent-skills/README.md +1 -1
  123. package/templates/agent-skills/ark-adopt/SKILL.md +4 -1
  124. package/templates/agent-skills/ark-order/SKILL.md +4 -2
  125. package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
  126. package/templates/arkrules/PersistenceAdapters.json +1 -1
  127. package/templates/arkrules/PresentationAdapters.json +1 -1
  128. package/templates/skills/ark-adopt.md +4 -1
  129. package/templates/skills/ark-order.md +4 -2
  130. package/templates/skills/ark-runtime.md +6 -1
  131. 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,8 +4,12 @@
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,
@@ -18,7 +22,7 @@ import {
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,
@@ -96,9 +100,26 @@ function arkOrderMergeInput(config, residualCount = 0) {
96
100
  };
97
101
  }
98
102
 
99
- export function summarizeRulesUnderContract(root, config, facts, classification) {
103
+ /** ADR 0012 D2 drift: arkrules/*.json no arkRules entry references (advisory). */
104
+ function unreferencedArkRulesFiles(loaded) {
105
+ const files = (loaded?.warnings ?? []).map((w) => w.path).filter(Boolean);
106
+ return files.length > 0 ? { unreferencedFiles: files } : {};
107
+ }
108
+
109
+ /**
110
+ * @param {{ rulesMigration?: boolean }} [options] rulesMigration:false skips the
111
+ * migration counts (the inventory payload computes them itself).
112
+ */
113
+ export function summarizeRulesUnderContract(root, config, facts, classification, options = {}) {
100
114
  if (!config?.arkRules || Object.keys(config.arkRules).length === 0) {
115
+ let drift = {};
116
+ try {
117
+ drift = unreferencedArkRulesFiles(loadEffectiveArkRulesFromDisk(root, config));
118
+ } catch {
119
+ drift = {};
120
+ }
101
121
  return {
122
+ ...drift,
102
123
  active: false,
103
124
  structureRules: 0,
104
125
  invariants: 0,
@@ -191,7 +212,7 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
191
212
 
192
213
  const symbolEvidence = (coverage.coverage ?? [])
193
214
  .filter((row) => typeof row.symbolEvidenceFile === 'string' && row.symbolEvidenceFile.length > 0)
194
- .map((row) => ({ id: row.invariantId, file: row.symbolEvidenceFile }));
215
+ .map((row) => ({ id: row.invariantId, file: projectRelativePath(root, row.symbolEvidenceFile) }));
195
216
  const coveredAll = (coverage.coverage ?? [])
196
217
  .filter((row) => row.covered)
197
218
  .map((row) => ({
@@ -200,7 +221,7 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
200
221
  mode: row.mode ?? null,
201
222
  description: row.description ?? null,
202
223
  ...(typeof row.symbolEvidenceFile === 'string' && row.symbolEvidenceFile.length > 0
203
- ? { symbolEvidenceFile: row.symbolEvidenceFile }
224
+ ? { symbolEvidenceFile: projectRelativePath(root, row.symbolEvidenceFile) }
204
225
  : {}),
205
226
  }));
206
227
  const coveredTruncated = Math.max(0, coveredAll.length - COVERED_SAMPLE_MAX);
@@ -266,6 +287,14 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
266
287
  coveredSample,
267
288
  coveredTruncated,
268
289
  symbolEvidence,
290
+ ...unreferencedArkRulesFiles(loaded),
291
+ // AR15: inventoried / under contract / frozen — same helper as --rules-inventory.
292
+ ...(options.rulesMigration === false
293
+ ? {}
294
+ : {
295
+ rulesMigration: buildRulesMigration({ root, config, arkRules: loaded.arkRules })
296
+ .rulesMigration,
297
+ }),
269
298
  ...(coverageInputs.stats ? { coverageStats: coverageInputs.stats } : {}),
270
299
  mergePlanes,
271
300
  notAScore: true,
@@ -314,9 +343,19 @@ export function formatArkRulesEvidenceLines(section) {
314
343
  * @param {ReturnType<typeof summarizeRulesUnderContract>|null|undefined} section
315
344
  * @returns {string[]}
316
345
  */
346
+ function unreferencedFilesLine(section) {
347
+ const files = Array.isArray(section?.unreferencedFiles) ? section.unreferencedFiles : [];
348
+ if (files.length === 0) return [];
349
+ const shown = files.slice(0, 3).join(', ');
350
+ const more = files.length > 3 ? ` (+${files.length - 3} more)` : '';
351
+ return [
352
+ `ArkRules: ${shown}${more} not referenced by arkRules — nothing in it is enforced (ARKRULE_FILE_UNREFERENCED, advisory).`,
353
+ ];
354
+ }
355
+
317
356
  export function formatArkRulesDoctorLines(section) {
318
357
  if (!section || typeof section !== 'object') return [];
319
- if (section.active !== true) return [];
358
+ if (section.active !== true) return unreferencedFilesLine(section);
320
359
 
321
360
  if (Array.isArray(section.loadErrors) && section.loadErrors.length > 0) {
322
361
  const first = section.loadErrors[0];
@@ -362,6 +401,10 @@ export function formatArkRulesDoctorLines(section) {
362
401
  lines.push(`ArkRules: ${section.note}`);
363
402
  }
364
403
  lines.push(...formatArkRulesEvidenceLines(section));
404
+ if (section.rulesMigration) {
405
+ lines.push(`ArkRules migration: ${formatRulesMigrationCounts(section.rulesMigration)}.`);
406
+ }
407
+ lines.push(...unreferencedFilesLine(section));
365
408
  return lines;
366
409
  }
367
410
 
@@ -559,6 +602,11 @@ export function formatRulesUnderContractHtml(section, esc) {
559
602
  }
560
603
  ${uncoveredBlock}
561
604
  ${coveredBlock}
605
+ ${
606
+ section.rulesMigration
607
+ ? `<p class="muted" style="margin-top:.35rem;font-size:.84rem">Rules migration: ${escape(formatRulesMigrationCounts(section.rulesMigration))}</p>`
608
+ : ''
609
+ }
562
610
  ${
563
611
  formatArkRulesEvidenceLines(section)
564
612
  .map((line) => `<p class="muted" style="margin-top:.35rem;font-size:.84rem">${escape(line)}</p>`)
@@ -572,3 +620,129 @@ export function formatRulesUnderContractHtml(section, esc) {
572
620
  ${note}
573
621
  </section>`;
574
622
  }
623
+
624
+ export const RULES_INVENTORY_MAX_FILES = 400;
625
+
626
+ /**
627
+ * Brownfield rules migration (AR13/AR15): one code path for `--rules-inventory`,
628
+ * MCP `ark_rules_inventory`, doctor, and the HTML report. Honest counts, never a score:
629
+ * - inventoried: candidates the heuristic inventory found (first 400 governed files);
630
+ * - underContract: candidates whose suggested ArkRule is declared — structure
631
+ * suggestions by (layer, sensor), invariant suggestions by id;
632
+ * - frozen: ArkRules-plane keys (`ARKRULE_*` / `INVARIANT_*`) in .ark-baseline.json.
633
+ * @param {{ root: string, config: Record<string, any>, files?: string[], arkRules?: any }} input
634
+ * `files` defaults to the governed walk; `arkRules` to the catalog on disk.
635
+ */
636
+ export function buildRulesMigration({ root, config, files, arkRules }) {
637
+ const governed = Array.isArray(files) ? files : collectGovernedFiles(root, config);
638
+ const fileContents = {};
639
+ const fileLayers = {};
640
+ for (const file of governed.slice(0, RULES_INVENTORY_MAX_FILES)) {
641
+ const absolute = path.isAbsolute(file) ? file : path.resolve(root, file);
642
+ const rel = path.relative(root, absolute).split(path.sep).join('/');
643
+ try {
644
+ fileContents[rel] = fs.readFileSync(absolute, 'utf8');
645
+ const layer = layerForFile(root, absolute, config.layers);
646
+ if (layer) fileLayers[rel] = layer;
647
+ } catch {
648
+ /* skip unreadable */
649
+ }
650
+ }
651
+ let catalog = arkRules;
652
+ if (catalog === undefined && config?.arkRules) {
653
+ try {
654
+ const loaded = loadEffectiveArkRulesFromDisk(root, config);
655
+ catalog = loaded.errors.length > 0 ? null : loaded.arkRules;
656
+ } catch {
657
+ catalog = null; // the advisory inventory is still useful
658
+ }
659
+ }
660
+ const contractedRuleIds = [];
661
+ const contractedStructure = [];
662
+ for (const rule of catalog?.structure ?? []) {
663
+ contractedRuleIds.push(rule.id);
664
+ const layer = rule.provenance?.layer ?? rule.layer;
665
+ if (layer && rule.sensor) contractedStructure.push({ layer, sensor: rule.sensor });
666
+ }
667
+ for (const inv of catalog?.invariants ?? []) contractedRuleIds.push(inv.id);
668
+ let frozenKeys = [];
669
+ try {
670
+ frozenKeys = [...readBaseline(root, '.ark-baseline.json').keys];
671
+ } catch {
672
+ frozenKeys = []; // an unreadable baseline freezes nothing
673
+ }
674
+ const inventory = buildRulesInventory({
675
+ fileContents,
676
+ fileLayers,
677
+ layerContexts: (config.layers ?? []).map((layer) => ({
678
+ name: layer.name,
679
+ intentPrefixes: layer.intentPrefixes ?? [],
680
+ })),
681
+ contractedRuleIds,
682
+ contractedStructure,
683
+ frozenKeys,
684
+ });
685
+ return {
686
+ inventory,
687
+ rulesMigration: {
688
+ inventoried: inventory.inventoried,
689
+ underContract: inventory.underContract,
690
+ frozen: inventory.frozen,
691
+ notAScore: true,
692
+ },
693
+ nextPilot:
694
+ inventory.candidates[0] != null ? inventoryToExtractionCard(inventory.candidates[0]) : null,
695
+ };
696
+ }
697
+
698
+ /** `N inventoried, N under contract, N frozen (not a score)` — shared CLI/doctor wording. */
699
+ export function formatRulesMigrationCounts(rulesMigration) {
700
+ if (!rulesMigration || typeof rulesMigration !== 'object') return '';
701
+ return `${rulesMigration.inventoried} inventoried, ${rulesMigration.underContract} under contract, ${rulesMigration.frozen} frozen (not a score)`;
702
+ }
703
+
704
+ /**
705
+ * Full `--rules-inventory` / `ark_rules_inventory` payload (same keys on both surfaces).
706
+ * @param {string} root
707
+ * @param {Record<string, any>} config
708
+ * @param {string[]} [files] governed files (absolute or root-relative)
709
+ */
710
+ export function buildRulesInventoryPayload(root, config, files) {
711
+ const governed = Array.isArray(files) ? files : collectGovernedFiles(root, config);
712
+ const migration = buildRulesMigration({ root, config, files: governed });
713
+ let evidenceLines = [];
714
+ let coverageEvidence = null;
715
+ if (config?.arkRules && Object.keys(config.arkRules).length > 0) {
716
+ try {
717
+ const section = summarizeRulesUnderContract(
718
+ root,
719
+ config,
720
+ {
721
+ files: governed.map((file) => ({
722
+ path: path.relative(root, path.resolve(root, file)).split(path.sep).join('/'),
723
+ })),
724
+ },
725
+ undefined,
726
+ { rulesMigration: false }
727
+ );
728
+ evidenceLines = formatArkRulesEvidenceLines(section);
729
+ if (section.active === true) {
730
+ coverageEvidence = {
731
+ symbolEvidence: section.symbolEvidence ?? [],
732
+ discarded: section.coverageStats?.discarded ?? null,
733
+ };
734
+ }
735
+ } catch {
736
+ /* inventory still useful when coverage cannot be read */
737
+ }
738
+ }
739
+ return {
740
+ payload: {
741
+ rulesInventory: migration.inventory,
742
+ rulesMigration: migration.rulesMigration,
743
+ nextPilot: migration.nextPilot,
744
+ ...(coverageEvidence ? { coverageEvidence } : {}),
745
+ },
746
+ evidenceLines,
747
+ };
748
+ }
@@ -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
  }
@@ -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) =>
@@ -2,16 +2,31 @@
2
2
  import { layerForRelativePath } from '../ark-layer-match.mjs';
3
3
  import { ANALYSIS_COMPLETENESS } from './analysis-completeness.mjs';
4
4
  import { evaluateArkRunEditorSensorsFromSource } from './ark-run-sensors.mjs';
5
+ import { evaluateArkOrderEditorSensors } from './ark-order-sensors.mjs';
6
+ import {
7
+ buildArkRuleFileHints,
8
+ evaluateArkRuleSensors,
9
+ extractClassShapesFromSource,
10
+ } from './arkrules-sensors.mjs';
5
11
  import { getDiagnosticCatalogEntry } from './diagnostic-catalog.mjs';
12
+ import { demoteExtraPlaneTeethUnderClassificationFloor } from './extra-merge-teeth.mjs';
6
13
 
7
- function lexicalEvidenceIncompleteMessage(file) {
14
+ /** Hook-only guidance: a hook deny is already the verdict for that write. */
15
+ export const HOOK_DENY_PREPARE_CHANGE_NOTE = 'Do not call ark_prepare_change from a hook deny.';
16
+
17
+ function surfaceNote(context) {
18
+ return context?.surface === 'hook' ? ` ${HOOK_DENY_PREPARE_CHANGE_NOTE}` : '';
19
+ }
20
+
21
+ function lexicalEvidenceIncompleteMessage(file, context) {
8
22
  const entry = getDiagnosticCatalogEntry('LEXICAL_EVIDENCE_INCOMPLETE');
9
23
  const text = [entry?.why, entry?.fix].filter(Boolean).join(' ');
10
24
  return {
11
25
  code: 'LEXICAL_EVIDENCE_INCOMPLETE',
12
26
  message:
13
- text ||
14
- 'This check only saw one file, so it cannot fully prove how the import resolves. The result is provisional — `ark-check` on the project is the authority. Run `npx arkgate-check --root . --config ark.config.json` to confirm. Do not call ark_prepare_change from a hook deny.',
27
+ (text ||
28
+ 'This check only saw one file, so it cannot fully prove how the import resolves. The result is provisional — `ark-check` on the project is the authority. For a complete verdict, run `npx arkgate-check --root . --config ark.config.json` (or ark_prepare_change over MCP with the full candidate batch).') +
29
+ surfaceNote(context),
15
30
  ...(file ? { file } : {}),
16
31
  };
17
32
  }
@@ -81,14 +96,132 @@ function arkRunSnippetViolations(source, context = {}) {
81
96
  }));
82
97
  }
83
98
 
99
+ const TS_CLASS_SHAPE_FILE = /\.(tsx?|mts|cts)$/i;
100
+
101
+ /**
102
+ * File-local ArkRules structure sensors on the proposed source — the same sensors,
103
+ * class-shape extraction, and per-file hints ark-check runs, pinned to this one file.
104
+ * Only enforced (failsStrict) findings block; advisory rules stay CI/doctor warnings.
105
+ */
106
+ function arkRulesSnippetViolations(source, context = {}) {
107
+ const arkRules = context.arkRules;
108
+ const file = context.relFile;
109
+ if (!arkRules || !(arkRules.structure?.length > 0) || typeof file !== 'string' || !file) {
110
+ return [];
111
+ }
112
+ const layers = Array.isArray(context.layers) ? context.layers : [];
113
+ const layerForFile = (pathValue) =>
114
+ pathValue === file && typeof context.layer === 'string'
115
+ ? context.layer
116
+ : layerForRelativePath(pathValue, layers);
117
+ let classShapes = [];
118
+ if (TS_CLASS_SHAPE_FILE.test(file)) {
119
+ try {
120
+ classShapes = extractClassShapesFromSource(file, source);
121
+ } catch {
122
+ // Same as the CI resolver: shape extraction never fails the run.
123
+ classShapes = [];
124
+ }
125
+ }
126
+ const findings = evaluateArkRuleSensors({
127
+ arkRules,
128
+ classShapes,
129
+ files: [file],
130
+ layerForFile,
131
+ fileHints: buildArkRuleFileHints({ [file]: source }),
132
+ });
133
+ // Same classification floor ark-check applies before merge (teeth only on a classified tree).
134
+ return demoteExtraPlaneTeethUnderClassificationFloor(findings, context.classification ?? {})
135
+ .filter((finding) => finding.failsStrict)
136
+ .map((finding) => ({
137
+ ...finding,
138
+ nextAction:
139
+ getDiagnosticCatalogEntry('ARKRULE_STRUCTURE')?.fix ??
140
+ 'Fix the ArkRules structure finding in this file, then retry the write.',
141
+ failsStrict: true,
142
+ severity: 'error',
143
+ }));
144
+ }
145
+
146
+ /** ArkOrder editor sensors (generic update, kernel-in-domain, ξ writes) on one source. */
147
+ function arkOrderSnippetViolations(source, context = {}) {
148
+ const arkOrder = context.arkOrder;
149
+ const file = context.relFile;
150
+ if (!arkOrder || typeof file !== 'string' || !file) return [];
151
+ const layers = Array.isArray(context.layers) ? context.layers : [];
152
+ const fromLayer =
153
+ typeof context.layer === 'string' ? context.layer : layerForRelativePath(file, layers);
154
+ const layerMeta = layers.find((layer) => layer?.name === fromLayer);
155
+ return evaluateArkOrderEditorSensors({
156
+ arkOrder,
157
+ file,
158
+ source,
159
+ fromLayer,
160
+ intentPrefixes: layerMeta?.intentPrefixes ?? [],
161
+ ...(context.classification ? { classification: context.classification } : {}),
162
+ })
163
+ .filter((finding) => finding.failsStrict)
164
+ .map((finding) => ({
165
+ ruleId: finding.ruleId,
166
+ code: finding.ruleId,
167
+ message: finding.message,
168
+ file: finding.file,
169
+ line: finding.line,
170
+ ...(finding.fromLayer ? { fromLayer: finding.fromLayer } : {}),
171
+ ...(finding.target ? { target: finding.target } : {}),
172
+ nextAction: finding.nextAction,
173
+ failsStrict: true,
174
+ severity: 'error',
175
+ }));
176
+ }
177
+
178
+ /**
179
+ * The write gate could not load part of its own contract (e.g. a referenced ArkRules
180
+ * file is missing). CI fails closed on the same input, so the write path does too.
181
+ */
182
+ function contractLoadViolations(context = {}) {
183
+ const errors = Array.isArray(context.contractErrors) ? context.contractErrors : [];
184
+ return errors.map((error) => ({
185
+ ruleId: 'WRITE_GATE_UNAVAILABLE',
186
+ code: 'WRITE_GATE_UNAVAILABLE',
187
+ message: `Invalid Effective Contract: ${
188
+ typeof error === 'string' ? error : `${error?.path ?? ''}: ${error?.message ?? ''}`.trim()
189
+ }`,
190
+ ...(context.relFile ? { file: context.relFile } : {}),
191
+ nextAction:
192
+ getDiagnosticCatalogEntry('WRITE_GATE_UNAVAILABLE')?.fix ??
193
+ 'Fix the ArkRules reference in ark.config.json, then retry the write.',
194
+ failsStrict: true,
195
+ severity: 'error',
196
+ }));
197
+ }
198
+
199
+ /**
200
+ * Extras (ArkRules structure + ArkOrder) run only on files CI would scan. A file
201
+ * outside include / excluded is never judged by these planes in CI either.
202
+ */
203
+ function extraPlaneSnippetViolations(source, context = {}) {
204
+ if (context.inScope === false) return [];
205
+ return [
206
+ ...contractLoadViolations(context),
207
+ ...arkRulesSnippetViolations(source, context),
208
+ ...arkOrderSnippetViolations(source, context),
209
+ ];
210
+ }
211
+
84
212
  export function validateSnippetAnalysis({ gate, ts, source, context = {} }) {
85
213
  const observed = gate.validate(source, context);
86
214
  const arkRunViolations = arkRunSnippetViolations(source, context);
215
+ const extraViolations = extraPlaneSnippetViolations(source, context);
87
216
  const base = {
88
- valid: Boolean(observed.lexicalValid ?? observed.valid) && arkRunViolations.length === 0,
217
+ valid:
218
+ Boolean(observed.lexicalValid ?? observed.valid) &&
219
+ arkRunViolations.length === 0 &&
220
+ extraViolations.length === 0,
89
221
  violations: [
90
222
  ...(Array.isArray(observed.violations) ? observed.violations : []),
91
223
  ...arkRunViolations,
224
+ ...extraViolations,
92
225
  ],
93
226
  };
94
227
  const file = context.filePath;
@@ -154,7 +287,8 @@ export function validateSnippetAnalysis({ gate, ts, source, context = {} }) {
154
287
  'ANALYSIS_PARSE_INCOMPLETE',
155
288
  `Analysis partial: ${detail}`,
156
289
  file,
157
- 'Incremental mid-edit parse errors are normal. Finish the source, then re-run `npx arkgate-check` (or the write hook). Do not call ark_prepare_change from a hook deny.'
290
+ 'Incremental mid-edit parse errors are normal. Finish the source, then re-run `npx arkgate-check` (or the write hook).' +
291
+ surfaceNote(context)
158
292
  ),
159
293
  line: first?.line ?? 1,
160
294
  column: first?.column ?? 1,
@@ -169,7 +303,7 @@ export function validateSnippetAnalysis({ gate, ts, source, context = {} }) {
169
303
  valid: false,
170
304
  lexicalValid: base.valid,
171
305
  completeness: ANALYSIS_COMPLETENESS.partial,
172
- completenessReasons: [lexicalEvidenceIncompleteMessage(file)],
306
+ completenessReasons: [lexicalEvidenceIncompleteMessage(file, context)],
173
307
  };
174
308
  } catch {
175
309
  return {
@@ -25,6 +25,40 @@ export const DEFAULT_INTENT_PREFIXES = Object.freeze([
25
25
  { layer: 'SecurityAuditObservability', prefixes: ['Security.', 'Audit.', 'Observability.'] },
26
26
  { layer: 'Kernel', prefixes: ['Kernel.'] },
27
27
  ]);
28
+ /**
29
+ * Runtime intent prefixes for one config layer: its declared `intentPrefixes`,
30
+ * else the built-in prefixes when the layer uses a canonical 11-layer name
31
+ * (`DomainModel` → `Domain.`, …; the same table the static gate falls back to),
32
+ * else none. Keeps `ark init` configs, which name canonical layers without
33
+ * declaring prefixes, resolvable by the ArkRun kernel.
34
+ */
35
+ export function effectiveIntentPrefixes(layer) {
36
+ const declared = (layer.intentPrefixes ?? []).filter((prefix) => prefix.trim().length > 0);
37
+ if (declared.length > 0)
38
+ return declared;
39
+ return DEFAULT_INTENT_PREFIXES.find((entry) => entry.layer === layer.name)?.prefixes ?? [];
40
+ }
41
+ /**
42
+ * Layers named by an `allowed: false` (non-peerIsolation) rule that no intent can map to at runtime
43
+ * (no declared `intentPrefixes` and not a canonical layer name). Hard observed
44
+ * layer flow can never fire for such a rule. Sorted, unique.
45
+ */
46
+ export function unresolvableLayerFlowLayers(config) {
47
+ const resolvable = new Set(config.layers
48
+ .filter((layer) => effectiveIntentPrefixes(layer).length > 0)
49
+ .map((layer) => layer.name));
50
+ const missing = new Set();
51
+ for (const rule of config.rules ?? []) {
52
+ // peerIsolation walls are file-path slice rules; runtime flow never evaluates them.
53
+ if (rule.allowed !== false || rule.peerIsolation === true)
54
+ continue;
55
+ for (const name of [rule.from, rule.to]) {
56
+ if (!resolvable.has(name))
57
+ missing.add(name);
58
+ }
59
+ }
60
+ return [...missing].sort();
61
+ }
28
62
  /** Longest matching prefix wins; declaration order resolves an identical-prefix tie. */
29
63
  export function resolveIntentLayer(intent, layers) {
30
64
  const candidates = layers.flatMap((layer, layerIndex) => (layer.prefixes ?? layer.intentPrefixes ?? []).map((prefix) => ({
@@ -0,0 +1,90 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/stableHash.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/stable-hash.mjs). Zero Node I/O.
9
+ */
10
+
11
+ /** Stable FNV-1a hash. Identity/fingerprint only — not a security primitive. */
12
+ export function deterministicHash(value) {
13
+ let hash = 0x811c9dc5;
14
+ for (let index = 0; index < value.length; index += 1) {
15
+ hash ^= value.charCodeAt(index);
16
+ hash = Math.imul(hash, 0x01000193);
17
+ }
18
+ return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`;
19
+ }
20
+ /** Serialize JSON-like values with sorted object keys for reproducible hashes. */
21
+ export function stableSerialize(value) {
22
+ if (value === null || typeof value !== 'object')
23
+ return JSON.stringify(value);
24
+ if (Array.isArray(value))
25
+ return `[${value.map(stableSerialize).join(',')}]`;
26
+ const object = value;
27
+ return `{${Object.keys(object)
28
+ .sort()
29
+ .map((key) => `${JSON.stringify(key)}:${stableSerialize(object[key])}`)
30
+ .join(',')}}`;
31
+ }
32
+ function feedText(text, state) {
33
+ let hash = state.hash;
34
+ for (let index = 0; index < text.length; index += 1) {
35
+ hash ^= text.charCodeAt(index);
36
+ hash = Math.imul(hash, 0x01000193);
37
+ }
38
+ state.hash = hash;
39
+ }
40
+ function feedStable(value, state) {
41
+ if (Array.isArray(value)) {
42
+ feedText('[', state);
43
+ for (let index = 0; index < value.length; index += 1) {
44
+ if (index > 0)
45
+ feedText(',', state);
46
+ // `map` skips holes and `join` renders an undefined element as ''.
47
+ if (!(index in value))
48
+ continue;
49
+ const item = value[index];
50
+ if (item !== null && typeof item === 'object')
51
+ feedStable(item, state);
52
+ else {
53
+ const text = JSON.stringify(item);
54
+ if (text !== undefined)
55
+ feedText(text, state);
56
+ }
57
+ }
58
+ feedText(']', state);
59
+ return;
60
+ }
61
+ const object = value;
62
+ const keys = Object.keys(object).sort();
63
+ feedText('{', state);
64
+ for (let index = 0; index < keys.length; index += 1) {
65
+ if (index > 0)
66
+ feedText(',', state);
67
+ const key = keys[index];
68
+ feedText(`${JSON.stringify(key)}:`, state);
69
+ const item = object[key];
70
+ // A template literal renders an undefined serialization as 'undefined'.
71
+ if (item !== null && typeof item === 'object')
72
+ feedStable(item, state);
73
+ else
74
+ feedText(`${JSON.stringify(item)}`, state);
75
+ }
76
+ feedText('}', state);
77
+ }
78
+ /**
79
+ * `deterministicHash(stableSerialize(value))`, byte for byte, without building
80
+ * the serialized text. A large fact set hashes without first materializing a
81
+ * multi-megabyte string and every intermediate join.
82
+ */
83
+ export function stableHash(value) {
84
+ if (value === null || typeof value !== 'object') {
85
+ return deterministicHash(stableSerialize(value));
86
+ }
87
+ const state = { hash: 0x811c9dc5 };
88
+ feedStable(value, state);
89
+ return `fnv1a-${(state.hash >>> 0).toString(16).padStart(8, '0')}`;
90
+ }