arkgate 4.8.21 → 4.8.23

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 (42) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +2 -2
  3. package/bin/ark-check-runtime.mjs +4 -2
  4. package/bin/ark-layer-match.mjs +606 -11
  5. package/bin/lib/analysis-engine.mjs +11 -11
  6. package/bin/lib/config-contract.mjs +492 -1
  7. package/bin/lib/diagnostic-catalog.mjs +32 -0
  8. package/bin/lib/doctor-advisories.mjs +60 -1
  9. package/bin/lib/doctor-plan.mjs +2 -2
  10. package/bin/lib/enforcement-honesty.mjs +22 -0
  11. package/bin/lib/flat-parent-pilot.mjs +252 -0
  12. package/bin/lib/html-report-advisories.mjs +2 -0
  13. package/bin/lib/html-report-depth.mjs +3 -1
  14. package/bin/lib/invariant-coverage.mjs +27 -3
  15. package/bin/lib/physical-cohesion.mjs +16 -18
  16. package/bin/lib/pilot-loop.mjs +21 -8
  17. package/bin/lib/team-parliament-io.mjs +4 -2
  18. package/dist/{configTypes-CTELo87-.d.ts → configTypes-Bbf_MFGe.d.ts} +36 -2
  19. package/dist/{diagnosticCatalog-B7BXlet6.d.ts → diagnosticCatalog-35mrwAHe.d.ts} +20 -5
  20. package/dist/eslint/index.cjs +4 -4
  21. package/dist/eslint/index.d.ts +51 -5
  22. package/dist/eslint/index.js +4 -4
  23. package/dist/index.cjs +36 -36
  24. package/dist/index.d.ts +104 -5
  25. package/dist/index.js +36 -36
  26. package/dist/nestjs/index.cjs +9 -9
  27. package/dist/nestjs/index.d.ts +3 -3
  28. package/dist/nestjs/index.js +9 -9
  29. package/dist/runtime/index.cjs +19 -19
  30. package/dist/runtime/index.d.ts +6 -6
  31. package/dist/runtime/index.js +19 -19
  32. package/dist/{types-yDNEQ603.d.ts → types-Bdj32QwA.d.ts} +1 -1
  33. package/dist/{types-fQ9-KrBj.d.ts → types-Ik_OakcI.d.ts} +1 -1
  34. package/docs/README.md +3 -1
  35. package/docs/agent-guide.md +9 -1
  36. package/docs/configuration.md +92 -1
  37. package/docs/diagnostics.md +32 -0
  38. package/docs/product-voice.md +1 -1
  39. package/package.json +2 -1
  40. package/schemas/ark.config.schema.json +124 -2
  41. package/server.json +2 -2
  42. package/templates/agent-skills/README.md +1 -1
@@ -9,6 +9,11 @@
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
17
  const regexpCache = new Map();
13
18
  function escapeLiteral(ch) {
14
19
  return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
@@ -598,6 +603,157 @@ export function peerIsolationDecision(input) {
598
603
  export function peerIsolationMustDeny(input) {
599
604
  return peerIsolationDecision(input).denied;
600
605
  }
606
+ export const SLICE_ALIAS_DEBT = 'Aliases are an owed move, not a destination. These files are not finished.';
607
+ function aliasGlobPattern(glob) {
608
+ return trimTrailingSlashes(glob.trim().replace(/\\/g, '/')).toLowerCase();
609
+ }
610
+ /** Existing path glob matcher. Also accepts a leading src/ or app/. */
611
+ function aliasGlobMatches(glob, relPath) {
612
+ const pattern = aliasGlobPattern(glob);
613
+ if (!pattern || pattern === '*' || pattern === '**')
614
+ return false;
615
+ const file = String(relPath).replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase();
616
+ const rooted = stripSrcOrApp(file);
617
+ const re = globToRegExp(pattern);
618
+ return re.test(file) || (rooted !== file && re.test(rooted));
619
+ }
620
+ function splitAliasTarget(to) {
621
+ const parts = aliasGlobPattern(to).split('/').filter(Boolean);
622
+ if (parts.length < 2 || parts.some((part) => part.includes('*') || part === '.' || part === '..')) {
623
+ return null;
624
+ }
625
+ return { universeId: parts.slice(0, -1).join('/'), childId: parts.join('/') };
626
+ }
627
+ function firstSliceAlias(relPath, rule) {
628
+ for (const alias of rule.childSlices?.sliceAliases ?? []) {
629
+ if (!alias || typeof alias.from !== 'string' || typeof alias.to !== 'string')
630
+ continue;
631
+ if (!aliasGlobMatches(alias.from, relPath))
632
+ continue;
633
+ return splitAliasTarget(alias.to);
634
+ }
635
+ return null;
636
+ }
637
+ /**
638
+ * Universe id and child id for one file. An alias runs only when both walls
639
+ * leave the path unclassified, so a real slice folder is never overridden.
640
+ */
641
+ export function resolveGovernedSlice(relPath, rule, sliceFolders) {
642
+ if (!relPath)
643
+ return {};
644
+ const folders = sliceFolders ?? rule.sliceFolders;
645
+ const universeId = sliceIdForPath(relPath, folders ? [...folders] : undefined, rule.sliceIdentity);
646
+ const resolved = resolveChildSliceId(relPath, universeId, rule.childSlices);
647
+ if (universeId || resolved.childId || resolved.mismatched) {
648
+ return resolved.childId ? { universeId, childId: resolved.childId } : { universeId };
649
+ }
650
+ const alias = firstSliceAlias(relPath, rule);
651
+ return alias ? { universeId: alias.universeId, childId: alias.childId } : {};
652
+ }
653
+ function childPatternDirectory(pattern, childId, identity) {
654
+ const segments = pattern.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
655
+ const idParts = childId.split('/').filter(Boolean).map((part) => part.toLowerCase());
656
+ if (segments.length === 0 || idParts.length === 0)
657
+ return null;
658
+ if (segments.some((part) => part === '**' || (part.includes('*') && part !== '*')))
659
+ return null;
660
+ 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)
670
+ return null;
671
+ for (let index = 0; index < tail.length; index += 1) {
672
+ if (tail[index] === '*')
673
+ continue;
674
+ if (tail[index] !== idParts[index])
675
+ return null;
676
+ }
677
+ return [...segments.slice(0, lastLiteral), ...idParts].join('/');
678
+ }
679
+ if (segments.length !== idParts.length)
680
+ return null;
681
+ for (let index = 0; index < segments.length; index += 1) {
682
+ if (segments[index] === '*')
683
+ continue;
684
+ if (segments[index] !== idParts[index])
685
+ return null;
686
+ }
687
+ return idParts.join('/');
688
+ }
689
+ function sharedDirectoryPrefix(file, dir) {
690
+ const left = stripSrcOrApp(file.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase()).split('/');
691
+ const right = dir.split('/');
692
+ let count = 0;
693
+ while (count < left.length && count < right.length && left[count] === right[count])
694
+ count += 1;
695
+ return count;
696
+ }
697
+ function withSourcePrefix(file, dir) {
698
+ const norm = file.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase();
699
+ if (norm.startsWith('src/') && !dir.startsWith('src/'))
700
+ return `src/${dir}`;
701
+ if (norm.startsWith('app/') && !dir.startsWith('app/'))
702
+ return `app/${dir}`;
703
+ return dir;
704
+ }
705
+ /** Folder the aliased file should move into. Parallel trees pick the closest prefix. */
706
+ export function sliceAliasDestination(file, childId, child) {
707
+ let best = null;
708
+ for (const pattern of child.sliceFolders) {
709
+ if (typeof pattern !== 'string')
710
+ continue;
711
+ const dir = childPatternDirectory(pattern, childId, child.sliceIdentity);
712
+ if (!dir)
713
+ continue;
714
+ const score = sharedDirectoryPrefix(file, dir);
715
+ if (!best || score > best.score || (score === best.score && dir.length < best.dir.length)) {
716
+ best = { dir, score };
717
+ }
718
+ }
719
+ return best ? withSourcePrefix(file, best.dir) : '';
720
+ }
721
+ export function anySliceAlias(rules) {
722
+ return (rules ?? []).some((rule) => (rule?.childSlices?.sliceAliases?.length ?? 0) > 0);
723
+ }
724
+ /** Doctor list. Absent when no rule sets sliceAliases, so the key stays off. */
725
+ export function sliceAliasReport(rules, files) {
726
+ if (!anySliceAlias(rules))
727
+ return null;
728
+ const moves = new Map();
729
+ for (const rule of rules ?? []) {
730
+ const child = rule?.childSlices;
731
+ if (!child?.sliceAliases)
732
+ continue;
733
+ for (const alias of child.sliceAliases) {
734
+ if (!alias || typeof alias.from !== 'string' || typeof alias.to !== 'string')
735
+ continue;
736
+ const key = `${aliasGlobPattern(alias.from)}\0${aliasGlobPattern(alias.to)}`;
737
+ let move = moves.get(key);
738
+ if (!move) {
739
+ move = { from: alias.from, to: alias.to, destination: '', files: [] };
740
+ moves.set(key, move);
741
+ }
742
+ const seen = new Set(move.files);
743
+ for (const file of files) {
744
+ if (typeof file !== 'string' || !aliasGlobMatches(alias.from, file) || seen.has(file))
745
+ continue;
746
+ seen.add(file);
747
+ move.files.push(file);
748
+ if (!move.destination)
749
+ move.destination = sliceAliasDestination(file, alias.to, child);
750
+ }
751
+ }
752
+ }
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 };
756
+ }
601
757
  /**
602
758
  * One human sentence naming which peerIsolation reason fired — so the denial
603
759
  * reports a fact about their code (`cross-slice`) or a fact about our evidence
@@ -624,6 +780,433 @@ export function peerIsolationDenyExplanation(reason, context) {
624
780
  return 'no path evidence for this edge — peerIsolation needs the importer and importee paths.';
625
781
  }
626
782
  }
783
+ function crossParentExplanation(fromSlice, toSlice) {
784
+ return `cross-parent slice ${fromSlice ?? '?'} → ${toSlice ?? '?'}. The child wall cannot allow another universe.`;
785
+ }
786
+ function crossSiblingExplanation(fromChild, toChild, universe) {
787
+ return `cross-sibling slice ${fromChild} → ${toChild} inside ${universe}.`;
788
+ }
789
+ function parentImportsChildExplanation(toChild) {
790
+ return `universe common imports child ${toChild}. parentMayImportChild is off.`;
791
+ }
792
+ /** Last path segment of a universe slice id (`features/projects` → `projects`). */
793
+ export function universePairLabel(sliceId) {
794
+ if (!sliceId)
795
+ return undefined;
796
+ const parts = sliceId.split('/').filter(Boolean);
797
+ return parts.length > 0 ? parts[parts.length - 1] : undefined;
798
+ }
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)
812
+ return false;
813
+ return directorySegments(relPath).some((part) => names.has(part.toLowerCase()));
814
+ }
815
+ /**
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.
819
+ */
820
+ export function resolveChildSliceId(relPath, universeId, child) {
821
+ if (!relPath || !child?.sliceFolders?.length)
822
+ return {};
823
+ if (pathHasCommonFolder(relPath, child.commonFolders))
824
+ return {};
825
+ const raw = sliceIdForPath(relPath, child.sliceFolders, child.sliceIdentity);
826
+ if (!raw || !universeId)
827
+ return {};
828
+ const childKey = raw.toLowerCase();
829
+ const universeKey = universeId.toLowerCase();
830
+ if (childKey === universeKey)
831
+ return {};
832
+ if (childKey.startsWith(`${universeKey}/`))
833
+ return { childId: raw };
834
+ return { mismatched: { childId: raw, universeId } };
835
+ }
836
+ function siblingEntryKey(entry) {
837
+ return trimTrailingSlashes(entry.trim().replace(/\\/g, '/')).toLowerCase();
838
+ }
839
+ function stripSrcOrApp(value) {
840
+ return value.replace(/^(?:src|app)\//, '');
841
+ }
842
+ /** True when the importer file sits in this directory, with or without src/ or app/. */
843
+ function pathUnderSubtree(filePath, entry) {
844
+ const file = filePath.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase();
845
+ const prefix = stripSrcOrApp(entry);
846
+ if (!prefix)
847
+ return false;
848
+ const rooted = stripSrcOrApp(file);
849
+ return (file === prefix ||
850
+ file.startsWith(`${prefix}/`) ||
851
+ rooted === prefix ||
852
+ rooted.startsWith(`${prefix}/`));
853
+ }
854
+ /**
855
+ * Child slice id matches exactly. A path matches the importer file's directory.
856
+ * Bare names do not match. `*` is not a wildcard.
857
+ */
858
+ export function importerInEnforcedSubtree(fromPath, fromChild, entries) {
859
+ if (!fromChild || !entries?.length)
860
+ return false;
861
+ const child = fromChild.toLowerCase();
862
+ for (const raw of entries) {
863
+ if (typeof raw !== 'string')
864
+ continue;
865
+ const entry = siblingEntryKey(raw);
866
+ if (!entry || entry.includes('*'))
867
+ continue;
868
+ if (child === entry)
869
+ return true;
870
+ if (fromPath && pathUnderSubtree(fromPath, entry))
871
+ return true;
872
+ }
873
+ return false;
874
+ }
875
+ /**
876
+ * String `advisory` warns every sibling crossing. String `deny` and an absent
877
+ * value deny every one. An object denies the importer only when it is listed,
878
+ * unless `default` is already `deny`.
879
+ */
880
+ export function siblingCrossingAdvisory(siblings, fromPath, fromChild) {
881
+ if (siblings == null || siblings === 'deny')
882
+ return false;
883
+ if (siblings === 'advisory')
884
+ return true;
885
+ if (siblings.default === 'deny')
886
+ return false;
887
+ return !importerInEnforcedSubtree(fromPath, fromChild, siblings.enforce);
888
+ }
889
+ /**
890
+ * Segments of a child-slice pattern. `*` is one whole segment.
891
+ * A bare name, `**`, or a partial segment does not match.
892
+ */
893
+ function childSlicePatternSegments(raw) {
894
+ if (typeof raw !== 'string')
895
+ return null;
896
+ const trimmed = trimTrailingSlashes(raw.trim().replace(/\\/g, '/')).toLowerCase();
897
+ if (!trimmed.includes('/'))
898
+ return null;
899
+ const parts = trimmed.split('/');
900
+ for (const part of parts) {
901
+ if (part.length === 0 || part === '.' || part === '..')
902
+ return null;
903
+ if (part.includes('*') && part !== '*')
904
+ return null;
905
+ }
906
+ return parts;
907
+ }
908
+ /** Whole-segment match. The universe list does not call this. */
909
+ export function childSlicePatternMatches(pattern, sliceId) {
910
+ const want = childSlicePatternSegments(pattern);
911
+ const have = childSlicePatternSegments(sliceId);
912
+ if (!want || !have || want.length !== have.length)
913
+ return false;
914
+ for (let index = 0; index < want.length; index += 1) {
915
+ const segment = want[index];
916
+ if (segment === '*')
917
+ continue;
918
+ if (segment !== have[index])
919
+ return false;
920
+ }
921
+ return true;
922
+ }
923
+ function childCrossSliceAllowed(edges, fromChild, toChild) {
924
+ if (!edges?.length)
925
+ return false;
926
+ for (const edge of edges) {
927
+ if (!edge || typeof edge.from !== 'string' || typeof edge.to !== 'string')
928
+ continue;
929
+ if (childSlicePatternMatches(edge.from, fromChild) && childSlicePatternMatches(edge.to, toChild)) {
930
+ return true;
931
+ }
932
+ }
933
+ return false;
934
+ }
935
+ /**
936
+ * True when the two patterns can name child ids in different universes.
937
+ * The universe is every segment but the last. A `*` there, or two different
938
+ * literals, means the entry cannot be a same-universe sibling allowance.
939
+ */
940
+ function childAllowanceSpansUniverses(from, to) {
941
+ const fromParts = childSlicePatternSegments(from);
942
+ const toParts = childSlicePatternSegments(to);
943
+ if (!fromParts || !toParts)
944
+ return false;
945
+ const fromPrefix = fromParts.slice(0, -1);
946
+ const toPrefix = toParts.slice(0, -1);
947
+ if (fromPrefix.length !== toPrefix.length)
948
+ return true;
949
+ for (let index = 0; index < fromPrefix.length; index += 1) {
950
+ const left = fromPrefix[index];
951
+ const right = toPrefix[index];
952
+ if (left === '*' || right === '*')
953
+ return true;
954
+ if (left !== right)
955
+ return true;
956
+ }
957
+ return false;
958
+ }
959
+ /**
960
+ * Universe wall, then the child wall. An allow from the child wall never
961
+ * overturns a universe deny: that deny returns before the child wall runs.
962
+ */
963
+ export function evaluateNestedSliceWall(input) {
964
+ const universe = peerIsolationDecision({
965
+ fromPath: input.fromPath,
966
+ toPath: input.toPath,
967
+ folderCount: input.folderCount,
968
+ fromSlice: input.fromSlice,
969
+ toSlice: input.toSlice,
970
+ fromShared: input.fromShared,
971
+ toShared: input.toShared,
972
+ crossSliceAllowed: input.crossSliceAllowed,
973
+ sharedImportsSlice: input.rule.sharedImportsSlice,
974
+ });
975
+ if (universe.denied) {
976
+ const crossParent = Boolean(input.rule.childSlices) && universe.reason === 'cross-slice';
977
+ return {
978
+ crossing: universe.reason === 'cross-slice' ? 'cross-parent' : 'fail-closed',
979
+ decision: 'deny',
980
+ ...(crossParent ? { reasonId: 'CROSS_PARENT_SLICE' } : {}),
981
+ explanation: crossParent
982
+ ? crossParentExplanation(input.fromSlice, input.toSlice)
983
+ : peerIsolationDenyExplanation(universe.reason ?? 'missing-path', {
984
+ fromPath: input.fromPath,
985
+ toPath: input.toPath,
986
+ fromSlice: input.fromSlice,
987
+ toSlice: input.toSlice,
988
+ }),
989
+ peerIsolationReason: universe.reason,
990
+ fromUniverse: input.fromSlice,
991
+ toUniverse: input.toSlice,
992
+ };
993
+ }
994
+ const child = input.rule.childSlices;
995
+ if (!child || !input.fromSlice || !input.toSlice || input.fromSlice !== input.toSlice || !input.toPath) {
996
+ return {
997
+ crossing: 'none',
998
+ decision: 'allow',
999
+ fromUniverse: input.fromSlice,
1000
+ toUniverse: input.toSlice,
1001
+ };
1002
+ }
1003
+ const fromChild = input.fromChild ?? resolveChildSliceId(input.fromPath, input.fromSlice, child).childId;
1004
+ const toChild = input.toChild ?? resolveChildSliceId(input.toPath, input.toSlice, child).childId;
1005
+ const allowSameUniverse = {
1006
+ crossing: 'none',
1007
+ decision: 'allow',
1008
+ fromUniverse: input.fromSlice,
1009
+ toUniverse: input.toSlice,
1010
+ };
1011
+ if (fromChild && toChild && fromChild !== toChild) {
1012
+ if (childCrossSliceAllowed(child.allowedCrossSlice, fromChild, toChild)) {
1013
+ return allowSameUniverse;
1014
+ }
1015
+ const advisory = siblingCrossingAdvisory(child.siblings, input.fromPath, fromChild);
1016
+ return {
1017
+ crossing: 'cross-sibling',
1018
+ decision: advisory ? 'advisory' : 'deny',
1019
+ reasonId: 'CROSS_SIBLING_SLICE',
1020
+ explanation: crossSiblingExplanation(fromChild, toChild, input.fromSlice),
1021
+ fromUniverse: input.fromSlice,
1022
+ toUniverse: input.toSlice,
1023
+ };
1024
+ }
1025
+ if (!fromChild && toChild && child.parentMayImportChild !== true) {
1026
+ return {
1027
+ crossing: 'parent-imports-child',
1028
+ decision: 'deny',
1029
+ explanation: parentImportsChildExplanation(toChild),
1030
+ fromUniverse: input.fromSlice,
1031
+ toUniverse: input.toSlice,
1032
+ };
1033
+ }
1034
+ return allowSameUniverse;
1035
+ }
1036
+ /**
1037
+ * Finding message. Import sites always append the explanation. Intent sites
1038
+ * keep today's wording when there is no reasonId: a plain cross-slice deny
1039
+ * does not gain a clause, and any other universe-wall reason does.
1040
+ */
1041
+ export function composeSliceDenialMessage(input) {
1042
+ const explanation = input.verdict.explanation;
1043
+ if (input.surface === 'intent') {
1044
+ const defaultMessage = input.defaultMessage ?? `${input.fromLayer} must not reference ${input.toLayer} intent.`;
1045
+ if (input.verdict.reasonId && explanation)
1046
+ return `${defaultMessage} ${explanation}`;
1047
+ if (explanation && input.verdict.peerIsolationReason !== 'cross-slice') {
1048
+ return `${defaultMessage} ${explanation}`;
1049
+ }
1050
+ if (input.ruleMessage) {
1051
+ return explanation ? `${input.ruleMessage} (${explanation})` : input.ruleMessage;
1052
+ }
1053
+ return defaultMessage;
1054
+ }
1055
+ if (input.ruleMessage) {
1056
+ return explanation ? `${input.ruleMessage} (${explanation})` : input.ruleMessage;
1057
+ }
1058
+ if (explanation) {
1059
+ const kind = input.kind ?? 'import';
1060
+ return `${input.fromLayer} must not ${kind} another slice of ${input.toLayer} (${input.fromPath} → ${input.toPath}): ${explanation}`;
1061
+ }
1062
+ return `${input.fromLayer} must not ${input.kind ?? 'import'} ${input.toLayer}.`;
1063
+ }
1064
+ /** Fields a reporting site copies onto a finding. Absent when the verdict allows. */
1065
+ export function sliceFindingExtras(verdict) {
1066
+ if (!verdict || verdict.decision === 'allow')
1067
+ return {};
1068
+ const from = universePairLabel(verdict.fromUniverse);
1069
+ const to = universePairLabel(verdict.toUniverse);
1070
+ return {
1071
+ ...(verdict.reasonId ? { reasonId: verdict.reasonId } : {}),
1072
+ ...(verdict.reasonId && from && to ? { universeFrom: from, universeTo: to } : {}),
1073
+ ...(verdict.decision === 'advisory'
1074
+ ? { failsStrict: false, severity: 'warning' }
1075
+ : {}),
1076
+ };
1077
+ }
1078
+ /** True when this siblings value still leaves some crossings advisory. An enforce list does not finish the house. */
1079
+ export function childWallSiblingsAdvisory(siblings) {
1080
+ if (siblings === 'advisory')
1081
+ return true;
1082
+ if (siblings !== null && typeof siblings === 'object') {
1083
+ return siblings.default !== 'deny';
1084
+ }
1085
+ return false;
1086
+ }
1087
+ export function anyChildWallAdvisory(rules) {
1088
+ return (rules ?? []).some((rule) => rule?.childSlices != null && childWallSiblingsAdvisory(rule.childSlices.siblings));
1089
+ }
1090
+ /** Doctor counts. Null when this scan has no nested-wall reason, so the key stays absent. */
1091
+ export function sliceCountReport(violations) {
1092
+ let crossParent = 0;
1093
+ let crossSibling = 0;
1094
+ const pairs = new Map();
1095
+ for (const row of violations ?? []) {
1096
+ if (row.reasonId === 'CROSS_PARENT_SLICE') {
1097
+ crossParent += 1;
1098
+ if (typeof row.universeFrom === 'string' && typeof row.universeTo === 'string') {
1099
+ const key = `${row.universeFrom}\0${row.universeTo}`;
1100
+ const prev = pairs.get(key);
1101
+ if (prev)
1102
+ prev.count += 1;
1103
+ else
1104
+ pairs.set(key, { from: row.universeFrom, to: row.universeTo, count: 1 });
1105
+ }
1106
+ }
1107
+ else if (row.reasonId === 'CROSS_SIBLING_SLICE') {
1108
+ crossSibling += 1;
1109
+ }
1110
+ }
1111
+ if (crossParent === 0 && crossSibling === 0)
1112
+ return null;
1113
+ return {
1114
+ crossParent,
1115
+ crossSibling,
1116
+ pairs: [...pairs.values()].sort((left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to)),
1117
+ };
1118
+ }
1119
+ /**
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.
1122
+ */
1123
+ export function childSliceConfigFindings(rules, files) {
1124
+ const out = [];
1125
+ let versionWarned = false;
1126
+ const seenMismatch = new Set();
1127
+ const seenSpan = new Set();
1128
+ for (const rule of rules ?? []) {
1129
+ if (!rule?.childSlices)
1130
+ continue;
1131
+ if (!versionWarned) {
1132
+ versionWarned = true;
1133
+ out.push({
1134
+ ruleId: 'CONFIG_CHILD_SLICES_VERSION',
1135
+ 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.`,
1137
+ });
1138
+ }
1139
+ if (!rule.peerIsolation)
1140
+ continue;
1141
+ for (const file of files) {
1142
+ const universeId = sliceIdForPath(file, rule.sliceFolders, rule.sliceIdentity);
1143
+ const resolved = resolveChildSliceId(file, universeId, rule.childSlices);
1144
+ if (!resolved.mismatched)
1145
+ continue;
1146
+ const key = `${rule.from}\0${rule.to}\0${resolved.mismatched.universeId}\0${resolved.mismatched.childId}`;
1147
+ if (seenMismatch.has(key))
1148
+ continue;
1149
+ seenMismatch.add(key);
1150
+ out.push({
1151
+ ruleId: 'CONFIG_CHILD_SLICE_EXTENDS',
1152
+ failsStrict: false,
1153
+ fromLayer: rule.from,
1154
+ toLayer: rule.to,
1155
+ path: file,
1156
+ message: `${rule.from} → ${rule.to}: child id ${resolved.mismatched.childId} does not extend universe id ${resolved.mismatched.universeId} (${file}).`,
1157
+ });
1158
+ }
1159
+ for (const edge of rule.childSlices.allowedCrossSlice ?? []) {
1160
+ if (!edge || typeof edge.from !== 'string' || typeof edge.to !== 'string')
1161
+ continue;
1162
+ if (!childAllowanceSpansUniverses(edge.from, edge.to))
1163
+ continue;
1164
+ const key = `${edge.from.trim().toLowerCase()}\0${edge.to.trim().toLowerCase()}`;
1165
+ if (seenSpan.has(key))
1166
+ continue;
1167
+ seenSpan.add(key);
1168
+ out.push({
1169
+ ruleId: 'CONFIG_CHILD_SLICE_CROSS_UNIVERSE',
1170
+ failsStrict: false,
1171
+ fromLayer: rule.from,
1172
+ toLayer: rule.to,
1173
+ message: `childSlices.allowedCrossSlice ${edge.from} → ${edge.to} cannot cross the universe wall. The universe wall still denies that edge.`,
1174
+ });
1175
+ }
1176
+ }
1177
+ return out;
1178
+ }
1179
+ /**
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.
1184
+ */
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);
1190
+ }
1191
+ if (indexes.length === 0)
1192
+ return [...violations];
1193
+ let recorded = 0;
1194
+ for (const index of indexes) {
1195
+ if (recordedKeys.has(occurrenceKeys[index] ?? ''))
1196
+ recorded += 1;
1197
+ }
1198
+ if (indexes.length <= recorded)
1199
+ return [...violations];
1200
+ 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)
1206
+ return violation;
1207
+ return { ...violation, failsStrict: true, severity: 'error' };
1208
+ });
1209
+ }
627
1210
  /**
628
1211
  * Find the first denying rule for a layer edge.
629
1212
  *
@@ -656,25 +1239,33 @@ export function findDeniedEdgeDecision(rules, from, to, options) {
656
1239
  const fromPath = options?.fromPath;
657
1240
  const toPath = options?.toPath;
658
1241
  const folders = resolveSliceFolders(rule, from, options?.layers);
659
- const fromSlice = fromPath
660
- ? sliceIdForPath(fromPath, folders, rule.sliceIdentity)
661
- : undefined;
662
- const toSlice = toPath ? sliceIdForPath(toPath, folders, rule.sliceIdentity) : undefined;
663
- const decision = peerIsolationDecision({
1242
+ const fromPlace = resolveGovernedSlice(fromPath, rule, folders);
1243
+ const toPlace = resolveGovernedSlice(toPath, rule, folders);
1244
+ const fromSlice = fromPlace.universeId;
1245
+ const toSlice = toPlace.universeId;
1246
+ const verdict = evaluateNestedSliceWall({
1247
+ rule,
664
1248
  fromPath,
665
1249
  toPath,
666
1250
  folderCount: folders.length,
667
1251
  fromSlice,
668
1252
  toSlice,
1253
+ fromChild: fromPlace.childId,
1254
+ toChild: toPlace.childId,
669
1255
  fromShared: !fromSlice && pathUnderSharedRoot(fromPath, rule.sharedRoots),
670
1256
  toShared: !toSlice && pathUnderSharedRoot(toPath, rule.sharedRoots),
671
1257
  crossSliceAllowed: crossSliceEdgeAllowed(rule.allowedCrossSlice, fromSlice, toSlice),
672
- sharedImportsSlice: rule.sharedImportsSlice,
673
1258
  });
674
- if (decision.denied) {
675
- return { rule, peerIsolationReason: decision.reason, fromSlice, toSlice };
1259
+ if (verdict.decision !== 'allow') {
1260
+ return {
1261
+ rule,
1262
+ peerIsolationReason: verdict.peerIsolationReason,
1263
+ fromSlice,
1264
+ toSlice,
1265
+ sliceVerdict: verdict,
1266
+ };
676
1267
  }
677
- continue; // same slice, declared shared, or declared cross edge: no denial
1268
+ continue; // same slice, declared shared, declared cross edge, or child wall allow
678
1269
  }
679
1270
  // Classic deny — same-layer always allowed without peerIsolation
680
1271
  if (from === to)
@@ -701,8 +1292,8 @@ export function findSharedImportsSliceBridge(rules, from, to, options) {
701
1292
  if (rule.sharedImportsSlice === 'deny')
702
1293
  continue;
703
1294
  const folders = resolveSliceFolders(rule, from, options?.layers);
704
- const fromSlice = sliceIdForPath(fromPath, folders, rule.sliceIdentity);
705
- const toSlice = sliceIdForPath(toPath, folders, rule.sliceIdentity);
1295
+ const fromSlice = resolveGovernedSlice(fromPath, rule, folders).universeId;
1296
+ const toSlice = resolveGovernedSlice(toPath, rule, folders).universeId;
706
1297
  if (fromSlice || !toSlice)
707
1298
  continue;
708
1299
  if (!pathUnderSharedRoot(fromPath, rule.sharedRoots))
@@ -732,6 +1323,10 @@ export function isScanExcludedRelative(relPath, config) {
732
1323
  const rel = String(relPath).split(/[/\\]/).join('/');
733
1324
  return scanExcludePatterns(config).some((pattern) => globToRegExp(pattern).test(rel));
734
1325
  }
1326
+ /** Slice folders the universe wall uses: the rule's list, or the from-layer patterns. */
1327
+ export function peerSliceFolders(rule, layerName, layers) {
1328
+ return resolveSliceFolders(rule, layerName, layers);
1329
+ }
735
1330
 
736
1331
 
737
1332
  import path from 'node:path';