arkgate 4.8.23 → 4.8.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +24 -77
  4. package/bin/ark-dashboard.mjs +56 -53
  5. package/bin/ark-layer-match.mjs +596 -112
  6. package/bin/ark-mcp-runtime.mjs +412 -138
  7. package/bin/ark-mcp.mjs +68 -4
  8. package/bin/ark-shared.mjs +39 -0
  9. package/bin/ark.mjs +65 -97
  10. package/bin/lib/adapter-contract-types.mjs +5 -0
  11. package/bin/lib/adapter-contract.mjs +15 -0
  12. package/bin/lib/agent-projection-command.mjs +5 -1
  13. package/bin/lib/analysis-engine.mjs +15 -11
  14. package/bin/lib/architecture-scan.mjs +50 -6
  15. package/bin/lib/ark-order-doctor.mjs +1 -1
  16. package/bin/lib/ark-order-facts.mjs +410 -15
  17. package/bin/lib/ark-order-invariants.mjs +51 -9
  18. package/bin/lib/ark-order-sensors.mjs +28 -101
  19. package/bin/lib/ark-order-types.mjs +2 -0
  20. package/bin/lib/ark-run-doctor.mjs +14 -0
  21. package/bin/lib/ark-run-facts.mjs +434 -11
  22. package/bin/lib/ark-run-sensors.mjs +5 -3
  23. package/bin/lib/arkgate-pins.mjs +91 -0
  24. package/bin/lib/arkrules-sensors.mjs +50 -249
  25. package/bin/lib/check-args.mjs +23 -1
  26. package/bin/lib/ci-and-commands.mjs +16 -6
  27. package/bin/lib/class-source-scan.mjs +479 -0
  28. package/bin/lib/codex-home.mjs +4 -2
  29. package/bin/lib/config-contract-slices.mjs +701 -0
  30. package/bin/lib/config-contract.mjs +5 -493
  31. package/bin/lib/config-warnings.mjs +32 -0
  32. package/bin/lib/dashboard-cli.mjs +62 -0
  33. package/bin/lib/design-delta.mjs +1 -1
  34. package/bin/lib/diagnostic-catalog.mjs +16 -9
  35. package/bin/lib/doctor-advisories.mjs +25 -1
  36. package/bin/lib/doctor-next-actions.mjs +17 -2
  37. package/bin/lib/doctor-plan.mjs +6 -8
  38. package/bin/lib/effective-contract-load.mjs +121 -59
  39. package/bin/lib/enforcement-profiles.mjs +40 -4
  40. package/bin/lib/first-run-help.mjs +3 -2
  41. package/bin/lib/flat-parent-importers.mjs +137 -0
  42. package/bin/lib/flat-parent-pilot.mjs +40 -100
  43. package/bin/lib/gate-files.mjs +29 -2
  44. package/bin/lib/git-change-scope.mjs +109 -0
  45. package/bin/lib/github-enforcement.mjs +1 -1
  46. package/bin/lib/hook-templates.mjs +18 -16
  47. package/bin/lib/host-support-matrix.mjs +3 -1
  48. package/bin/lib/import-resolve.mjs +13 -1
  49. package/bin/lib/install-migrate.mjs +50 -73
  50. package/bin/lib/invariant-coverage.mjs +59 -11
  51. package/bin/lib/mcp-adoption.mjs +9 -1
  52. package/bin/lib/mcp-hook-payload.mjs +401 -61
  53. package/bin/lib/package-manager.mjs +59 -1
  54. package/bin/lib/passthrough-commands.mjs +83 -0
  55. package/bin/lib/persistence-write-hint.mjs +81 -0
  56. package/bin/lib/pin-dependent-gates.mjs +117 -0
  57. package/bin/lib/policy-delta-io.mjs +168 -10
  58. package/bin/lib/prepare-change.mjs +120 -6
  59. package/bin/lib/prepare-write.mjs +1 -0
  60. package/bin/lib/presets.mjs +3 -5
  61. package/bin/lib/remediation.mjs +23 -4
  62. package/bin/lib/resolved-candidate-facts.mjs +315 -55
  63. package/bin/lib/rules-inventory.mjs +22 -3
  64. package/bin/lib/rules-under-contract.mjs +179 -5
  65. package/bin/lib/scan-files.mjs +32 -0
  66. package/bin/lib/snippet-analysis.mjs +140 -6
  67. package/bin/lib/source-policy.mjs +34 -0
  68. package/bin/lib/stable-hash.mjs +90 -0
  69. package/bin/lib/start-install-recovery.mjs +71 -1
  70. package/bin/lib/start-preview.mjs +30 -0
  71. package/bin/lib/status-command.mjs +33 -4
  72. package/bin/lib/status-manifest.mjs +32 -4
  73. package/bin/lib/team-parliament-io.mjs +77 -75
  74. package/bin/lib/team-parliament.mjs +6 -1
  75. package/bin/lib/write-path-capabilities.mjs +4 -2
  76. package/dist/chunk-A5EIY3HI.cjs +5 -0
  77. package/dist/chunk-MBXCFLO4.js +16 -0
  78. package/dist/chunk-RNQHRDCQ.cjs +16 -0
  79. package/dist/chunk-SVSQVGFJ.js +5 -0
  80. package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-BJbaeoj4.d.cts} +18 -3
  81. package/dist/configTypes-BJbaeoj4.d.ts +246 -0
  82. package/dist/eslint/index.cjs +17 -4
  83. package/dist/eslint/index.d.cts +359 -0
  84. package/dist/eslint/index.d.ts +130 -156
  85. package/dist/eslint/index.js +10 -4
  86. package/dist/index.cjs +35 -34
  87. package/dist/index.d.cts +5475 -0
  88. package/dist/index.d.ts +3312 -299
  89. package/dist/index.js +35 -34
  90. package/dist/inspectorListen-6DQVJ4DC.js +1 -0
  91. package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
  92. package/dist/nestjs/index.cjs +1 -18
  93. package/dist/nestjs/index.d.cts +22 -0
  94. package/dist/nestjs/index.d.ts +1 -3
  95. package/dist/nestjs/index.js +1 -18
  96. package/dist/order/index.cjs +1 -1
  97. package/dist/order/index.d.cts +155 -0
  98. package/dist/order/index.d.ts +15 -3
  99. package/dist/order/index.js +1 -1
  100. package/dist/runtime/index.cjs +13 -30
  101. package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +804 -290
  102. package/dist/runtime/index.d.ts +2678 -108
  103. package/dist/runtime/index.js +13 -30
  104. package/dist/{types-Bdj32QwA.d.ts → types-DRinG-vT.d.cts} +576 -140
  105. package/dist/types-DRinG-vT.d.ts +1792 -0
  106. package/docs/README.md +2 -1
  107. package/docs/agent-guide.md +84 -15
  108. package/docs/ai-gates.md +160 -26
  109. package/docs/arkorder.md +32 -15
  110. package/docs/brownfield-adoption.md +5 -1
  111. package/docs/configuration.md +196 -26
  112. package/docs/develop.md +2 -1
  113. package/docs/diagnostics.md +96 -19
  114. package/docs/package-surface.md +31 -8
  115. package/docs/product-voice.md +1 -1
  116. package/package.json +59 -18
  117. package/schemas/ark.analysis-result.schema.json +15 -0
  118. package/schemas/ark.config.schema.json +47 -10
  119. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  120. package/schemas/ark.status-manifest.schema.json +22 -0
  121. package/server.json +3 -9
  122. package/templates/agent-skills/README.md +1 -1
  123. package/templates/agent-skills/ark-adopt/SKILL.md +4 -1
  124. package/templates/agent-skills/ark-order/SKILL.md +4 -2
  125. package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
  126. package/templates/arkrules/PersistenceAdapters.json +1 -1
  127. package/templates/arkrules/PresentationAdapters.json +1 -1
  128. package/templates/skills/ark-adopt.md +4 -1
  129. package/templates/skills/ark-order.md +4 -2
  130. package/templates/skills/ark-runtime.md +6 -1
  131. package/dist/types-Ik_OakcI.d.ts +0 -183
@@ -9,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;
@@ -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
+ export 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
@@ -607,7 +684,7 @@ export const SLICE_ALIAS_DEBT = 'Aliases are an owed move, not a destination. Th
607
684
  function aliasGlobPattern(glob) {
608
685
  return trimTrailingSlashes(glob.trim().replace(/\\/g, '/')).toLowerCase();
609
686
  }
610
- /** Existing path glob matcher. Also accepts a leading src/ or app/. */
687
+ /** Existing path glob matcher. Also accepts a leading src/ or app/, and a plain folder covers its subtree. */
611
688
  function aliasGlobMatches(glob, relPath) {
612
689
  const pattern = aliasGlobPattern(glob);
613
690
  if (!pattern || pattern === '*' || pattern === '**')
@@ -615,7 +692,25 @@ function aliasGlobMatches(glob, relPath) {
615
692
  const file = String(relPath).replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase();
616
693
  const rooted = stripSrcOrApp(file);
617
694
  const re = globToRegExp(pattern);
618
- return re.test(file) || (rooted !== file && re.test(rooted));
695
+ // A folder form (no wildcard in the last segment) also covers its subtree,
696
+ // as sharedRoots does: `lib/compliance` matches `lib/compliance/x.ts`.
697
+ const last = pattern.split('/').pop() ?? '';
698
+ const under = last.includes('*') ? null : globToRegExp(`${pattern}/**`);
699
+ const hit = (candidate) => re.test(candidate) || (under !== null && under.test(candidate));
700
+ return hit(file) || (rooted !== file && hit(rooted));
701
+ }
702
+ /**
703
+ * Does `relPath` match a `sharedImportsSlice.stopAt` entry? An entry matches
704
+ * the way a shared root does (anchored, optional leading src/ or app/, a plain
705
+ * folder covers its subtree) or the way an alias glob does (`kernel/registrations/**`
706
+ * against `src/kernel/registrations/x.ts`). A blanket `*` / `**` never matches.
707
+ */
708
+ export function pathMatchesSharedWalkStop(relPath, stopAt) {
709
+ if (!relPath || !stopAt?.length)
710
+ return false;
711
+ if (pathUnderSharedRoot(relPath, [...stopAt]))
712
+ return true;
713
+ return stopAt.some((glob) => typeof glob === 'string' && aliasGlobMatches(glob, relPath));
619
714
  }
620
715
  function splitAliasTarget(to) {
621
716
  const parts = aliasGlobPattern(to).split('/').filter(Boolean);
@@ -624,16 +719,19 @@ function splitAliasTarget(to) {
624
719
  }
625
720
  return { universeId: parts.slice(0, -1).join('/'), childId: parts.join('/') };
626
721
  }
627
- function firstSliceAlias(relPath, rule) {
722
+ function firstSliceAliasEntry(relPath, rule) {
628
723
  for (const alias of rule.childSlices?.sliceAliases ?? []) {
629
724
  if (!alias || typeof alias.from !== 'string' || typeof alias.to !== 'string')
630
725
  continue;
631
- if (!aliasGlobMatches(alias.from, relPath))
632
- continue;
633
- return splitAliasTarget(alias.to);
726
+ if (aliasGlobMatches(alias.from, relPath))
727
+ return alias;
634
728
  }
635
729
  return null;
636
730
  }
731
+ function firstSliceAlias(relPath, rule) {
732
+ const alias = firstSliceAliasEntry(relPath, rule);
733
+ return alias ? splitAliasTarget(alias.to) : null;
734
+ }
637
735
  /**
638
736
  * Universe id and child id for one file. An alias runs only when both walls
639
737
  * leave the path unclassified, so a real slice folder is never overridden.
@@ -650,6 +748,130 @@ export function resolveGovernedSlice(relPath, rule, sliceFolders) {
650
748
  const alias = firstSliceAlias(relPath, rule);
651
749
  return alias ? { universeId: alias.universeId, childId: alias.childId } : {};
652
750
  }
751
+ /** Universe shapes of a stars rule, with the 4.8.23 id shape beside each new one. */
752
+ function starsUniverseShapes(folders) {
753
+ const shapes = [];
754
+ for (const raw of folders ?? []) {
755
+ if (typeof raw !== 'string' || raw.length === 0)
756
+ continue;
757
+ const segments = raw.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
758
+ if (segments.length === 1 && segments[0] && !segments[0].includes('*')) {
759
+ shapes.push({ literals: [segments[0], null], legacy: [segments[0], null] });
760
+ continue;
761
+ }
762
+ if (segments.some((part) => part === '**' || (part.includes('*') && part !== '*')))
763
+ continue;
764
+ const kept = starsKeptSegmentIndexes(segments);
765
+ if (!kept || !segments.includes('*'))
766
+ continue;
767
+ const star = (part) => (part === '*' ? null : part);
768
+ const lastLiteral = kept.find((at) => segments[at] !== '*') ?? 0;
769
+ shapes.push({
770
+ literals: kept.map((at) => star(segments[at] ?? '')),
771
+ legacy: segments.slice(lastLiteral).map(star),
772
+ });
773
+ }
774
+ return shapes;
775
+ }
776
+ function literalsMatch(shape, parts) {
777
+ return (shape.length === parts.length &&
778
+ shape.every((literal, at) => literal === null || parts[at] === '*' || literal === parts[at]));
779
+ }
780
+ /**
781
+ * New-id shapes (`*` for a star) that a 4.8.23 stars id maps to. Empty when the id is
782
+ * already a new id or no shape takes it. Config load rejects more than one.
783
+ */
784
+ export function legacyStarsIdTargets(folders, id) {
785
+ const parts = id.split('/').filter(Boolean).map((part) => part.toLowerCase());
786
+ const shapes = starsUniverseShapes(folders);
787
+ if (parts.length === 0 || shapes.some((shape) => literalsMatch(shape.literals, parts)))
788
+ return [];
789
+ const targets = new Set();
790
+ for (const shape of shapes) {
791
+ if (shape.legacy.length === shape.literals.length)
792
+ continue;
793
+ if (literalsMatch(shape.legacy, parts)) {
794
+ const tail = [...parts];
795
+ // Fill the kept literals and trailing stars from the legacy id; pre-literal stars stay `*`.
796
+ const lead = shape.literals.length - shape.legacy.length;
797
+ targets.add([...shape.literals.slice(0, lead).map(() => '*'), ...tail].join('/'));
798
+ }
799
+ }
800
+ return [...targets].sort();
801
+ }
802
+ /** The legacy universe id when this alias target is a 4.8.23 stars child id. */
803
+ function legacyAliasUniverse(rule, to) {
804
+ if (rule.sliceIdentity !== 'stars')
805
+ return undefined;
806
+ const target = splitAliasTarget(to);
807
+ if (!target)
808
+ return undefined;
809
+ return legacyStarsIdTargets(rule.sliceFolders, target.universeId).length === 1
810
+ ? target.universeId
811
+ : undefined;
812
+ }
813
+ function placeForPair(relPath, rule, folders) {
814
+ const place = resolveGovernedSlice(relPath, rule, folders);
815
+ if (!relPath || rule.sliceIdentity !== 'stars')
816
+ return place;
817
+ if (place.universeId) {
818
+ const alias = firstSliceAliasEntry(relPath, rule);
819
+ const fromFolders = sliceIdForPath(relPath, [...folders], rule.sliceIdentity);
820
+ if (!fromFolders && alias && legacyAliasUniverse(rule, alias.to) !== undefined) {
821
+ return { ...place, legacyAlias: true, legacyUniverse: place.universeId, legacyChild: place.childId };
822
+ }
823
+ const legacyUniverse = legacyStarsSliceIdForPath(relPath, folders);
824
+ const child = rule.childSlices;
825
+ const legacyChild = child?.sliceIdentity === 'stars' ? legacyStarsSliceIdForPath(relPath, child.sliceFolders) : undefined;
826
+ return {
827
+ ...place,
828
+ ...(legacyUniverse ? { legacyUniverse } : {}),
829
+ ...(legacyChild && place.childId ? { legacyChild } : {}),
830
+ };
831
+ }
832
+ return place;
833
+ }
834
+ /**
835
+ * Both endpoints of one edge under a peerIsolation rule, with the one-release 4.8.23
836
+ * `stars` compatibility: a `sliceAliases` target written as a 4.8.23 stars id joins
837
+ * the universe of the other endpoint whose 4.8.23 id it names, and an
838
+ * `allowedCrossSlice` entry written against 4.8.23 ids still clears the edge it cleared
839
+ * then. ark-check warns CONFIG_SLICE_LEGACY_STARS_ID for both.
840
+ */
841
+ export function resolveGovernedSlicePair(rule, folders, fromPath, toPath) {
842
+ const from = placeForPair(fromPath, rule, folders);
843
+ const to = placeForPair(toPath, rule, folders);
844
+ const legacyFrom = from.legacyUniverse;
845
+ const legacyTo = to.legacyUniverse;
846
+ const adopt = (alias, other) => {
847
+ if (!alias.legacyAlias || other.legacyAlias || !other.universeId)
848
+ return alias;
849
+ if (other.legacyUniverse?.toLowerCase() !== alias.universeId?.toLowerCase())
850
+ return alias;
851
+ const childSegment = alias.childId?.split('/').pop();
852
+ return {
853
+ ...alias,
854
+ universeId: other.universeId,
855
+ ...(childSegment ? { childId: `${other.universeId}/${childSegment}` } : {}),
856
+ };
857
+ };
858
+ const fromPlace = adopt(from, to);
859
+ const toPlace = adopt(to, from);
860
+ const crossSliceAllowed = crossSliceEdgeAllowed(rule.allowedCrossSlice, fromPlace.universeId, toPlace.universeId) ||
861
+ (rule.sliceIdentity === 'stars' &&
862
+ (legacyFrom !== fromPlace.universeId || legacyTo !== toPlace.universeId) &&
863
+ crossSliceEdgeAllowed(rule.allowedCrossSlice, legacyFrom, legacyTo));
864
+ const legacyChildAllowed = rule.childSlices?.sliceIdentity === 'stars' &&
865
+ from.legacyChild !== undefined &&
866
+ to.legacyChild !== undefined &&
867
+ from.legacyChild !== to.legacyChild &&
868
+ childCrossSliceAllowed(rule.childSlices.allowedCrossSlice, from.legacyChild, to.legacyChild);
869
+ const strip = (place) => ({
870
+ ...(place.universeId ? { universeId: place.universeId } : {}),
871
+ ...(place.childId ? { childId: place.childId } : {}),
872
+ });
873
+ return { from: strip(fromPlace), to: strip(toPlace), crossSliceAllowed, legacyChildAllowed };
874
+ }
653
875
  function childPatternDirectory(pattern, childId, identity) {
654
876
  const segments = pattern.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
655
877
  const idParts = childId.split('/').filter(Boolean).map((part) => part.toLowerCase());
@@ -658,23 +880,17 @@ function childPatternDirectory(pattern, childId, identity) {
658
880
  if (segments.some((part) => part === '**' || (part.includes('*') && part !== '*')))
659
881
  return null;
660
882
  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)
883
+ const kept = starsKeptSegmentIndexes(segments);
884
+ if (!kept || kept.length !== idParts.length)
670
885
  return null;
671
- for (let index = 0; index < tail.length; index += 1) {
672
- if (tail[index] === '*')
673
- continue;
674
- if (tail[index] !== idParts[index])
886
+ const dir = [...segments];
887
+ for (let index = 0; index < kept.length; index += 1) {
888
+ const at = kept[index] ?? 0;
889
+ if (segments[at] !== '*' && segments[at] !== idParts[index])
675
890
  return null;
891
+ dir[at] = idParts[index] ?? '';
676
892
  }
677
- return [...segments.slice(0, lastLiteral), ...idParts].join('/');
893
+ return dir.join('/');
678
894
  }
679
895
  if (segments.length !== idParts.length)
680
896
  return null;
@@ -750,10 +966,46 @@ export function sliceAliasReport(rules, files) {
750
966
  }
751
967
  }
752
968
  }
753
- const listed = [...moves.values()].map((move) => ({ ...move, files: [...move.files].sort() }));
969
+ const universes = knownUniverseIds(rules, files);
970
+ const listed = [...moves.values()].map((move) => {
971
+ const target = splitAliasTarget(move.to);
972
+ const unknown = universes !== null && target !== null && !universes.has(target.universeId);
973
+ return {
974
+ ...move,
975
+ files: [...move.files].sort(),
976
+ ...(unknown
977
+ ? {
978
+ unknownUniverse: true,
979
+ advisory: `alias target ${move.to} names universe ${target.universeId}, which no file belongs to. Check for a typo.`,
980
+ }
981
+ : {}),
982
+ };
983
+ });
754
984
  listed.sort((left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to));
755
985
  return { notAScore: true, finished: false, debt: SLICE_ALIAS_DEBT, moves: listed };
756
986
  }
987
+ /**
988
+ * Universe ids that real (non-aliased) files resolve to, across rules that set
989
+ * sliceAliases. Null when a rule has no explicit sliceFolders (the universe
990
+ * would come from layer patterns this report does not see): no claim then.
991
+ */
992
+ function knownUniverseIds(rules, files) {
993
+ const ids = new Set();
994
+ for (const rule of rules ?? []) {
995
+ if (!rule?.childSlices?.sliceAliases?.length)
996
+ continue;
997
+ if (!rule.sliceFolders?.length)
998
+ return null;
999
+ for (const file of files) {
1000
+ if (typeof file !== 'string')
1001
+ continue;
1002
+ const id = sliceIdForPath(file, rule.sliceFolders, rule.sliceIdentity);
1003
+ if (id)
1004
+ ids.add(id.toLowerCase());
1005
+ }
1006
+ }
1007
+ return ids;
1008
+ }
757
1009
  /**
758
1010
  * One human sentence naming which peerIsolation reason fired — so the denial
759
1011
  * reports a fact about their code (`cross-slice`) or a fact about our evidence
@@ -796,32 +1048,23 @@ export function universePairLabel(sliceId) {
796
1048
  const parts = sliceId.split('/').filter(Boolean);
797
1049
  return parts.length > 0 ? parts[parts.length - 1] : undefined;
798
1050
  }
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)
1051
+ function isCommonFolderName(segment, commonFolders) {
1052
+ if (!segment || !commonFolders?.length)
812
1053
  return false;
813
- return directorySegments(relPath).some((part) => names.has(part.toLowerCase()));
1054
+ const want = segment.toLowerCase();
1055
+ return commonFolders.some((raw) => typeof raw === 'string' && raw.length > 0 && raw.toLowerCase() === want);
814
1056
  }
815
1057
  /**
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.
1058
+ * Child id for one path. A flat file (the starred id does not grow past the
1059
+ * universe id) is universe common. So is a `commonFolders` directory, but only
1060
+ * at the child position: the segment directly under the universe id
1061
+ * (`features/projects/domain/**`). A folder of the same name inside a child
1062
+ * (`features/projects/rfi/domain/**`) belongs to that child. A child id that
1063
+ * does not extend the universe id is not used; the caller warns.
819
1064
  */
820
1065
  export function resolveChildSliceId(relPath, universeId, child) {
821
1066
  if (!relPath || !child?.sliceFolders?.length)
822
1067
  return {};
823
- if (pathHasCommonFolder(relPath, child.commonFolders))
824
- return {};
825
1068
  const raw = sliceIdForPath(relPath, child.sliceFolders, child.sliceIdentity);
826
1069
  if (!raw || !universeId)
827
1070
  return {};
@@ -829,9 +1072,12 @@ export function resolveChildSliceId(relPath, universeId, child) {
829
1072
  const universeKey = universeId.toLowerCase();
830
1073
  if (childKey === universeKey)
831
1074
  return {};
832
- if (childKey.startsWith(`${universeKey}/`))
833
- return { childId: raw };
834
- return { mismatched: { childId: raw, universeId } };
1075
+ if (!childKey.startsWith(`${universeKey}/`))
1076
+ return { mismatched: { childId: raw, universeId } };
1077
+ const firstChildSegment = childKey.slice(universeKey.length + 1).split('/')[0];
1078
+ if (isCommonFolderName(firstChildSegment, child.commonFolders))
1079
+ return {};
1080
+ return { childId: raw };
835
1081
  }
836
1082
  function siblingEntryKey(entry) {
837
1083
  return trimTrailingSlashes(entry.trim().replace(/\\/g, '/')).toLowerCase();
@@ -970,7 +1216,7 @@ export function evaluateNestedSliceWall(input) {
970
1216
  fromShared: input.fromShared,
971
1217
  toShared: input.toShared,
972
1218
  crossSliceAllowed: input.crossSliceAllowed,
973
- sharedImportsSlice: input.rule.sharedImportsSlice,
1219
+ sharedImportsSlice: sharedImportsSliceMode(input.rule.sharedImportsSlice),
974
1220
  });
975
1221
  if (universe.denied) {
976
1222
  const crossParent = Boolean(input.rule.childSlices) && universe.reason === 'cross-slice';
@@ -1009,7 +1255,7 @@ export function evaluateNestedSliceWall(input) {
1009
1255
  toUniverse: input.toSlice,
1010
1256
  };
1011
1257
  if (fromChild && toChild && fromChild !== toChild) {
1012
- if (childCrossSliceAllowed(child.allowedCrossSlice, fromChild, toChild)) {
1258
+ if (input.legacyChildAllowed || childCrossSliceAllowed(child.allowedCrossSlice, fromChild, toChild)) {
1013
1259
  return allowSameUniverse;
1014
1260
  }
1015
1261
  const advisory = siblingCrossingAdvisory(child.siblings, input.fromPath, fromChild);
@@ -1033,27 +1279,45 @@ export function evaluateNestedSliceWall(input) {
1033
1279
  }
1034
1280
  return allowSameUniverse;
1035
1281
  }
1282
+ /** Did the child (inner) wall produce this finding, not the universe wall? */
1283
+ export function isChildWallCrossing(verdict) {
1284
+ return verdict?.crossing === 'cross-sibling' || verdict?.crossing === 'parent-imports-child';
1285
+ }
1286
+ /**
1287
+ * The consumer text for one slice finding. `rule.message` is the universe-wall
1288
+ * text. `childSlices.message` is the inner-wall text. An inner-wall finding
1289
+ * never falls back to the rule message; undefined means "use ArkGate's default".
1290
+ */
1291
+ export function sliceConsumerMessage(rule, verdict) {
1292
+ return isChildWallCrossing(verdict) ? rule?.childSlices?.message : rule?.message;
1293
+ }
1036
1294
  /**
1037
1295
  * Finding message. Import sites always append the explanation. Intent sites
1038
1296
  * keep today's wording when there is no reasonId: a plain cross-slice deny
1039
1297
  * does not gain a clause, and any other universe-wall reason does.
1298
+ * `ruleMessage` is only used for universe-wall findings; an inner-wall finding
1299
+ * uses `childMessage` or ArkGate's default text.
1040
1300
  */
1041
1301
  export function composeSliceDenialMessage(input) {
1042
1302
  const explanation = input.verdict.explanation;
1303
+ const inner = isChildWallCrossing(input.verdict);
1304
+ const consumer = inner ? input.childMessage : input.ruleMessage;
1043
1305
  if (input.surface === 'intent') {
1044
1306
  const defaultMessage = input.defaultMessage ?? `${input.fromLayer} must not reference ${input.toLayer} intent.`;
1307
+ if (inner && explanation)
1308
+ return `${input.childMessage ?? defaultMessage} ${explanation}`;
1045
1309
  if (input.verdict.reasonId && explanation)
1046
1310
  return `${defaultMessage} ${explanation}`;
1047
1311
  if (explanation && input.verdict.peerIsolationReason !== 'cross-slice') {
1048
1312
  return `${defaultMessage} ${explanation}`;
1049
1313
  }
1050
- if (input.ruleMessage) {
1051
- return explanation ? `${input.ruleMessage} (${explanation})` : input.ruleMessage;
1314
+ if (consumer) {
1315
+ return explanation ? `${consumer} (${explanation})` : consumer;
1052
1316
  }
1053
1317
  return defaultMessage;
1054
1318
  }
1055
- if (input.ruleMessage) {
1056
- return explanation ? `${input.ruleMessage} (${explanation})` : input.ruleMessage;
1319
+ if (consumer) {
1320
+ return explanation ? `${consumer} (${explanation})` : consumer;
1057
1321
  }
1058
1322
  if (explanation) {
1059
1323
  const kind = input.kind ?? 'import';
@@ -1117,27 +1381,65 @@ export function sliceCountReport(violations) {
1117
1381
  };
1118
1382
  }
1119
1383
  /**
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.
1384
+ * Doctor advisory: a shared file that sits on many CROSS_PARENT_VIA_SHARED
1385
+ * paths is often a composition root. Null below `minFindings` findings or when
1386
+ * no file reaches `minShare` of them. Not a finding and not in the baseline.
1387
+ */
1388
+ export function crossParentViaSharedHubs(violations, options) {
1389
+ const minFindings = options?.minFindings ?? 10;
1390
+ const minShare = options?.minShare ?? 0.5;
1391
+ let total = 0;
1392
+ const counts = new Map();
1393
+ for (const row of violations ?? []) {
1394
+ if (row?.reasonId !== 'CROSS_PARENT_VIA_SHARED')
1395
+ continue;
1396
+ total += 1;
1397
+ if (!Array.isArray(row.via))
1398
+ continue;
1399
+ const once = new Set(row.via.filter((file) => typeof file === 'string'));
1400
+ for (const file of once)
1401
+ counts.set(file, (counts.get(file) ?? 0) + 1);
1402
+ }
1403
+ if (total < minFindings)
1404
+ return null;
1405
+ const hubs = [...counts.entries()]
1406
+ .map(([file, count]) => ({ file, count, share: Math.round((count / total) * 100) / 100 }))
1407
+ .filter((hub) => hub.count / total >= minShare)
1408
+ .sort((left, right) => right.count - left.count || left.file.localeCompare(right.file));
1409
+ if (hubs.length === 0)
1410
+ return null;
1411
+ const first = hubs[0]?.file ?? '';
1412
+ return {
1413
+ notAScore: true,
1414
+ total,
1415
+ hubs,
1416
+ nextAction: `If ${first} is a composition root, add it to sharedImportsSlice.stopAt. Otherwise these findings are real.`,
1417
+ };
1418
+ }
1419
+ /**
1420
+ * One warning per directed rule when a path's child id does not extend its
1421
+ * universe id, and one per child allowance that spans universes. The version
1422
+ * floor lives in configVersionFloor.ts and needs pin evidence (#338).
1122
1423
  */
1123
1424
  export function childSliceConfigFindings(rules, files) {
1124
1425
  const out = [];
1125
- let versionWarned = false;
1126
1426
  const seenMismatch = new Set();
1127
1427
  const seenSpan = new Set();
1128
1428
  for (const rule of rules ?? []) {
1429
+ if (rule?.peerIsolation && rule.allowed === false)
1430
+ out.push(...legacyStarsIdFindings(rule, files));
1129
1431
  if (!rule?.childSlices)
1130
1432
  continue;
1131
- if (!versionWarned) {
1132
- versionWarned = true;
1433
+ if (!rule.peerIsolation || rule.allowed !== false) {
1133
1434
  out.push({
1134
- ruleId: 'CONFIG_CHILD_SLICES_VERSION',
1435
+ ruleId: 'CONFIG_CHILD_SLICES_INERT',
1135
1436
  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.`,
1437
+ fromLayer: rule.from,
1438
+ toLayer: rule.to,
1439
+ 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
1440
  });
1138
- }
1139
- if (!rule.peerIsolation)
1140
1441
  continue;
1442
+ }
1141
1443
  for (const file of files) {
1142
1444
  const universeId = sliceIdForPath(file, rule.sliceFolders, rule.sliceIdentity);
1143
1445
  const resolved = resolveChildSliceId(file, universeId, rule.childSlices);
@@ -1176,35 +1478,215 @@ export function childSliceConfigFindings(rules, files) {
1176
1478
  }
1177
1479
  return out;
1178
1480
  }
1481
+ /** Observed new ids per 4.8.23 stars id, from real files (for the warning text). */
1482
+ function observedIdsByLegacy(folders, files) {
1483
+ const byLegacy = new Map();
1484
+ if (!folders?.length)
1485
+ return byLegacy;
1486
+ for (const file of files) {
1487
+ if (typeof file !== 'string')
1488
+ continue;
1489
+ const id = sliceIdForPath(file, [...folders], 'stars');
1490
+ const legacy = legacyStarsSliceIdForPath(file, folders);
1491
+ if (!id || !legacy || id === legacy)
1492
+ continue;
1493
+ const set = byLegacy.get(legacy) ?? new Set();
1494
+ set.add(id);
1495
+ byLegacy.set(legacy, set);
1496
+ }
1497
+ return byLegacy;
1498
+ }
1499
+ function newIdHint(legacyId, shapes, observed, childSegment) {
1500
+ const suffix = childSegment ? `/${childSegment}` : '';
1501
+ const shape = `${shapes[0] ?? legacyId}${suffix}`;
1502
+ const seen = [...(observed.get(legacyId.toLowerCase()) ?? [])].sort().map((id) => `${id}${suffix}`);
1503
+ if (seen.length === 1)
1504
+ return `${seen[0]}`;
1505
+ if (seen.length > 1)
1506
+ return `${shape} (one entry per module: ${seen.join(', ')})`;
1507
+ return shape;
1508
+ }
1179
1509
  /**
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.
1510
+ * CONFIG_SLICE_LEGACY_STARS_ID: under sliceIdentity "stars", a sliceAliases target or an
1511
+ * allowedCrossSlice entry written against the 4.8.23 stars id (star bindings before the
1512
+ * last literal dropped). It keeps its 4.8.23 verdict for one release; the warning names
1513
+ * the new id.
1184
1514
  */
1185
- export function applyAdvisorySiblingRatchet(violations, occurrenceKeys, recordedKeys) {
1186
- const indexes = [];
1187
- for (let index = 0; index < violations.length; index += 1) {
1188
- if (violations[index]?.reasonId === 'CROSS_SIBLING_SLICE')
1189
- indexes.push(index);
1515
+ function legacyStarsIdFindings(rule, files) {
1516
+ const out = [];
1517
+ const push = (message) => out.push({
1518
+ ruleId: 'CONFIG_SLICE_LEGACY_STARS_ID',
1519
+ failsStrict: false,
1520
+ fromLayer: rule.from,
1521
+ toLayer: rule.to,
1522
+ message: `${rule.from} → ${rule.to}: ${message} It keeps its 4.8.23 meaning for this release only.`,
1523
+ });
1524
+ const child = rule.childSlices;
1525
+ if (rule.sliceIdentity === 'stars') {
1526
+ const observed = observedIdsByLegacy(rule.sliceFolders, files);
1527
+ for (const alias of child?.sliceAliases ?? []) {
1528
+ if (!alias || typeof alias.to !== 'string')
1529
+ continue;
1530
+ const target = splitAliasTarget(alias.to);
1531
+ if (!target || legacyAliasUniverse(rule, alias.to) === undefined)
1532
+ continue;
1533
+ const shapes = legacyStarsIdTargets(rule.sliceFolders, target.universeId);
1534
+ const segment = target.childId.split('/').pop();
1535
+ 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.`);
1536
+ }
1537
+ for (const edge of rule.allowedCrossSlice ?? []) {
1538
+ if (!edge || typeof edge.from !== 'string' || typeof edge.to !== 'string')
1539
+ continue;
1540
+ const legacySides = [edge.from, edge.to].filter((id) => id.includes('/') && legacyStarsIdTargets(rule.sliceFolders, id).length > 0);
1541
+ if (legacySides.length === 0)
1542
+ continue;
1543
+ const rename = (id) => legacySides.includes(id)
1544
+ ? newIdHint(id, legacyStarsIdTargets(rule.sliceFolders, id), observed)
1545
+ : id;
1546
+ 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.`);
1547
+ }
1190
1548
  }
1191
- if (indexes.length === 0)
1192
- return [...violations];
1549
+ if (child?.sliceIdentity === 'stars') {
1550
+ const observed = observedIdsByLegacy(child.sliceFolders, files);
1551
+ for (const edge of child.allowedCrossSlice ?? []) {
1552
+ if (!edge || typeof edge.from !== 'string' || typeof edge.to !== 'string')
1553
+ continue;
1554
+ const legacySides = [edge.from, edge.to].filter((id) => legacyStarsIdTargets(child.sliceFolders, id).length > 0);
1555
+ if (legacySides.length === 0)
1556
+ continue;
1557
+ const rename = (id) => legacySides.includes(id)
1558
+ ? newIdHint(id, legacyStarsIdTargets(child.sliceFolders, id), observed)
1559
+ : id;
1560
+ 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.`);
1561
+ }
1562
+ }
1563
+ return out;
1564
+ }
1565
+ /** `ratchet: true` → always, `ratchet: false` → never, anything else → auto. */
1566
+ export function siblingRatchetMode(siblings) {
1567
+ if (siblings !== null && typeof siblings === 'object') {
1568
+ const ratchet = siblings.ratchet;
1569
+ if (ratchet === true)
1570
+ return 'always';
1571
+ if (ratchet === false)
1572
+ return 'never';
1573
+ }
1574
+ return 'auto';
1575
+ }
1576
+ /**
1577
+ * How many advisory sibling crossings of one rule the baseline records. A key
1578
+ * still present counts when its current row is in the group. A stale key (the
1579
+ * crossing was removed) counts when the rule still classifies that edge as an
1580
+ * advisory sibling crossing, so replacing a recorded crossing neither disarms
1581
+ * the ratchet nor looks like growth.
1582
+ */
1583
+ function recordedAdvisorySiblingCount(input) {
1584
+ const { rule } = input;
1193
1585
  let recorded = 0;
1194
- for (const index of indexes) {
1195
- if (recordedKeys.has(occurrenceKeys[index] ?? ''))
1586
+ for (const key of input.recordedKeys) {
1587
+ if (input.groupKeys.has(key)) {
1588
+ recorded += 1;
1589
+ continue;
1590
+ }
1591
+ if (!rule || input.currentKeys.has(key))
1592
+ continue;
1593
+ const parts = key.split('|');
1594
+ if (parts.length !== 5)
1595
+ continue;
1596
+ const [ruleId = '', file = '', fromLayer = '', toLayer = '', rawTarget = ''] = parts;
1597
+ if (!input.ruleIds.has(ruleId) || fromLayer !== rule.from || toLayer !== rule.to)
1598
+ continue;
1599
+ const target = rawTarget.replace(/#\d+$/, '');
1600
+ if (!file || !target)
1601
+ continue;
1602
+ const verdict = findDeniedEdgeDecision([rule], fromLayer, toLayer, {
1603
+ fromPath: file,
1604
+ toPath: target,
1605
+ layers: input.layers,
1606
+ })?.sliceVerdict;
1607
+ if (verdict?.reasonId === 'CROSS_SIBLING_SLICE' && verdict.decision === 'advisory')
1196
1608
  recorded += 1;
1197
1609
  }
1198
- if (indexes.length <= recorded)
1610
+ return recorded;
1611
+ }
1612
+ /**
1613
+ * Advisory sibling crossings past the recorded baseline become blocking, per
1614
+ * directed rule. Only advisory (`failsStrict: false`) crossings count; an
1615
+ * enforced crossing never switches the ratchet on. For each rule:
1616
+ *
1617
+ * - `ratchet` absent (every string form too): on only when the baseline
1618
+ * records at least one advisory sibling crossing of that rule (a key still
1619
+ * present, or a stale key that the rule still classifies as one), and only
1620
+ * when that rule's current advisory count grew past the recorded count.
1621
+ * Swapping a recorded crossing for a new one keeps the count and stays a
1622
+ * warning. An empty baseline, or one frozen before the child wall, promotes
1623
+ * nothing.
1624
+ * - `ratchet: true`: any unrecorded advisory crossing of the rule is promoted.
1625
+ * - `ratchet: false`: never promoted.
1626
+ *
1627
+ * The key is the existing baseline identity (ruleId, file, layers, target);
1628
+ * reasonId is not part of it. Recorded crossings stay advisory. Callers run
1629
+ * this only when a baseline (or an `--against` base) is in use.
1630
+ */
1631
+ export function applyAdvisorySiblingRatchet(violations, occurrenceKeys, recordedKeys, options) {
1632
+ const groups = new Map();
1633
+ for (let index = 0; index < violations.length; index += 1) {
1634
+ const row = violations[index];
1635
+ if (row?.reasonId !== 'CROSS_SIBLING_SLICE' || row.failsStrict !== false)
1636
+ continue;
1637
+ const key = `${row.fromLayer ?? ''}\0${row.toLayer ?? ''}`;
1638
+ const list = groups.get(key);
1639
+ if (list)
1640
+ list.push(index);
1641
+ else
1642
+ groups.set(key, [index]);
1643
+ }
1644
+ if (groups.size === 0)
1645
+ return [...violations];
1646
+ const currentKeys = new Set(occurrenceKeys);
1647
+ const promote = new Set();
1648
+ for (const indexes of groups.values()) {
1649
+ const first = violations[indexes[0] ?? 0];
1650
+ const rule = (options?.rules ?? []).find((candidate) => candidate &&
1651
+ candidate.from === first?.fromLayer &&
1652
+ candidate.to === first?.toLayer &&
1653
+ candidate.allowed === false &&
1654
+ candidate.peerIsolation === true &&
1655
+ candidate.childSlices != null);
1656
+ const mode = siblingRatchetMode(rule?.childSlices?.siblings);
1657
+ if (mode === 'never')
1658
+ continue;
1659
+ if (mode === 'auto') {
1660
+ const recorded = recordedAdvisorySiblingCount({
1661
+ rule: rule,
1662
+ ruleIds: new Set(indexes.map((index) => String(violations[index]?.ruleId ?? ''))),
1663
+ groupKeys: new Set(indexes.map((index) => occurrenceKeys[index] ?? '')),
1664
+ currentKeys,
1665
+ recordedKeys,
1666
+ layers: options?.layers,
1667
+ });
1668
+ if (recorded === 0 || indexes.length <= recorded)
1669
+ continue;
1670
+ }
1671
+ for (const index of indexes) {
1672
+ if (!recordedKeys.has(occurrenceKeys[index] ?? ''))
1673
+ promote.add(index);
1674
+ }
1675
+ }
1676
+ if (promote.size === 0)
1199
1677
  return [...violations];
1200
1678
  return violations.map((violation, index) => {
1201
- if (violation.reasonId !== 'CROSS_SIBLING_SLICE')
1202
- return violation;
1203
- if (recordedKeys.has(occurrenceKeys[index] ?? ''))
1204
- return violation;
1205
- if (violation.failsStrict !== false)
1679
+ if (!promote.has(index))
1206
1680
  return violation;
1207
- return { ...violation, failsStrict: true, severity: 'error' };
1681
+ const from = violation.fromLayer ?? '?';
1682
+ const to = violation.toLayer ?? '?';
1683
+ const why = `New advisory sibling crossing past the recorded baseline for ${from} → ${to}. Set childSlices.siblings.ratchet: false to measure only.`;
1684
+ return {
1685
+ ...violation,
1686
+ failsStrict: true,
1687
+ severity: 'error',
1688
+ ...(typeof violation.message === 'string' ? { message: `${violation.message} ${why}` } : {}),
1689
+ };
1208
1690
  });
1209
1691
  }
1210
1692
  /**
@@ -1239,8 +1721,9 @@ export function findDeniedEdgeDecision(rules, from, to, options) {
1239
1721
  const fromPath = options?.fromPath;
1240
1722
  const toPath = options?.toPath;
1241
1723
  const folders = resolveSliceFolders(rule, from, options?.layers);
1242
- const fromPlace = resolveGovernedSlice(fromPath, rule, folders);
1243
- const toPlace = resolveGovernedSlice(toPath, rule, folders);
1724
+ const pair = resolveGovernedSlicePair(rule, folders, fromPath, toPath);
1725
+ const fromPlace = pair.from;
1726
+ const toPlace = pair.to;
1244
1727
  const fromSlice = fromPlace.universeId;
1245
1728
  const toSlice = toPlace.universeId;
1246
1729
  const verdict = evaluateNestedSliceWall({
@@ -1254,7 +1737,8 @@ export function findDeniedEdgeDecision(rules, from, to, options) {
1254
1737
  toChild: toPlace.childId,
1255
1738
  fromShared: !fromSlice && pathUnderSharedRoot(fromPath, rule.sharedRoots),
1256
1739
  toShared: !toSlice && pathUnderSharedRoot(toPath, rule.sharedRoots),
1257
- crossSliceAllowed: crossSliceEdgeAllowed(rule.allowedCrossSlice, fromSlice, toSlice),
1740
+ crossSliceAllowed: pair.crossSliceAllowed,
1741
+ legacyChildAllowed: pair.legacyChildAllowed,
1258
1742
  });
1259
1743
  if (verdict.decision !== 'allow') {
1260
1744
  return {
@@ -1289,7 +1773,7 @@ export function findSharedImportsSliceBridge(rules, from, to, options) {
1289
1773
  continue;
1290
1774
  if (rule.allowed !== false || !rule.peerIsolation)
1291
1775
  continue;
1292
- if (rule.sharedImportsSlice === 'deny')
1776
+ if (sharedImportsSliceMode(rule.sharedImportsSlice) === 'deny')
1293
1777
  continue;
1294
1778
  const folders = resolveSliceFolders(rule, from, options?.layers);
1295
1779
  const fromSlice = resolveGovernedSlice(fromPath, rule, folders).universeId;