arkgate 4.8.23 → 4.8.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/README.md +7 -5
- package/bin/ark-check-runtime.mjs +24 -77
- package/bin/ark-dashboard.mjs +56 -53
- package/bin/ark-layer-match.mjs +596 -112
- 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 -493
- 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 +16 -9
- package/bin/lib/doctor-advisories.mjs +25 -1
- package/bin/lib/doctor-next-actions.mjs +17 -2
- package/bin/lib/doctor-plan.mjs +6 -8
- package/bin/lib/effective-contract-load.mjs +121 -59
- 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 +40 -100
- 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/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/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 +77 -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-Bbf_MFGe.d.ts → configTypes-BJbaeoj4.d.cts} +18 -3
- 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 +130 -156
- package/dist/eslint/index.js +10 -4
- package/dist/index.cjs +35 -34
- package/dist/index.d.cts +5475 -0
- package/dist/index.d.ts +3312 -299
- package/dist/index.js +35 -34
- 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-35mrwAHe.d.ts → runtime/index.d.cts} +804 -290
- package/dist/runtime/index.d.ts +2678 -108
- package/dist/runtime/index.js +13 -30
- package/dist/{types-Bdj32QwA.d.ts → types-DRinG-vT.d.cts} +576 -140
- package/dist/types-DRinG-vT.d.ts +1792 -0
- package/docs/README.md +2 -1
- package/docs/agent-guide.md +84 -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 +196 -26
- package/docs/develop.md +2 -1
- package/docs/diagnostics.md +96 -19
- 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 +47 -10
- 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-Ik_OakcI.d.ts +0 -183
package/bin/ark-layer-match.mjs
CHANGED
|
@@ -9,11 +9,26 @@
|
|
|
9
9
|
* CLI-only layerForFile (Node path resolution) is appended below the pure core.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
/** The mode of a `sharedImportsSlice` value. Never compare the raw value to a string. */
|
|
13
|
+
export function sharedImportsSliceMode(setting) {
|
|
14
|
+
if (setting === 'deny' || setting === 'deny-cross-parent')
|
|
15
|
+
return setting;
|
|
16
|
+
if (setting !== null && typeof setting === 'object') {
|
|
17
|
+
const mode = setting.mode;
|
|
18
|
+
if (mode === 'deny-cross-parent')
|
|
19
|
+
return mode;
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
/** Composition roots the deny-cross-parent walk stops at. Empty for the string forms. */
|
|
24
|
+
export function sharedImportsSliceStopAt(setting) {
|
|
25
|
+
if (setting === null || typeof setting !== 'object')
|
|
26
|
+
return [];
|
|
27
|
+
const stopAt = setting.stopAt;
|
|
28
|
+
if (!Array.isArray(stopAt))
|
|
29
|
+
return [];
|
|
30
|
+
return stopAt.filter((entry) => typeof entry === 'string' && entry.length > 0);
|
|
31
|
+
}
|
|
17
32
|
const regexpCache = new Map();
|
|
18
33
|
function escapeLiteral(ch) {
|
|
19
34
|
return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
|
|
@@ -228,13 +243,25 @@ export function layerForRelativePath(relPath, layers) {
|
|
|
228
243
|
* With `sliceIdentity` `"path"` (the default) the slice id includes the
|
|
229
244
|
* literal prefix plus the star bindings (`lib/features/projects/rfi`), so
|
|
230
245
|
* parallel trees get different ids. With `"stars"` it is the last literal
|
|
231
|
-
* plus
|
|
246
|
+
* plus every star binding, including one before the last literal. A star never binds the filename; a file directly
|
|
232
247
|
* under the last bound directory keeps that directory as its slice.
|
|
233
248
|
*/
|
|
234
249
|
export function sliceIdForPath(relPath, sliceFolders, sliceIdentity) {
|
|
250
|
+
return sliceIdWithBinding(relPath, sliceFolders, sliceIdentity === 'stars' ? 'stars' : 'path');
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The 4.8.23 `stars` id: bound segments from the last literal on, so a star
|
|
254
|
+
* before the last literal was dropped (`modules` / `*` / `api` / `*` bound `api/v1`).
|
|
255
|
+
* Kept for one release so config written against that id keeps its verdict.
|
|
256
|
+
*/
|
|
257
|
+
export function legacyStarsSliceIdForPath(relPath, sliceFolders) {
|
|
258
|
+
return sliceIdWithBinding(relPath, sliceFolders ? [...sliceFolders] : undefined, LEGACY_STARS);
|
|
259
|
+
}
|
|
260
|
+
/** Internal binding mode for {@link legacyStarsSliceIdForPath}; never a config value. */
|
|
261
|
+
const LEGACY_STARS = 'stars-4.8.23';
|
|
262
|
+
function sliceIdWithBinding(relPath, sliceFolders, identity) {
|
|
235
263
|
if (!sliceFolders?.length)
|
|
236
264
|
return undefined;
|
|
237
|
-
const identity = sliceIdentity === 'stars' ? 'stars' : 'path';
|
|
238
265
|
const parts = String(relPath)
|
|
239
266
|
.split(/[/\\]/)
|
|
240
267
|
.filter(Boolean);
|
|
@@ -244,18 +271,41 @@ export function sliceIdForPath(relPath, sliceFolders, sliceIdentity) {
|
|
|
244
271
|
for (const raw of sliceFolders) {
|
|
245
272
|
if (typeof raw !== 'string' || raw.length === 0)
|
|
246
273
|
continue;
|
|
247
|
-
|
|
248
|
-
|
|
274
|
+
const entry = parsedSliceFolder(raw);
|
|
275
|
+
if (entry.anchored) {
|
|
276
|
+
const anchored = anchoredSliceIdFromPattern(parts, entry.anchored, identity);
|
|
249
277
|
if (anchored)
|
|
250
278
|
return anchored;
|
|
251
279
|
continue;
|
|
252
280
|
}
|
|
253
|
-
if (
|
|
254
|
-
bare.add(
|
|
255
|
-
}
|
|
281
|
+
if (entry.bare !== undefined)
|
|
282
|
+
bare.add(entry.bare);
|
|
256
283
|
}
|
|
257
284
|
return bareSliceId(parts, bare);
|
|
258
285
|
}
|
|
286
|
+
const parsedSliceFolders = new Map();
|
|
287
|
+
/**
|
|
288
|
+
* `sliceIdForPath` runs for every edge endpoint under every peerIsolation rule.
|
|
289
|
+
* Re-splitting the same declared entries per call dominated the allocation of
|
|
290
|
+
* a large-repo analysis; the parse depends only on the entry text.
|
|
291
|
+
*/
|
|
292
|
+
function parsedSliceFolder(raw) {
|
|
293
|
+
const cached = parsedSliceFolders.get(raw);
|
|
294
|
+
if (cached)
|
|
295
|
+
return cached;
|
|
296
|
+
let parsed = {};
|
|
297
|
+
if (isAnchoredSliceEntry(raw)) {
|
|
298
|
+
parsed = { anchored: anchoredSlicePattern(raw) };
|
|
299
|
+
}
|
|
300
|
+
else if (!raw.includes('/') && !raw.includes('\\') && !raw.includes('*')) {
|
|
301
|
+
parsed = { bare: raw.toLowerCase() };
|
|
302
|
+
}
|
|
303
|
+
// Bounded: entries come from contracts, but a long-lived process may see many.
|
|
304
|
+
if (parsedSliceFolders.size >= 1024)
|
|
305
|
+
parsedSliceFolders.clear();
|
|
306
|
+
parsedSliceFolders.set(raw, parsed);
|
|
307
|
+
return parsed;
|
|
308
|
+
}
|
|
259
309
|
/** A starred prefix with a real leading directory, not a bare name and not `*`. */
|
|
260
310
|
function isAnchoredSliceEntry(entry) {
|
|
261
311
|
const segments = entry.split(/[/\\]/).filter((part) => part.length > 0);
|
|
@@ -265,11 +315,13 @@ function isAnchoredSliceEntry(entry) {
|
|
|
265
315
|
return false;
|
|
266
316
|
return segments.some((part) => part === '*');
|
|
267
317
|
}
|
|
268
|
-
function
|
|
269
|
-
|
|
318
|
+
function anchoredSlicePattern(raw) {
|
|
319
|
+
return raw
|
|
270
320
|
.split(/[/\\]/)
|
|
271
321
|
.filter((part) => part.length > 0)
|
|
272
322
|
.map((part) => part.toLowerCase());
|
|
323
|
+
}
|
|
324
|
+
function anchoredSliceIdFromPattern(parts, pattern, identity) {
|
|
273
325
|
const offsets = anchorOffsets(parts, pattern[0] ?? '');
|
|
274
326
|
for (const offset of offsets) {
|
|
275
327
|
const id = bindAnchoredSlice(parts, pattern, offset, identity);
|
|
@@ -288,11 +340,14 @@ function anchoredSliceId(parts, raw, identity) {
|
|
|
288
340
|
*
|
|
289
341
|
* `path` returns every bound segment (literal prefix plus star bindings), so
|
|
290
342
|
* a parallel tree (`components` / `features` / `*` / `*`) gets a different
|
|
291
|
-
* id. `stars` drops the
|
|
292
|
-
*
|
|
343
|
+
* id. `stars` drops only the literals before the last literal. It keeps that
|
|
344
|
+
* literal and every star binding, including a star before it: `modules` /
|
|
345
|
+
* `*` / `api` / `*` binds `orders/api/v1`, never a bare `api/v1` that two
|
|
346
|
+
* modules would share.
|
|
293
347
|
*/
|
|
294
348
|
function bindAnchoredSlice(parts, pattern, offset, identity = 'path') {
|
|
295
349
|
const bound = [];
|
|
350
|
+
const fromStar = [];
|
|
296
351
|
let lastLiteralAt = -1;
|
|
297
352
|
let index = offset;
|
|
298
353
|
for (let pi = 0; pi < pattern.length; pi += 1) {
|
|
@@ -306,6 +361,7 @@ function bindAnchoredSlice(parts, pattern, offset, identity = 'path') {
|
|
|
306
361
|
break;
|
|
307
362
|
}
|
|
308
363
|
bound.push(parts[index].toLowerCase());
|
|
364
|
+
fromStar.push(true);
|
|
309
365
|
index += 1;
|
|
310
366
|
continue;
|
|
311
367
|
}
|
|
@@ -314,18 +370,44 @@ function bindAnchoredSlice(parts, pattern, offset, identity = 'path') {
|
|
|
314
370
|
if (parts[index].toLowerCase() !== segment)
|
|
315
371
|
return undefined;
|
|
316
372
|
bound.push(parts[index].toLowerCase());
|
|
373
|
+
fromStar.push(false);
|
|
317
374
|
lastLiteralAt = bound.length - 1;
|
|
318
375
|
index += 1;
|
|
319
376
|
}
|
|
320
377
|
if (bound.length === 0)
|
|
321
378
|
return undefined;
|
|
322
|
-
if (identity ===
|
|
379
|
+
if (identity === LEGACY_STARS) {
|
|
323
380
|
if (lastLiteralAt < 0)
|
|
324
381
|
return undefined;
|
|
325
382
|
return bound.slice(lastLiteralAt).join('/');
|
|
326
383
|
}
|
|
384
|
+
if (identity === 'stars') {
|
|
385
|
+
if (lastLiteralAt < 0)
|
|
386
|
+
return undefined;
|
|
387
|
+
return bound.filter((_, at) => at >= lastLiteralAt || fromStar[at]).join('/');
|
|
388
|
+
}
|
|
327
389
|
return bound.join('/');
|
|
328
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* Indexes of the pattern segments a `stars` id keeps: every `*`, the last
|
|
393
|
+
* literal, and everything after it. Literals before the last literal drop.
|
|
394
|
+
* Returns null when the pattern has no literal.
|
|
395
|
+
*/
|
|
396
|
+
export function starsKeptSegmentIndexes(segments) {
|
|
397
|
+
let lastLiteral = -1;
|
|
398
|
+
for (let at = 0; at < segments.length; at += 1) {
|
|
399
|
+
if (segments[at] !== '*')
|
|
400
|
+
lastLiteral = at;
|
|
401
|
+
}
|
|
402
|
+
if (lastLiteral < 0)
|
|
403
|
+
return null;
|
|
404
|
+
const kept = [];
|
|
405
|
+
for (let at = 0; at < segments.length; at += 1) {
|
|
406
|
+
if (at >= lastLiteral || segments[at] === '*')
|
|
407
|
+
kept.push(at);
|
|
408
|
+
}
|
|
409
|
+
return kept;
|
|
410
|
+
}
|
|
329
411
|
/**
|
|
330
412
|
* Doctor warning input. Silent unless `sliceIdentity` is `"stars"` and two
|
|
331
413
|
* different anchored prefixes share a stem (last literal plus the stars after it).
|
|
@@ -367,7 +449,10 @@ export function sliceIdentityCollisions(rules) {
|
|
|
367
449
|
}
|
|
368
450
|
return out;
|
|
369
451
|
}
|
|
370
|
-
/**
|
|
452
|
+
/**
|
|
453
|
+
* The segments a `stars` id keeps: every star, the last literal, and the stars
|
|
454
|
+
* after it. Literals before the last literal drop. `**` never forms a stem.
|
|
455
|
+
*/
|
|
371
456
|
function starsIdentityStem(raw) {
|
|
372
457
|
if (!isAnchoredSliceEntry(raw))
|
|
373
458
|
return undefined;
|
|
@@ -375,20 +460,12 @@ function starsIdentityStem(raw) {
|
|
|
375
460
|
.split(/[/\\]/)
|
|
376
461
|
.filter((part) => part.length > 0)
|
|
377
462
|
.map((part) => part.toLowerCase());
|
|
378
|
-
|
|
379
|
-
for (let i = 0; i < segments.length; i += 1) {
|
|
380
|
-
const part = segments[i] ?? '';
|
|
381
|
-
if (part === '**')
|
|
382
|
-
return undefined;
|
|
383
|
-
if (part !== '*')
|
|
384
|
-
lastLiteral = i;
|
|
385
|
-
}
|
|
386
|
-
if (lastLiteral < 0)
|
|
463
|
+
if (segments.some((part) => part === '**'))
|
|
387
464
|
return undefined;
|
|
388
|
-
const
|
|
389
|
-
if (
|
|
465
|
+
const kept = starsKeptSegmentIndexes(segments);
|
|
466
|
+
if (!kept)
|
|
390
467
|
return undefined;
|
|
391
|
-
return
|
|
468
|
+
return kept.map((at) => segments[at]).join('/');
|
|
392
469
|
}
|
|
393
470
|
function formatSliceIdentityCollision(from, to, paths, stem) {
|
|
394
471
|
const named = paths.length === 2
|
|
@@ -607,7 +684,7 @@ export const SLICE_ALIAS_DEBT = 'Aliases are an owed move, not a destination. Th
|
|
|
607
684
|
function aliasGlobPattern(glob) {
|
|
608
685
|
return trimTrailingSlashes(glob.trim().replace(/\\/g, '/')).toLowerCase();
|
|
609
686
|
}
|
|
610
|
-
/** Existing path glob matcher. Also accepts a leading src/ or app
|
|
687
|
+
/** Existing path glob matcher. Also accepts a leading src/ or app/, and a plain folder covers its subtree. */
|
|
611
688
|
function aliasGlobMatches(glob, relPath) {
|
|
612
689
|
const pattern = aliasGlobPattern(glob);
|
|
613
690
|
if (!pattern || pattern === '*' || pattern === '**')
|
|
@@ -615,7 +692,25 @@ function aliasGlobMatches(glob, relPath) {
|
|
|
615
692
|
const file = String(relPath).replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase();
|
|
616
693
|
const rooted = stripSrcOrApp(file);
|
|
617
694
|
const re = globToRegExp(pattern);
|
|
618
|
-
|
|
695
|
+
// A folder form (no wildcard in the last segment) also covers its subtree,
|
|
696
|
+
// as sharedRoots does: `lib/compliance` matches `lib/compliance/x.ts`.
|
|
697
|
+
const last = pattern.split('/').pop() ?? '';
|
|
698
|
+
const under = last.includes('*') ? null : globToRegExp(`${pattern}/**`);
|
|
699
|
+
const hit = (candidate) => re.test(candidate) || (under !== null && under.test(candidate));
|
|
700
|
+
return hit(file) || (rooted !== file && hit(rooted));
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Does `relPath` match a `sharedImportsSlice.stopAt` entry? An entry matches
|
|
704
|
+
* the way a shared root does (anchored, optional leading src/ or app/, a plain
|
|
705
|
+
* folder covers its subtree) or the way an alias glob does (`kernel/registrations/**`
|
|
706
|
+
* against `src/kernel/registrations/x.ts`). A blanket `*` / `**` never matches.
|
|
707
|
+
*/
|
|
708
|
+
export function pathMatchesSharedWalkStop(relPath, stopAt) {
|
|
709
|
+
if (!relPath || !stopAt?.length)
|
|
710
|
+
return false;
|
|
711
|
+
if (pathUnderSharedRoot(relPath, [...stopAt]))
|
|
712
|
+
return true;
|
|
713
|
+
return stopAt.some((glob) => typeof glob === 'string' && aliasGlobMatches(glob, relPath));
|
|
619
714
|
}
|
|
620
715
|
function splitAliasTarget(to) {
|
|
621
716
|
const parts = aliasGlobPattern(to).split('/').filter(Boolean);
|
|
@@ -624,16 +719,19 @@ function splitAliasTarget(to) {
|
|
|
624
719
|
}
|
|
625
720
|
return { universeId: parts.slice(0, -1).join('/'), childId: parts.join('/') };
|
|
626
721
|
}
|
|
627
|
-
function
|
|
722
|
+
function firstSliceAliasEntry(relPath, rule) {
|
|
628
723
|
for (const alias of rule.childSlices?.sliceAliases ?? []) {
|
|
629
724
|
if (!alias || typeof alias.from !== 'string' || typeof alias.to !== 'string')
|
|
630
725
|
continue;
|
|
631
|
-
if (
|
|
632
|
-
|
|
633
|
-
return splitAliasTarget(alias.to);
|
|
726
|
+
if (aliasGlobMatches(alias.from, relPath))
|
|
727
|
+
return alias;
|
|
634
728
|
}
|
|
635
729
|
return null;
|
|
636
730
|
}
|
|
731
|
+
function firstSliceAlias(relPath, rule) {
|
|
732
|
+
const alias = firstSliceAliasEntry(relPath, rule);
|
|
733
|
+
return alias ? splitAliasTarget(alias.to) : null;
|
|
734
|
+
}
|
|
637
735
|
/**
|
|
638
736
|
* Universe id and child id for one file. An alias runs only when both walls
|
|
639
737
|
* leave the path unclassified, so a real slice folder is never overridden.
|
|
@@ -650,6 +748,130 @@ export function resolveGovernedSlice(relPath, rule, sliceFolders) {
|
|
|
650
748
|
const alias = firstSliceAlias(relPath, rule);
|
|
651
749
|
return alias ? { universeId: alias.universeId, childId: alias.childId } : {};
|
|
652
750
|
}
|
|
751
|
+
/** Universe shapes of a stars rule, with the 4.8.23 id shape beside each new one. */
|
|
752
|
+
function starsUniverseShapes(folders) {
|
|
753
|
+
const shapes = [];
|
|
754
|
+
for (const raw of folders ?? []) {
|
|
755
|
+
if (typeof raw !== 'string' || raw.length === 0)
|
|
756
|
+
continue;
|
|
757
|
+
const segments = raw.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
|
|
758
|
+
if (segments.length === 1 && segments[0] && !segments[0].includes('*')) {
|
|
759
|
+
shapes.push({ literals: [segments[0], null], legacy: [segments[0], null] });
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
if (segments.some((part) => part === '**' || (part.includes('*') && part !== '*')))
|
|
763
|
+
continue;
|
|
764
|
+
const kept = starsKeptSegmentIndexes(segments);
|
|
765
|
+
if (!kept || !segments.includes('*'))
|
|
766
|
+
continue;
|
|
767
|
+
const star = (part) => (part === '*' ? null : part);
|
|
768
|
+
const lastLiteral = kept.find((at) => segments[at] !== '*') ?? 0;
|
|
769
|
+
shapes.push({
|
|
770
|
+
literals: kept.map((at) => star(segments[at] ?? '')),
|
|
771
|
+
legacy: segments.slice(lastLiteral).map(star),
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
return shapes;
|
|
775
|
+
}
|
|
776
|
+
function literalsMatch(shape, parts) {
|
|
777
|
+
return (shape.length === parts.length &&
|
|
778
|
+
shape.every((literal, at) => literal === null || parts[at] === '*' || literal === parts[at]));
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* New-id shapes (`*` for a star) that a 4.8.23 stars id maps to. Empty when the id is
|
|
782
|
+
* already a new id or no shape takes it. Config load rejects more than one.
|
|
783
|
+
*/
|
|
784
|
+
export function legacyStarsIdTargets(folders, id) {
|
|
785
|
+
const parts = id.split('/').filter(Boolean).map((part) => part.toLowerCase());
|
|
786
|
+
const shapes = starsUniverseShapes(folders);
|
|
787
|
+
if (parts.length === 0 || shapes.some((shape) => literalsMatch(shape.literals, parts)))
|
|
788
|
+
return [];
|
|
789
|
+
const targets = new Set();
|
|
790
|
+
for (const shape of shapes) {
|
|
791
|
+
if (shape.legacy.length === shape.literals.length)
|
|
792
|
+
continue;
|
|
793
|
+
if (literalsMatch(shape.legacy, parts)) {
|
|
794
|
+
const tail = [...parts];
|
|
795
|
+
// Fill the kept literals and trailing stars from the legacy id; pre-literal stars stay `*`.
|
|
796
|
+
const lead = shape.literals.length - shape.legacy.length;
|
|
797
|
+
targets.add([...shape.literals.slice(0, lead).map(() => '*'), ...tail].join('/'));
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return [...targets].sort();
|
|
801
|
+
}
|
|
802
|
+
/** The legacy universe id when this alias target is a 4.8.23 stars child id. */
|
|
803
|
+
function legacyAliasUniverse(rule, to) {
|
|
804
|
+
if (rule.sliceIdentity !== 'stars')
|
|
805
|
+
return undefined;
|
|
806
|
+
const target = splitAliasTarget(to);
|
|
807
|
+
if (!target)
|
|
808
|
+
return undefined;
|
|
809
|
+
return legacyStarsIdTargets(rule.sliceFolders, target.universeId).length === 1
|
|
810
|
+
? target.universeId
|
|
811
|
+
: undefined;
|
|
812
|
+
}
|
|
813
|
+
function placeForPair(relPath, rule, folders) {
|
|
814
|
+
const place = resolveGovernedSlice(relPath, rule, folders);
|
|
815
|
+
if (!relPath || rule.sliceIdentity !== 'stars')
|
|
816
|
+
return place;
|
|
817
|
+
if (place.universeId) {
|
|
818
|
+
const alias = firstSliceAliasEntry(relPath, rule);
|
|
819
|
+
const fromFolders = sliceIdForPath(relPath, [...folders], rule.sliceIdentity);
|
|
820
|
+
if (!fromFolders && alias && legacyAliasUniverse(rule, alias.to) !== undefined) {
|
|
821
|
+
return { ...place, legacyAlias: true, legacyUniverse: place.universeId, legacyChild: place.childId };
|
|
822
|
+
}
|
|
823
|
+
const legacyUniverse = legacyStarsSliceIdForPath(relPath, folders);
|
|
824
|
+
const child = rule.childSlices;
|
|
825
|
+
const legacyChild = child?.sliceIdentity === 'stars' ? legacyStarsSliceIdForPath(relPath, child.sliceFolders) : undefined;
|
|
826
|
+
return {
|
|
827
|
+
...place,
|
|
828
|
+
...(legacyUniverse ? { legacyUniverse } : {}),
|
|
829
|
+
...(legacyChild && place.childId ? { legacyChild } : {}),
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
return place;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Both endpoints of one edge under a peerIsolation rule, with the one-release 4.8.23
|
|
836
|
+
* `stars` compatibility: a `sliceAliases` target written as a 4.8.23 stars id joins
|
|
837
|
+
* the universe of the other endpoint whose 4.8.23 id it names, and an
|
|
838
|
+
* `allowedCrossSlice` entry written against 4.8.23 ids still clears the edge it cleared
|
|
839
|
+
* then. ark-check warns CONFIG_SLICE_LEGACY_STARS_ID for both.
|
|
840
|
+
*/
|
|
841
|
+
export function resolveGovernedSlicePair(rule, folders, fromPath, toPath) {
|
|
842
|
+
const from = placeForPair(fromPath, rule, folders);
|
|
843
|
+
const to = placeForPair(toPath, rule, folders);
|
|
844
|
+
const legacyFrom = from.legacyUniverse;
|
|
845
|
+
const legacyTo = to.legacyUniverse;
|
|
846
|
+
const adopt = (alias, other) => {
|
|
847
|
+
if (!alias.legacyAlias || other.legacyAlias || !other.universeId)
|
|
848
|
+
return alias;
|
|
849
|
+
if (other.legacyUniverse?.toLowerCase() !== alias.universeId?.toLowerCase())
|
|
850
|
+
return alias;
|
|
851
|
+
const childSegment = alias.childId?.split('/').pop();
|
|
852
|
+
return {
|
|
853
|
+
...alias,
|
|
854
|
+
universeId: other.universeId,
|
|
855
|
+
...(childSegment ? { childId: `${other.universeId}/${childSegment}` } : {}),
|
|
856
|
+
};
|
|
857
|
+
};
|
|
858
|
+
const fromPlace = adopt(from, to);
|
|
859
|
+
const toPlace = adopt(to, from);
|
|
860
|
+
const crossSliceAllowed = crossSliceEdgeAllowed(rule.allowedCrossSlice, fromPlace.universeId, toPlace.universeId) ||
|
|
861
|
+
(rule.sliceIdentity === 'stars' &&
|
|
862
|
+
(legacyFrom !== fromPlace.universeId || legacyTo !== toPlace.universeId) &&
|
|
863
|
+
crossSliceEdgeAllowed(rule.allowedCrossSlice, legacyFrom, legacyTo));
|
|
864
|
+
const legacyChildAllowed = rule.childSlices?.sliceIdentity === 'stars' &&
|
|
865
|
+
from.legacyChild !== undefined &&
|
|
866
|
+
to.legacyChild !== undefined &&
|
|
867
|
+
from.legacyChild !== to.legacyChild &&
|
|
868
|
+
childCrossSliceAllowed(rule.childSlices.allowedCrossSlice, from.legacyChild, to.legacyChild);
|
|
869
|
+
const strip = (place) => ({
|
|
870
|
+
...(place.universeId ? { universeId: place.universeId } : {}),
|
|
871
|
+
...(place.childId ? { childId: place.childId } : {}),
|
|
872
|
+
});
|
|
873
|
+
return { from: strip(fromPlace), to: strip(toPlace), crossSliceAllowed, legacyChildAllowed };
|
|
874
|
+
}
|
|
653
875
|
function childPatternDirectory(pattern, childId, identity) {
|
|
654
876
|
const segments = pattern.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
|
|
655
877
|
const idParts = childId.split('/').filter(Boolean).map((part) => part.toLowerCase());
|
|
@@ -658,23 +880,17 @@ function childPatternDirectory(pattern, childId, identity) {
|
|
|
658
880
|
if (segments.some((part) => part === '**' || (part.includes('*') && part !== '*')))
|
|
659
881
|
return null;
|
|
660
882
|
if (identity === 'stars') {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
if (segments[index] !== '*')
|
|
664
|
-
lastLiteral = index;
|
|
665
|
-
}
|
|
666
|
-
if (lastLiteral < 0)
|
|
667
|
-
return null;
|
|
668
|
-
const tail = segments.slice(lastLiteral);
|
|
669
|
-
if (tail.length !== idParts.length)
|
|
883
|
+
const kept = starsKeptSegmentIndexes(segments);
|
|
884
|
+
if (!kept || kept.length !== idParts.length)
|
|
670
885
|
return null;
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
if (
|
|
886
|
+
const dir = [...segments];
|
|
887
|
+
for (let index = 0; index < kept.length; index += 1) {
|
|
888
|
+
const at = kept[index] ?? 0;
|
|
889
|
+
if (segments[at] !== '*' && segments[at] !== idParts[index])
|
|
675
890
|
return null;
|
|
891
|
+
dir[at] = idParts[index] ?? '';
|
|
676
892
|
}
|
|
677
|
-
return
|
|
893
|
+
return dir.join('/');
|
|
678
894
|
}
|
|
679
895
|
if (segments.length !== idParts.length)
|
|
680
896
|
return null;
|
|
@@ -750,10 +966,46 @@ export function sliceAliasReport(rules, files) {
|
|
|
750
966
|
}
|
|
751
967
|
}
|
|
752
968
|
}
|
|
753
|
-
const
|
|
969
|
+
const universes = knownUniverseIds(rules, files);
|
|
970
|
+
const listed = [...moves.values()].map((move) => {
|
|
971
|
+
const target = splitAliasTarget(move.to);
|
|
972
|
+
const unknown = universes !== null && target !== null && !universes.has(target.universeId);
|
|
973
|
+
return {
|
|
974
|
+
...move,
|
|
975
|
+
files: [...move.files].sort(),
|
|
976
|
+
...(unknown
|
|
977
|
+
? {
|
|
978
|
+
unknownUniverse: true,
|
|
979
|
+
advisory: `alias target ${move.to} names universe ${target.universeId}, which no file belongs to. Check for a typo.`,
|
|
980
|
+
}
|
|
981
|
+
: {}),
|
|
982
|
+
};
|
|
983
|
+
});
|
|
754
984
|
listed.sort((left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to));
|
|
755
985
|
return { notAScore: true, finished: false, debt: SLICE_ALIAS_DEBT, moves: listed };
|
|
756
986
|
}
|
|
987
|
+
/**
|
|
988
|
+
* Universe ids that real (non-aliased) files resolve to, across rules that set
|
|
989
|
+
* sliceAliases. Null when a rule has no explicit sliceFolders (the universe
|
|
990
|
+
* would come from layer patterns this report does not see): no claim then.
|
|
991
|
+
*/
|
|
992
|
+
function knownUniverseIds(rules, files) {
|
|
993
|
+
const ids = new Set();
|
|
994
|
+
for (const rule of rules ?? []) {
|
|
995
|
+
if (!rule?.childSlices?.sliceAliases?.length)
|
|
996
|
+
continue;
|
|
997
|
+
if (!rule.sliceFolders?.length)
|
|
998
|
+
return null;
|
|
999
|
+
for (const file of files) {
|
|
1000
|
+
if (typeof file !== 'string')
|
|
1001
|
+
continue;
|
|
1002
|
+
const id = sliceIdForPath(file, rule.sliceFolders, rule.sliceIdentity);
|
|
1003
|
+
if (id)
|
|
1004
|
+
ids.add(id.toLowerCase());
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return ids;
|
|
1008
|
+
}
|
|
757
1009
|
/**
|
|
758
1010
|
* One human sentence naming which peerIsolation reason fired — so the denial
|
|
759
1011
|
* reports a fact about their code (`cross-slice`) or a fact about our evidence
|
|
@@ -796,32 +1048,23 @@ export function universePairLabel(sliceId) {
|
|
|
796
1048
|
const parts = sliceId.split('/').filter(Boolean);
|
|
797
1049
|
return parts.length > 0 ? parts[parts.length - 1] : undefined;
|
|
798
1050
|
}
|
|
799
|
-
function
|
|
800
|
-
|
|
801
|
-
return parts.slice(0, -1);
|
|
802
|
-
}
|
|
803
|
-
function pathHasCommonFolder(relPath, commonFolders) {
|
|
804
|
-
if (!commonFolders?.length)
|
|
805
|
-
return false;
|
|
806
|
-
const names = new Set();
|
|
807
|
-
for (const raw of commonFolders) {
|
|
808
|
-
if (typeof raw === 'string' && raw.length > 0)
|
|
809
|
-
names.add(raw.toLowerCase());
|
|
810
|
-
}
|
|
811
|
-
if (names.size === 0)
|
|
1051
|
+
function isCommonFolderName(segment, commonFolders) {
|
|
1052
|
+
if (!segment || !commonFolders?.length)
|
|
812
1053
|
return false;
|
|
813
|
-
|
|
1054
|
+
const want = segment.toLowerCase();
|
|
1055
|
+
return commonFolders.some((raw) => typeof raw === 'string' && raw.length > 0 && raw.toLowerCase() === want);
|
|
814
1056
|
}
|
|
815
1057
|
/**
|
|
816
|
-
* Child id for one path. A
|
|
817
|
-
*
|
|
818
|
-
*
|
|
1058
|
+
* Child id for one path. A flat file (the starred id does not grow past the
|
|
1059
|
+
* universe id) is universe common. So is a `commonFolders` directory, but only
|
|
1060
|
+
* at the child position: the segment directly under the universe id
|
|
1061
|
+
* (`features/projects/domain/**`). A folder of the same name inside a child
|
|
1062
|
+
* (`features/projects/rfi/domain/**`) belongs to that child. A child id that
|
|
1063
|
+
* does not extend the universe id is not used; the caller warns.
|
|
819
1064
|
*/
|
|
820
1065
|
export function resolveChildSliceId(relPath, universeId, child) {
|
|
821
1066
|
if (!relPath || !child?.sliceFolders?.length)
|
|
822
1067
|
return {};
|
|
823
|
-
if (pathHasCommonFolder(relPath, child.commonFolders))
|
|
824
|
-
return {};
|
|
825
1068
|
const raw = sliceIdForPath(relPath, child.sliceFolders, child.sliceIdentity);
|
|
826
1069
|
if (!raw || !universeId)
|
|
827
1070
|
return {};
|
|
@@ -829,9 +1072,12 @@ export function resolveChildSliceId(relPath, universeId, child) {
|
|
|
829
1072
|
const universeKey = universeId.toLowerCase();
|
|
830
1073
|
if (childKey === universeKey)
|
|
831
1074
|
return {};
|
|
832
|
-
if (childKey.startsWith(`${universeKey}/`))
|
|
833
|
-
return { childId: raw };
|
|
834
|
-
|
|
1075
|
+
if (!childKey.startsWith(`${universeKey}/`))
|
|
1076
|
+
return { mismatched: { childId: raw, universeId } };
|
|
1077
|
+
const firstChildSegment = childKey.slice(universeKey.length + 1).split('/')[0];
|
|
1078
|
+
if (isCommonFolderName(firstChildSegment, child.commonFolders))
|
|
1079
|
+
return {};
|
|
1080
|
+
return { childId: raw };
|
|
835
1081
|
}
|
|
836
1082
|
function siblingEntryKey(entry) {
|
|
837
1083
|
return trimTrailingSlashes(entry.trim().replace(/\\/g, '/')).toLowerCase();
|
|
@@ -970,7 +1216,7 @@ export function evaluateNestedSliceWall(input) {
|
|
|
970
1216
|
fromShared: input.fromShared,
|
|
971
1217
|
toShared: input.toShared,
|
|
972
1218
|
crossSliceAllowed: input.crossSliceAllowed,
|
|
973
|
-
sharedImportsSlice: input.rule.sharedImportsSlice,
|
|
1219
|
+
sharedImportsSlice: sharedImportsSliceMode(input.rule.sharedImportsSlice),
|
|
974
1220
|
});
|
|
975
1221
|
if (universe.denied) {
|
|
976
1222
|
const crossParent = Boolean(input.rule.childSlices) && universe.reason === 'cross-slice';
|
|
@@ -1009,7 +1255,7 @@ export function evaluateNestedSliceWall(input) {
|
|
|
1009
1255
|
toUniverse: input.toSlice,
|
|
1010
1256
|
};
|
|
1011
1257
|
if (fromChild && toChild && fromChild !== toChild) {
|
|
1012
|
-
if (childCrossSliceAllowed(child.allowedCrossSlice, fromChild, toChild)) {
|
|
1258
|
+
if (input.legacyChildAllowed || childCrossSliceAllowed(child.allowedCrossSlice, fromChild, toChild)) {
|
|
1013
1259
|
return allowSameUniverse;
|
|
1014
1260
|
}
|
|
1015
1261
|
const advisory = siblingCrossingAdvisory(child.siblings, input.fromPath, fromChild);
|
|
@@ -1033,27 +1279,45 @@ export function evaluateNestedSliceWall(input) {
|
|
|
1033
1279
|
}
|
|
1034
1280
|
return allowSameUniverse;
|
|
1035
1281
|
}
|
|
1282
|
+
/** Did the child (inner) wall produce this finding, not the universe wall? */
|
|
1283
|
+
export function isChildWallCrossing(verdict) {
|
|
1284
|
+
return verdict?.crossing === 'cross-sibling' || verdict?.crossing === 'parent-imports-child';
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* The consumer text for one slice finding. `rule.message` is the universe-wall
|
|
1288
|
+
* text. `childSlices.message` is the inner-wall text. An inner-wall finding
|
|
1289
|
+
* never falls back to the rule message; undefined means "use ArkGate's default".
|
|
1290
|
+
*/
|
|
1291
|
+
export function sliceConsumerMessage(rule, verdict) {
|
|
1292
|
+
return isChildWallCrossing(verdict) ? rule?.childSlices?.message : rule?.message;
|
|
1293
|
+
}
|
|
1036
1294
|
/**
|
|
1037
1295
|
* Finding message. Import sites always append the explanation. Intent sites
|
|
1038
1296
|
* keep today's wording when there is no reasonId: a plain cross-slice deny
|
|
1039
1297
|
* does not gain a clause, and any other universe-wall reason does.
|
|
1298
|
+
* `ruleMessage` is only used for universe-wall findings; an inner-wall finding
|
|
1299
|
+
* uses `childMessage` or ArkGate's default text.
|
|
1040
1300
|
*/
|
|
1041
1301
|
export function composeSliceDenialMessage(input) {
|
|
1042
1302
|
const explanation = input.verdict.explanation;
|
|
1303
|
+
const inner = isChildWallCrossing(input.verdict);
|
|
1304
|
+
const consumer = inner ? input.childMessage : input.ruleMessage;
|
|
1043
1305
|
if (input.surface === 'intent') {
|
|
1044
1306
|
const defaultMessage = input.defaultMessage ?? `${input.fromLayer} must not reference ${input.toLayer} intent.`;
|
|
1307
|
+
if (inner && explanation)
|
|
1308
|
+
return `${input.childMessage ?? defaultMessage} ${explanation}`;
|
|
1045
1309
|
if (input.verdict.reasonId && explanation)
|
|
1046
1310
|
return `${defaultMessage} ${explanation}`;
|
|
1047
1311
|
if (explanation && input.verdict.peerIsolationReason !== 'cross-slice') {
|
|
1048
1312
|
return `${defaultMessage} ${explanation}`;
|
|
1049
1313
|
}
|
|
1050
|
-
if (
|
|
1051
|
-
return explanation ? `${
|
|
1314
|
+
if (consumer) {
|
|
1315
|
+
return explanation ? `${consumer} (${explanation})` : consumer;
|
|
1052
1316
|
}
|
|
1053
1317
|
return defaultMessage;
|
|
1054
1318
|
}
|
|
1055
|
-
if (
|
|
1056
|
-
return explanation ? `${
|
|
1319
|
+
if (consumer) {
|
|
1320
|
+
return explanation ? `${consumer} (${explanation})` : consumer;
|
|
1057
1321
|
}
|
|
1058
1322
|
if (explanation) {
|
|
1059
1323
|
const kind = input.kind ?? 'import';
|
|
@@ -1117,27 +1381,65 @@ export function sliceCountReport(violations) {
|
|
|
1117
1381
|
};
|
|
1118
1382
|
}
|
|
1119
1383
|
/**
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1384
|
+
* Doctor advisory: a shared file that sits on many CROSS_PARENT_VIA_SHARED
|
|
1385
|
+
* paths is often a composition root. Null below `minFindings` findings or when
|
|
1386
|
+
* no file reaches `minShare` of them. Not a finding and not in the baseline.
|
|
1387
|
+
*/
|
|
1388
|
+
export function crossParentViaSharedHubs(violations, options) {
|
|
1389
|
+
const minFindings = options?.minFindings ?? 10;
|
|
1390
|
+
const minShare = options?.minShare ?? 0.5;
|
|
1391
|
+
let total = 0;
|
|
1392
|
+
const counts = new Map();
|
|
1393
|
+
for (const row of violations ?? []) {
|
|
1394
|
+
if (row?.reasonId !== 'CROSS_PARENT_VIA_SHARED')
|
|
1395
|
+
continue;
|
|
1396
|
+
total += 1;
|
|
1397
|
+
if (!Array.isArray(row.via))
|
|
1398
|
+
continue;
|
|
1399
|
+
const once = new Set(row.via.filter((file) => typeof file === 'string'));
|
|
1400
|
+
for (const file of once)
|
|
1401
|
+
counts.set(file, (counts.get(file) ?? 0) + 1);
|
|
1402
|
+
}
|
|
1403
|
+
if (total < minFindings)
|
|
1404
|
+
return null;
|
|
1405
|
+
const hubs = [...counts.entries()]
|
|
1406
|
+
.map(([file, count]) => ({ file, count, share: Math.round((count / total) * 100) / 100 }))
|
|
1407
|
+
.filter((hub) => hub.count / total >= minShare)
|
|
1408
|
+
.sort((left, right) => right.count - left.count || left.file.localeCompare(right.file));
|
|
1409
|
+
if (hubs.length === 0)
|
|
1410
|
+
return null;
|
|
1411
|
+
const first = hubs[0]?.file ?? '';
|
|
1412
|
+
return {
|
|
1413
|
+
notAScore: true,
|
|
1414
|
+
total,
|
|
1415
|
+
hubs,
|
|
1416
|
+
nextAction: `If ${first} is a composition root, add it to sharedImportsSlice.stopAt. Otherwise these findings are real.`,
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* One warning per directed rule when a path's child id does not extend its
|
|
1421
|
+
* universe id, and one per child allowance that spans universes. The version
|
|
1422
|
+
* floor lives in configVersionFloor.ts and needs pin evidence (#338).
|
|
1122
1423
|
*/
|
|
1123
1424
|
export function childSliceConfigFindings(rules, files) {
|
|
1124
1425
|
const out = [];
|
|
1125
|
-
let versionWarned = false;
|
|
1126
1426
|
const seenMismatch = new Set();
|
|
1127
1427
|
const seenSpan = new Set();
|
|
1128
1428
|
for (const rule of rules ?? []) {
|
|
1429
|
+
if (rule?.peerIsolation && rule.allowed === false)
|
|
1430
|
+
out.push(...legacyStarsIdFindings(rule, files));
|
|
1129
1431
|
if (!rule?.childSlices)
|
|
1130
1432
|
continue;
|
|
1131
|
-
if (!
|
|
1132
|
-
versionWarned = true;
|
|
1433
|
+
if (!rule.peerIsolation || rule.allowed !== false) {
|
|
1133
1434
|
out.push({
|
|
1134
|
-
ruleId: '
|
|
1435
|
+
ruleId: 'CONFIG_CHILD_SLICES_INERT',
|
|
1135
1436
|
failsStrict: false,
|
|
1136
|
-
|
|
1437
|
+
fromLayer: rule.from,
|
|
1438
|
+
toLayer: rule.to,
|
|
1439
|
+
message: `${rule.from} → ${rule.to}: childSlices is inert on this rule and enforces nothing. The child wall runs only inside a universe wall. Add "peerIsolation": true and "allowed": false to the same rule, or remove childSlices.`,
|
|
1137
1440
|
});
|
|
1138
|
-
}
|
|
1139
|
-
if (!rule.peerIsolation)
|
|
1140
1441
|
continue;
|
|
1442
|
+
}
|
|
1141
1443
|
for (const file of files) {
|
|
1142
1444
|
const universeId = sliceIdForPath(file, rule.sliceFolders, rule.sliceIdentity);
|
|
1143
1445
|
const resolved = resolveChildSliceId(file, universeId, rule.childSlices);
|
|
@@ -1176,35 +1478,215 @@ export function childSliceConfigFindings(rules, files) {
|
|
|
1176
1478
|
}
|
|
1177
1479
|
return out;
|
|
1178
1480
|
}
|
|
1481
|
+
/** Observed new ids per 4.8.23 stars id, from real files (for the warning text). */
|
|
1482
|
+
function observedIdsByLegacy(folders, files) {
|
|
1483
|
+
const byLegacy = new Map();
|
|
1484
|
+
if (!folders?.length)
|
|
1485
|
+
return byLegacy;
|
|
1486
|
+
for (const file of files) {
|
|
1487
|
+
if (typeof file !== 'string')
|
|
1488
|
+
continue;
|
|
1489
|
+
const id = sliceIdForPath(file, [...folders], 'stars');
|
|
1490
|
+
const legacy = legacyStarsSliceIdForPath(file, folders);
|
|
1491
|
+
if (!id || !legacy || id === legacy)
|
|
1492
|
+
continue;
|
|
1493
|
+
const set = byLegacy.get(legacy) ?? new Set();
|
|
1494
|
+
set.add(id);
|
|
1495
|
+
byLegacy.set(legacy, set);
|
|
1496
|
+
}
|
|
1497
|
+
return byLegacy;
|
|
1498
|
+
}
|
|
1499
|
+
function newIdHint(legacyId, shapes, observed, childSegment) {
|
|
1500
|
+
const suffix = childSegment ? `/${childSegment}` : '';
|
|
1501
|
+
const shape = `${shapes[0] ?? legacyId}${suffix}`;
|
|
1502
|
+
const seen = [...(observed.get(legacyId.toLowerCase()) ?? [])].sort().map((id) => `${id}${suffix}`);
|
|
1503
|
+
if (seen.length === 1)
|
|
1504
|
+
return `${seen[0]}`;
|
|
1505
|
+
if (seen.length > 1)
|
|
1506
|
+
return `${shape} (one entry per module: ${seen.join(', ')})`;
|
|
1507
|
+
return shape;
|
|
1508
|
+
}
|
|
1179
1509
|
/**
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1510
|
+
* CONFIG_SLICE_LEGACY_STARS_ID: under sliceIdentity "stars", a sliceAliases target or an
|
|
1511
|
+
* allowedCrossSlice entry written against the 4.8.23 stars id (star bindings before the
|
|
1512
|
+
* last literal dropped). It keeps its 4.8.23 verdict for one release; the warning names
|
|
1513
|
+
* the new id.
|
|
1184
1514
|
*/
|
|
1185
|
-
|
|
1186
|
-
const
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1515
|
+
function legacyStarsIdFindings(rule, files) {
|
|
1516
|
+
const out = [];
|
|
1517
|
+
const push = (message) => out.push({
|
|
1518
|
+
ruleId: 'CONFIG_SLICE_LEGACY_STARS_ID',
|
|
1519
|
+
failsStrict: false,
|
|
1520
|
+
fromLayer: rule.from,
|
|
1521
|
+
toLayer: rule.to,
|
|
1522
|
+
message: `${rule.from} → ${rule.to}: ${message} It keeps its 4.8.23 meaning for this release only.`,
|
|
1523
|
+
});
|
|
1524
|
+
const child = rule.childSlices;
|
|
1525
|
+
if (rule.sliceIdentity === 'stars') {
|
|
1526
|
+
const observed = observedIdsByLegacy(rule.sliceFolders, files);
|
|
1527
|
+
for (const alias of child?.sliceAliases ?? []) {
|
|
1528
|
+
if (!alias || typeof alias.to !== 'string')
|
|
1529
|
+
continue;
|
|
1530
|
+
const target = splitAliasTarget(alias.to);
|
|
1531
|
+
if (!target || legacyAliasUniverse(rule, alias.to) === undefined)
|
|
1532
|
+
continue;
|
|
1533
|
+
const shapes = legacyStarsIdTargets(rule.sliceFolders, target.universeId);
|
|
1534
|
+
const segment = target.childId.split('/').pop();
|
|
1535
|
+
push(`sliceAliases to "${alias.to}" is a 4.8.23 stars id (the star before the last literal was dropped). The id is now ${newIdHint(target.universeId, shapes, observed, segment)}; write that instead.`);
|
|
1536
|
+
}
|
|
1537
|
+
for (const edge of rule.allowedCrossSlice ?? []) {
|
|
1538
|
+
if (!edge || typeof edge.from !== 'string' || typeof edge.to !== 'string')
|
|
1539
|
+
continue;
|
|
1540
|
+
const legacySides = [edge.from, edge.to].filter((id) => id.includes('/') && legacyStarsIdTargets(rule.sliceFolders, id).length > 0);
|
|
1541
|
+
if (legacySides.length === 0)
|
|
1542
|
+
continue;
|
|
1543
|
+
const rename = (id) => legacySides.includes(id)
|
|
1544
|
+
? newIdHint(id, legacyStarsIdTargets(rule.sliceFolders, id), observed)
|
|
1545
|
+
: id;
|
|
1546
|
+
push(`allowedCrossSlice ${edge.from} → ${edge.to} names a 4.8.23 stars id. The ids are now ${rename(edge.from)} → ${rename(edge.to)}; write those instead.`);
|
|
1547
|
+
}
|
|
1190
1548
|
}
|
|
1191
|
-
if (
|
|
1192
|
-
|
|
1549
|
+
if (child?.sliceIdentity === 'stars') {
|
|
1550
|
+
const observed = observedIdsByLegacy(child.sliceFolders, files);
|
|
1551
|
+
for (const edge of child.allowedCrossSlice ?? []) {
|
|
1552
|
+
if (!edge || typeof edge.from !== 'string' || typeof edge.to !== 'string')
|
|
1553
|
+
continue;
|
|
1554
|
+
const legacySides = [edge.from, edge.to].filter((id) => legacyStarsIdTargets(child.sliceFolders, id).length > 0);
|
|
1555
|
+
if (legacySides.length === 0)
|
|
1556
|
+
continue;
|
|
1557
|
+
const rename = (id) => legacySides.includes(id)
|
|
1558
|
+
? newIdHint(id, legacyStarsIdTargets(child.sliceFolders, id), observed)
|
|
1559
|
+
: id;
|
|
1560
|
+
push(`childSlices.allowedCrossSlice ${edge.from} → ${edge.to} names a 4.8.23 stars id. The ids are now ${rename(edge.from)} → ${rename(edge.to)}; write those instead.`);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
return out;
|
|
1564
|
+
}
|
|
1565
|
+
/** `ratchet: true` → always, `ratchet: false` → never, anything else → auto. */
|
|
1566
|
+
export function siblingRatchetMode(siblings) {
|
|
1567
|
+
if (siblings !== null && typeof siblings === 'object') {
|
|
1568
|
+
const ratchet = siblings.ratchet;
|
|
1569
|
+
if (ratchet === true)
|
|
1570
|
+
return 'always';
|
|
1571
|
+
if (ratchet === false)
|
|
1572
|
+
return 'never';
|
|
1573
|
+
}
|
|
1574
|
+
return 'auto';
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* How many advisory sibling crossings of one rule the baseline records. A key
|
|
1578
|
+
* still present counts when its current row is in the group. A stale key (the
|
|
1579
|
+
* crossing was removed) counts when the rule still classifies that edge as an
|
|
1580
|
+
* advisory sibling crossing, so replacing a recorded crossing neither disarms
|
|
1581
|
+
* the ratchet nor looks like growth.
|
|
1582
|
+
*/
|
|
1583
|
+
function recordedAdvisorySiblingCount(input) {
|
|
1584
|
+
const { rule } = input;
|
|
1193
1585
|
let recorded = 0;
|
|
1194
|
-
for (const
|
|
1195
|
-
if (
|
|
1586
|
+
for (const key of input.recordedKeys) {
|
|
1587
|
+
if (input.groupKeys.has(key)) {
|
|
1588
|
+
recorded += 1;
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
if (!rule || input.currentKeys.has(key))
|
|
1592
|
+
continue;
|
|
1593
|
+
const parts = key.split('|');
|
|
1594
|
+
if (parts.length !== 5)
|
|
1595
|
+
continue;
|
|
1596
|
+
const [ruleId = '', file = '', fromLayer = '', toLayer = '', rawTarget = ''] = parts;
|
|
1597
|
+
if (!input.ruleIds.has(ruleId) || fromLayer !== rule.from || toLayer !== rule.to)
|
|
1598
|
+
continue;
|
|
1599
|
+
const target = rawTarget.replace(/#\d+$/, '');
|
|
1600
|
+
if (!file || !target)
|
|
1601
|
+
continue;
|
|
1602
|
+
const verdict = findDeniedEdgeDecision([rule], fromLayer, toLayer, {
|
|
1603
|
+
fromPath: file,
|
|
1604
|
+
toPath: target,
|
|
1605
|
+
layers: input.layers,
|
|
1606
|
+
})?.sliceVerdict;
|
|
1607
|
+
if (verdict?.reasonId === 'CROSS_SIBLING_SLICE' && verdict.decision === 'advisory')
|
|
1196
1608
|
recorded += 1;
|
|
1197
1609
|
}
|
|
1198
|
-
|
|
1610
|
+
return recorded;
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Advisory sibling crossings past the recorded baseline become blocking, per
|
|
1614
|
+
* directed rule. Only advisory (`failsStrict: false`) crossings count; an
|
|
1615
|
+
* enforced crossing never switches the ratchet on. For each rule:
|
|
1616
|
+
*
|
|
1617
|
+
* - `ratchet` absent (every string form too): on only when the baseline
|
|
1618
|
+
* records at least one advisory sibling crossing of that rule (a key still
|
|
1619
|
+
* present, or a stale key that the rule still classifies as one), and only
|
|
1620
|
+
* when that rule's current advisory count grew past the recorded count.
|
|
1621
|
+
* Swapping a recorded crossing for a new one keeps the count and stays a
|
|
1622
|
+
* warning. An empty baseline, or one frozen before the child wall, promotes
|
|
1623
|
+
* nothing.
|
|
1624
|
+
* - `ratchet: true`: any unrecorded advisory crossing of the rule is promoted.
|
|
1625
|
+
* - `ratchet: false`: never promoted.
|
|
1626
|
+
*
|
|
1627
|
+
* The key is the existing baseline identity (ruleId, file, layers, target);
|
|
1628
|
+
* reasonId is not part of it. Recorded crossings stay advisory. Callers run
|
|
1629
|
+
* this only when a baseline (or an `--against` base) is in use.
|
|
1630
|
+
*/
|
|
1631
|
+
export function applyAdvisorySiblingRatchet(violations, occurrenceKeys, recordedKeys, options) {
|
|
1632
|
+
const groups = new Map();
|
|
1633
|
+
for (let index = 0; index < violations.length; index += 1) {
|
|
1634
|
+
const row = violations[index];
|
|
1635
|
+
if (row?.reasonId !== 'CROSS_SIBLING_SLICE' || row.failsStrict !== false)
|
|
1636
|
+
continue;
|
|
1637
|
+
const key = `${row.fromLayer ?? ''}\0${row.toLayer ?? ''}`;
|
|
1638
|
+
const list = groups.get(key);
|
|
1639
|
+
if (list)
|
|
1640
|
+
list.push(index);
|
|
1641
|
+
else
|
|
1642
|
+
groups.set(key, [index]);
|
|
1643
|
+
}
|
|
1644
|
+
if (groups.size === 0)
|
|
1645
|
+
return [...violations];
|
|
1646
|
+
const currentKeys = new Set(occurrenceKeys);
|
|
1647
|
+
const promote = new Set();
|
|
1648
|
+
for (const indexes of groups.values()) {
|
|
1649
|
+
const first = violations[indexes[0] ?? 0];
|
|
1650
|
+
const rule = (options?.rules ?? []).find((candidate) => candidate &&
|
|
1651
|
+
candidate.from === first?.fromLayer &&
|
|
1652
|
+
candidate.to === first?.toLayer &&
|
|
1653
|
+
candidate.allowed === false &&
|
|
1654
|
+
candidate.peerIsolation === true &&
|
|
1655
|
+
candidate.childSlices != null);
|
|
1656
|
+
const mode = siblingRatchetMode(rule?.childSlices?.siblings);
|
|
1657
|
+
if (mode === 'never')
|
|
1658
|
+
continue;
|
|
1659
|
+
if (mode === 'auto') {
|
|
1660
|
+
const recorded = recordedAdvisorySiblingCount({
|
|
1661
|
+
rule: rule,
|
|
1662
|
+
ruleIds: new Set(indexes.map((index) => String(violations[index]?.ruleId ?? ''))),
|
|
1663
|
+
groupKeys: new Set(indexes.map((index) => occurrenceKeys[index] ?? '')),
|
|
1664
|
+
currentKeys,
|
|
1665
|
+
recordedKeys,
|
|
1666
|
+
layers: options?.layers,
|
|
1667
|
+
});
|
|
1668
|
+
if (recorded === 0 || indexes.length <= recorded)
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
for (const index of indexes) {
|
|
1672
|
+
if (!recordedKeys.has(occurrenceKeys[index] ?? ''))
|
|
1673
|
+
promote.add(index);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
if (promote.size === 0)
|
|
1199
1677
|
return [...violations];
|
|
1200
1678
|
return violations.map((violation, index) => {
|
|
1201
|
-
if (
|
|
1202
|
-
return violation;
|
|
1203
|
-
if (recordedKeys.has(occurrenceKeys[index] ?? ''))
|
|
1204
|
-
return violation;
|
|
1205
|
-
if (violation.failsStrict !== false)
|
|
1679
|
+
if (!promote.has(index))
|
|
1206
1680
|
return violation;
|
|
1207
|
-
|
|
1681
|
+
const from = violation.fromLayer ?? '?';
|
|
1682
|
+
const to = violation.toLayer ?? '?';
|
|
1683
|
+
const why = `New advisory sibling crossing past the recorded baseline for ${from} → ${to}. Set childSlices.siblings.ratchet: false to measure only.`;
|
|
1684
|
+
return {
|
|
1685
|
+
...violation,
|
|
1686
|
+
failsStrict: true,
|
|
1687
|
+
severity: 'error',
|
|
1688
|
+
...(typeof violation.message === 'string' ? { message: `${violation.message} ${why}` } : {}),
|
|
1689
|
+
};
|
|
1208
1690
|
});
|
|
1209
1691
|
}
|
|
1210
1692
|
/**
|
|
@@ -1239,8 +1721,9 @@ export function findDeniedEdgeDecision(rules, from, to, options) {
|
|
|
1239
1721
|
const fromPath = options?.fromPath;
|
|
1240
1722
|
const toPath = options?.toPath;
|
|
1241
1723
|
const folders = resolveSliceFolders(rule, from, options?.layers);
|
|
1242
|
-
const
|
|
1243
|
-
const
|
|
1724
|
+
const pair = resolveGovernedSlicePair(rule, folders, fromPath, toPath);
|
|
1725
|
+
const fromPlace = pair.from;
|
|
1726
|
+
const toPlace = pair.to;
|
|
1244
1727
|
const fromSlice = fromPlace.universeId;
|
|
1245
1728
|
const toSlice = toPlace.universeId;
|
|
1246
1729
|
const verdict = evaluateNestedSliceWall({
|
|
@@ -1254,7 +1737,8 @@ export function findDeniedEdgeDecision(rules, from, to, options) {
|
|
|
1254
1737
|
toChild: toPlace.childId,
|
|
1255
1738
|
fromShared: !fromSlice && pathUnderSharedRoot(fromPath, rule.sharedRoots),
|
|
1256
1739
|
toShared: !toSlice && pathUnderSharedRoot(toPath, rule.sharedRoots),
|
|
1257
|
-
crossSliceAllowed:
|
|
1740
|
+
crossSliceAllowed: pair.crossSliceAllowed,
|
|
1741
|
+
legacyChildAllowed: pair.legacyChildAllowed,
|
|
1258
1742
|
});
|
|
1259
1743
|
if (verdict.decision !== 'allow') {
|
|
1260
1744
|
return {
|
|
@@ -1289,7 +1773,7 @@ export function findSharedImportsSliceBridge(rules, from, to, options) {
|
|
|
1289
1773
|
continue;
|
|
1290
1774
|
if (rule.allowed !== false || !rule.peerIsolation)
|
|
1291
1775
|
continue;
|
|
1292
|
-
if (rule.sharedImportsSlice === 'deny')
|
|
1776
|
+
if (sharedImportsSliceMode(rule.sharedImportsSlice) === 'deny')
|
|
1293
1777
|
continue;
|
|
1294
1778
|
const folders = resolveSliceFolders(rule, from, options?.layers);
|
|
1295
1779
|
const fromSlice = resolveGovernedSlice(fromPath, rule, folders).universeId;
|