arkgate 4.8.24 → 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 (166) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +2 -2
  3. package/bin/ark-check-runtime.mjs +17 -14
  4. package/bin/ark-layer-match.mjs +115 -13
  5. package/bin/ark-mcp-runtime.mjs +36 -31
  6. package/bin/ark-shared.mjs +4 -23
  7. package/bin/lib/adoption-stance.mjs +2 -2
  8. package/bin/lib/adr-path.mjs +3 -3
  9. package/bin/lib/agent-gates.mjs +0 -61
  10. package/bin/lib/agent-homes.mjs +12 -21
  11. package/bin/lib/agent-projection-command.mjs +4 -4
  12. package/bin/lib/ambient-state.mjs +9 -30
  13. package/bin/lib/analysis-engine.mjs +14 -14
  14. package/bin/lib/architecture-scan.mjs +11 -9
  15. package/bin/lib/ark-order-doctor.mjs +3 -28
  16. package/bin/lib/ark-order-facts.mjs +6 -99
  17. package/bin/lib/ark-order-types.mjs +1 -2
  18. package/bin/lib/ark-run-doctor.mjs +3 -28
  19. package/bin/lib/ark-run-facts.mjs +30 -40
  20. package/bin/lib/ark-run-sensors.mjs +1 -1
  21. package/bin/lib/arkrule-file-hints.mjs +2 -47
  22. package/bin/lib/arkrules-contract.mjs +124 -133
  23. package/bin/lib/arkrules-sensors.mjs +7 -5
  24. package/bin/lib/check-args.mjs +63 -1
  25. package/bin/lib/ci-and-commands.mjs +8 -72
  26. package/bin/lib/ci-merge-boundary.mjs +1 -1
  27. package/bin/lib/class-source-scan.mjs +5 -5
  28. package/bin/lib/clone-detection.mjs +474 -0
  29. package/bin/lib/codex-home.mjs +1 -1
  30. package/bin/lib/config-contract-slices.mjs +44 -6
  31. package/bin/lib/config-contract.mjs +40 -117
  32. package/bin/lib/config-extras.mjs +16 -22
  33. package/bin/lib/contract-smells.mjs +33 -15
  34. package/bin/lib/core-ratchet.mjs +0 -2
  35. package/bin/lib/dashboard-cli.mjs +2 -2
  36. package/bin/lib/deep-module-coach.mjs +2 -4
  37. package/bin/lib/deploy-path.mjs +13 -38
  38. package/bin/lib/design-smells.mjs +2 -2
  39. package/bin/lib/diagnostic-catalog.mjs +8 -1
  40. package/bin/lib/doctor-advisories.mjs +55 -9
  41. package/bin/lib/doctor-green-cite.mjs +1 -1
  42. package/bin/lib/doctor-human.mjs +1 -1
  43. package/bin/lib/doctor-next-actions.mjs +2 -2
  44. package/bin/lib/doctor-plan.mjs +4 -6
  45. package/bin/lib/duplication-io.mjs +572 -0
  46. package/bin/lib/effective-contract-load.mjs +181 -44
  47. package/bin/lib/entry-points-io.mjs +424 -0
  48. package/bin/lib/extra-merge-teeth.mjs +36 -0
  49. package/bin/lib/field-install.mjs +38 -14
  50. package/bin/lib/first-run-help.mjs +7 -3
  51. package/bin/lib/flat-parent-importers.mjs +5 -3
  52. package/bin/lib/gate-files.mjs +30 -89
  53. package/bin/lib/github-enforcement.mjs +165 -28
  54. package/bin/lib/golden-pattern.mjs +1 -1
  55. package/bin/lib/hook-templates.mjs +16 -16
  56. package/bin/lib/html-report-advisories.mjs +4 -0
  57. package/bin/lib/html-report-depth.mjs +2 -2
  58. package/bin/lib/html-report.mjs +2 -2
  59. package/bin/lib/import-graph-projection.mjs +112 -0
  60. package/bin/lib/improvement-compass-doctor.mjs +1 -24
  61. package/bin/lib/invariant-coverage-io.mjs +4 -3
  62. package/bin/lib/invariant-coverage.mjs +26 -0
  63. package/bin/lib/invariant-probe-cli.mjs +347 -0
  64. package/bin/lib/invariant-probe-io.mjs +165 -0
  65. package/bin/lib/invariant-probe-runner.mjs +268 -0
  66. package/bin/lib/invariant-probe-sites.mjs +174 -0
  67. package/bin/lib/invariant-probe-workspace.mjs +183 -0
  68. package/bin/lib/invariant-probe.mjs +461 -0
  69. package/bin/lib/invariant-tests-path.mjs +16 -22
  70. package/bin/lib/literal-path-drift.mjs +4 -2
  71. package/bin/lib/managed-upgrade.mjs +2 -8
  72. package/bin/lib/mcp-adoption.mjs +4 -5
  73. package/bin/lib/mcp-hook-payload.mjs +4 -4
  74. package/bin/lib/orphan-modules-io.mjs +458 -0
  75. package/bin/lib/orphan-modules.mjs +251 -0
  76. package/bin/lib/outside-importers.mjs +197 -0
  77. package/bin/lib/package-manager.mjs +3 -3
  78. package/bin/lib/parse-health.mjs +1 -1
  79. package/bin/lib/persistence-write-hint.mjs +33 -8
  80. package/bin/lib/pin-dependent-gates.mjs +2 -2
  81. package/bin/lib/policy-delta-io.mjs +55 -8
  82. package/bin/lib/post-green-path.mjs +1 -1
  83. package/bin/lib/prepare-change.mjs +3 -9
  84. package/bin/lib/prepare-write.mjs +1 -2
  85. package/bin/lib/presets.mjs +56 -68
  86. package/bin/lib/product-copy.mjs +1 -6
  87. package/bin/lib/project-root.mjs +31 -62
  88. package/bin/lib/resolved-candidate-facts.mjs +90 -62
  89. package/bin/lib/rules-under-contract.mjs +72 -8
  90. package/bin/lib/scan-files.mjs +4 -4
  91. package/bin/lib/schema-validation.mjs +130 -0
  92. package/bin/lib/sensor-promote-cli.mjs +2 -11
  93. package/bin/lib/sensor-promote-io.mjs +33 -22
  94. package/bin/lib/sensor-promotion.mjs +1 -1
  95. package/bin/lib/skill-catalog-apply.mjs +1 -1
  96. package/bin/lib/skill-install.mjs +42 -38
  97. package/bin/lib/snippet-analysis.mjs +1 -1
  98. package/bin/lib/start-install-recovery.mjs +1 -1
  99. package/bin/lib/start-preview.mjs +1 -1
  100. package/bin/lib/states-transitions-presence.mjs +3 -3
  101. package/bin/lib/status-command.mjs +2 -18
  102. package/bin/lib/status-manifest.mjs +18 -26
  103. package/bin/lib/status-transition-catalog.mjs +0 -6
  104. package/bin/lib/suggestions.mjs +3 -3
  105. package/bin/lib/team-parliament-io.mjs +3 -17
  106. package/bin/lib/team-parliament.mjs +15 -9
  107. package/bin/lib/upgrade-behavior-changes.mjs +2 -2
  108. package/bin/lib/upgrade-command.mjs +2 -24
  109. package/bin/lib/upgrade-outcome.mjs +1 -1
  110. package/bin/lib/upstream-report.mjs +21 -10
  111. package/bin/lib/violations.mjs +10 -9
  112. package/bin/lib/weakest-link.mjs +13 -0
  113. package/bin/lib/write-path-capabilities.mjs +2 -2
  114. package/dist/chunk-AHACEKSV.js +16 -0
  115. package/dist/chunk-UN5EH4QV.cjs +16 -0
  116. package/dist/{configTypes-BJbaeoj4.d.cts → configTypes-GPvvdleM.d.cts} +12 -1
  117. package/dist/{configTypes-BJbaeoj4.d.ts → configTypes-GPvvdleM.d.ts} +12 -1
  118. package/dist/eslint/index.cjs +11 -11
  119. package/dist/eslint/index.d.cts +10 -1
  120. package/dist/eslint/index.d.ts +10 -1
  121. package/dist/eslint/index.js +11 -11
  122. package/dist/index.cjs +34 -34
  123. package/dist/index.d.cts +587 -515
  124. package/dist/index.d.ts +587 -515
  125. package/dist/index.js +34 -34
  126. package/dist/nestjs/index.cjs +1 -1
  127. package/dist/nestjs/index.d.cts +1 -1
  128. package/dist/nestjs/index.d.ts +1 -1
  129. package/dist/nestjs/index.js +1 -1
  130. package/dist/order/index.cjs +1 -1
  131. package/dist/order/index.js +1 -1
  132. package/dist/runtime/index.cjs +12 -12
  133. package/dist/runtime/index.d.cts +174 -136
  134. package/dist/runtime/index.d.ts +174 -136
  135. package/dist/runtime/index.js +12 -12
  136. package/dist/{types-DRinG-vT.d.cts → types-CnauMQQL.d.cts} +33 -25
  137. package/dist/{types-DRinG-vT.d.ts → types-CnauMQQL.d.ts} +33 -25
  138. package/docs/README.md +2 -1
  139. package/docs/agent-guide.md +134 -2
  140. package/docs/ai-gates.md +15 -1
  141. package/docs/configuration.md +40 -11
  142. package/docs/diagnostics.md +118 -2
  143. package/docs/package-surface.md +4 -1
  144. package/docs/product-voice.md +7 -1
  145. package/package.json +11 -2
  146. package/schemas/ark.config.schema.json +31 -3
  147. package/schemas/ark.invariant-probe.schema.json +368 -0
  148. package/server.json +2 -2
  149. package/templates/agent-skills/README.md +1 -1
  150. package/templates/agent-skills/ark-adopt/SKILL.md +1 -0
  151. package/templates/agent-skills/ark-contract/SKILL.md +17 -0
  152. package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
  153. package/templates/agent-skills/ark-explore/SKILL.md +7 -0
  154. package/templates/agent-skills/ark-fix/SKILL.md +20 -0
  155. package/templates/agent-skills/ark-place/SKILL.md +8 -0
  156. package/templates/skills/ark-adopt.md +1 -0
  157. package/templates/skills/ark-contract.md +17 -0
  158. package/templates/skills/ark-coverage.md +2 -0
  159. package/templates/skills/ark-explore.md +7 -0
  160. package/templates/skills/ark-fix.md +20 -0
  161. package/templates/skills/ark-place.md +8 -0
  162. package/bin/lib/ark-order-error.mjs +0 -18
  163. package/bin/lib/ark-order-invariants.mjs +0 -388
  164. package/bin/lib/stable-hash.mjs +0 -90
  165. package/dist/chunk-MBXCFLO4.js +0 -16
  166. package/dist/chunk-RNQHRDCQ.cjs +0 -16
package/CHANGELOG.md CHANGED
@@ -5,7 +5,78 @@ in the immutable pre-2.0 archive linked below.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 4.8.25 — 2026-10-05
9
+
10
+ **Patch** over **4.8.24**. Weekly accumulate (#342, #344, #345, #346, #347, #348, #349, #350). **Write. Check. Ship.** **No required config migration.** `childSlices.arkRulesFile` and pinned `sliceAliases` need 4.8.25. No `schemaVersion` bump. Does not close `K01` / `Z09`. This mother `ark.config.json` still does **not** turn `arkOrder` on.
11
+
12
+ **Status: released** (tag `v4.8.25`). Run `npm view arkgate@4.8.25 version` to confirm (#314).
13
+
8
14
  ### Added
15
+ - Per-slice ArkRules file `childSlices.arkRulesFile` and pinned `sliceAliases` (#342, #341).
16
+ - `arkgate-check --probe-invariants[=<id>]` checks that the tests covering an
17
+ invariant actually pin it. A test title that names the rule proves the test
18
+ exists, not that it would fail if the rule broke. For each invariant with a
19
+ `coverage.symbol`, the probe copies the project to a temporary folder and
20
+ runs only the covering tests (titles that name the id, and tests that import
21
+ the symbol's file) with your own runner — vitest, jest or `node --test`:
22
+ unchanged, then with the file throwing on load, then with the symbol
23
+ throwing when called, then with at most three small changes inside it
24
+ (negate a guard, drop a `throw`, flip or shift a comparison, shift a
25
+ constant). Each invariant reads `killed`, `survived`, `not-reached`,
26
+ `inconclusive` or `unprobeable`, with the change that went unnoticed and the
27
+ line. Your files are never changed. Report by default; `--write` saves
28
+ `.ark/invariant-probe.json` (new schema export `arkgate/schema/invariant-probe`).
29
+ Exit 0 when nothing survived, 1 when something did, 2 when it could not run.
30
+ Not a score.
31
+ - Promotion reads the committed probe result. When `.ark/invariant-probe.json`
32
+ has a fresh `survived` or `not-reached` row — the symbol file, its covering
33
+ tests and the invariant unchanged since the run — `--promote` and the policy
34
+ delta refuse to promote that invariant to `enforced` (blocker
35
+ `probe-survived`). A stale, `killed`, `inconclusive` or missing result
36
+ changes nothing, so every promotion without the file behaves as before.
37
+ Status and `--rules-inventory` show the probe state per invariant when the
38
+ file exists; new catalog id `INVARIANT_PROBE_SURVIVED` (advisory, never in
39
+ the check). The probe runs only when you start it: never from the write
40
+ hook, MCP, ESLint, the GitHub Action or `--strict-merge`, and it refuses to
41
+ combine with them. The runner gets a short environment allowlist (no
42
+ tokens); that is best effort, not a sandbox. No config key, no new skill;
43
+ `/ark-contract` and `/ark-coverage` explain the blocker and the next test to
44
+ write.
45
+ - Status lists code copied across a wall. When a slice wall denies an import,
46
+ the cheapest way around it is a copy, and a copy is not an import.
47
+ `--doctor --all`, the HTML report and `--doctor --all --json` (under
48
+ `doctor.crossWallDuplication`) now list near-identical code whose copies sit
49
+ on two sides of a slice wall, in two child slices of one universe, or in two
50
+ layers, with a place the shared code could live (the declared shared root,
51
+ the universe common folder, or the lower layer both sides may import).
52
+ Copies inside one slice are counted, never listed. It reads token
53
+ fingerprints taken while status runs, with the TypeScript ArkGate already
54
+ loaded; they never enter the check, so the verdict and `factsHash` do not
55
+ change. Tests, `.d.ts`, generated files and files over 256 KB are skipped.
56
+ Fixed thresholds (at least 50 tokens, 5 lines, most names matching), no
57
+ config key. Compact status never runs it; its JSON says `not-run` and names
58
+ the command. When a cap stops the pass the section says `partial` and how
59
+ much was left. New catalog ids `CROSS_WALL_DUPLICATE` and
60
+ `CROSS_LAYER_DUPLICATE` (advisory). Never a score, never in the write hook,
61
+ MCP, ESLint, `--changed`, or `--strict-merge`. No new skill; `/ark-place`
62
+ names the destination and `/ark-fix` moves one copy at a time.
63
+ - Status lists the files nothing imports. A governed file with no importer —
64
+ no governed file, test, project script, or known entry point — shows in
65
+ `--doctor --all`, the HTML report, and `--doctor --json` under
66
+ `doctor.orphanModules`; compact status prints one count line at most. It
67
+ reads the import edges the check already resolved, so the verdict and
68
+ `factsHash` do not change. Entry points come from `package.json` (built
69
+ `dist/` paths map back to source), CI workflow run steps, Next / Vite / Nest /
70
+ Vercel / Storybook conventions, the arkRun / arkOrder roots, config and setup
71
+ files, ambient declaration files, and an optional `.ark/entry-points.json`
72
+ (glob + reason + optional `reviewBy`; not a config key). Files only tests
73
+ import are counted, not listed. When a dynamic import, `import.meta.glob`, a
74
+ quoted path, or an unresolved import may still reach a file, the item says
75
+ so and the list is `partial`. `--doctor --all` and `--report` also list
76
+ exports nothing imports by name. New catalog ids `ORPHAN_MODULE` and
77
+ `UNUSED_EXPORT` (advisory). Never a score, never in the write hook, MCP
78
+ write tools, ESLint, or `--strict-merge`. No new skill; `/ark-explore` maps
79
+ the list and `/ark-fix` deletes one file at a time through the write gate.
9
80
  - Soft doctor residual when Domain is declared but empty and the UI holds the
10
81
  rules (`noDomainFrontend`). Projects empty Domain + presentation share, or
11
82
  the existing `domain-logic-in-ui` smell. Friendly next step: one Domain file
@@ -48,6 +119,10 @@ in the immutable pre-2.0 archive linked below.
48
119
  short phrase templates so the shape is visible. No new skill, schema, or flag.
49
120
 
50
121
  ### Changed
122
+ - `brace-expansion` is overridden to 5.0.12 (Dependabot medium, quadratic expansion) (#350).
123
+ - Eval fixtures pin Next.js 16.3.6 (#346).
124
+ - The package no longer ships `bin/lib/ark-order-invariants.mjs`, `bin/lib/ark-order-error.mjs`, or `bin/lib/stable-hash.mjs`; no CLI loaded them. ArkOrder invariants still ship in `arkgate/order`. One new generated CLI module, `bin/lib/schema-validation.mjs`, holds the JSON-Schema walker shared by the config and ArkRules contracts.
125
+ - Internal cleanup with no public export or behavior change: about 390 unused internal exports removed (knip) and hand-written copy/paste duplication cut from 1.01% to 0% (jscpd). Maintainers: `npm run check:knip` and `npm run check:duplication` run in the CI build job.
51
126
  - Compact `--doctor` names ArkRules only when the `arkRules` map is on
52
127
  (one breath + counts, not a score). Absence stays silent. Reuses
53
128
  `rulesUnderContract` — no new schema, flag, or skill. `--doctor --all`
@@ -66,6 +141,16 @@ in the immutable pre-2.0 archive linked below.
66
141
  Required CI is still the shared merge line. No new skill, schema, or host.
67
142
 
68
143
  ### Fixed
144
+ - `writes-via-aggregate` flags tagged-template SQL writes (`UPDATE`, `MERGE`, `TRUNCATE`, including `ONLY`, quoted names, and interpolated tables). Comments, `FOR UPDATE`, and `DO UPDATE SET` stay silent (#344, #343).
145
+ - `--policy-base` and MCP policy-delta now judge an advisory → enforced
146
+ invariant promotion with the declared `coverage.coverageRoots`, the same way
147
+ `--promote` does. Before, a covered promotion with roots declared came out
148
+ `judgment-required` ("Declare coverage.coverageRoots…") while `--promote`
149
+ allowed it.
150
+ - The flat-parent suggestion in status reads the resolved importer index
151
+ instead of re-reading every file for import text.
152
+ - Doctor sees CI jobs gated by `needs:`: a job that runs the fail-closed Ark check behind unconditional jobs (such as a CI-profile job) now counts as a merge gate; `ciMergeBoundary.ci.workflowPresent` / `merge-gate` no longer report it absent. Jobs a profile output can skip, jobs with `continue-on-error`, and chains with a conditional job still do not count.
153
+ - With `ARK_DOCTOR_GITHUB=1`, the Ark check counts as required only when every job that can skip it is required too (a skipped job satisfies branch protection); otherwise the doctor reports `unverified`, `arkCheckUpstreamNotRequired`, and the gap `enforcement-ark-check-upstream-not-required`. Required checks bound to the GitHub Actions app now correlate (`arkCheckSourceBound: true`).
69
154
  - Write hook and `ark-check` now agree on overlapping layer globs: the hook
70
155
  probes the same specifier extensions as `ark-check` and classifies with
71
156
  `layerForRelativePath` (explicit `money.ts` beats `src/lib/**`). Field
package/README.md CHANGED
@@ -25,11 +25,11 @@ Design the boundary before you implement. Put a small interface in front of the
25
25
 
26
26
  ## Try it
27
27
 
28
- One command. Preview only — no files change. Current release is **4.8.24**
28
+ One command. Preview only — no files change. Current release is **4.8.25**
29
29
  (check npm `latest` with `npm view arkgate version`):
30
30
 
31
31
  ```bash
32
- npx arkgate@4.8.24 start
32
+ npx arkgate@4.8.25 start
33
33
  ```
34
34
 
35
35
  ## What a red result looks like
@@ -77,6 +77,7 @@ import {
77
77
  printViolationBreakdown,
78
78
  printHumanWarnings,
79
79
  CONCENTRATION_MIN_VIOLATIONS,
80
+ color,
80
81
  } from './lib/violations.mjs';
81
82
  import {
82
83
  detectBestFitModel,
@@ -1033,15 +1034,6 @@ function readManifest(root, manifestPath) {
1033
1034
  return readJson(fullPath);
1034
1035
  }
1035
1036
 
1036
- const useColor = process.stderr.isTTY && !process.env.NO_COLOR;
1037
- const color = {
1038
- red: (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
1039
- yellow: (s) => (useColor ? `\x1b[33m${s}\x1b[0m` : s),
1040
- green: (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
1041
- dim: (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
1042
- bold: (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
1043
- };
1044
-
1045
1037
  /**
1046
1038
  * Monorepo honesty: when cwd/--root has no ark.config.json, walk parents (bounded).
1047
1039
  * Stamps configRoot / configWalkedUp. Mutates args.root only for read paths, or
@@ -1147,6 +1139,14 @@ async function main() {
1147
1139
  return;
1148
1140
  }
1149
1141
 
1142
+ if (args.probeInvariants) {
1143
+ // ADR 0039: owner-invoked, outside the gate path. Loaded only here; parseArgs
1144
+ // refuses every pairing with --strict-merge, --changed, --local or --doctor.
1145
+ const { runProbeInvariants } = await import('./lib/invariant-probe-cli.mjs');
1146
+ await runProbeInvariants(args, readConfig);
1147
+ return;
1148
+ }
1149
+
1150
1150
  if (args.sensors) {
1151
1151
  const { runSensors } = await import('./lib/sensor-promote-cli.mjs');
1152
1152
  await withSensorsPartialModeHonesty(args, () => runSensors(args, readConfig));
@@ -1437,7 +1437,7 @@ async function main() {
1437
1437
 
1438
1438
  const {
1439
1439
  violations: rawViolations, warnings, safety, parseHealth, completeness, completenessReasons,
1440
- mode, policyHash, resolverIdentity, factsHash, candidateTreeHash,
1440
+ mode, policyHash, resolverIdentity, factsHash, candidateTreeHash, importGraph,
1441
1441
  } = runArchitectureScan({
1442
1442
  root,
1443
1443
  config,
@@ -1449,6 +1449,8 @@ async function main() {
1449
1449
  files: args.changed ? files : [],
1450
1450
  ts,
1451
1451
  args,
1452
+ // ADR 0037: doctor / report read a compact importer index, never the verdict path.
1453
+ graphProjection: Boolean(args.doctor || args.report) && !args.changed,
1452
1454
  });
1453
1455
 
1454
1456
  if (args.changed) pruneScopedPatternWarnings(warnings, root, loadGovernedFiles);
@@ -1508,8 +1510,8 @@ async function main() {
1508
1510
  configWalkedUp: args.configWalkedUp === true,
1509
1511
  safety, designDelta,
1510
1512
  ts, parseHealth, completeness,
1511
- all: args.all === true, requireGates: args.requireGates === true,
1512
- warnings,
1513
+ all: args.all === true, changed: args.changed === true, requireGates: args.requireGates === true,
1514
+ warnings, architectureFacts: importGraph ? { importGraph } : undefined,
1513
1515
  });
1514
1516
  if (designDelta) process.exitCode = !designDelta.complete ? 2 : designDelta.valid ? 0 : 1; return;
1515
1517
  }
@@ -1790,8 +1792,9 @@ async function main() {
1790
1792
  files,
1791
1793
  ts,
1792
1794
  parseHealth,
1793
- undefined,
1794
- activeViolations
1795
+ importGraph ? { importGraph } : undefined,
1796
+ activeViolations,
1797
+ { details: true, changed: args.changed === true }
1795
1798
  ),
1796
1799
  // Doctor parity: always emit improvement compass when doctor would (reportParity).
1797
1800
  ...(designDepth?.improvementCompass
@@ -132,7 +132,7 @@ export function globToRegExp(pattern) {
132
132
  * Used for path-anchored ranking so a domain folder glob can beat a broad
133
133
  * Application bag like src/lib when the file actually sits under domain/.
134
134
  */
135
- export function concreteGlobSegments(pattern) {
135
+ function concreteGlobSegments(pattern) {
136
136
  const glob = normalizeGlobSeparators(String(pattern));
137
137
  return glob
138
138
  .split('/')
@@ -393,7 +393,7 @@ function bindAnchoredSlice(parts, pattern, offset, identity = 'path') {
393
393
  * literal, and everything after it. Literals before the last literal drop.
394
394
  * Returns null when the pattern has no literal.
395
395
  */
396
- export function starsKeptSegmentIndexes(segments) {
396
+ function starsKeptSegmentIndexes(segments) {
397
397
  let lastLiteral = -1;
398
398
  for (let at = 0; at < segments.length; at += 1) {
399
399
  if (segments[at] !== '*')
@@ -681,6 +681,7 @@ export function peerIsolationMustDeny(input) {
681
681
  return peerIsolationDecision(input).denied;
682
682
  }
683
683
  export const SLICE_ALIAS_DEBT = 'Aliases are an owed move, not a destination. These files are not finished.';
684
+ const PINNED_ALIAS_NOTE = 'Pinned aliases stay where they are. They are not an owed move.';
684
685
  function aliasGlobPattern(glob) {
685
686
  return trimTrailingSlashes(glob.trim().replace(/\\/g, '/')).toLowerCase();
686
687
  }
@@ -781,7 +782,7 @@ function literalsMatch(shape, parts) {
781
782
  * New-id shapes (`*` for a star) that a 4.8.23 stars id maps to. Empty when the id is
782
783
  * already a new id or no shape takes it. Config load rejects more than one.
783
784
  */
784
- export function legacyStarsIdTargets(folders, id) {
785
+ function legacyStarsIdTargets(folders, id) {
785
786
  const parts = id.split('/').filter(Boolean).map((part) => part.toLowerCase());
786
787
  const shapes = starsUniverseShapes(folders);
787
788
  if (parts.length === 0 || shapes.some((shape) => literalsMatch(shape.literals, parts)))
@@ -937,11 +938,93 @@ export function sliceAliasDestination(file, childId, child) {
937
938
  export function anySliceAlias(rules) {
938
939
  return (rules ?? []).some((rule) => (rule?.childSlices?.sliceAliases?.length ?? 0) > 0);
939
940
  }
941
+ /** True when an alias is still an owed move. `pinned: true` does not count. A reason alone does. */
942
+ export function sliceAliasOwesMove(rules) {
943
+ return (rules ?? []).some((rule) => (rule?.childSlices?.sliceAliases ?? []).some((alias) => alias?.pinned !== true));
944
+ }
945
+ const SLICE_RULE_LAYER_TOKEN = '<Layer>';
946
+ function sliceRuleFileName(pattern, layer) {
947
+ const token = SLICE_RULE_LAYER_TOKEN;
948
+ if (!pattern.includes(token) || pattern.split(token).length !== 2)
949
+ return null;
950
+ if (/[*\\/]/.test(pattern))
951
+ return null;
952
+ const name = pattern.replace(token, layer);
953
+ if (!name || name.includes('..'))
954
+ return null;
955
+ return name;
956
+ }
957
+ /**
958
+ * Child-slice ArkRules files this wall already named.
959
+ * Roots come from the governed file index. The pattern is a filename, not a search.
960
+ */
961
+ export function resolveSliceRulePlan(input) {
962
+ const layers = [...new Set(input.layers.filter((name) => typeof name === 'string' && name.length > 0))].sort();
963
+ const entries = [];
964
+ const seen = new Set();
965
+ for (const rule of input.rules ?? []) {
966
+ const child = rule?.childSlices;
967
+ const pattern = child?.arkRulesFile;
968
+ if (!child || typeof pattern !== 'string')
969
+ continue;
970
+ const roots = new Map();
971
+ for (const file of input.files) {
972
+ if (typeof file !== 'string' || file.length === 0)
973
+ continue;
974
+ const universeId = rule.sliceFolders?.length
975
+ ? sliceIdForPath(file, rule.sliceFolders, rule.sliceIdentity)
976
+ : undefined;
977
+ const resolved = resolveChildSliceId(file, universeId, child);
978
+ if (!resolved.childId || roots.has(resolved.childId))
979
+ continue;
980
+ const root = sliceAliasDestination(file, resolved.childId, child);
981
+ if (root)
982
+ roots.set(resolved.childId, root);
983
+ }
984
+ for (const [childId, sliceRoot] of [...roots.entries()].sort((left, right) => left[0].localeCompare(right[0]))) {
985
+ for (const layer of layers) {
986
+ const name = sliceRuleFileName(pattern, layer);
987
+ if (!name)
988
+ continue;
989
+ const rel = `${sliceRoot}/${name}`.replace(/\\/g, '/');
990
+ if (seen.has(`${layer}\0${rel}`))
991
+ continue;
992
+ seen.add(`${layer}\0${rel}`);
993
+ entries.push({
994
+ path: rel,
995
+ childId,
996
+ layer,
997
+ defaultAppliesTo: [`${sliceRoot}/**`],
998
+ });
999
+ }
1000
+ }
1001
+ }
1002
+ return entries;
1003
+ }
1004
+ /** Ids from the effective catalog that belong to the child slice of this file. */
1005
+ export function sliceRuleIdsForFile(filePath, rules, catalog) {
1006
+ const childIds = new Set();
1007
+ for (const rule of rules ?? []) {
1008
+ if (!rule?.childSlices)
1009
+ continue;
1010
+ const universeId = rule.sliceFolders?.length
1011
+ ? sliceIdForPath(filePath, rule.sliceFolders, rule.sliceIdentity)
1012
+ : undefined;
1013
+ const resolved = resolveChildSliceId(filePath, universeId, rule.childSlices);
1014
+ if (resolved.childId)
1015
+ childIds.add(resolved.childId);
1016
+ }
1017
+ return catalog
1018
+ .filter((row) => typeof row.childId === 'string' && childIds.has(row.childId))
1019
+ .map((row) => row.id)
1020
+ .sort();
1021
+ }
940
1022
  /** Doctor list. Absent when no rule sets sliceAliases, so the key stays off. */
941
1023
  export function sliceAliasReport(rules, files) {
942
1024
  if (!anySliceAlias(rules))
943
1025
  return null;
944
1026
  const moves = new Map();
1027
+ const pinned = new Map();
945
1028
  for (const rule of rules ?? []) {
946
1029
  const child = rule?.childSlices;
947
1030
  if (!child?.sliceAliases)
@@ -950,10 +1033,19 @@ export function sliceAliasReport(rules, files) {
950
1033
  if (!alias || typeof alias.from !== 'string' || typeof alias.to !== 'string')
951
1034
  continue;
952
1035
  const key = `${aliasGlobPattern(alias.from)}\0${aliasGlobPattern(alias.to)}`;
953
- let move = moves.get(key);
1036
+ const bucket = alias.pinned === true ? pinned : moves;
1037
+ let move = bucket.get(key);
954
1038
  if (!move) {
955
- move = { from: alias.from, to: alias.to, destination: '', files: [] };
956
- moves.set(key, move);
1039
+ move = {
1040
+ from: alias.from,
1041
+ to: alias.to,
1042
+ destination: '',
1043
+ files: [],
1044
+ ...(alias.pinned === true && typeof alias.reason === 'string' && alias.reason.length > 0
1045
+ ? { reason: alias.reason }
1046
+ : {}),
1047
+ };
1048
+ bucket.set(key, move);
957
1049
  }
958
1050
  const seen = new Set(move.files);
959
1051
  for (const file of files) {
@@ -967,7 +1059,7 @@ export function sliceAliasReport(rules, files) {
967
1059
  }
968
1060
  }
969
1061
  const universes = knownUniverseIds(rules, files);
970
- const listed = [...moves.values()].map((move) => {
1062
+ const decorate = (move) => {
971
1063
  const target = splitAliasTarget(move.to);
972
1064
  const unknown = universes !== null && target !== null && !universes.has(target.universeId);
973
1065
  return {
@@ -980,9 +1072,19 @@ export function sliceAliasReport(rules, files) {
980
1072
  }
981
1073
  : {}),
982
1074
  };
983
- });
984
- listed.sort((left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to));
985
- return { notAScore: true, finished: false, debt: SLICE_ALIAS_DEBT, moves: listed };
1075
+ };
1076
+ const listed = [...moves.values()].map((move) => decorate(move));
1077
+ const pinnedListed = [...pinned.values()].map((move) => decorate(move));
1078
+ const byAlias = (left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to);
1079
+ listed.sort(byAlias);
1080
+ pinnedListed.sort(byAlias);
1081
+ return {
1082
+ notAScore: true,
1083
+ finished: false,
1084
+ debt: listed.length > 0 ? SLICE_ALIAS_DEBT : PINNED_ALIAS_NOTE,
1085
+ moves: listed,
1086
+ ...(pinnedListed.length > 0 ? { pinned: pinnedListed } : {}),
1087
+ };
986
1088
  }
987
1089
  /**
988
1090
  * Universe ids that real (non-aliased) files resolve to, across rules that set
@@ -1062,7 +1164,7 @@ function isCommonFolderName(segment, commonFolders) {
1062
1164
  * (`features/projects/rfi/domain/**`) belongs to that child. A child id that
1063
1165
  * does not extend the universe id is not used; the caller warns.
1064
1166
  */
1065
- export function resolveChildSliceId(relPath, universeId, child) {
1167
+ function resolveChildSliceId(relPath, universeId, child) {
1066
1168
  if (!relPath || !child?.sliceFolders?.length)
1067
1169
  return {};
1068
1170
  const raw = sliceIdForPath(relPath, child.sliceFolders, child.sliceIdentity);
@@ -1123,7 +1225,7 @@ export function importerInEnforcedSubtree(fromPath, fromChild, entries) {
1123
1225
  * value deny every one. An object denies the importer only when it is listed,
1124
1226
  * unless `default` is already `deny`.
1125
1227
  */
1126
- export function siblingCrossingAdvisory(siblings, fromPath, fromChild) {
1228
+ function siblingCrossingAdvisory(siblings, fromPath, fromChild) {
1127
1229
  if (siblings == null || siblings === 'deny')
1128
1230
  return false;
1129
1231
  if (siblings === 'advisory')
@@ -1206,7 +1308,7 @@ function childAllowanceSpansUniverses(from, to) {
1206
1308
  * Universe wall, then the child wall. An allow from the child wall never
1207
1309
  * overturns a universe deny: that deny returns before the child wall runs.
1208
1310
  */
1209
- export function evaluateNestedSliceWall(input) {
1311
+ function evaluateNestedSliceWall(input) {
1210
1312
  const universe = peerIsolationDecision({
1211
1313
  fromPath: input.fromPath,
1212
1314
  toPath: input.toPath,
@@ -28,6 +28,7 @@ import { createImportTargetResolver } from './lib/import-resolve.mjs';
28
28
  import { validateWithAutoPatch, resolveImportFileAbs } from './lib/auto-patch.mjs';
29
29
  import { composePrepareWrite } from './lib/prepare-write.mjs';
30
30
  import { placementDescriptionFields } from './lib/layer-description.mjs';
31
+ import { sliceRuleIdsForFile } from './ark-layer-match.mjs';
31
32
  import { loadArkConfigContract } from './lib/config-contract.mjs';
32
33
  import { loadEffectiveArkRulesFromDisk } from './lib/effective-contract-load.mjs';
33
34
  import { loadArkRulesContract } from './lib/arkrules-contract.mjs';
@@ -112,6 +113,7 @@ import {
112
113
  startResidentHookServer,
113
114
  } from './lib/resident-hook.mjs';
114
115
  import { resolveArchitectureSnapshot } from './lib/architecture-scan.mjs';
116
+ import { projectImporterIndex } from './lib/import-graph-projection.mjs';
115
117
  import { runDoctor } from './lib/doctor-plan.mjs';
116
118
  import {
117
119
  evaluateWriteDesignDelta,
@@ -126,6 +128,23 @@ import {
126
128
  const arkCheckBin = fileURLToPath(new URL('./ark-check.mjs', import.meta.url));
127
129
  const arkMcpLauncher = fileURLToPath(new URL('./ark-mcp.mjs', import.meta.url));
128
130
 
131
+ /** MCP tool result for a thrown error: its message as text, flagged isError. */
132
+ function toolErrorResult(error) {
133
+ return {
134
+ content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
135
+ isError: true,
136
+ };
137
+ }
138
+
139
+ /** MCP tool result for a `{ valid }` verdict: pretty JSON text + structuredContent. */
140
+ function validityToolResult(result) {
141
+ return {
142
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
143
+ structuredContent: result,
144
+ isError: !result.valid,
145
+ };
146
+ }
147
+
129
148
  /**
130
149
  * W4 — opt-in hook repair payload.
131
150
  * True when CLI `--hook-repair` or env ARK_HOOK_REPAIR is 1/true/yes.
@@ -1266,7 +1285,9 @@ function createResidentDoctorSession(args, config, ts) {
1266
1285
  const resolutionInputs = snapshotInputPaths(snapshot.inputs);
1267
1286
  const ledger = createResidentInputLedger([...after.paths, ...resolutionInputs]);
1268
1287
  if (!ledger.matches([...after.paths, ...resolutionInputs])) return null;
1269
- return { root: args.root, files: after.files, ledger, resolutionInputs, rules, snapshot };
1288
+ // Same importer index the cold doctor projects (ADR 0037), built once per snapshot.
1289
+ const importGraph = projectImporterIndex(snapshot.facts);
1290
+ return { root: args.root, files: after.files, ledger, resolutionInputs, rules, snapshot, importGraph };
1270
1291
  }
1271
1292
 
1272
1293
  function verifyResidentDoctorSession(session) {
@@ -1287,6 +1308,7 @@ function renderResidentDoctor(session, args, config, ts) {
1287
1308
  ts,
1288
1309
  parseHealth: session.snapshot.result.parseHealth,
1289
1310
  completeness: session.snapshot.result.completeness,
1311
+ architectureFacts: { importGraph: session.importGraph },
1290
1312
  writeJson: (value) => {
1291
1313
  stdout += `${value}\n`;
1292
1314
  },
@@ -2788,16 +2810,9 @@ export async function runArkMcp({ hookInput, hookRoot } = {}) {
2788
2810
  }),
2789
2811
  { root: args.root, acknowledgement, failClosed: true }
2790
2812
  );
2791
- return {
2792
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
2793
- structuredContent: result,
2794
- isError: !result.valid,
2795
- };
2813
+ return validityToolResult(result);
2796
2814
  } catch (error) {
2797
- return {
2798
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
2799
- isError: true,
2800
- };
2815
+ return toolErrorResult(error);
2801
2816
  }
2802
2817
  }
2803
2818
 
@@ -2859,6 +2874,11 @@ export async function runArkMcp({ hookInput, hookRoot } = {}) {
2859
2874
  rules.some((rule) => !rule.allowed && rule.from === layerName && rule.to === to)
2860
2875
  );
2861
2876
  const mayImport = otherNames.filter((name) => !mustNotImport.includes(name));
2877
+ const sliceCatalog = [
2878
+ ...(args.arkRulesSnapshot?.arkRules?.structure ?? []),
2879
+ ...(args.arkRulesSnapshot?.arkRules?.invariants ?? []),
2880
+ ].map((row) => ({ id: row.id, childId: row.provenance?.childId }));
2881
+ const sliceRules = sliceRuleIdsForFile(filePath, rules, sliceCatalog);
2862
2882
  return withGolden({
2863
2883
  filePath,
2864
2884
  layer: layerName,
@@ -2866,6 +2886,7 @@ export async function runArkMcp({ hookInput, hookRoot } = {}) {
2866
2886
  ...placementDescriptionFields(layerMeta),
2867
2887
  forbiddenGlobals: layerMeta?.forbiddenGlobals ?? [],
2868
2888
  ...(layerMeta?.mayImportInfrastructure ? { mayImportInfrastructure: true } : {}),
2889
+ ...(sliceRules.length > 0 ? { sliceRules } : {}),
2869
2890
  mayImport,
2870
2891
  mustNotImport,
2871
2892
  note:
@@ -2978,16 +2999,9 @@ export async function runArkMcp({ hookInput, hookRoot } = {}) {
2978
2999
  tsconfig: args.tsconfig,
2979
3000
  manifest: projectManifest,
2980
3001
  });
2981
- return {
2982
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
2983
- structuredContent: result,
2984
- isError: !result.valid,
2985
- };
3002
+ return validityToolResult(result);
2986
3003
  } catch (error) {
2987
- return {
2988
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
2989
- isError: true,
2990
- };
3004
+ return toolErrorResult(error);
2991
3005
  }
2992
3006
  }
2993
3007
 
@@ -3028,10 +3042,7 @@ export async function runArkMcp({ hookInput, hookRoot } = {}) {
3028
3042
  isError: binding?.status === 'mismatch',
3029
3043
  };
3030
3044
  } catch (error) {
3031
- return {
3032
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
3033
- isError: true,
3034
- };
3045
+ return toolErrorResult(error);
3035
3046
  }
3036
3047
  }
3037
3048
 
@@ -3049,10 +3060,7 @@ export async function runArkMcp({ hookInput, hookRoot } = {}) {
3049
3060
  structuredContent: payload,
3050
3061
  };
3051
3062
  } catch (error) {
3052
- return {
3053
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
3054
- isError: true,
3055
- };
3063
+ return toolErrorResult(error);
3056
3064
  }
3057
3065
  }
3058
3066
 
@@ -3086,10 +3094,7 @@ export async function runArkMcp({ hookInput, hookRoot } = {}) {
3086
3094
  isError: false,
3087
3095
  };
3088
3096
  } catch (error) {
3089
- return {
3090
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
3091
- isError: true,
3092
- };
3097
+ return toolErrorResult(error);
3093
3098
  }
3094
3099
  }
3095
3100
 
@@ -19,10 +19,8 @@ import {
19
19
  execCommandParts,
20
20
  isPnpmWorkspaceRoot,
21
21
  isNpmYarnWorkspaceRoot,
22
- normalizeArkgateInstallSpec,
23
22
  packageInstallArgv,
24
23
  installDevHint,
25
- arkPackageRecoveryCommand,
26
24
  } from './lib/package-manager.mjs';
27
25
 
28
26
  /**
@@ -566,16 +564,8 @@ export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
566
564
  export {
567
565
  globToRegExp,
568
566
  patternSpecificity,
569
- concreteGlobSegments,
570
567
  matchingLayersForRelativePath,
571
568
  layerForFile,
572
- layerForRelativePath,
573
- isEdgeDenied,
574
- findDeniedEdgeRule,
575
- sliceIdForPath,
576
- inferSliceFoldersFromPatterns,
577
- DEFAULT_GENERATED_FILE_GLOBS,
578
- scanExcludePatterns,
579
569
  isScanExcludedRelative,
580
570
  } from './ark-layer-match.mjs';
581
571
 
@@ -601,7 +591,6 @@ export function looksLikeIntent(value) {
601
591
  * Generated CLI load path: bin/lib/remediation.mjs (`npm run generate:cli-pure`).
602
592
  */
603
593
  export {
604
- REMEDIATION_CLASSES,
605
594
  classifyRemediation,
606
595
  enrichViolationWithFixClass,
607
596
  } from './lib/remediation.mjs';
@@ -671,11 +660,8 @@ export {
671
660
  arkCommand,
672
661
  execCommandParts,
673
662
  isPnpmWorkspaceRoot,
674
- isNpmYarnWorkspaceRoot,
675
- normalizeArkgateInstallSpec,
676
663
  packageInstallArgv,
677
664
  installDevHint,
678
- arkPackageRecoveryCommand,
679
665
  };
680
666
 
681
667
  // FX01–FX02: registry-aware skip lives in upgrade-package-decision (injectable probe).
@@ -684,7 +670,6 @@ export {
684
670
  buildPackageInstallSkipPayload,
685
671
  formatPackageInstallDecisionHuman,
686
672
  compareSemverCore,
687
- probeRegistryArkgateLatest,
688
673
  } from './lib/upgrade-package-decision.mjs';
689
674
 
690
675
  export const ARCHETYPE_IDS = [
@@ -880,7 +865,7 @@ const SKIP_DIR_NAMES = new Set([
880
865
  * (`isSkippedSourceDir` in scan-files). Start/recommend/discovery must not
881
866
  * treat playground, fixtures, scaffolds, or tests as production architecture.
882
867
  */
883
- export const NON_PRODUCT_SOURCE_DIR_NAMES = new Set([
868
+ const NON_PRODUCT_SOURCE_DIR_NAMES = new Set([
884
869
  'bench',
885
870
  'benches',
886
871
  'benchmark',
@@ -902,10 +887,6 @@ export const NON_PRODUCT_SOURCE_DIR_NAMES = new Set([
902
887
  'tests',
903
888
  ]);
904
889
 
905
- export function isNonProductSourceDirName(name) {
906
- return NON_PRODUCT_SOURCE_DIR_NAMES.has(name);
907
- }
908
-
909
890
  export function isNonProductRelativePath(relPath) {
910
891
  return String(relPath)
911
892
  .split(/[/\\]/)
@@ -1067,7 +1048,7 @@ function packageRole(rel, pkg) {
1067
1048
  }
1068
1049
 
1069
1050
  /** Path tokens already treated as UI app folders in discoverRepoUnits (root scan). */
1070
- export const UI_PACKAGE_PATH_TOKENS = new Set(['web', 'frontend', 'client', 'ui']);
1051
+ const UI_PACKAGE_PATH_TOKENS = new Set(['web', 'frontend', 'client', 'ui']);
1071
1052
 
1072
1053
  const UI_PACKAGE_DEP_NAMES =
1073
1054
  /^(react|react-dom|vue|svelte|preact|solid-js|next|@angular\/core|@sveltejs\/kit)$/i;
@@ -2218,7 +2199,7 @@ export function writeAdoptionPlan(root, recommendation, filename = ADOPTION_PLAN
2218
2199
 
2219
2200
  const __arkSharedDir = path.dirname(fileURLToPath(import.meta.url));
2220
2201
 
2221
- export function defaultPolicyPacksPath() {
2202
+ function defaultPolicyPacksPath() {
2222
2203
  return path.resolve(__arkSharedDir, '../templates/policy-packs');
2223
2204
  }
2224
2205
 
@@ -2271,7 +2252,7 @@ export function loadPolicyPackMeta(packId, packsPath = defaultPolicyPacksPath())
2271
2252
  * (`INV-*` invariants keep their prefix: `INV-<LAYER>-…`).
2272
2253
  */
2273
2254
 
2274
- export function kebabLayerName(layerName) {
2255
+ function kebabLayerName(layerName) {
2275
2256
  return String(layerName)
2276
2257
  .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
2277
2258
  .replace(/[^A-Za-z0-9]+/g, '-')
@@ -5,8 +5,8 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
- export const ADOPTION_STANCE_REL = '.ark/adoption-stance.json';
9
- export const ADOPTION_STANCE_VALUE = 'advisory-only';
8
+ const ADOPTION_STANCE_REL = '.ark/adoption-stance.json';
9
+ const ADOPTION_STANCE_VALUE = 'advisory-only';
10
10
  export const ADOPTED_REQUIRED_MERGE = 'required-merge';
11
11
  export const ADOPTED_ADVISORY_ACKED = 'advisory-only-acked';
12
12
  export const ADOPTED_NOT = 'not-adopted';