arkgate 4.8.22 → 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.
- package/CHANGELOG.md +23 -0
- package/README.md +2 -2
- package/bin/ark-check-runtime.mjs +4 -2
- package/bin/ark-layer-match.mjs +606 -11
- package/bin/lib/analysis-engine.mjs +11 -11
- package/bin/lib/config-contract.mjs +492 -1
- package/bin/lib/diagnostic-catalog.mjs +32 -0
- package/bin/lib/doctor-advisories.mjs +60 -1
- package/bin/lib/doctor-plan.mjs +2 -2
- package/bin/lib/enforcement-honesty.mjs +22 -0
- package/bin/lib/flat-parent-pilot.mjs +252 -0
- package/bin/lib/html-report-advisories.mjs +2 -0
- package/bin/lib/html-report-depth.mjs +3 -1
- package/bin/lib/physical-cohesion.mjs +16 -18
- package/bin/lib/pilot-loop.mjs +21 -8
- package/bin/lib/team-parliament-io.mjs +4 -2
- package/dist/{configTypes-CTELo87-.d.ts → configTypes-Bbf_MFGe.d.ts} +36 -2
- package/dist/{diagnosticCatalog-WD2TPSwZ.d.ts → diagnosticCatalog-35mrwAHe.d.ts} +13 -3
- package/dist/eslint/index.cjs +4 -4
- package/dist/eslint/index.d.ts +51 -5
- package/dist/eslint/index.js +4 -4
- package/dist/index.cjs +35 -35
- package/dist/index.d.ts +104 -5
- package/dist/index.js +35 -35
- package/dist/nestjs/index.cjs +9 -9
- package/dist/nestjs/index.d.ts +3 -3
- package/dist/nestjs/index.js +9 -9
- package/dist/runtime/index.cjs +13 -13
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +13 -13
- package/dist/{types-yDNEQ603.d.ts → types-Bdj32QwA.d.ts} +1 -1
- package/dist/{types-fQ9-KrBj.d.ts → types-Ik_OakcI.d.ts} +1 -1
- package/docs/README.md +2 -1
- package/docs/agent-guide.md +9 -1
- package/docs/configuration.md +92 -1
- package/docs/diagnostics.md +32 -0
- package/docs/product-voice.md +1 -1
- package/package.json +1 -1
- package/schemas/ark.config.schema.json +124 -2
- package/server.json +2 -2
- package/templates/agent-skills/README.md +1 -1
package/bin/ark-layer-match.mjs
CHANGED
|
@@ -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
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
const toSlice =
|
|
663
|
-
const
|
|
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
|
|
675
|
-
return {
|
|
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,
|
|
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 =
|
|
705
|
-
const toSlice =
|
|
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';
|