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,11 +9,26 @@
9
9
  * CLI-only layerForFile (Node path resolution) is appended below the pure core.
10
10
  */
11
11
 
12
- /**
13
- * Last published arkgate that rejects `childSlices`. The rule schema sets
14
- * `additionalProperties: false`, so 4.8.22 and older refuse the whole config.
15
- */
16
- export const CHILD_SLICES_REJECTED_THROUGH = '4.8.22';
12
+ /** The mode of a `sharedImportsSlice` value. Never compare the raw value to a string. */
13
+ export function sharedImportsSliceMode(setting) {
14
+ if (setting === 'deny' || setting === 'deny-cross-parent')
15
+ return setting;
16
+ if (setting !== null && typeof setting === 'object') {
17
+ const mode = setting.mode;
18
+ if (mode === 'deny-cross-parent')
19
+ return mode;
20
+ }
21
+ return undefined;
22
+ }
23
+ /** Composition roots the deny-cross-parent walk stops at. Empty for the string forms. */
24
+ export function sharedImportsSliceStopAt(setting) {
25
+ if (setting === null || typeof setting !== 'object')
26
+ return [];
27
+ const stopAt = setting.stopAt;
28
+ if (!Array.isArray(stopAt))
29
+ return [];
30
+ return stopAt.filter((entry) => typeof entry === 'string' && entry.length > 0);
31
+ }
17
32
  const regexpCache = new Map();
18
33
  function escapeLiteral(ch) {
19
34
  return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
@@ -117,7 +132,7 @@ export function globToRegExp(pattern) {
117
132
  * Used for path-anchored ranking so a domain folder glob can beat a broad
118
133
  * Application bag like src/lib when the file actually sits under domain/.
119
134
  */
120
- export function concreteGlobSegments(pattern) {
135
+ function concreteGlobSegments(pattern) {
121
136
  const glob = normalizeGlobSeparators(String(pattern));
122
137
  return glob
123
138
  .split('/')
@@ -228,13 +243,25 @@ export function layerForRelativePath(relPath, layers) {
228
243
  * With `sliceIdentity` `"path"` (the default) the slice id includes the
229
244
  * literal prefix plus the star bindings (`lib/features/projects/rfi`), so
230
245
  * parallel trees get different ids. With `"stars"` it is the last literal
231
- * plus those star bindings. A star never binds the filename; a file directly
246
+ * plus every star binding, including one before the last literal. A star never binds the filename; a file directly
232
247
  * under the last bound directory keeps that directory as its slice.
233
248
  */
234
249
  export function sliceIdForPath(relPath, sliceFolders, sliceIdentity) {
250
+ return sliceIdWithBinding(relPath, sliceFolders, sliceIdentity === 'stars' ? 'stars' : 'path');
251
+ }
252
+ /**
253
+ * The 4.8.23 `stars` id: bound segments from the last literal on, so a star
254
+ * before the last literal was dropped (`modules` / `*` / `api` / `*` bound `api/v1`).
255
+ * Kept for one release so config written against that id keeps its verdict.
256
+ */
257
+ export function legacyStarsSliceIdForPath(relPath, sliceFolders) {
258
+ return sliceIdWithBinding(relPath, sliceFolders ? [...sliceFolders] : undefined, LEGACY_STARS);
259
+ }
260
+ /** Internal binding mode for {@link legacyStarsSliceIdForPath}; never a config value. */
261
+ const LEGACY_STARS = 'stars-4.8.23';
262
+ function sliceIdWithBinding(relPath, sliceFolders, identity) {
235
263
  if (!sliceFolders?.length)
236
264
  return undefined;
237
- const identity = sliceIdentity === 'stars' ? 'stars' : 'path';
238
265
  const parts = String(relPath)
239
266
  .split(/[/\\]/)
240
267
  .filter(Boolean);
@@ -244,18 +271,41 @@ export function sliceIdForPath(relPath, sliceFolders, sliceIdentity) {
244
271
  for (const raw of sliceFolders) {
245
272
  if (typeof raw !== 'string' || raw.length === 0)
246
273
  continue;
247
- if (isAnchoredSliceEntry(raw)) {
248
- const anchored = anchoredSliceId(parts, raw, identity);
274
+ const entry = parsedSliceFolder(raw);
275
+ if (entry.anchored) {
276
+ const anchored = anchoredSliceIdFromPattern(parts, entry.anchored, identity);
249
277
  if (anchored)
250
278
  return anchored;
251
279
  continue;
252
280
  }
253
- if (!raw.includes('/') && !raw.includes('\\') && !raw.includes('*')) {
254
- bare.add(raw.toLowerCase());
255
- }
281
+ if (entry.bare !== undefined)
282
+ bare.add(entry.bare);
256
283
  }
257
284
  return bareSliceId(parts, bare);
258
285
  }
286
+ const parsedSliceFolders = new Map();
287
+ /**
288
+ * `sliceIdForPath` runs for every edge endpoint under every peerIsolation rule.
289
+ * Re-splitting the same declared entries per call dominated the allocation of
290
+ * a large-repo analysis; the parse depends only on the entry text.
291
+ */
292
+ function parsedSliceFolder(raw) {
293
+ const cached = parsedSliceFolders.get(raw);
294
+ if (cached)
295
+ return cached;
296
+ let parsed = {};
297
+ if (isAnchoredSliceEntry(raw)) {
298
+ parsed = { anchored: anchoredSlicePattern(raw) };
299
+ }
300
+ else if (!raw.includes('/') && !raw.includes('\\') && !raw.includes('*')) {
301
+ parsed = { bare: raw.toLowerCase() };
302
+ }
303
+ // Bounded: entries come from contracts, but a long-lived process may see many.
304
+ if (parsedSliceFolders.size >= 1024)
305
+ parsedSliceFolders.clear();
306
+ parsedSliceFolders.set(raw, parsed);
307
+ return parsed;
308
+ }
259
309
  /** A starred prefix with a real leading directory, not a bare name and not `*`. */
260
310
  function isAnchoredSliceEntry(entry) {
261
311
  const segments = entry.split(/[/\\]/).filter((part) => part.length > 0);
@@ -265,11 +315,13 @@ function isAnchoredSliceEntry(entry) {
265
315
  return false;
266
316
  return segments.some((part) => part === '*');
267
317
  }
268
- function anchoredSliceId(parts, raw, identity) {
269
- const pattern = raw
318
+ function anchoredSlicePattern(raw) {
319
+ return raw
270
320
  .split(/[/\\]/)
271
321
  .filter((part) => part.length > 0)
272
322
  .map((part) => part.toLowerCase());
323
+ }
324
+ function anchoredSliceIdFromPattern(parts, pattern, identity) {
273
325
  const offsets = anchorOffsets(parts, pattern[0] ?? '');
274
326
  for (const offset of offsets) {
275
327
  const id = bindAnchoredSlice(parts, pattern, offset, identity);
@@ -288,11 +340,14 @@ function anchoredSliceId(parts, raw, identity) {
288
340
  *
289
341
  * `path` returns every bound segment (literal prefix plus star bindings), so
290
342
  * a parallel tree (`components` / `features` / `*` / `*`) gets a different
291
- * id. `stars` drops the literal prefix before the last literal and keeps
292
- * that literal plus the star bindings.
343
+ * id. `stars` drops only the literals before the last literal. It keeps that
344
+ * literal and every star binding, including a star before it: `modules` /
345
+ * `*` / `api` / `*` binds `orders/api/v1`, never a bare `api/v1` that two
346
+ * modules would share.
293
347
  */
294
348
  function bindAnchoredSlice(parts, pattern, offset, identity = 'path') {
295
349
  const bound = [];
350
+ const fromStar = [];
296
351
  let lastLiteralAt = -1;
297
352
  let index = offset;
298
353
  for (let pi = 0; pi < pattern.length; pi += 1) {
@@ -306,6 +361,7 @@ function bindAnchoredSlice(parts, pattern, offset, identity = 'path') {
306
361
  break;
307
362
  }
308
363
  bound.push(parts[index].toLowerCase());
364
+ fromStar.push(true);
309
365
  index += 1;
310
366
  continue;
311
367
  }
@@ -314,18 +370,44 @@ function bindAnchoredSlice(parts, pattern, offset, identity = 'path') {
314
370
  if (parts[index].toLowerCase() !== segment)
315
371
  return undefined;
316
372
  bound.push(parts[index].toLowerCase());
373
+ fromStar.push(false);
317
374
  lastLiteralAt = bound.length - 1;
318
375
  index += 1;
319
376
  }
320
377
  if (bound.length === 0)
321
378
  return undefined;
322
- if (identity === 'stars') {
379
+ if (identity === LEGACY_STARS) {
323
380
  if (lastLiteralAt < 0)
324
381
  return undefined;
325
382
  return bound.slice(lastLiteralAt).join('/');
326
383
  }
384
+ if (identity === 'stars') {
385
+ if (lastLiteralAt < 0)
386
+ return undefined;
387
+ return bound.filter((_, at) => at >= lastLiteralAt || fromStar[at]).join('/');
388
+ }
327
389
  return bound.join('/');
328
390
  }
391
+ /**
392
+ * Indexes of the pattern segments a `stars` id keeps: every `*`, the last
393
+ * literal, and everything after it. Literals before the last literal drop.
394
+ * Returns null when the pattern has no literal.
395
+ */
396
+ function starsKeptSegmentIndexes(segments) {
397
+ let lastLiteral = -1;
398
+ for (let at = 0; at < segments.length; at += 1) {
399
+ if (segments[at] !== '*')
400
+ lastLiteral = at;
401
+ }
402
+ if (lastLiteral < 0)
403
+ return null;
404
+ const kept = [];
405
+ for (let at = 0; at < segments.length; at += 1) {
406
+ if (at >= lastLiteral || segments[at] === '*')
407
+ kept.push(at);
408
+ }
409
+ return kept;
410
+ }
329
411
  /**
330
412
  * Doctor warning input. Silent unless `sliceIdentity` is `"stars"` and two
331
413
  * different anchored prefixes share a stem (last literal plus the stars after it).
@@ -367,7 +449,10 @@ export function sliceIdentityCollisions(rules) {
367
449
  }
368
450
  return out;
369
451
  }
370
- /** Last literal plus the stars that follow it. `**` never forms a stem. */
452
+ /**
453
+ * The segments a `stars` id keeps: every star, the last literal, and the stars
454
+ * after it. Literals before the last literal drop. `**` never forms a stem.
455
+ */
371
456
  function starsIdentityStem(raw) {
372
457
  if (!isAnchoredSliceEntry(raw))
373
458
  return undefined;
@@ -375,20 +460,12 @@ function starsIdentityStem(raw) {
375
460
  .split(/[/\\]/)
376
461
  .filter((part) => part.length > 0)
377
462
  .map((part) => part.toLowerCase());
378
- let lastLiteral = -1;
379
- for (let i = 0; i < segments.length; i += 1) {
380
- const part = segments[i] ?? '';
381
- if (part === '**')
382
- return undefined;
383
- if (part !== '*')
384
- lastLiteral = i;
385
- }
386
- if (lastLiteral < 0)
463
+ if (segments.some((part) => part === '**'))
387
464
  return undefined;
388
- const tail = segments.slice(lastLiteral);
389
- if (tail.some((part, index) => index > 0 && part !== '*'))
465
+ const kept = starsKeptSegmentIndexes(segments);
466
+ if (!kept)
390
467
  return undefined;
391
- return tail.join('/');
468
+ return kept.map((at) => segments[at]).join('/');
392
469
  }
393
470
  function formatSliceIdentityCollision(from, to, paths, stem) {
394
471
  const named = paths.length === 2
@@ -604,10 +681,11 @@ export function peerIsolationMustDeny(input) {
604
681
  return peerIsolationDecision(input).denied;
605
682
  }
606
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.';
607
685
  function aliasGlobPattern(glob) {
608
686
  return trimTrailingSlashes(glob.trim().replace(/\\/g, '/')).toLowerCase();
609
687
  }
610
- /** Existing path glob matcher. Also accepts a leading src/ or app/. */
688
+ /** Existing path glob matcher. Also accepts a leading src/ or app/, and a plain folder covers its subtree. */
611
689
  function aliasGlobMatches(glob, relPath) {
612
690
  const pattern = aliasGlobPattern(glob);
613
691
  if (!pattern || pattern === '*' || pattern === '**')
@@ -615,7 +693,25 @@ function aliasGlobMatches(glob, relPath) {
615
693
  const file = String(relPath).replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase();
616
694
  const rooted = stripSrcOrApp(file);
617
695
  const re = globToRegExp(pattern);
618
- return re.test(file) || (rooted !== file && re.test(rooted));
696
+ // A folder form (no wildcard in the last segment) also covers its subtree,
697
+ // as sharedRoots does: `lib/compliance` matches `lib/compliance/x.ts`.
698
+ const last = pattern.split('/').pop() ?? '';
699
+ const under = last.includes('*') ? null : globToRegExp(`${pattern}/**`);
700
+ const hit = (candidate) => re.test(candidate) || (under !== null && under.test(candidate));
701
+ return hit(file) || (rooted !== file && hit(rooted));
702
+ }
703
+ /**
704
+ * Does `relPath` match a `sharedImportsSlice.stopAt` entry? An entry matches
705
+ * the way a shared root does (anchored, optional leading src/ or app/, a plain
706
+ * folder covers its subtree) or the way an alias glob does (`kernel/registrations/**`
707
+ * against `src/kernel/registrations/x.ts`). A blanket `*` / `**` never matches.
708
+ */
709
+ export function pathMatchesSharedWalkStop(relPath, stopAt) {
710
+ if (!relPath || !stopAt?.length)
711
+ return false;
712
+ if (pathUnderSharedRoot(relPath, [...stopAt]))
713
+ return true;
714
+ return stopAt.some((glob) => typeof glob === 'string' && aliasGlobMatches(glob, relPath));
619
715
  }
620
716
  function splitAliasTarget(to) {
621
717
  const parts = aliasGlobPattern(to).split('/').filter(Boolean);
@@ -624,16 +720,19 @@ function splitAliasTarget(to) {
624
720
  }
625
721
  return { universeId: parts.slice(0, -1).join('/'), childId: parts.join('/') };
626
722
  }
627
- function firstSliceAlias(relPath, rule) {
723
+ function firstSliceAliasEntry(relPath, rule) {
628
724
  for (const alias of rule.childSlices?.sliceAliases ?? []) {
629
725
  if (!alias || typeof alias.from !== 'string' || typeof alias.to !== 'string')
630
726
  continue;
631
- if (!aliasGlobMatches(alias.from, relPath))
632
- continue;
633
- return splitAliasTarget(alias.to);
727
+ if (aliasGlobMatches(alias.from, relPath))
728
+ return alias;
634
729
  }
635
730
  return null;
636
731
  }
732
+ function firstSliceAlias(relPath, rule) {
733
+ const alias = firstSliceAliasEntry(relPath, rule);
734
+ return alias ? splitAliasTarget(alias.to) : null;
735
+ }
637
736
  /**
638
737
  * Universe id and child id for one file. An alias runs only when both walls
639
738
  * leave the path unclassified, so a real slice folder is never overridden.
@@ -650,6 +749,130 @@ export function resolveGovernedSlice(relPath, rule, sliceFolders) {
650
749
  const alias = firstSliceAlias(relPath, rule);
651
750
  return alias ? { universeId: alias.universeId, childId: alias.childId } : {};
652
751
  }
752
+ /** Universe shapes of a stars rule, with the 4.8.23 id shape beside each new one. */
753
+ function starsUniverseShapes(folders) {
754
+ const shapes = [];
755
+ for (const raw of folders ?? []) {
756
+ if (typeof raw !== 'string' || raw.length === 0)
757
+ continue;
758
+ const segments = raw.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
759
+ if (segments.length === 1 && segments[0] && !segments[0].includes('*')) {
760
+ shapes.push({ literals: [segments[0], null], legacy: [segments[0], null] });
761
+ continue;
762
+ }
763
+ if (segments.some((part) => part === '**' || (part.includes('*') && part !== '*')))
764
+ continue;
765
+ const kept = starsKeptSegmentIndexes(segments);
766
+ if (!kept || !segments.includes('*'))
767
+ continue;
768
+ const star = (part) => (part === '*' ? null : part);
769
+ const lastLiteral = kept.find((at) => segments[at] !== '*') ?? 0;
770
+ shapes.push({
771
+ literals: kept.map((at) => star(segments[at] ?? '')),
772
+ legacy: segments.slice(lastLiteral).map(star),
773
+ });
774
+ }
775
+ return shapes;
776
+ }
777
+ function literalsMatch(shape, parts) {
778
+ return (shape.length === parts.length &&
779
+ shape.every((literal, at) => literal === null || parts[at] === '*' || literal === parts[at]));
780
+ }
781
+ /**
782
+ * New-id shapes (`*` for a star) that a 4.8.23 stars id maps to. Empty when the id is
783
+ * already a new id or no shape takes it. Config load rejects more than one.
784
+ */
785
+ function legacyStarsIdTargets(folders, id) {
786
+ const parts = id.split('/').filter(Boolean).map((part) => part.toLowerCase());
787
+ const shapes = starsUniverseShapes(folders);
788
+ if (parts.length === 0 || shapes.some((shape) => literalsMatch(shape.literals, parts)))
789
+ return [];
790
+ const targets = new Set();
791
+ for (const shape of shapes) {
792
+ if (shape.legacy.length === shape.literals.length)
793
+ continue;
794
+ if (literalsMatch(shape.legacy, parts)) {
795
+ const tail = [...parts];
796
+ // Fill the kept literals and trailing stars from the legacy id; pre-literal stars stay `*`.
797
+ const lead = shape.literals.length - shape.legacy.length;
798
+ targets.add([...shape.literals.slice(0, lead).map(() => '*'), ...tail].join('/'));
799
+ }
800
+ }
801
+ return [...targets].sort();
802
+ }
803
+ /** The legacy universe id when this alias target is a 4.8.23 stars child id. */
804
+ function legacyAliasUniverse(rule, to) {
805
+ if (rule.sliceIdentity !== 'stars')
806
+ return undefined;
807
+ const target = splitAliasTarget(to);
808
+ if (!target)
809
+ return undefined;
810
+ return legacyStarsIdTargets(rule.sliceFolders, target.universeId).length === 1
811
+ ? target.universeId
812
+ : undefined;
813
+ }
814
+ function placeForPair(relPath, rule, folders) {
815
+ const place = resolveGovernedSlice(relPath, rule, folders);
816
+ if (!relPath || rule.sliceIdentity !== 'stars')
817
+ return place;
818
+ if (place.universeId) {
819
+ const alias = firstSliceAliasEntry(relPath, rule);
820
+ const fromFolders = sliceIdForPath(relPath, [...folders], rule.sliceIdentity);
821
+ if (!fromFolders && alias && legacyAliasUniverse(rule, alias.to) !== undefined) {
822
+ return { ...place, legacyAlias: true, legacyUniverse: place.universeId, legacyChild: place.childId };
823
+ }
824
+ const legacyUniverse = legacyStarsSliceIdForPath(relPath, folders);
825
+ const child = rule.childSlices;
826
+ const legacyChild = child?.sliceIdentity === 'stars' ? legacyStarsSliceIdForPath(relPath, child.sliceFolders) : undefined;
827
+ return {
828
+ ...place,
829
+ ...(legacyUniverse ? { legacyUniverse } : {}),
830
+ ...(legacyChild && place.childId ? { legacyChild } : {}),
831
+ };
832
+ }
833
+ return place;
834
+ }
835
+ /**
836
+ * Both endpoints of one edge under a peerIsolation rule, with the one-release 4.8.23
837
+ * `stars` compatibility: a `sliceAliases` target written as a 4.8.23 stars id joins
838
+ * the universe of the other endpoint whose 4.8.23 id it names, and an
839
+ * `allowedCrossSlice` entry written against 4.8.23 ids still clears the edge it cleared
840
+ * then. ark-check warns CONFIG_SLICE_LEGACY_STARS_ID for both.
841
+ */
842
+ export function resolveGovernedSlicePair(rule, folders, fromPath, toPath) {
843
+ const from = placeForPair(fromPath, rule, folders);
844
+ const to = placeForPair(toPath, rule, folders);
845
+ const legacyFrom = from.legacyUniverse;
846
+ const legacyTo = to.legacyUniverse;
847
+ const adopt = (alias, other) => {
848
+ if (!alias.legacyAlias || other.legacyAlias || !other.universeId)
849
+ return alias;
850
+ if (other.legacyUniverse?.toLowerCase() !== alias.universeId?.toLowerCase())
851
+ return alias;
852
+ const childSegment = alias.childId?.split('/').pop();
853
+ return {
854
+ ...alias,
855
+ universeId: other.universeId,
856
+ ...(childSegment ? { childId: `${other.universeId}/${childSegment}` } : {}),
857
+ };
858
+ };
859
+ const fromPlace = adopt(from, to);
860
+ const toPlace = adopt(to, from);
861
+ const crossSliceAllowed = crossSliceEdgeAllowed(rule.allowedCrossSlice, fromPlace.universeId, toPlace.universeId) ||
862
+ (rule.sliceIdentity === 'stars' &&
863
+ (legacyFrom !== fromPlace.universeId || legacyTo !== toPlace.universeId) &&
864
+ crossSliceEdgeAllowed(rule.allowedCrossSlice, legacyFrom, legacyTo));
865
+ const legacyChildAllowed = rule.childSlices?.sliceIdentity === 'stars' &&
866
+ from.legacyChild !== undefined &&
867
+ to.legacyChild !== undefined &&
868
+ from.legacyChild !== to.legacyChild &&
869
+ childCrossSliceAllowed(rule.childSlices.allowedCrossSlice, from.legacyChild, to.legacyChild);
870
+ const strip = (place) => ({
871
+ ...(place.universeId ? { universeId: place.universeId } : {}),
872
+ ...(place.childId ? { childId: place.childId } : {}),
873
+ });
874
+ return { from: strip(fromPlace), to: strip(toPlace), crossSliceAllowed, legacyChildAllowed };
875
+ }
653
876
  function childPatternDirectory(pattern, childId, identity) {
654
877
  const segments = pattern.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
655
878
  const idParts = childId.split('/').filter(Boolean).map((part) => part.toLowerCase());
@@ -658,23 +881,17 @@ function childPatternDirectory(pattern, childId, identity) {
658
881
  if (segments.some((part) => part === '**' || (part.includes('*') && part !== '*')))
659
882
  return null;
660
883
  if (identity === 'stars') {
661
- let lastLiteral = -1;
662
- for (let index = 0; index < segments.length; index += 1) {
663
- if (segments[index] !== '*')
664
- lastLiteral = index;
665
- }
666
- if (lastLiteral < 0)
667
- return null;
668
- const tail = segments.slice(lastLiteral);
669
- if (tail.length !== idParts.length)
884
+ const kept = starsKeptSegmentIndexes(segments);
885
+ if (!kept || kept.length !== idParts.length)
670
886
  return null;
671
- for (let index = 0; index < tail.length; index += 1) {
672
- if (tail[index] === '*')
673
- continue;
674
- if (tail[index] !== idParts[index])
887
+ const dir = [...segments];
888
+ for (let index = 0; index < kept.length; index += 1) {
889
+ const at = kept[index] ?? 0;
890
+ if (segments[at] !== '*' && segments[at] !== idParts[index])
675
891
  return null;
892
+ dir[at] = idParts[index] ?? '';
676
893
  }
677
- return [...segments.slice(0, lastLiteral), ...idParts].join('/');
894
+ return dir.join('/');
678
895
  }
679
896
  if (segments.length !== idParts.length)
680
897
  return null;
@@ -721,11 +938,93 @@ export function sliceAliasDestination(file, childId, child) {
721
938
  export function anySliceAlias(rules) {
722
939
  return (rules ?? []).some((rule) => (rule?.childSlices?.sliceAliases?.length ?? 0) > 0);
723
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
+ }
724
1022
  /** Doctor list. Absent when no rule sets sliceAliases, so the key stays off. */
725
1023
  export function sliceAliasReport(rules, files) {
726
1024
  if (!anySliceAlias(rules))
727
1025
  return null;
728
1026
  const moves = new Map();
1027
+ const pinned = new Map();
729
1028
  for (const rule of rules ?? []) {
730
1029
  const child = rule?.childSlices;
731
1030
  if (!child?.sliceAliases)
@@ -734,10 +1033,19 @@ export function sliceAliasReport(rules, files) {
734
1033
  if (!alias || typeof alias.from !== 'string' || typeof alias.to !== 'string')
735
1034
  continue;
736
1035
  const key = `${aliasGlobPattern(alias.from)}\0${aliasGlobPattern(alias.to)}`;
737
- let move = moves.get(key);
1036
+ const bucket = alias.pinned === true ? pinned : moves;
1037
+ let move = bucket.get(key);
738
1038
  if (!move) {
739
- move = { from: alias.from, to: alias.to, destination: '', files: [] };
740
- 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);
741
1049
  }
742
1050
  const seen = new Set(move.files);
743
1051
  for (const file of files) {
@@ -750,9 +1058,55 @@ export function sliceAliasReport(rules, files) {
750
1058
  }
751
1059
  }
752
1060
  }
753
- const listed = [...moves.values()].map((move) => ({ ...move, files: [...move.files].sort() }));
754
- listed.sort((left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to));
755
- return { notAScore: true, finished: false, debt: SLICE_ALIAS_DEBT, moves: listed };
1061
+ const universes = knownUniverseIds(rules, files);
1062
+ const decorate = (move) => {
1063
+ const target = splitAliasTarget(move.to);
1064
+ const unknown = universes !== null && target !== null && !universes.has(target.universeId);
1065
+ return {
1066
+ ...move,
1067
+ files: [...move.files].sort(),
1068
+ ...(unknown
1069
+ ? {
1070
+ unknownUniverse: true,
1071
+ advisory: `alias target ${move.to} names universe ${target.universeId}, which no file belongs to. Check for a typo.`,
1072
+ }
1073
+ : {}),
1074
+ };
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
+ };
1088
+ }
1089
+ /**
1090
+ * Universe ids that real (non-aliased) files resolve to, across rules that set
1091
+ * sliceAliases. Null when a rule has no explicit sliceFolders (the universe
1092
+ * would come from layer patterns this report does not see): no claim then.
1093
+ */
1094
+ function knownUniverseIds(rules, files) {
1095
+ const ids = new Set();
1096
+ for (const rule of rules ?? []) {
1097
+ if (!rule?.childSlices?.sliceAliases?.length)
1098
+ continue;
1099
+ if (!rule.sliceFolders?.length)
1100
+ return null;
1101
+ for (const file of files) {
1102
+ if (typeof file !== 'string')
1103
+ continue;
1104
+ const id = sliceIdForPath(file, rule.sliceFolders, rule.sliceIdentity);
1105
+ if (id)
1106
+ ids.add(id.toLowerCase());
1107
+ }
1108
+ }
1109
+ return ids;
756
1110
  }
757
1111
  /**
758
1112
  * One human sentence naming which peerIsolation reason fired — so the denial
@@ -796,32 +1150,23 @@ export function universePairLabel(sliceId) {
796
1150
  const parts = sliceId.split('/').filter(Boolean);
797
1151
  return parts.length > 0 ? parts[parts.length - 1] : undefined;
798
1152
  }
799
- function directorySegments(relPath) {
800
- const parts = String(relPath).split(/[/\\]/).filter(Boolean);
801
- return parts.slice(0, -1);
802
- }
803
- function pathHasCommonFolder(relPath, commonFolders) {
804
- if (!commonFolders?.length)
805
- return false;
806
- const names = new Set();
807
- for (const raw of commonFolders) {
808
- if (typeof raw === 'string' && raw.length > 0)
809
- names.add(raw.toLowerCase());
810
- }
811
- if (names.size === 0)
1153
+ function isCommonFolderName(segment, commonFolders) {
1154
+ if (!segment || !commonFolders?.length)
812
1155
  return false;
813
- return directorySegments(relPath).some((part) => names.has(part.toLowerCase()));
1156
+ const want = segment.toLowerCase();
1157
+ return commonFolders.some((raw) => typeof raw === 'string' && raw.length > 0 && raw.toLowerCase() === want);
814
1158
  }
815
1159
  /**
816
- * Child id for one path. A common folder and a flat file (the starred id does
817
- * not grow past the universe id) are universe common. A child id that does not
818
- * extend the universe id is not used; the caller warns.
1160
+ * Child id for one path. A flat file (the starred id does not grow past the
1161
+ * universe id) is universe common. So is a `commonFolders` directory, but only
1162
+ * at the child position: the segment directly under the universe id
1163
+ * (`features/projects/domain/**`). A folder of the same name inside a child
1164
+ * (`features/projects/rfi/domain/**`) belongs to that child. A child id that
1165
+ * does not extend the universe id is not used; the caller warns.
819
1166
  */
820
- export function resolveChildSliceId(relPath, universeId, child) {
1167
+ function resolveChildSliceId(relPath, universeId, child) {
821
1168
  if (!relPath || !child?.sliceFolders?.length)
822
1169
  return {};
823
- if (pathHasCommonFolder(relPath, child.commonFolders))
824
- return {};
825
1170
  const raw = sliceIdForPath(relPath, child.sliceFolders, child.sliceIdentity);
826
1171
  if (!raw || !universeId)
827
1172
  return {};
@@ -829,9 +1174,12 @@ export function resolveChildSliceId(relPath, universeId, child) {
829
1174
  const universeKey = universeId.toLowerCase();
830
1175
  if (childKey === universeKey)
831
1176
  return {};
832
- if (childKey.startsWith(`${universeKey}/`))
833
- return { childId: raw };
834
- return { mismatched: { childId: raw, universeId } };
1177
+ if (!childKey.startsWith(`${universeKey}/`))
1178
+ return { mismatched: { childId: raw, universeId } };
1179
+ const firstChildSegment = childKey.slice(universeKey.length + 1).split('/')[0];
1180
+ if (isCommonFolderName(firstChildSegment, child.commonFolders))
1181
+ return {};
1182
+ return { childId: raw };
835
1183
  }
836
1184
  function siblingEntryKey(entry) {
837
1185
  return trimTrailingSlashes(entry.trim().replace(/\\/g, '/')).toLowerCase();
@@ -877,7 +1225,7 @@ export function importerInEnforcedSubtree(fromPath, fromChild, entries) {
877
1225
  * value deny every one. An object denies the importer only when it is listed,
878
1226
  * unless `default` is already `deny`.
879
1227
  */
880
- export function siblingCrossingAdvisory(siblings, fromPath, fromChild) {
1228
+ function siblingCrossingAdvisory(siblings, fromPath, fromChild) {
881
1229
  if (siblings == null || siblings === 'deny')
882
1230
  return false;
883
1231
  if (siblings === 'advisory')
@@ -960,7 +1308,7 @@ function childAllowanceSpansUniverses(from, to) {
960
1308
  * Universe wall, then the child wall. An allow from the child wall never
961
1309
  * overturns a universe deny: that deny returns before the child wall runs.
962
1310
  */
963
- export function evaluateNestedSliceWall(input) {
1311
+ function evaluateNestedSliceWall(input) {
964
1312
  const universe = peerIsolationDecision({
965
1313
  fromPath: input.fromPath,
966
1314
  toPath: input.toPath,
@@ -970,7 +1318,7 @@ export function evaluateNestedSliceWall(input) {
970
1318
  fromShared: input.fromShared,
971
1319
  toShared: input.toShared,
972
1320
  crossSliceAllowed: input.crossSliceAllowed,
973
- sharedImportsSlice: input.rule.sharedImportsSlice,
1321
+ sharedImportsSlice: sharedImportsSliceMode(input.rule.sharedImportsSlice),
974
1322
  });
975
1323
  if (universe.denied) {
976
1324
  const crossParent = Boolean(input.rule.childSlices) && universe.reason === 'cross-slice';
@@ -1009,7 +1357,7 @@ export function evaluateNestedSliceWall(input) {
1009
1357
  toUniverse: input.toSlice,
1010
1358
  };
1011
1359
  if (fromChild && toChild && fromChild !== toChild) {
1012
- if (childCrossSliceAllowed(child.allowedCrossSlice, fromChild, toChild)) {
1360
+ if (input.legacyChildAllowed || childCrossSliceAllowed(child.allowedCrossSlice, fromChild, toChild)) {
1013
1361
  return allowSameUniverse;
1014
1362
  }
1015
1363
  const advisory = siblingCrossingAdvisory(child.siblings, input.fromPath, fromChild);
@@ -1033,27 +1381,45 @@ export function evaluateNestedSliceWall(input) {
1033
1381
  }
1034
1382
  return allowSameUniverse;
1035
1383
  }
1384
+ /** Did the child (inner) wall produce this finding, not the universe wall? */
1385
+ export function isChildWallCrossing(verdict) {
1386
+ return verdict?.crossing === 'cross-sibling' || verdict?.crossing === 'parent-imports-child';
1387
+ }
1388
+ /**
1389
+ * The consumer text for one slice finding. `rule.message` is the universe-wall
1390
+ * text. `childSlices.message` is the inner-wall text. An inner-wall finding
1391
+ * never falls back to the rule message; undefined means "use ArkGate's default".
1392
+ */
1393
+ export function sliceConsumerMessage(rule, verdict) {
1394
+ return isChildWallCrossing(verdict) ? rule?.childSlices?.message : rule?.message;
1395
+ }
1036
1396
  /**
1037
1397
  * Finding message. Import sites always append the explanation. Intent sites
1038
1398
  * keep today's wording when there is no reasonId: a plain cross-slice deny
1039
1399
  * does not gain a clause, and any other universe-wall reason does.
1400
+ * `ruleMessage` is only used for universe-wall findings; an inner-wall finding
1401
+ * uses `childMessage` or ArkGate's default text.
1040
1402
  */
1041
1403
  export function composeSliceDenialMessage(input) {
1042
1404
  const explanation = input.verdict.explanation;
1405
+ const inner = isChildWallCrossing(input.verdict);
1406
+ const consumer = inner ? input.childMessage : input.ruleMessage;
1043
1407
  if (input.surface === 'intent') {
1044
1408
  const defaultMessage = input.defaultMessage ?? `${input.fromLayer} must not reference ${input.toLayer} intent.`;
1409
+ if (inner && explanation)
1410
+ return `${input.childMessage ?? defaultMessage} ${explanation}`;
1045
1411
  if (input.verdict.reasonId && explanation)
1046
1412
  return `${defaultMessage} ${explanation}`;
1047
1413
  if (explanation && input.verdict.peerIsolationReason !== 'cross-slice') {
1048
1414
  return `${defaultMessage} ${explanation}`;
1049
1415
  }
1050
- if (input.ruleMessage) {
1051
- return explanation ? `${input.ruleMessage} (${explanation})` : input.ruleMessage;
1416
+ if (consumer) {
1417
+ return explanation ? `${consumer} (${explanation})` : consumer;
1052
1418
  }
1053
1419
  return defaultMessage;
1054
1420
  }
1055
- if (input.ruleMessage) {
1056
- return explanation ? `${input.ruleMessage} (${explanation})` : input.ruleMessage;
1421
+ if (consumer) {
1422
+ return explanation ? `${consumer} (${explanation})` : consumer;
1057
1423
  }
1058
1424
  if (explanation) {
1059
1425
  const kind = input.kind ?? 'import';
@@ -1117,27 +1483,65 @@ export function sliceCountReport(violations) {
1117
1483
  };
1118
1484
  }
1119
1485
  /**
1120
- * One version warning when any rule sets childSlices, plus one warning per
1121
- * directed rule when a path's child id does not extend its universe id.
1486
+ * Doctor advisory: a shared file that sits on many CROSS_PARENT_VIA_SHARED
1487
+ * paths is often a composition root. Null below `minFindings` findings or when
1488
+ * no file reaches `minShare` of them. Not a finding and not in the baseline.
1489
+ */
1490
+ export function crossParentViaSharedHubs(violations, options) {
1491
+ const minFindings = options?.minFindings ?? 10;
1492
+ const minShare = options?.minShare ?? 0.5;
1493
+ let total = 0;
1494
+ const counts = new Map();
1495
+ for (const row of violations ?? []) {
1496
+ if (row?.reasonId !== 'CROSS_PARENT_VIA_SHARED')
1497
+ continue;
1498
+ total += 1;
1499
+ if (!Array.isArray(row.via))
1500
+ continue;
1501
+ const once = new Set(row.via.filter((file) => typeof file === 'string'));
1502
+ for (const file of once)
1503
+ counts.set(file, (counts.get(file) ?? 0) + 1);
1504
+ }
1505
+ if (total < minFindings)
1506
+ return null;
1507
+ const hubs = [...counts.entries()]
1508
+ .map(([file, count]) => ({ file, count, share: Math.round((count / total) * 100) / 100 }))
1509
+ .filter((hub) => hub.count / total >= minShare)
1510
+ .sort((left, right) => right.count - left.count || left.file.localeCompare(right.file));
1511
+ if (hubs.length === 0)
1512
+ return null;
1513
+ const first = hubs[0]?.file ?? '';
1514
+ return {
1515
+ notAScore: true,
1516
+ total,
1517
+ hubs,
1518
+ nextAction: `If ${first} is a composition root, add it to sharedImportsSlice.stopAt. Otherwise these findings are real.`,
1519
+ };
1520
+ }
1521
+ /**
1522
+ * One warning per directed rule when a path's child id does not extend its
1523
+ * universe id, and one per child allowance that spans universes. The version
1524
+ * floor lives in configVersionFloor.ts and needs pin evidence (#338).
1122
1525
  */
1123
1526
  export function childSliceConfigFindings(rules, files) {
1124
1527
  const out = [];
1125
- let versionWarned = false;
1126
1528
  const seenMismatch = new Set();
1127
1529
  const seenSpan = new Set();
1128
1530
  for (const rule of rules ?? []) {
1531
+ if (rule?.peerIsolation && rule.allowed === false)
1532
+ out.push(...legacyStarsIdFindings(rule, files));
1129
1533
  if (!rule?.childSlices)
1130
1534
  continue;
1131
- if (!versionWarned) {
1132
- versionWarned = true;
1535
+ if (!rule.peerIsolation || rule.allowed !== false) {
1133
1536
  out.push({
1134
- ruleId: 'CONFIG_CHILD_SLICES_VERSION',
1537
+ ruleId: 'CONFIG_CHILD_SLICES_INERT',
1135
1538
  failsStrict: false,
1136
- message: `childSlices needs an arkgate newer than ${CHILD_SLICES_REJECTED_THROUGH}. Version ${CHILD_SLICES_REJECTED_THROUGH} and older reject the key because the rule schema sets additionalProperties: false.`,
1539
+ fromLayer: rule.from,
1540
+ toLayer: rule.to,
1541
+ message: `${rule.from} → ${rule.to}: childSlices is inert on this rule and enforces nothing. The child wall runs only inside a universe wall. Add "peerIsolation": true and "allowed": false to the same rule, or remove childSlices.`,
1137
1542
  });
1138
- }
1139
- if (!rule.peerIsolation)
1140
1543
  continue;
1544
+ }
1141
1545
  for (const file of files) {
1142
1546
  const universeId = sliceIdForPath(file, rule.sliceFolders, rule.sliceIdentity);
1143
1547
  const resolved = resolveChildSliceId(file, universeId, rule.childSlices);
@@ -1176,35 +1580,215 @@ export function childSliceConfigFindings(rules, files) {
1176
1580
  }
1177
1581
  return out;
1178
1582
  }
1583
+ /** Observed new ids per 4.8.23 stars id, from real files (for the warning text). */
1584
+ function observedIdsByLegacy(folders, files) {
1585
+ const byLegacy = new Map();
1586
+ if (!folders?.length)
1587
+ return byLegacy;
1588
+ for (const file of files) {
1589
+ if (typeof file !== 'string')
1590
+ continue;
1591
+ const id = sliceIdForPath(file, [...folders], 'stars');
1592
+ const legacy = legacyStarsSliceIdForPath(file, folders);
1593
+ if (!id || !legacy || id === legacy)
1594
+ continue;
1595
+ const set = byLegacy.get(legacy) ?? new Set();
1596
+ set.add(id);
1597
+ byLegacy.set(legacy, set);
1598
+ }
1599
+ return byLegacy;
1600
+ }
1601
+ function newIdHint(legacyId, shapes, observed, childSegment) {
1602
+ const suffix = childSegment ? `/${childSegment}` : '';
1603
+ const shape = `${shapes[0] ?? legacyId}${suffix}`;
1604
+ const seen = [...(observed.get(legacyId.toLowerCase()) ?? [])].sort().map((id) => `${id}${suffix}`);
1605
+ if (seen.length === 1)
1606
+ return `${seen[0]}`;
1607
+ if (seen.length > 1)
1608
+ return `${shape} (one entry per module: ${seen.join(', ')})`;
1609
+ return shape;
1610
+ }
1611
+ /**
1612
+ * CONFIG_SLICE_LEGACY_STARS_ID: under sliceIdentity "stars", a sliceAliases target or an
1613
+ * allowedCrossSlice entry written against the 4.8.23 stars id (star bindings before the
1614
+ * last literal dropped). It keeps its 4.8.23 verdict for one release; the warning names
1615
+ * the new id.
1616
+ */
1617
+ function legacyStarsIdFindings(rule, files) {
1618
+ const out = [];
1619
+ const push = (message) => out.push({
1620
+ ruleId: 'CONFIG_SLICE_LEGACY_STARS_ID',
1621
+ failsStrict: false,
1622
+ fromLayer: rule.from,
1623
+ toLayer: rule.to,
1624
+ message: `${rule.from} → ${rule.to}: ${message} It keeps its 4.8.23 meaning for this release only.`,
1625
+ });
1626
+ const child = rule.childSlices;
1627
+ if (rule.sliceIdentity === 'stars') {
1628
+ const observed = observedIdsByLegacy(rule.sliceFolders, files);
1629
+ for (const alias of child?.sliceAliases ?? []) {
1630
+ if (!alias || typeof alias.to !== 'string')
1631
+ continue;
1632
+ const target = splitAliasTarget(alias.to);
1633
+ if (!target || legacyAliasUniverse(rule, alias.to) === undefined)
1634
+ continue;
1635
+ const shapes = legacyStarsIdTargets(rule.sliceFolders, target.universeId);
1636
+ const segment = target.childId.split('/').pop();
1637
+ push(`sliceAliases to "${alias.to}" is a 4.8.23 stars id (the star before the last literal was dropped). The id is now ${newIdHint(target.universeId, shapes, observed, segment)}; write that instead.`);
1638
+ }
1639
+ for (const edge of rule.allowedCrossSlice ?? []) {
1640
+ if (!edge || typeof edge.from !== 'string' || typeof edge.to !== 'string')
1641
+ continue;
1642
+ const legacySides = [edge.from, edge.to].filter((id) => id.includes('/') && legacyStarsIdTargets(rule.sliceFolders, id).length > 0);
1643
+ if (legacySides.length === 0)
1644
+ continue;
1645
+ const rename = (id) => legacySides.includes(id)
1646
+ ? newIdHint(id, legacyStarsIdTargets(rule.sliceFolders, id), observed)
1647
+ : id;
1648
+ push(`allowedCrossSlice ${edge.from} → ${edge.to} names a 4.8.23 stars id. The ids are now ${rename(edge.from)} → ${rename(edge.to)}; write those instead.`);
1649
+ }
1650
+ }
1651
+ if (child?.sliceIdentity === 'stars') {
1652
+ const observed = observedIdsByLegacy(child.sliceFolders, files);
1653
+ for (const edge of child.allowedCrossSlice ?? []) {
1654
+ if (!edge || typeof edge.from !== 'string' || typeof edge.to !== 'string')
1655
+ continue;
1656
+ const legacySides = [edge.from, edge.to].filter((id) => legacyStarsIdTargets(child.sliceFolders, id).length > 0);
1657
+ if (legacySides.length === 0)
1658
+ continue;
1659
+ const rename = (id) => legacySides.includes(id)
1660
+ ? newIdHint(id, legacyStarsIdTargets(child.sliceFolders, id), observed)
1661
+ : id;
1662
+ push(`childSlices.allowedCrossSlice ${edge.from} → ${edge.to} names a 4.8.23 stars id. The ids are now ${rename(edge.from)} → ${rename(edge.to)}; write those instead.`);
1663
+ }
1664
+ }
1665
+ return out;
1666
+ }
1667
+ /** `ratchet: true` → always, `ratchet: false` → never, anything else → auto. */
1668
+ export function siblingRatchetMode(siblings) {
1669
+ if (siblings !== null && typeof siblings === 'object') {
1670
+ const ratchet = siblings.ratchet;
1671
+ if (ratchet === true)
1672
+ return 'always';
1673
+ if (ratchet === false)
1674
+ return 'never';
1675
+ }
1676
+ return 'auto';
1677
+ }
1179
1678
  /**
1180
- * Advisory sibling crossings above the recorded baseline count become blocking.
1181
- * The key is the existing baseline identity (ruleId, file, layers, target) —
1182
- * reasonId is not part of it. No baseline means there is no recorded count,
1183
- * so advisory stays non-blocking. Recorded crossings stay advisory.
1679
+ * How many advisory sibling crossings of one rule the baseline records. A key
1680
+ * still present counts when its current row is in the group. A stale key (the
1681
+ * crossing was removed) counts when the rule still classifies that edge as an
1682
+ * advisory sibling crossing, so replacing a recorded crossing neither disarms
1683
+ * the ratchet nor looks like growth.
1184
1684
  */
1185
- export function applyAdvisorySiblingRatchet(violations, occurrenceKeys, recordedKeys) {
1186
- const indexes = [];
1685
+ function recordedAdvisorySiblingCount(input) {
1686
+ const { rule } = input;
1687
+ let recorded = 0;
1688
+ for (const key of input.recordedKeys) {
1689
+ if (input.groupKeys.has(key)) {
1690
+ recorded += 1;
1691
+ continue;
1692
+ }
1693
+ if (!rule || input.currentKeys.has(key))
1694
+ continue;
1695
+ const parts = key.split('|');
1696
+ if (parts.length !== 5)
1697
+ continue;
1698
+ const [ruleId = '', file = '', fromLayer = '', toLayer = '', rawTarget = ''] = parts;
1699
+ if (!input.ruleIds.has(ruleId) || fromLayer !== rule.from || toLayer !== rule.to)
1700
+ continue;
1701
+ const target = rawTarget.replace(/#\d+$/, '');
1702
+ if (!file || !target)
1703
+ continue;
1704
+ const verdict = findDeniedEdgeDecision([rule], fromLayer, toLayer, {
1705
+ fromPath: file,
1706
+ toPath: target,
1707
+ layers: input.layers,
1708
+ })?.sliceVerdict;
1709
+ if (verdict?.reasonId === 'CROSS_SIBLING_SLICE' && verdict.decision === 'advisory')
1710
+ recorded += 1;
1711
+ }
1712
+ return recorded;
1713
+ }
1714
+ /**
1715
+ * Advisory sibling crossings past the recorded baseline become blocking, per
1716
+ * directed rule. Only advisory (`failsStrict: false`) crossings count; an
1717
+ * enforced crossing never switches the ratchet on. For each rule:
1718
+ *
1719
+ * - `ratchet` absent (every string form too): on only when the baseline
1720
+ * records at least one advisory sibling crossing of that rule (a key still
1721
+ * present, or a stale key that the rule still classifies as one), and only
1722
+ * when that rule's current advisory count grew past the recorded count.
1723
+ * Swapping a recorded crossing for a new one keeps the count and stays a
1724
+ * warning. An empty baseline, or one frozen before the child wall, promotes
1725
+ * nothing.
1726
+ * - `ratchet: true`: any unrecorded advisory crossing of the rule is promoted.
1727
+ * - `ratchet: false`: never promoted.
1728
+ *
1729
+ * The key is the existing baseline identity (ruleId, file, layers, target);
1730
+ * reasonId is not part of it. Recorded crossings stay advisory. Callers run
1731
+ * this only when a baseline (or an `--against` base) is in use.
1732
+ */
1733
+ export function applyAdvisorySiblingRatchet(violations, occurrenceKeys, recordedKeys, options) {
1734
+ const groups = new Map();
1187
1735
  for (let index = 0; index < violations.length; index += 1) {
1188
- if (violations[index]?.reasonId === 'CROSS_SIBLING_SLICE')
1189
- indexes.push(index);
1736
+ const row = violations[index];
1737
+ if (row?.reasonId !== 'CROSS_SIBLING_SLICE' || row.failsStrict !== false)
1738
+ continue;
1739
+ const key = `${row.fromLayer ?? ''}\0${row.toLayer ?? ''}`;
1740
+ const list = groups.get(key);
1741
+ if (list)
1742
+ list.push(index);
1743
+ else
1744
+ groups.set(key, [index]);
1190
1745
  }
1191
- if (indexes.length === 0)
1746
+ if (groups.size === 0)
1192
1747
  return [...violations];
1193
- let recorded = 0;
1194
- for (const index of indexes) {
1195
- if (recordedKeys.has(occurrenceKeys[index] ?? ''))
1196
- recorded += 1;
1748
+ const currentKeys = new Set(occurrenceKeys);
1749
+ const promote = new Set();
1750
+ for (const indexes of groups.values()) {
1751
+ const first = violations[indexes[0] ?? 0];
1752
+ const rule = (options?.rules ?? []).find((candidate) => candidate &&
1753
+ candidate.from === first?.fromLayer &&
1754
+ candidate.to === first?.toLayer &&
1755
+ candidate.allowed === false &&
1756
+ candidate.peerIsolation === true &&
1757
+ candidate.childSlices != null);
1758
+ const mode = siblingRatchetMode(rule?.childSlices?.siblings);
1759
+ if (mode === 'never')
1760
+ continue;
1761
+ if (mode === 'auto') {
1762
+ const recorded = recordedAdvisorySiblingCount({
1763
+ rule: rule,
1764
+ ruleIds: new Set(indexes.map((index) => String(violations[index]?.ruleId ?? ''))),
1765
+ groupKeys: new Set(indexes.map((index) => occurrenceKeys[index] ?? '')),
1766
+ currentKeys,
1767
+ recordedKeys,
1768
+ layers: options?.layers,
1769
+ });
1770
+ if (recorded === 0 || indexes.length <= recorded)
1771
+ continue;
1772
+ }
1773
+ for (const index of indexes) {
1774
+ if (!recordedKeys.has(occurrenceKeys[index] ?? ''))
1775
+ promote.add(index);
1776
+ }
1197
1777
  }
1198
- if (indexes.length <= recorded)
1778
+ if (promote.size === 0)
1199
1779
  return [...violations];
1200
1780
  return violations.map((violation, index) => {
1201
- if (violation.reasonId !== 'CROSS_SIBLING_SLICE')
1202
- return violation;
1203
- if (recordedKeys.has(occurrenceKeys[index] ?? ''))
1781
+ if (!promote.has(index))
1204
1782
  return violation;
1205
- if (violation.failsStrict !== false)
1206
- return violation;
1207
- return { ...violation, failsStrict: true, severity: 'error' };
1783
+ const from = violation.fromLayer ?? '?';
1784
+ const to = violation.toLayer ?? '?';
1785
+ const why = `New advisory sibling crossing past the recorded baseline for ${from} → ${to}. Set childSlices.siblings.ratchet: false to measure only.`;
1786
+ return {
1787
+ ...violation,
1788
+ failsStrict: true,
1789
+ severity: 'error',
1790
+ ...(typeof violation.message === 'string' ? { message: `${violation.message} ${why}` } : {}),
1791
+ };
1208
1792
  });
1209
1793
  }
1210
1794
  /**
@@ -1239,8 +1823,9 @@ export function findDeniedEdgeDecision(rules, from, to, options) {
1239
1823
  const fromPath = options?.fromPath;
1240
1824
  const toPath = options?.toPath;
1241
1825
  const folders = resolveSliceFolders(rule, from, options?.layers);
1242
- const fromPlace = resolveGovernedSlice(fromPath, rule, folders);
1243
- const toPlace = resolveGovernedSlice(toPath, rule, folders);
1826
+ const pair = resolveGovernedSlicePair(rule, folders, fromPath, toPath);
1827
+ const fromPlace = pair.from;
1828
+ const toPlace = pair.to;
1244
1829
  const fromSlice = fromPlace.universeId;
1245
1830
  const toSlice = toPlace.universeId;
1246
1831
  const verdict = evaluateNestedSliceWall({
@@ -1254,7 +1839,8 @@ export function findDeniedEdgeDecision(rules, from, to, options) {
1254
1839
  toChild: toPlace.childId,
1255
1840
  fromShared: !fromSlice && pathUnderSharedRoot(fromPath, rule.sharedRoots),
1256
1841
  toShared: !toSlice && pathUnderSharedRoot(toPath, rule.sharedRoots),
1257
- crossSliceAllowed: crossSliceEdgeAllowed(rule.allowedCrossSlice, fromSlice, toSlice),
1842
+ crossSliceAllowed: pair.crossSliceAllowed,
1843
+ legacyChildAllowed: pair.legacyChildAllowed,
1258
1844
  });
1259
1845
  if (verdict.decision !== 'allow') {
1260
1846
  return {
@@ -1289,7 +1875,7 @@ export function findSharedImportsSliceBridge(rules, from, to, options) {
1289
1875
  continue;
1290
1876
  if (rule.allowed !== false || !rule.peerIsolation)
1291
1877
  continue;
1292
- if (rule.sharedImportsSlice === 'deny')
1878
+ if (sharedImportsSliceMode(rule.sharedImportsSlice) === 'deny')
1293
1879
  continue;
1294
1880
  const folders = resolveSliceFolders(rule, from, options?.layers);
1295
1881
  const fromSlice = resolveGovernedSlice(fromPath, rule, folders).universeId;