arkgate 4.8.22 → 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.
- package/CHANGELOG.md +91 -0
- package/README.md +7 -5
- package/bin/ark-check-runtime.mjs +27 -78
- package/bin/ark-dashboard.mjs +56 -53
- package/bin/ark-layer-match.mjs +1117 -38
- package/bin/ark-mcp-runtime.mjs +412 -138
- package/bin/ark-mcp.mjs +68 -4
- package/bin/ark-shared.mjs +39 -0
- package/bin/ark.mjs +65 -97
- package/bin/lib/adapter-contract-types.mjs +5 -0
- package/bin/lib/adapter-contract.mjs +15 -0
- package/bin/lib/agent-projection-command.mjs +5 -1
- package/bin/lib/analysis-engine.mjs +15 -11
- package/bin/lib/architecture-scan.mjs +50 -6
- package/bin/lib/ark-order-doctor.mjs +1 -1
- package/bin/lib/ark-order-facts.mjs +410 -15
- package/bin/lib/ark-order-invariants.mjs +51 -9
- package/bin/lib/ark-order-sensors.mjs +28 -101
- package/bin/lib/ark-order-types.mjs +2 -0
- package/bin/lib/ark-run-doctor.mjs +14 -0
- package/bin/lib/ark-run-facts.mjs +434 -11
- package/bin/lib/ark-run-sensors.mjs +5 -3
- package/bin/lib/arkgate-pins.mjs +91 -0
- package/bin/lib/arkrules-sensors.mjs +50 -249
- package/bin/lib/check-args.mjs +23 -1
- package/bin/lib/ci-and-commands.mjs +16 -6
- package/bin/lib/class-source-scan.mjs +479 -0
- package/bin/lib/codex-home.mjs +4 -2
- package/bin/lib/config-contract-slices.mjs +701 -0
- package/bin/lib/config-contract.mjs +5 -2
- package/bin/lib/config-warnings.mjs +32 -0
- package/bin/lib/dashboard-cli.mjs +62 -0
- package/bin/lib/design-delta.mjs +1 -1
- package/bin/lib/diagnostic-catalog.mjs +45 -6
- package/bin/lib/doctor-advisories.mjs +84 -1
- package/bin/lib/doctor-next-actions.mjs +17 -2
- package/bin/lib/doctor-plan.mjs +7 -9
- package/bin/lib/effective-contract-load.mjs +121 -59
- package/bin/lib/enforcement-honesty.mjs +22 -0
- package/bin/lib/enforcement-profiles.mjs +40 -4
- package/bin/lib/first-run-help.mjs +3 -2
- package/bin/lib/flat-parent-importers.mjs +137 -0
- package/bin/lib/flat-parent-pilot.mjs +192 -0
- package/bin/lib/gate-files.mjs +29 -2
- package/bin/lib/git-change-scope.mjs +109 -0
- package/bin/lib/github-enforcement.mjs +1 -1
- package/bin/lib/hook-templates.mjs +18 -16
- package/bin/lib/host-support-matrix.mjs +3 -1
- package/bin/lib/html-report-advisories.mjs +2 -0
- package/bin/lib/html-report-depth.mjs +3 -1
- package/bin/lib/import-resolve.mjs +13 -1
- package/bin/lib/install-migrate.mjs +50 -73
- package/bin/lib/invariant-coverage.mjs +59 -11
- package/bin/lib/mcp-adoption.mjs +9 -1
- package/bin/lib/mcp-hook-payload.mjs +401 -61
- package/bin/lib/package-manager.mjs +59 -1
- package/bin/lib/passthrough-commands.mjs +83 -0
- package/bin/lib/persistence-write-hint.mjs +81 -0
- package/bin/lib/physical-cohesion.mjs +16 -18
- package/bin/lib/pilot-loop.mjs +21 -8
- package/bin/lib/pin-dependent-gates.mjs +117 -0
- package/bin/lib/policy-delta-io.mjs +168 -10
- package/bin/lib/prepare-change.mjs +120 -6
- package/bin/lib/prepare-write.mjs +1 -0
- package/bin/lib/presets.mjs +3 -5
- package/bin/lib/remediation.mjs +23 -4
- package/bin/lib/resolved-candidate-facts.mjs +315 -55
- package/bin/lib/rules-inventory.mjs +22 -3
- package/bin/lib/rules-under-contract.mjs +179 -5
- package/bin/lib/scan-files.mjs +32 -0
- package/bin/lib/snippet-analysis.mjs +140 -6
- package/bin/lib/source-policy.mjs +34 -0
- package/bin/lib/stable-hash.mjs +90 -0
- package/bin/lib/start-install-recovery.mjs +71 -1
- package/bin/lib/start-preview.mjs +30 -0
- package/bin/lib/status-command.mjs +33 -4
- package/bin/lib/status-manifest.mjs +32 -4
- package/bin/lib/team-parliament-io.mjs +79 -75
- package/bin/lib/team-parliament.mjs +6 -1
- package/bin/lib/write-path-capabilities.mjs +4 -2
- package/dist/chunk-A5EIY3HI.cjs +5 -0
- package/dist/chunk-MBXCFLO4.js +16 -0
- package/dist/chunk-RNQHRDCQ.cjs +16 -0
- package/dist/chunk-SVSQVGFJ.js +5 -0
- package/dist/{configTypes-CTELo87-.d.ts → configTypes-BJbaeoj4.d.cts} +53 -4
- package/dist/configTypes-BJbaeoj4.d.ts +246 -0
- package/dist/eslint/index.cjs +17 -4
- package/dist/eslint/index.d.cts +359 -0
- package/dist/eslint/index.d.ts +178 -158
- package/dist/eslint/index.js +10 -4
- package/dist/index.cjs +38 -37
- package/dist/index.d.cts +5475 -0
- package/dist/index.d.ts +3408 -296
- package/dist/index.js +38 -37
- package/dist/inspectorListen-6DQVJ4DC.js +1 -0
- package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
- package/dist/nestjs/index.cjs +1 -18
- package/dist/nestjs/index.d.cts +22 -0
- package/dist/nestjs/index.d.ts +1 -3
- package/dist/nestjs/index.js +1 -18
- package/dist/order/index.cjs +1 -1
- package/dist/order/index.d.cts +155 -0
- package/dist/order/index.d.ts +15 -3
- package/dist/order/index.js +1 -1
- package/dist/runtime/index.cjs +13 -30
- package/dist/{diagnosticCatalog-WD2TPSwZ.d.ts → runtime/index.d.cts} +814 -290
- package/dist/runtime/index.d.ts +2678 -108
- package/dist/runtime/index.js +13 -30
- package/dist/{types-yDNEQ603.d.ts → types-DRinG-vT.d.cts} +576 -140
- package/dist/types-DRinG-vT.d.ts +1792 -0
- package/docs/README.md +3 -1
- package/docs/agent-guide.md +92 -15
- package/docs/ai-gates.md +160 -26
- package/docs/arkorder.md +32 -15
- package/docs/brownfield-adoption.md +5 -1
- package/docs/configuration.md +273 -12
- package/docs/develop.md +2 -1
- package/docs/diagnostics.md +122 -13
- package/docs/package-surface.md +31 -8
- package/docs/product-voice.md +1 -1
- package/package.json +59 -18
- package/schemas/ark.analysis-result.schema.json +15 -0
- package/schemas/ark.config.schema.json +163 -4
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/schemas/ark.status-manifest.schema.json +22 -0
- package/server.json +3 -9
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +4 -1
- package/templates/agent-skills/ark-order/SKILL.md +4 -2
- package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
- package/templates/arkrules/PersistenceAdapters.json +1 -1
- package/templates/arkrules/PresentationAdapters.json +1 -1
- package/templates/skills/ark-adopt.md +4 -1
- package/templates/skills/ark-order.md +4 -2
- package/templates/skills/ark-runtime.md +6 -1
- package/dist/types-fQ9-KrBj.d.ts +0 -183
package/bin/ark-layer-match.mjs
CHANGED
|
@@ -9,6 +9,26 @@
|
|
|
9
9
|
* CLI-only layerForFile (Node path resolution) is appended below the pure core.
|
|
10
10
|
*/
|
|
11
11
|
|
|
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
|
+
}
|
|
12
32
|
const regexpCache = new Map();
|
|
13
33
|
function escapeLiteral(ch) {
|
|
14
34
|
return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
|
|
@@ -223,13 +243,25 @@ export function layerForRelativePath(relPath, layers) {
|
|
|
223
243
|
* With `sliceIdentity` `"path"` (the default) the slice id includes the
|
|
224
244
|
* literal prefix plus the star bindings (`lib/features/projects/rfi`), so
|
|
225
245
|
* parallel trees get different ids. With `"stars"` it is the last literal
|
|
226
|
-
* plus
|
|
246
|
+
* plus every star binding, including one before the last literal. A star never binds the filename; a file directly
|
|
227
247
|
* under the last bound directory keeps that directory as its slice.
|
|
228
248
|
*/
|
|
229
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) {
|
|
230
263
|
if (!sliceFolders?.length)
|
|
231
264
|
return undefined;
|
|
232
|
-
const identity = sliceIdentity === 'stars' ? 'stars' : 'path';
|
|
233
265
|
const parts = String(relPath)
|
|
234
266
|
.split(/[/\\]/)
|
|
235
267
|
.filter(Boolean);
|
|
@@ -239,18 +271,41 @@ export function sliceIdForPath(relPath, sliceFolders, sliceIdentity) {
|
|
|
239
271
|
for (const raw of sliceFolders) {
|
|
240
272
|
if (typeof raw !== 'string' || raw.length === 0)
|
|
241
273
|
continue;
|
|
242
|
-
|
|
243
|
-
|
|
274
|
+
const entry = parsedSliceFolder(raw);
|
|
275
|
+
if (entry.anchored) {
|
|
276
|
+
const anchored = anchoredSliceIdFromPattern(parts, entry.anchored, identity);
|
|
244
277
|
if (anchored)
|
|
245
278
|
return anchored;
|
|
246
279
|
continue;
|
|
247
280
|
}
|
|
248
|
-
if (
|
|
249
|
-
bare.add(
|
|
250
|
-
}
|
|
281
|
+
if (entry.bare !== undefined)
|
|
282
|
+
bare.add(entry.bare);
|
|
251
283
|
}
|
|
252
284
|
return bareSliceId(parts, bare);
|
|
253
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
|
+
}
|
|
254
309
|
/** A starred prefix with a real leading directory, not a bare name and not `*`. */
|
|
255
310
|
function isAnchoredSliceEntry(entry) {
|
|
256
311
|
const segments = entry.split(/[/\\]/).filter((part) => part.length > 0);
|
|
@@ -260,11 +315,13 @@ function isAnchoredSliceEntry(entry) {
|
|
|
260
315
|
return false;
|
|
261
316
|
return segments.some((part) => part === '*');
|
|
262
317
|
}
|
|
263
|
-
function
|
|
264
|
-
|
|
318
|
+
function anchoredSlicePattern(raw) {
|
|
319
|
+
return raw
|
|
265
320
|
.split(/[/\\]/)
|
|
266
321
|
.filter((part) => part.length > 0)
|
|
267
322
|
.map((part) => part.toLowerCase());
|
|
323
|
+
}
|
|
324
|
+
function anchoredSliceIdFromPattern(parts, pattern, identity) {
|
|
268
325
|
const offsets = anchorOffsets(parts, pattern[0] ?? '');
|
|
269
326
|
for (const offset of offsets) {
|
|
270
327
|
const id = bindAnchoredSlice(parts, pattern, offset, identity);
|
|
@@ -283,11 +340,14 @@ function anchoredSliceId(parts, raw, identity) {
|
|
|
283
340
|
*
|
|
284
341
|
* `path` returns every bound segment (literal prefix plus star bindings), so
|
|
285
342
|
* a parallel tree (`components` / `features` / `*` / `*`) gets a different
|
|
286
|
-
* id. `stars` drops the
|
|
287
|
-
*
|
|
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.
|
|
288
347
|
*/
|
|
289
348
|
function bindAnchoredSlice(parts, pattern, offset, identity = 'path') {
|
|
290
349
|
const bound = [];
|
|
350
|
+
const fromStar = [];
|
|
291
351
|
let lastLiteralAt = -1;
|
|
292
352
|
let index = offset;
|
|
293
353
|
for (let pi = 0; pi < pattern.length; pi += 1) {
|
|
@@ -301,6 +361,7 @@ function bindAnchoredSlice(parts, pattern, offset, identity = 'path') {
|
|
|
301
361
|
break;
|
|
302
362
|
}
|
|
303
363
|
bound.push(parts[index].toLowerCase());
|
|
364
|
+
fromStar.push(true);
|
|
304
365
|
index += 1;
|
|
305
366
|
continue;
|
|
306
367
|
}
|
|
@@ -309,18 +370,44 @@ function bindAnchoredSlice(parts, pattern, offset, identity = 'path') {
|
|
|
309
370
|
if (parts[index].toLowerCase() !== segment)
|
|
310
371
|
return undefined;
|
|
311
372
|
bound.push(parts[index].toLowerCase());
|
|
373
|
+
fromStar.push(false);
|
|
312
374
|
lastLiteralAt = bound.length - 1;
|
|
313
375
|
index += 1;
|
|
314
376
|
}
|
|
315
377
|
if (bound.length === 0)
|
|
316
378
|
return undefined;
|
|
317
|
-
if (identity ===
|
|
379
|
+
if (identity === LEGACY_STARS) {
|
|
318
380
|
if (lastLiteralAt < 0)
|
|
319
381
|
return undefined;
|
|
320
382
|
return bound.slice(lastLiteralAt).join('/');
|
|
321
383
|
}
|
|
384
|
+
if (identity === 'stars') {
|
|
385
|
+
if (lastLiteralAt < 0)
|
|
386
|
+
return undefined;
|
|
387
|
+
return bound.filter((_, at) => at >= lastLiteralAt || fromStar[at]).join('/');
|
|
388
|
+
}
|
|
322
389
|
return bound.join('/');
|
|
323
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
|
+
}
|
|
324
411
|
/**
|
|
325
412
|
* Doctor warning input. Silent unless `sliceIdentity` is `"stars"` and two
|
|
326
413
|
* different anchored prefixes share a stem (last literal plus the stars after it).
|
|
@@ -362,7 +449,10 @@ export function sliceIdentityCollisions(rules) {
|
|
|
362
449
|
}
|
|
363
450
|
return out;
|
|
364
451
|
}
|
|
365
|
-
/**
|
|
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
|
+
*/
|
|
366
456
|
function starsIdentityStem(raw) {
|
|
367
457
|
if (!isAnchoredSliceEntry(raw))
|
|
368
458
|
return undefined;
|
|
@@ -370,20 +460,12 @@ function starsIdentityStem(raw) {
|
|
|
370
460
|
.split(/[/\\]/)
|
|
371
461
|
.filter((part) => part.length > 0)
|
|
372
462
|
.map((part) => part.toLowerCase());
|
|
373
|
-
|
|
374
|
-
for (let i = 0; i < segments.length; i += 1) {
|
|
375
|
-
const part = segments[i] ?? '';
|
|
376
|
-
if (part === '**')
|
|
377
|
-
return undefined;
|
|
378
|
-
if (part !== '*')
|
|
379
|
-
lastLiteral = i;
|
|
380
|
-
}
|
|
381
|
-
if (lastLiteral < 0)
|
|
463
|
+
if (segments.some((part) => part === '**'))
|
|
382
464
|
return undefined;
|
|
383
|
-
const
|
|
384
|
-
if (
|
|
465
|
+
const kept = starsKeptSegmentIndexes(segments);
|
|
466
|
+
if (!kept)
|
|
385
467
|
return undefined;
|
|
386
|
-
return
|
|
468
|
+
return kept.map((at) => segments[at]).join('/');
|
|
387
469
|
}
|
|
388
470
|
function formatSliceIdentityCollision(from, to, paths, stem) {
|
|
389
471
|
const named = paths.length === 2
|
|
@@ -598,6 +680,332 @@ export function peerIsolationDecision(input) {
|
|
|
598
680
|
export function peerIsolationMustDeny(input) {
|
|
599
681
|
return peerIsolationDecision(input).denied;
|
|
600
682
|
}
|
|
683
|
+
export const SLICE_ALIAS_DEBT = 'Aliases are an owed move, not a destination. These files are not finished.';
|
|
684
|
+
function aliasGlobPattern(glob) {
|
|
685
|
+
return trimTrailingSlashes(glob.trim().replace(/\\/g, '/')).toLowerCase();
|
|
686
|
+
}
|
|
687
|
+
/** Existing path glob matcher. Also accepts a leading src/ or app/, and a plain folder covers its subtree. */
|
|
688
|
+
function aliasGlobMatches(glob, relPath) {
|
|
689
|
+
const pattern = aliasGlobPattern(glob);
|
|
690
|
+
if (!pattern || pattern === '*' || pattern === '**')
|
|
691
|
+
return false;
|
|
692
|
+
const file = String(relPath).replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase();
|
|
693
|
+
const rooted = stripSrcOrApp(file);
|
|
694
|
+
const re = globToRegExp(pattern);
|
|
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));
|
|
714
|
+
}
|
|
715
|
+
function splitAliasTarget(to) {
|
|
716
|
+
const parts = aliasGlobPattern(to).split('/').filter(Boolean);
|
|
717
|
+
if (parts.length < 2 || parts.some((part) => part.includes('*') || part === '.' || part === '..')) {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
return { universeId: parts.slice(0, -1).join('/'), childId: parts.join('/') };
|
|
721
|
+
}
|
|
722
|
+
function firstSliceAliasEntry(relPath, rule) {
|
|
723
|
+
for (const alias of rule.childSlices?.sliceAliases ?? []) {
|
|
724
|
+
if (!alias || typeof alias.from !== 'string' || typeof alias.to !== 'string')
|
|
725
|
+
continue;
|
|
726
|
+
if (aliasGlobMatches(alias.from, relPath))
|
|
727
|
+
return alias;
|
|
728
|
+
}
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
function firstSliceAlias(relPath, rule) {
|
|
732
|
+
const alias = firstSliceAliasEntry(relPath, rule);
|
|
733
|
+
return alias ? splitAliasTarget(alias.to) : null;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Universe id and child id for one file. An alias runs only when both walls
|
|
737
|
+
* leave the path unclassified, so a real slice folder is never overridden.
|
|
738
|
+
*/
|
|
739
|
+
export function resolveGovernedSlice(relPath, rule, sliceFolders) {
|
|
740
|
+
if (!relPath)
|
|
741
|
+
return {};
|
|
742
|
+
const folders = sliceFolders ?? rule.sliceFolders;
|
|
743
|
+
const universeId = sliceIdForPath(relPath, folders ? [...folders] : undefined, rule.sliceIdentity);
|
|
744
|
+
const resolved = resolveChildSliceId(relPath, universeId, rule.childSlices);
|
|
745
|
+
if (universeId || resolved.childId || resolved.mismatched) {
|
|
746
|
+
return resolved.childId ? { universeId, childId: resolved.childId } : { universeId };
|
|
747
|
+
}
|
|
748
|
+
const alias = firstSliceAlias(relPath, rule);
|
|
749
|
+
return alias ? { universeId: alias.universeId, childId: alias.childId } : {};
|
|
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
|
+
}
|
|
875
|
+
function childPatternDirectory(pattern, childId, identity) {
|
|
876
|
+
const segments = pattern.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
|
|
877
|
+
const idParts = childId.split('/').filter(Boolean).map((part) => part.toLowerCase());
|
|
878
|
+
if (segments.length === 0 || idParts.length === 0)
|
|
879
|
+
return null;
|
|
880
|
+
if (segments.some((part) => part === '**' || (part.includes('*') && part !== '*')))
|
|
881
|
+
return null;
|
|
882
|
+
if (identity === 'stars') {
|
|
883
|
+
const kept = starsKeptSegmentIndexes(segments);
|
|
884
|
+
if (!kept || kept.length !== idParts.length)
|
|
885
|
+
return null;
|
|
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])
|
|
890
|
+
return null;
|
|
891
|
+
dir[at] = idParts[index] ?? '';
|
|
892
|
+
}
|
|
893
|
+
return dir.join('/');
|
|
894
|
+
}
|
|
895
|
+
if (segments.length !== idParts.length)
|
|
896
|
+
return null;
|
|
897
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
898
|
+
if (segments[index] === '*')
|
|
899
|
+
continue;
|
|
900
|
+
if (segments[index] !== idParts[index])
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
return idParts.join('/');
|
|
904
|
+
}
|
|
905
|
+
function sharedDirectoryPrefix(file, dir) {
|
|
906
|
+
const left = stripSrcOrApp(file.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase()).split('/');
|
|
907
|
+
const right = dir.split('/');
|
|
908
|
+
let count = 0;
|
|
909
|
+
while (count < left.length && count < right.length && left[count] === right[count])
|
|
910
|
+
count += 1;
|
|
911
|
+
return count;
|
|
912
|
+
}
|
|
913
|
+
function withSourcePrefix(file, dir) {
|
|
914
|
+
const norm = file.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase();
|
|
915
|
+
if (norm.startsWith('src/') && !dir.startsWith('src/'))
|
|
916
|
+
return `src/${dir}`;
|
|
917
|
+
if (norm.startsWith('app/') && !dir.startsWith('app/'))
|
|
918
|
+
return `app/${dir}`;
|
|
919
|
+
return dir;
|
|
920
|
+
}
|
|
921
|
+
/** Folder the aliased file should move into. Parallel trees pick the closest prefix. */
|
|
922
|
+
export function sliceAliasDestination(file, childId, child) {
|
|
923
|
+
let best = null;
|
|
924
|
+
for (const pattern of child.sliceFolders) {
|
|
925
|
+
if (typeof pattern !== 'string')
|
|
926
|
+
continue;
|
|
927
|
+
const dir = childPatternDirectory(pattern, childId, child.sliceIdentity);
|
|
928
|
+
if (!dir)
|
|
929
|
+
continue;
|
|
930
|
+
const score = sharedDirectoryPrefix(file, dir);
|
|
931
|
+
if (!best || score > best.score || (score === best.score && dir.length < best.dir.length)) {
|
|
932
|
+
best = { dir, score };
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return best ? withSourcePrefix(file, best.dir) : '';
|
|
936
|
+
}
|
|
937
|
+
export function anySliceAlias(rules) {
|
|
938
|
+
return (rules ?? []).some((rule) => (rule?.childSlices?.sliceAliases?.length ?? 0) > 0);
|
|
939
|
+
}
|
|
940
|
+
/** Doctor list. Absent when no rule sets sliceAliases, so the key stays off. */
|
|
941
|
+
export function sliceAliasReport(rules, files) {
|
|
942
|
+
if (!anySliceAlias(rules))
|
|
943
|
+
return null;
|
|
944
|
+
const moves = new Map();
|
|
945
|
+
for (const rule of rules ?? []) {
|
|
946
|
+
const child = rule?.childSlices;
|
|
947
|
+
if (!child?.sliceAliases)
|
|
948
|
+
continue;
|
|
949
|
+
for (const alias of child.sliceAliases) {
|
|
950
|
+
if (!alias || typeof alias.from !== 'string' || typeof alias.to !== 'string')
|
|
951
|
+
continue;
|
|
952
|
+
const key = `${aliasGlobPattern(alias.from)}\0${aliasGlobPattern(alias.to)}`;
|
|
953
|
+
let move = moves.get(key);
|
|
954
|
+
if (!move) {
|
|
955
|
+
move = { from: alias.from, to: alias.to, destination: '', files: [] };
|
|
956
|
+
moves.set(key, move);
|
|
957
|
+
}
|
|
958
|
+
const seen = new Set(move.files);
|
|
959
|
+
for (const file of files) {
|
|
960
|
+
if (typeof file !== 'string' || !aliasGlobMatches(alias.from, file) || seen.has(file))
|
|
961
|
+
continue;
|
|
962
|
+
seen.add(file);
|
|
963
|
+
move.files.push(file);
|
|
964
|
+
if (!move.destination)
|
|
965
|
+
move.destination = sliceAliasDestination(file, alias.to, child);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
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
|
+
});
|
|
984
|
+
listed.sort((left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to));
|
|
985
|
+
return { notAScore: true, finished: false, debt: SLICE_ALIAS_DEBT, moves: listed };
|
|
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
|
+
}
|
|
601
1009
|
/**
|
|
602
1010
|
* One human sentence naming which peerIsolation reason fired — so the denial
|
|
603
1011
|
* reports a fact about their code (`cross-slice`) or a fact about our evidence
|
|
@@ -624,6 +1032,663 @@ export function peerIsolationDenyExplanation(reason, context) {
|
|
|
624
1032
|
return 'no path evidence for this edge — peerIsolation needs the importer and importee paths.';
|
|
625
1033
|
}
|
|
626
1034
|
}
|
|
1035
|
+
function crossParentExplanation(fromSlice, toSlice) {
|
|
1036
|
+
return `cross-parent slice ${fromSlice ?? '?'} → ${toSlice ?? '?'}. The child wall cannot allow another universe.`;
|
|
1037
|
+
}
|
|
1038
|
+
function crossSiblingExplanation(fromChild, toChild, universe) {
|
|
1039
|
+
return `cross-sibling slice ${fromChild} → ${toChild} inside ${universe}.`;
|
|
1040
|
+
}
|
|
1041
|
+
function parentImportsChildExplanation(toChild) {
|
|
1042
|
+
return `universe common imports child ${toChild}. parentMayImportChild is off.`;
|
|
1043
|
+
}
|
|
1044
|
+
/** Last path segment of a universe slice id (`features/projects` → `projects`). */
|
|
1045
|
+
export function universePairLabel(sliceId) {
|
|
1046
|
+
if (!sliceId)
|
|
1047
|
+
return undefined;
|
|
1048
|
+
const parts = sliceId.split('/').filter(Boolean);
|
|
1049
|
+
return parts.length > 0 ? parts[parts.length - 1] : undefined;
|
|
1050
|
+
}
|
|
1051
|
+
function isCommonFolderName(segment, commonFolders) {
|
|
1052
|
+
if (!segment || !commonFolders?.length)
|
|
1053
|
+
return false;
|
|
1054
|
+
const want = segment.toLowerCase();
|
|
1055
|
+
return commonFolders.some((raw) => typeof raw === 'string' && raw.length > 0 && raw.toLowerCase() === want);
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
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.
|
|
1064
|
+
*/
|
|
1065
|
+
export function resolveChildSliceId(relPath, universeId, child) {
|
|
1066
|
+
if (!relPath || !child?.sliceFolders?.length)
|
|
1067
|
+
return {};
|
|
1068
|
+
const raw = sliceIdForPath(relPath, child.sliceFolders, child.sliceIdentity);
|
|
1069
|
+
if (!raw || !universeId)
|
|
1070
|
+
return {};
|
|
1071
|
+
const childKey = raw.toLowerCase();
|
|
1072
|
+
const universeKey = universeId.toLowerCase();
|
|
1073
|
+
if (childKey === universeKey)
|
|
1074
|
+
return {};
|
|
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 };
|
|
1081
|
+
}
|
|
1082
|
+
function siblingEntryKey(entry) {
|
|
1083
|
+
return trimTrailingSlashes(entry.trim().replace(/\\/g, '/')).toLowerCase();
|
|
1084
|
+
}
|
|
1085
|
+
function stripSrcOrApp(value) {
|
|
1086
|
+
return value.replace(/^(?:src|app)\//, '');
|
|
1087
|
+
}
|
|
1088
|
+
/** True when the importer file sits in this directory, with or without src/ or app/. */
|
|
1089
|
+
function pathUnderSubtree(filePath, entry) {
|
|
1090
|
+
const file = filePath.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase();
|
|
1091
|
+
const prefix = stripSrcOrApp(entry);
|
|
1092
|
+
if (!prefix)
|
|
1093
|
+
return false;
|
|
1094
|
+
const rooted = stripSrcOrApp(file);
|
|
1095
|
+
return (file === prefix ||
|
|
1096
|
+
file.startsWith(`${prefix}/`) ||
|
|
1097
|
+
rooted === prefix ||
|
|
1098
|
+
rooted.startsWith(`${prefix}/`));
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Child slice id matches exactly. A path matches the importer file's directory.
|
|
1102
|
+
* Bare names do not match. `*` is not a wildcard.
|
|
1103
|
+
*/
|
|
1104
|
+
export function importerInEnforcedSubtree(fromPath, fromChild, entries) {
|
|
1105
|
+
if (!fromChild || !entries?.length)
|
|
1106
|
+
return false;
|
|
1107
|
+
const child = fromChild.toLowerCase();
|
|
1108
|
+
for (const raw of entries) {
|
|
1109
|
+
if (typeof raw !== 'string')
|
|
1110
|
+
continue;
|
|
1111
|
+
const entry = siblingEntryKey(raw);
|
|
1112
|
+
if (!entry || entry.includes('*'))
|
|
1113
|
+
continue;
|
|
1114
|
+
if (child === entry)
|
|
1115
|
+
return true;
|
|
1116
|
+
if (fromPath && pathUnderSubtree(fromPath, entry))
|
|
1117
|
+
return true;
|
|
1118
|
+
}
|
|
1119
|
+
return false;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* String `advisory` warns every sibling crossing. String `deny` and an absent
|
|
1123
|
+
* value deny every one. An object denies the importer only when it is listed,
|
|
1124
|
+
* unless `default` is already `deny`.
|
|
1125
|
+
*/
|
|
1126
|
+
export function siblingCrossingAdvisory(siblings, fromPath, fromChild) {
|
|
1127
|
+
if (siblings == null || siblings === 'deny')
|
|
1128
|
+
return false;
|
|
1129
|
+
if (siblings === 'advisory')
|
|
1130
|
+
return true;
|
|
1131
|
+
if (siblings.default === 'deny')
|
|
1132
|
+
return false;
|
|
1133
|
+
return !importerInEnforcedSubtree(fromPath, fromChild, siblings.enforce);
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Segments of a child-slice pattern. `*` is one whole segment.
|
|
1137
|
+
* A bare name, `**`, or a partial segment does not match.
|
|
1138
|
+
*/
|
|
1139
|
+
function childSlicePatternSegments(raw) {
|
|
1140
|
+
if (typeof raw !== 'string')
|
|
1141
|
+
return null;
|
|
1142
|
+
const trimmed = trimTrailingSlashes(raw.trim().replace(/\\/g, '/')).toLowerCase();
|
|
1143
|
+
if (!trimmed.includes('/'))
|
|
1144
|
+
return null;
|
|
1145
|
+
const parts = trimmed.split('/');
|
|
1146
|
+
for (const part of parts) {
|
|
1147
|
+
if (part.length === 0 || part === '.' || part === '..')
|
|
1148
|
+
return null;
|
|
1149
|
+
if (part.includes('*') && part !== '*')
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
return parts;
|
|
1153
|
+
}
|
|
1154
|
+
/** Whole-segment match. The universe list does not call this. */
|
|
1155
|
+
export function childSlicePatternMatches(pattern, sliceId) {
|
|
1156
|
+
const want = childSlicePatternSegments(pattern);
|
|
1157
|
+
const have = childSlicePatternSegments(sliceId);
|
|
1158
|
+
if (!want || !have || want.length !== have.length)
|
|
1159
|
+
return false;
|
|
1160
|
+
for (let index = 0; index < want.length; index += 1) {
|
|
1161
|
+
const segment = want[index];
|
|
1162
|
+
if (segment === '*')
|
|
1163
|
+
continue;
|
|
1164
|
+
if (segment !== have[index])
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
return true;
|
|
1168
|
+
}
|
|
1169
|
+
function childCrossSliceAllowed(edges, fromChild, toChild) {
|
|
1170
|
+
if (!edges?.length)
|
|
1171
|
+
return false;
|
|
1172
|
+
for (const edge of edges) {
|
|
1173
|
+
if (!edge || typeof edge.from !== 'string' || typeof edge.to !== 'string')
|
|
1174
|
+
continue;
|
|
1175
|
+
if (childSlicePatternMatches(edge.from, fromChild) && childSlicePatternMatches(edge.to, toChild)) {
|
|
1176
|
+
return true;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
return false;
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* True when the two patterns can name child ids in different universes.
|
|
1183
|
+
* The universe is every segment but the last. A `*` there, or two different
|
|
1184
|
+
* literals, means the entry cannot be a same-universe sibling allowance.
|
|
1185
|
+
*/
|
|
1186
|
+
function childAllowanceSpansUniverses(from, to) {
|
|
1187
|
+
const fromParts = childSlicePatternSegments(from);
|
|
1188
|
+
const toParts = childSlicePatternSegments(to);
|
|
1189
|
+
if (!fromParts || !toParts)
|
|
1190
|
+
return false;
|
|
1191
|
+
const fromPrefix = fromParts.slice(0, -1);
|
|
1192
|
+
const toPrefix = toParts.slice(0, -1);
|
|
1193
|
+
if (fromPrefix.length !== toPrefix.length)
|
|
1194
|
+
return true;
|
|
1195
|
+
for (let index = 0; index < fromPrefix.length; index += 1) {
|
|
1196
|
+
const left = fromPrefix[index];
|
|
1197
|
+
const right = toPrefix[index];
|
|
1198
|
+
if (left === '*' || right === '*')
|
|
1199
|
+
return true;
|
|
1200
|
+
if (left !== right)
|
|
1201
|
+
return true;
|
|
1202
|
+
}
|
|
1203
|
+
return false;
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Universe wall, then the child wall. An allow from the child wall never
|
|
1207
|
+
* overturns a universe deny: that deny returns before the child wall runs.
|
|
1208
|
+
*/
|
|
1209
|
+
export function evaluateNestedSliceWall(input) {
|
|
1210
|
+
const universe = peerIsolationDecision({
|
|
1211
|
+
fromPath: input.fromPath,
|
|
1212
|
+
toPath: input.toPath,
|
|
1213
|
+
folderCount: input.folderCount,
|
|
1214
|
+
fromSlice: input.fromSlice,
|
|
1215
|
+
toSlice: input.toSlice,
|
|
1216
|
+
fromShared: input.fromShared,
|
|
1217
|
+
toShared: input.toShared,
|
|
1218
|
+
crossSliceAllowed: input.crossSliceAllowed,
|
|
1219
|
+
sharedImportsSlice: sharedImportsSliceMode(input.rule.sharedImportsSlice),
|
|
1220
|
+
});
|
|
1221
|
+
if (universe.denied) {
|
|
1222
|
+
const crossParent = Boolean(input.rule.childSlices) && universe.reason === 'cross-slice';
|
|
1223
|
+
return {
|
|
1224
|
+
crossing: universe.reason === 'cross-slice' ? 'cross-parent' : 'fail-closed',
|
|
1225
|
+
decision: 'deny',
|
|
1226
|
+
...(crossParent ? { reasonId: 'CROSS_PARENT_SLICE' } : {}),
|
|
1227
|
+
explanation: crossParent
|
|
1228
|
+
? crossParentExplanation(input.fromSlice, input.toSlice)
|
|
1229
|
+
: peerIsolationDenyExplanation(universe.reason ?? 'missing-path', {
|
|
1230
|
+
fromPath: input.fromPath,
|
|
1231
|
+
toPath: input.toPath,
|
|
1232
|
+
fromSlice: input.fromSlice,
|
|
1233
|
+
toSlice: input.toSlice,
|
|
1234
|
+
}),
|
|
1235
|
+
peerIsolationReason: universe.reason,
|
|
1236
|
+
fromUniverse: input.fromSlice,
|
|
1237
|
+
toUniverse: input.toSlice,
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
const child = input.rule.childSlices;
|
|
1241
|
+
if (!child || !input.fromSlice || !input.toSlice || input.fromSlice !== input.toSlice || !input.toPath) {
|
|
1242
|
+
return {
|
|
1243
|
+
crossing: 'none',
|
|
1244
|
+
decision: 'allow',
|
|
1245
|
+
fromUniverse: input.fromSlice,
|
|
1246
|
+
toUniverse: input.toSlice,
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
const fromChild = input.fromChild ?? resolveChildSliceId(input.fromPath, input.fromSlice, child).childId;
|
|
1250
|
+
const toChild = input.toChild ?? resolveChildSliceId(input.toPath, input.toSlice, child).childId;
|
|
1251
|
+
const allowSameUniverse = {
|
|
1252
|
+
crossing: 'none',
|
|
1253
|
+
decision: 'allow',
|
|
1254
|
+
fromUniverse: input.fromSlice,
|
|
1255
|
+
toUniverse: input.toSlice,
|
|
1256
|
+
};
|
|
1257
|
+
if (fromChild && toChild && fromChild !== toChild) {
|
|
1258
|
+
if (input.legacyChildAllowed || childCrossSliceAllowed(child.allowedCrossSlice, fromChild, toChild)) {
|
|
1259
|
+
return allowSameUniverse;
|
|
1260
|
+
}
|
|
1261
|
+
const advisory = siblingCrossingAdvisory(child.siblings, input.fromPath, fromChild);
|
|
1262
|
+
return {
|
|
1263
|
+
crossing: 'cross-sibling',
|
|
1264
|
+
decision: advisory ? 'advisory' : 'deny',
|
|
1265
|
+
reasonId: 'CROSS_SIBLING_SLICE',
|
|
1266
|
+
explanation: crossSiblingExplanation(fromChild, toChild, input.fromSlice),
|
|
1267
|
+
fromUniverse: input.fromSlice,
|
|
1268
|
+
toUniverse: input.toSlice,
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
if (!fromChild && toChild && child.parentMayImportChild !== true) {
|
|
1272
|
+
return {
|
|
1273
|
+
crossing: 'parent-imports-child',
|
|
1274
|
+
decision: 'deny',
|
|
1275
|
+
explanation: parentImportsChildExplanation(toChild),
|
|
1276
|
+
fromUniverse: input.fromSlice,
|
|
1277
|
+
toUniverse: input.toSlice,
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
return allowSameUniverse;
|
|
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
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Finding message. Import sites always append the explanation. Intent sites
|
|
1296
|
+
* keep today's wording when there is no reasonId: a plain cross-slice deny
|
|
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.
|
|
1300
|
+
*/
|
|
1301
|
+
export function composeSliceDenialMessage(input) {
|
|
1302
|
+
const explanation = input.verdict.explanation;
|
|
1303
|
+
const inner = isChildWallCrossing(input.verdict);
|
|
1304
|
+
const consumer = inner ? input.childMessage : input.ruleMessage;
|
|
1305
|
+
if (input.surface === 'intent') {
|
|
1306
|
+
const defaultMessage = input.defaultMessage ?? `${input.fromLayer} must not reference ${input.toLayer} intent.`;
|
|
1307
|
+
if (inner && explanation)
|
|
1308
|
+
return `${input.childMessage ?? defaultMessage} ${explanation}`;
|
|
1309
|
+
if (input.verdict.reasonId && explanation)
|
|
1310
|
+
return `${defaultMessage} ${explanation}`;
|
|
1311
|
+
if (explanation && input.verdict.peerIsolationReason !== 'cross-slice') {
|
|
1312
|
+
return `${defaultMessage} ${explanation}`;
|
|
1313
|
+
}
|
|
1314
|
+
if (consumer) {
|
|
1315
|
+
return explanation ? `${consumer} (${explanation})` : consumer;
|
|
1316
|
+
}
|
|
1317
|
+
return defaultMessage;
|
|
1318
|
+
}
|
|
1319
|
+
if (consumer) {
|
|
1320
|
+
return explanation ? `${consumer} (${explanation})` : consumer;
|
|
1321
|
+
}
|
|
1322
|
+
if (explanation) {
|
|
1323
|
+
const kind = input.kind ?? 'import';
|
|
1324
|
+
return `${input.fromLayer} must not ${kind} another slice of ${input.toLayer} (${input.fromPath} → ${input.toPath}): ${explanation}`;
|
|
1325
|
+
}
|
|
1326
|
+
return `${input.fromLayer} must not ${input.kind ?? 'import'} ${input.toLayer}.`;
|
|
1327
|
+
}
|
|
1328
|
+
/** Fields a reporting site copies onto a finding. Absent when the verdict allows. */
|
|
1329
|
+
export function sliceFindingExtras(verdict) {
|
|
1330
|
+
if (!verdict || verdict.decision === 'allow')
|
|
1331
|
+
return {};
|
|
1332
|
+
const from = universePairLabel(verdict.fromUniverse);
|
|
1333
|
+
const to = universePairLabel(verdict.toUniverse);
|
|
1334
|
+
return {
|
|
1335
|
+
...(verdict.reasonId ? { reasonId: verdict.reasonId } : {}),
|
|
1336
|
+
...(verdict.reasonId && from && to ? { universeFrom: from, universeTo: to } : {}),
|
|
1337
|
+
...(verdict.decision === 'advisory'
|
|
1338
|
+
? { failsStrict: false, severity: 'warning' }
|
|
1339
|
+
: {}),
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
/** True when this siblings value still leaves some crossings advisory. An enforce list does not finish the house. */
|
|
1343
|
+
export function childWallSiblingsAdvisory(siblings) {
|
|
1344
|
+
if (siblings === 'advisory')
|
|
1345
|
+
return true;
|
|
1346
|
+
if (siblings !== null && typeof siblings === 'object') {
|
|
1347
|
+
return siblings.default !== 'deny';
|
|
1348
|
+
}
|
|
1349
|
+
return false;
|
|
1350
|
+
}
|
|
1351
|
+
export function anyChildWallAdvisory(rules) {
|
|
1352
|
+
return (rules ?? []).some((rule) => rule?.childSlices != null && childWallSiblingsAdvisory(rule.childSlices.siblings));
|
|
1353
|
+
}
|
|
1354
|
+
/** Doctor counts. Null when this scan has no nested-wall reason, so the key stays absent. */
|
|
1355
|
+
export function sliceCountReport(violations) {
|
|
1356
|
+
let crossParent = 0;
|
|
1357
|
+
let crossSibling = 0;
|
|
1358
|
+
const pairs = new Map();
|
|
1359
|
+
for (const row of violations ?? []) {
|
|
1360
|
+
if (row.reasonId === 'CROSS_PARENT_SLICE') {
|
|
1361
|
+
crossParent += 1;
|
|
1362
|
+
if (typeof row.universeFrom === 'string' && typeof row.universeTo === 'string') {
|
|
1363
|
+
const key = `${row.universeFrom}\0${row.universeTo}`;
|
|
1364
|
+
const prev = pairs.get(key);
|
|
1365
|
+
if (prev)
|
|
1366
|
+
prev.count += 1;
|
|
1367
|
+
else
|
|
1368
|
+
pairs.set(key, { from: row.universeFrom, to: row.universeTo, count: 1 });
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
else if (row.reasonId === 'CROSS_SIBLING_SLICE') {
|
|
1372
|
+
crossSibling += 1;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
if (crossParent === 0 && crossSibling === 0)
|
|
1376
|
+
return null;
|
|
1377
|
+
return {
|
|
1378
|
+
crossParent,
|
|
1379
|
+
crossSibling,
|
|
1380
|
+
pairs: [...pairs.values()].sort((left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to)),
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
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).
|
|
1423
|
+
*/
|
|
1424
|
+
export function childSliceConfigFindings(rules, files) {
|
|
1425
|
+
const out = [];
|
|
1426
|
+
const seenMismatch = new Set();
|
|
1427
|
+
const seenSpan = new Set();
|
|
1428
|
+
for (const rule of rules ?? []) {
|
|
1429
|
+
if (rule?.peerIsolation && rule.allowed === false)
|
|
1430
|
+
out.push(...legacyStarsIdFindings(rule, files));
|
|
1431
|
+
if (!rule?.childSlices)
|
|
1432
|
+
continue;
|
|
1433
|
+
if (!rule.peerIsolation || rule.allowed !== false) {
|
|
1434
|
+
out.push({
|
|
1435
|
+
ruleId: 'CONFIG_CHILD_SLICES_INERT',
|
|
1436
|
+
failsStrict: 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.`,
|
|
1440
|
+
});
|
|
1441
|
+
continue;
|
|
1442
|
+
}
|
|
1443
|
+
for (const file of files) {
|
|
1444
|
+
const universeId = sliceIdForPath(file, rule.sliceFolders, rule.sliceIdentity);
|
|
1445
|
+
const resolved = resolveChildSliceId(file, universeId, rule.childSlices);
|
|
1446
|
+
if (!resolved.mismatched)
|
|
1447
|
+
continue;
|
|
1448
|
+
const key = `${rule.from}\0${rule.to}\0${resolved.mismatched.universeId}\0${resolved.mismatched.childId}`;
|
|
1449
|
+
if (seenMismatch.has(key))
|
|
1450
|
+
continue;
|
|
1451
|
+
seenMismatch.add(key);
|
|
1452
|
+
out.push({
|
|
1453
|
+
ruleId: 'CONFIG_CHILD_SLICE_EXTENDS',
|
|
1454
|
+
failsStrict: false,
|
|
1455
|
+
fromLayer: rule.from,
|
|
1456
|
+
toLayer: rule.to,
|
|
1457
|
+
path: file,
|
|
1458
|
+
message: `${rule.from} → ${rule.to}: child id ${resolved.mismatched.childId} does not extend universe id ${resolved.mismatched.universeId} (${file}).`,
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
for (const edge of rule.childSlices.allowedCrossSlice ?? []) {
|
|
1462
|
+
if (!edge || typeof edge.from !== 'string' || typeof edge.to !== 'string')
|
|
1463
|
+
continue;
|
|
1464
|
+
if (!childAllowanceSpansUniverses(edge.from, edge.to))
|
|
1465
|
+
continue;
|
|
1466
|
+
const key = `${edge.from.trim().toLowerCase()}\0${edge.to.trim().toLowerCase()}`;
|
|
1467
|
+
if (seenSpan.has(key))
|
|
1468
|
+
continue;
|
|
1469
|
+
seenSpan.add(key);
|
|
1470
|
+
out.push({
|
|
1471
|
+
ruleId: 'CONFIG_CHILD_SLICE_CROSS_UNIVERSE',
|
|
1472
|
+
failsStrict: false,
|
|
1473
|
+
fromLayer: rule.from,
|
|
1474
|
+
toLayer: rule.to,
|
|
1475
|
+
message: `childSlices.allowedCrossSlice ${edge.from} → ${edge.to} cannot cross the universe wall. The universe wall still denies that edge.`,
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
return out;
|
|
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
|
+
}
|
|
1509
|
+
/**
|
|
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.
|
|
1514
|
+
*/
|
|
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
|
+
}
|
|
1548
|
+
}
|
|
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;
|
|
1585
|
+
let recorded = 0;
|
|
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')
|
|
1608
|
+
recorded += 1;
|
|
1609
|
+
}
|
|
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)
|
|
1677
|
+
return [...violations];
|
|
1678
|
+
return violations.map((violation, index) => {
|
|
1679
|
+
if (!promote.has(index))
|
|
1680
|
+
return violation;
|
|
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
|
+
};
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
627
1692
|
/**
|
|
628
1693
|
* Find the first denying rule for a layer edge.
|
|
629
1694
|
*
|
|
@@ -656,25 +1721,35 @@ export function findDeniedEdgeDecision(rules, from, to, options) {
|
|
|
656
1721
|
const fromPath = options?.fromPath;
|
|
657
1722
|
const toPath = options?.toPath;
|
|
658
1723
|
const folders = resolveSliceFolders(rule, from, options?.layers);
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
const
|
|
663
|
-
const
|
|
1724
|
+
const pair = resolveGovernedSlicePair(rule, folders, fromPath, toPath);
|
|
1725
|
+
const fromPlace = pair.from;
|
|
1726
|
+
const toPlace = pair.to;
|
|
1727
|
+
const fromSlice = fromPlace.universeId;
|
|
1728
|
+
const toSlice = toPlace.universeId;
|
|
1729
|
+
const verdict = evaluateNestedSliceWall({
|
|
1730
|
+
rule,
|
|
664
1731
|
fromPath,
|
|
665
1732
|
toPath,
|
|
666
1733
|
folderCount: folders.length,
|
|
667
1734
|
fromSlice,
|
|
668
1735
|
toSlice,
|
|
1736
|
+
fromChild: fromPlace.childId,
|
|
1737
|
+
toChild: toPlace.childId,
|
|
669
1738
|
fromShared: !fromSlice && pathUnderSharedRoot(fromPath, rule.sharedRoots),
|
|
670
1739
|
toShared: !toSlice && pathUnderSharedRoot(toPath, rule.sharedRoots),
|
|
671
|
-
crossSliceAllowed:
|
|
672
|
-
|
|
1740
|
+
crossSliceAllowed: pair.crossSliceAllowed,
|
|
1741
|
+
legacyChildAllowed: pair.legacyChildAllowed,
|
|
673
1742
|
});
|
|
674
|
-
if (decision
|
|
675
|
-
return {
|
|
1743
|
+
if (verdict.decision !== 'allow') {
|
|
1744
|
+
return {
|
|
1745
|
+
rule,
|
|
1746
|
+
peerIsolationReason: verdict.peerIsolationReason,
|
|
1747
|
+
fromSlice,
|
|
1748
|
+
toSlice,
|
|
1749
|
+
sliceVerdict: verdict,
|
|
1750
|
+
};
|
|
676
1751
|
}
|
|
677
|
-
continue; // same slice, declared shared,
|
|
1752
|
+
continue; // same slice, declared shared, declared cross edge, or child wall allow
|
|
678
1753
|
}
|
|
679
1754
|
// Classic deny — same-layer always allowed without peerIsolation
|
|
680
1755
|
if (from === to)
|
|
@@ -698,11 +1773,11 @@ export function findSharedImportsSliceBridge(rules, from, to, options) {
|
|
|
698
1773
|
continue;
|
|
699
1774
|
if (rule.allowed !== false || !rule.peerIsolation)
|
|
700
1775
|
continue;
|
|
701
|
-
if (rule.sharedImportsSlice === 'deny')
|
|
1776
|
+
if (sharedImportsSliceMode(rule.sharedImportsSlice) === 'deny')
|
|
702
1777
|
continue;
|
|
703
1778
|
const folders = resolveSliceFolders(rule, from, options?.layers);
|
|
704
|
-
const fromSlice =
|
|
705
|
-
const toSlice =
|
|
1779
|
+
const fromSlice = resolveGovernedSlice(fromPath, rule, folders).universeId;
|
|
1780
|
+
const toSlice = resolveGovernedSlice(toPath, rule, folders).universeId;
|
|
706
1781
|
if (fromSlice || !toSlice)
|
|
707
1782
|
continue;
|
|
708
1783
|
if (!pathUnderSharedRoot(fromPath, rule.sharedRoots))
|
|
@@ -732,6 +1807,10 @@ export function isScanExcludedRelative(relPath, config) {
|
|
|
732
1807
|
const rel = String(relPath).split(/[/\\]/).join('/');
|
|
733
1808
|
return scanExcludePatterns(config).some((pattern) => globToRegExp(pattern).test(rel));
|
|
734
1809
|
}
|
|
1810
|
+
/** Slice folders the universe wall uses: the rule's list, or the from-layer patterns. */
|
|
1811
|
+
export function peerSliceFolders(rule, layerName, layers) {
|
|
1812
|
+
return resolveSliceFolders(rule, layerName, layers);
|
|
1813
|
+
}
|
|
735
1814
|
|
|
736
1815
|
|
|
737
1816
|
import path from 'node:path';
|