arkgate 4.8.23 → 4.8.25
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 +153 -0
- package/README.md +7 -5
- package/bin/ark-check-runtime.mjs +41 -91
- package/bin/ark-dashboard.mjs +56 -53
- package/bin/ark-layer-match.mjs +707 -121
- package/bin/ark-mcp-runtime.mjs +448 -169
- package/bin/ark-mcp.mjs +68 -4
- package/bin/ark-shared.mjs +42 -22
- 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/adoption-stance.mjs +2 -2
- package/bin/lib/adr-path.mjs +3 -3
- package/bin/lib/agent-gates.mjs +0 -61
- package/bin/lib/agent-homes.mjs +12 -21
- package/bin/lib/agent-projection-command.mjs +9 -5
- package/bin/lib/ambient-state.mjs +9 -30
- package/bin/lib/analysis-engine.mjs +15 -11
- package/bin/lib/architecture-scan.mjs +61 -15
- package/bin/lib/ark-order-doctor.mjs +4 -29
- package/bin/lib/ark-order-facts.mjs +319 -17
- package/bin/lib/ark-order-sensors.mjs +28 -101
- package/bin/lib/ark-order-types.mjs +3 -2
- package/bin/lib/ark-run-doctor.mjs +17 -28
- package/bin/lib/ark-run-facts.mjs +461 -48
- package/bin/lib/ark-run-sensors.mjs +6 -4
- package/bin/lib/arkgate-pins.mjs +91 -0
- package/bin/lib/arkrule-file-hints.mjs +2 -47
- package/bin/lib/arkrules-contract.mjs +124 -133
- package/bin/lib/arkrules-sensors.mjs +55 -252
- package/bin/lib/check-args.mjs +85 -1
- package/bin/lib/ci-and-commands.mjs +24 -78
- package/bin/lib/ci-merge-boundary.mjs +1 -1
- package/bin/lib/class-source-scan.mjs +479 -0
- package/bin/lib/clone-detection.mjs +474 -0
- package/bin/lib/codex-home.mjs +5 -3
- package/bin/lib/config-contract-slices.mjs +739 -0
- package/bin/lib/config-contract.mjs +42 -607
- package/bin/lib/config-extras.mjs +16 -22
- package/bin/lib/config-warnings.mjs +32 -0
- package/bin/lib/contract-smells.mjs +33 -15
- package/bin/lib/core-ratchet.mjs +0 -2
- package/bin/lib/dashboard-cli.mjs +62 -0
- package/bin/lib/deep-module-coach.mjs +2 -4
- package/bin/lib/deploy-path.mjs +13 -38
- package/bin/lib/design-delta.mjs +1 -1
- package/bin/lib/design-smells.mjs +2 -2
- package/bin/lib/diagnostic-catalog.mjs +23 -9
- package/bin/lib/doctor-advisories.mjs +78 -8
- package/bin/lib/doctor-green-cite.mjs +1 -1
- package/bin/lib/doctor-human.mjs +1 -1
- package/bin/lib/doctor-next-actions.mjs +19 -4
- package/bin/lib/doctor-plan.mjs +9 -13
- package/bin/lib/duplication-io.mjs +572 -0
- package/bin/lib/effective-contract-load.mjs +282 -83
- package/bin/lib/enforcement-profiles.mjs +40 -4
- package/bin/lib/entry-points-io.mjs +424 -0
- package/bin/lib/extra-merge-teeth.mjs +36 -0
- package/bin/lib/field-install.mjs +38 -14
- package/bin/lib/first-run-help.mjs +10 -5
- package/bin/lib/flat-parent-importers.mjs +139 -0
- package/bin/lib/flat-parent-pilot.mjs +40 -100
- package/bin/lib/gate-files.mjs +58 -90
- package/bin/lib/git-change-scope.mjs +109 -0
- package/bin/lib/github-enforcement.mjs +166 -29
- package/bin/lib/golden-pattern.mjs +1 -1
- package/bin/lib/hook-templates.mjs +17 -15
- package/bin/lib/host-support-matrix.mjs +3 -1
- package/bin/lib/html-report-advisories.mjs +4 -0
- package/bin/lib/html-report-depth.mjs +2 -2
- package/bin/lib/html-report.mjs +2 -2
- package/bin/lib/import-graph-projection.mjs +112 -0
- package/bin/lib/import-resolve.mjs +13 -1
- package/bin/lib/improvement-compass-doctor.mjs +1 -24
- package/bin/lib/install-migrate.mjs +50 -73
- package/bin/lib/invariant-coverage-io.mjs +4 -3
- package/bin/lib/invariant-coverage.mjs +85 -11
- package/bin/lib/invariant-probe-cli.mjs +347 -0
- package/bin/lib/invariant-probe-io.mjs +165 -0
- package/bin/lib/invariant-probe-runner.mjs +268 -0
- package/bin/lib/invariant-probe-sites.mjs +174 -0
- package/bin/lib/invariant-probe-workspace.mjs +183 -0
- package/bin/lib/invariant-probe.mjs +461 -0
- package/bin/lib/invariant-tests-path.mjs +16 -22
- package/bin/lib/literal-path-drift.mjs +4 -2
- package/bin/lib/managed-upgrade.mjs +2 -8
- package/bin/lib/mcp-adoption.mjs +13 -6
- package/bin/lib/mcp-hook-payload.mjs +403 -63
- package/bin/lib/orphan-modules-io.mjs +458 -0
- package/bin/lib/orphan-modules.mjs +251 -0
- package/bin/lib/outside-importers.mjs +197 -0
- package/bin/lib/package-manager.mjs +60 -2
- package/bin/lib/parse-health.mjs +1 -1
- package/bin/lib/passthrough-commands.mjs +83 -0
- package/bin/lib/persistence-write-hint.mjs +106 -0
- package/bin/lib/pin-dependent-gates.mjs +117 -0
- package/bin/lib/policy-delta-io.mjs +219 -14
- package/bin/lib/post-green-path.mjs +1 -1
- package/bin/lib/prepare-change.mjs +115 -7
- package/bin/lib/prepare-write.mjs +2 -2
- package/bin/lib/presets.mjs +59 -73
- package/bin/lib/product-copy.mjs +1 -6
- package/bin/lib/project-root.mjs +31 -62
- package/bin/lib/remediation.mjs +23 -4
- package/bin/lib/resolved-candidate-facts.mjs +405 -117
- package/bin/lib/rules-inventory.mjs +22 -3
- package/bin/lib/rules-under-contract.mjs +247 -9
- package/bin/lib/scan-files.mjs +36 -4
- package/bin/lib/schema-validation.mjs +130 -0
- package/bin/lib/sensor-promote-cli.mjs +2 -11
- package/bin/lib/sensor-promote-io.mjs +33 -22
- package/bin/lib/sensor-promotion.mjs +1 -1
- package/bin/lib/skill-catalog-apply.mjs +1 -1
- package/bin/lib/skill-install.mjs +42 -38
- package/bin/lib/snippet-analysis.mjs +140 -6
- package/bin/lib/source-policy.mjs +34 -0
- package/bin/lib/start-install-recovery.mjs +72 -2
- package/bin/lib/start-preview.mjs +31 -1
- package/bin/lib/states-transitions-presence.mjs +3 -3
- package/bin/lib/status-command.mjs +35 -22
- package/bin/lib/status-manifest.mjs +50 -30
- package/bin/lib/status-transition-catalog.mjs +0 -6
- package/bin/lib/suggestions.mjs +3 -3
- package/bin/lib/team-parliament-io.mjs +76 -88
- package/bin/lib/team-parliament.mjs +21 -10
- package/bin/lib/upgrade-behavior-changes.mjs +2 -2
- package/bin/lib/upgrade-command.mjs +2 -24
- package/bin/lib/upgrade-outcome.mjs +1 -1
- package/bin/lib/upstream-report.mjs +21 -10
- package/bin/lib/violations.mjs +10 -9
- package/bin/lib/weakest-link.mjs +13 -0
- package/bin/lib/write-path-capabilities.mjs +6 -4
- package/dist/chunk-A5EIY3HI.cjs +5 -0
- package/dist/chunk-AHACEKSV.js +16 -0
- package/dist/chunk-SVSQVGFJ.js +5 -0
- package/dist/chunk-UN5EH4QV.cjs +16 -0
- package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-GPvvdleM.d.cts} +30 -4
- package/dist/configTypes-GPvvdleM.d.ts +257 -0
- package/dist/eslint/index.cjs +18 -5
- package/dist/eslint/index.d.cts +368 -0
- package/dist/eslint/index.d.ts +139 -156
- package/dist/eslint/index.js +11 -5
- package/dist/index.cjs +36 -35
- package/dist/index.d.cts +5547 -0
- package/dist/index.d.ts +3283 -198
- package/dist/index.js +36 -35
- 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} +828 -276
- package/dist/runtime/index.d.ts +2741 -133
- package/dist/runtime/index.js +13 -30
- package/dist/{types-Bdj32QwA.d.ts → types-CnauMQQL.d.cts} +607 -163
- package/dist/types-CnauMQQL.d.ts +1800 -0
- package/docs/README.md +3 -1
- package/docs/agent-guide.md +217 -16
- package/docs/ai-gates.md +174 -26
- package/docs/arkorder.md +32 -15
- package/docs/brownfield-adoption.md +5 -1
- package/docs/configuration.md +230 -31
- package/docs/develop.md +2 -1
- package/docs/diagnostics.md +212 -19
- package/docs/package-surface.md +35 -9
- package/docs/product-voice.md +7 -1
- package/package.json +69 -19
- package/schemas/ark.analysis-result.schema.json +15 -0
- package/schemas/ark.config.schema.json +77 -12
- package/schemas/ark.invariant-probe.schema.json +368 -0
- 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 +5 -1
- package/templates/agent-skills/ark-contract/SKILL.md +17 -0
- package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
- package/templates/agent-skills/ark-explore/SKILL.md +7 -0
- package/templates/agent-skills/ark-fix/SKILL.md +20 -0
- package/templates/agent-skills/ark-order/SKILL.md +4 -2
- package/templates/agent-skills/ark-place/SKILL.md +8 -0
- 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 +5 -1
- package/templates/skills/ark-contract.md +17 -0
- package/templates/skills/ark-coverage.md +2 -0
- package/templates/skills/ark-explore.md +7 -0
- package/templates/skills/ark-fix.md +20 -0
- package/templates/skills/ark-order.md +4 -2
- package/templates/skills/ark-place.md +8 -0
- package/templates/skills/ark-runtime.md +6 -1
- package/bin/lib/ark-order-error.mjs +0 -18
- package/bin/lib/ark-order-invariants.mjs +0 -346
- 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;
|
|
@@ -117,7 +132,7 @@ export function globToRegExp(pattern) {
|
|
|
117
132
|
* Used for path-anchored ranking so a domain folder glob can beat a broad
|
|
118
133
|
* Application bag like src/lib when the file actually sits under domain/.
|
|
119
134
|
*/
|
|
120
|
-
|
|
135
|
+
function concreteGlobSegments(pattern) {
|
|
121
136
|
const glob = normalizeGlobSeparators(String(pattern));
|
|
122
137
|
return glob
|
|
123
138
|
.split('/')
|
|
@@ -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
|
+
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
|
|
@@ -604,10 +681,11 @@ export function peerIsolationMustDeny(input) {
|
|
|
604
681
|
return peerIsolationDecision(input).denied;
|
|
605
682
|
}
|
|
606
683
|
export const SLICE_ALIAS_DEBT = 'Aliases are an owed move, not a destination. These files are not finished.';
|
|
684
|
+
const PINNED_ALIAS_NOTE = 'Pinned aliases stay where they are. They are not an owed move.';
|
|
607
685
|
function aliasGlobPattern(glob) {
|
|
608
686
|
return trimTrailingSlashes(glob.trim().replace(/\\/g, '/')).toLowerCase();
|
|
609
687
|
}
|
|
610
|
-
/** Existing path glob matcher. Also accepts a leading src/ or app
|
|
688
|
+
/** Existing path glob matcher. Also accepts a leading src/ or app/, and a plain folder covers its subtree. */
|
|
611
689
|
function aliasGlobMatches(glob, relPath) {
|
|
612
690
|
const pattern = aliasGlobPattern(glob);
|
|
613
691
|
if (!pattern || pattern === '*' || pattern === '**')
|
|
@@ -615,7 +693,25 @@ function aliasGlobMatches(glob, relPath) {
|
|
|
615
693
|
const file = String(relPath).replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase();
|
|
616
694
|
const rooted = stripSrcOrApp(file);
|
|
617
695
|
const re = globToRegExp(pattern);
|
|
618
|
-
|
|
696
|
+
// A folder form (no wildcard in the last segment) also covers its subtree,
|
|
697
|
+
// as sharedRoots does: `lib/compliance` matches `lib/compliance/x.ts`.
|
|
698
|
+
const last = pattern.split('/').pop() ?? '';
|
|
699
|
+
const under = last.includes('*') ? null : globToRegExp(`${pattern}/**`);
|
|
700
|
+
const hit = (candidate) => re.test(candidate) || (under !== null && under.test(candidate));
|
|
701
|
+
return hit(file) || (rooted !== file && hit(rooted));
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Does `relPath` match a `sharedImportsSlice.stopAt` entry? An entry matches
|
|
705
|
+
* the way a shared root does (anchored, optional leading src/ or app/, a plain
|
|
706
|
+
* folder covers its subtree) or the way an alias glob does (`kernel/registrations/**`
|
|
707
|
+
* against `src/kernel/registrations/x.ts`). A blanket `*` / `**` never matches.
|
|
708
|
+
*/
|
|
709
|
+
export function pathMatchesSharedWalkStop(relPath, stopAt) {
|
|
710
|
+
if (!relPath || !stopAt?.length)
|
|
711
|
+
return false;
|
|
712
|
+
if (pathUnderSharedRoot(relPath, [...stopAt]))
|
|
713
|
+
return true;
|
|
714
|
+
return stopAt.some((glob) => typeof glob === 'string' && aliasGlobMatches(glob, relPath));
|
|
619
715
|
}
|
|
620
716
|
function splitAliasTarget(to) {
|
|
621
717
|
const parts = aliasGlobPattern(to).split('/').filter(Boolean);
|
|
@@ -624,16 +720,19 @@ function splitAliasTarget(to) {
|
|
|
624
720
|
}
|
|
625
721
|
return { universeId: parts.slice(0, -1).join('/'), childId: parts.join('/') };
|
|
626
722
|
}
|
|
627
|
-
function
|
|
723
|
+
function firstSliceAliasEntry(relPath, rule) {
|
|
628
724
|
for (const alias of rule.childSlices?.sliceAliases ?? []) {
|
|
629
725
|
if (!alias || typeof alias.from !== 'string' || typeof alias.to !== 'string')
|
|
630
726
|
continue;
|
|
631
|
-
if (
|
|
632
|
-
|
|
633
|
-
return splitAliasTarget(alias.to);
|
|
727
|
+
if (aliasGlobMatches(alias.from, relPath))
|
|
728
|
+
return alias;
|
|
634
729
|
}
|
|
635
730
|
return null;
|
|
636
731
|
}
|
|
732
|
+
function firstSliceAlias(relPath, rule) {
|
|
733
|
+
const alias = firstSliceAliasEntry(relPath, rule);
|
|
734
|
+
return alias ? splitAliasTarget(alias.to) : null;
|
|
735
|
+
}
|
|
637
736
|
/**
|
|
638
737
|
* Universe id and child id for one file. An alias runs only when both walls
|
|
639
738
|
* leave the path unclassified, so a real slice folder is never overridden.
|
|
@@ -650,6 +749,130 @@ export function resolveGovernedSlice(relPath, rule, sliceFolders) {
|
|
|
650
749
|
const alias = firstSliceAlias(relPath, rule);
|
|
651
750
|
return alias ? { universeId: alias.universeId, childId: alias.childId } : {};
|
|
652
751
|
}
|
|
752
|
+
/** Universe shapes of a stars rule, with the 4.8.23 id shape beside each new one. */
|
|
753
|
+
function starsUniverseShapes(folders) {
|
|
754
|
+
const shapes = [];
|
|
755
|
+
for (const raw of folders ?? []) {
|
|
756
|
+
if (typeof raw !== 'string' || raw.length === 0)
|
|
757
|
+
continue;
|
|
758
|
+
const segments = raw.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
|
|
759
|
+
if (segments.length === 1 && segments[0] && !segments[0].includes('*')) {
|
|
760
|
+
shapes.push({ literals: [segments[0], null], legacy: [segments[0], null] });
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (segments.some((part) => part === '**' || (part.includes('*') && part !== '*')))
|
|
764
|
+
continue;
|
|
765
|
+
const kept = starsKeptSegmentIndexes(segments);
|
|
766
|
+
if (!kept || !segments.includes('*'))
|
|
767
|
+
continue;
|
|
768
|
+
const star = (part) => (part === '*' ? null : part);
|
|
769
|
+
const lastLiteral = kept.find((at) => segments[at] !== '*') ?? 0;
|
|
770
|
+
shapes.push({
|
|
771
|
+
literals: kept.map((at) => star(segments[at] ?? '')),
|
|
772
|
+
legacy: segments.slice(lastLiteral).map(star),
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
return shapes;
|
|
776
|
+
}
|
|
777
|
+
function literalsMatch(shape, parts) {
|
|
778
|
+
return (shape.length === parts.length &&
|
|
779
|
+
shape.every((literal, at) => literal === null || parts[at] === '*' || literal === parts[at]));
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* New-id shapes (`*` for a star) that a 4.8.23 stars id maps to. Empty when the id is
|
|
783
|
+
* already a new id or no shape takes it. Config load rejects more than one.
|
|
784
|
+
*/
|
|
785
|
+
function legacyStarsIdTargets(folders, id) {
|
|
786
|
+
const parts = id.split('/').filter(Boolean).map((part) => part.toLowerCase());
|
|
787
|
+
const shapes = starsUniverseShapes(folders);
|
|
788
|
+
if (parts.length === 0 || shapes.some((shape) => literalsMatch(shape.literals, parts)))
|
|
789
|
+
return [];
|
|
790
|
+
const targets = new Set();
|
|
791
|
+
for (const shape of shapes) {
|
|
792
|
+
if (shape.legacy.length === shape.literals.length)
|
|
793
|
+
continue;
|
|
794
|
+
if (literalsMatch(shape.legacy, parts)) {
|
|
795
|
+
const tail = [...parts];
|
|
796
|
+
// Fill the kept literals and trailing stars from the legacy id; pre-literal stars stay `*`.
|
|
797
|
+
const lead = shape.literals.length - shape.legacy.length;
|
|
798
|
+
targets.add([...shape.literals.slice(0, lead).map(() => '*'), ...tail].join('/'));
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return [...targets].sort();
|
|
802
|
+
}
|
|
803
|
+
/** The legacy universe id when this alias target is a 4.8.23 stars child id. */
|
|
804
|
+
function legacyAliasUniverse(rule, to) {
|
|
805
|
+
if (rule.sliceIdentity !== 'stars')
|
|
806
|
+
return undefined;
|
|
807
|
+
const target = splitAliasTarget(to);
|
|
808
|
+
if (!target)
|
|
809
|
+
return undefined;
|
|
810
|
+
return legacyStarsIdTargets(rule.sliceFolders, target.universeId).length === 1
|
|
811
|
+
? target.universeId
|
|
812
|
+
: undefined;
|
|
813
|
+
}
|
|
814
|
+
function placeForPair(relPath, rule, folders) {
|
|
815
|
+
const place = resolveGovernedSlice(relPath, rule, folders);
|
|
816
|
+
if (!relPath || rule.sliceIdentity !== 'stars')
|
|
817
|
+
return place;
|
|
818
|
+
if (place.universeId) {
|
|
819
|
+
const alias = firstSliceAliasEntry(relPath, rule);
|
|
820
|
+
const fromFolders = sliceIdForPath(relPath, [...folders], rule.sliceIdentity);
|
|
821
|
+
if (!fromFolders && alias && legacyAliasUniverse(rule, alias.to) !== undefined) {
|
|
822
|
+
return { ...place, legacyAlias: true, legacyUniverse: place.universeId, legacyChild: place.childId };
|
|
823
|
+
}
|
|
824
|
+
const legacyUniverse = legacyStarsSliceIdForPath(relPath, folders);
|
|
825
|
+
const child = rule.childSlices;
|
|
826
|
+
const legacyChild = child?.sliceIdentity === 'stars' ? legacyStarsSliceIdForPath(relPath, child.sliceFolders) : undefined;
|
|
827
|
+
return {
|
|
828
|
+
...place,
|
|
829
|
+
...(legacyUniverse ? { legacyUniverse } : {}),
|
|
830
|
+
...(legacyChild && place.childId ? { legacyChild } : {}),
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
return place;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Both endpoints of one edge under a peerIsolation rule, with the one-release 4.8.23
|
|
837
|
+
* `stars` compatibility: a `sliceAliases` target written as a 4.8.23 stars id joins
|
|
838
|
+
* the universe of the other endpoint whose 4.8.23 id it names, and an
|
|
839
|
+
* `allowedCrossSlice` entry written against 4.8.23 ids still clears the edge it cleared
|
|
840
|
+
* then. ark-check warns CONFIG_SLICE_LEGACY_STARS_ID for both.
|
|
841
|
+
*/
|
|
842
|
+
export function resolveGovernedSlicePair(rule, folders, fromPath, toPath) {
|
|
843
|
+
const from = placeForPair(fromPath, rule, folders);
|
|
844
|
+
const to = placeForPair(toPath, rule, folders);
|
|
845
|
+
const legacyFrom = from.legacyUniverse;
|
|
846
|
+
const legacyTo = to.legacyUniverse;
|
|
847
|
+
const adopt = (alias, other) => {
|
|
848
|
+
if (!alias.legacyAlias || other.legacyAlias || !other.universeId)
|
|
849
|
+
return alias;
|
|
850
|
+
if (other.legacyUniverse?.toLowerCase() !== alias.universeId?.toLowerCase())
|
|
851
|
+
return alias;
|
|
852
|
+
const childSegment = alias.childId?.split('/').pop();
|
|
853
|
+
return {
|
|
854
|
+
...alias,
|
|
855
|
+
universeId: other.universeId,
|
|
856
|
+
...(childSegment ? { childId: `${other.universeId}/${childSegment}` } : {}),
|
|
857
|
+
};
|
|
858
|
+
};
|
|
859
|
+
const fromPlace = adopt(from, to);
|
|
860
|
+
const toPlace = adopt(to, from);
|
|
861
|
+
const crossSliceAllowed = crossSliceEdgeAllowed(rule.allowedCrossSlice, fromPlace.universeId, toPlace.universeId) ||
|
|
862
|
+
(rule.sliceIdentity === 'stars' &&
|
|
863
|
+
(legacyFrom !== fromPlace.universeId || legacyTo !== toPlace.universeId) &&
|
|
864
|
+
crossSliceEdgeAllowed(rule.allowedCrossSlice, legacyFrom, legacyTo));
|
|
865
|
+
const legacyChildAllowed = rule.childSlices?.sliceIdentity === 'stars' &&
|
|
866
|
+
from.legacyChild !== undefined &&
|
|
867
|
+
to.legacyChild !== undefined &&
|
|
868
|
+
from.legacyChild !== to.legacyChild &&
|
|
869
|
+
childCrossSliceAllowed(rule.childSlices.allowedCrossSlice, from.legacyChild, to.legacyChild);
|
|
870
|
+
const strip = (place) => ({
|
|
871
|
+
...(place.universeId ? { universeId: place.universeId } : {}),
|
|
872
|
+
...(place.childId ? { childId: place.childId } : {}),
|
|
873
|
+
});
|
|
874
|
+
return { from: strip(fromPlace), to: strip(toPlace), crossSliceAllowed, legacyChildAllowed };
|
|
875
|
+
}
|
|
653
876
|
function childPatternDirectory(pattern, childId, identity) {
|
|
654
877
|
const segments = pattern.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
|
|
655
878
|
const idParts = childId.split('/').filter(Boolean).map((part) => part.toLowerCase());
|
|
@@ -658,23 +881,17 @@ function childPatternDirectory(pattern, childId, identity) {
|
|
|
658
881
|
if (segments.some((part) => part === '**' || (part.includes('*') && part !== '*')))
|
|
659
882
|
return null;
|
|
660
883
|
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)
|
|
884
|
+
const kept = starsKeptSegmentIndexes(segments);
|
|
885
|
+
if (!kept || kept.length !== idParts.length)
|
|
670
886
|
return null;
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
if (
|
|
887
|
+
const dir = [...segments];
|
|
888
|
+
for (let index = 0; index < kept.length; index += 1) {
|
|
889
|
+
const at = kept[index] ?? 0;
|
|
890
|
+
if (segments[at] !== '*' && segments[at] !== idParts[index])
|
|
675
891
|
return null;
|
|
892
|
+
dir[at] = idParts[index] ?? '';
|
|
676
893
|
}
|
|
677
|
-
return
|
|
894
|
+
return dir.join('/');
|
|
678
895
|
}
|
|
679
896
|
if (segments.length !== idParts.length)
|
|
680
897
|
return null;
|
|
@@ -721,11 +938,93 @@ export function sliceAliasDestination(file, childId, child) {
|
|
|
721
938
|
export function anySliceAlias(rules) {
|
|
722
939
|
return (rules ?? []).some((rule) => (rule?.childSlices?.sliceAliases?.length ?? 0) > 0);
|
|
723
940
|
}
|
|
941
|
+
/** True when an alias is still an owed move. `pinned: true` does not count. A reason alone does. */
|
|
942
|
+
export function sliceAliasOwesMove(rules) {
|
|
943
|
+
return (rules ?? []).some((rule) => (rule?.childSlices?.sliceAliases ?? []).some((alias) => alias?.pinned !== true));
|
|
944
|
+
}
|
|
945
|
+
const SLICE_RULE_LAYER_TOKEN = '<Layer>';
|
|
946
|
+
function sliceRuleFileName(pattern, layer) {
|
|
947
|
+
const token = SLICE_RULE_LAYER_TOKEN;
|
|
948
|
+
if (!pattern.includes(token) || pattern.split(token).length !== 2)
|
|
949
|
+
return null;
|
|
950
|
+
if (/[*\\/]/.test(pattern))
|
|
951
|
+
return null;
|
|
952
|
+
const name = pattern.replace(token, layer);
|
|
953
|
+
if (!name || name.includes('..'))
|
|
954
|
+
return null;
|
|
955
|
+
return name;
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Child-slice ArkRules files this wall already named.
|
|
959
|
+
* Roots come from the governed file index. The pattern is a filename, not a search.
|
|
960
|
+
*/
|
|
961
|
+
export function resolveSliceRulePlan(input) {
|
|
962
|
+
const layers = [...new Set(input.layers.filter((name) => typeof name === 'string' && name.length > 0))].sort();
|
|
963
|
+
const entries = [];
|
|
964
|
+
const seen = new Set();
|
|
965
|
+
for (const rule of input.rules ?? []) {
|
|
966
|
+
const child = rule?.childSlices;
|
|
967
|
+
const pattern = child?.arkRulesFile;
|
|
968
|
+
if (!child || typeof pattern !== 'string')
|
|
969
|
+
continue;
|
|
970
|
+
const roots = new Map();
|
|
971
|
+
for (const file of input.files) {
|
|
972
|
+
if (typeof file !== 'string' || file.length === 0)
|
|
973
|
+
continue;
|
|
974
|
+
const universeId = rule.sliceFolders?.length
|
|
975
|
+
? sliceIdForPath(file, rule.sliceFolders, rule.sliceIdentity)
|
|
976
|
+
: undefined;
|
|
977
|
+
const resolved = resolveChildSliceId(file, universeId, child);
|
|
978
|
+
if (!resolved.childId || roots.has(resolved.childId))
|
|
979
|
+
continue;
|
|
980
|
+
const root = sliceAliasDestination(file, resolved.childId, child);
|
|
981
|
+
if (root)
|
|
982
|
+
roots.set(resolved.childId, root);
|
|
983
|
+
}
|
|
984
|
+
for (const [childId, sliceRoot] of [...roots.entries()].sort((left, right) => left[0].localeCompare(right[0]))) {
|
|
985
|
+
for (const layer of layers) {
|
|
986
|
+
const name = sliceRuleFileName(pattern, layer);
|
|
987
|
+
if (!name)
|
|
988
|
+
continue;
|
|
989
|
+
const rel = `${sliceRoot}/${name}`.replace(/\\/g, '/');
|
|
990
|
+
if (seen.has(`${layer}\0${rel}`))
|
|
991
|
+
continue;
|
|
992
|
+
seen.add(`${layer}\0${rel}`);
|
|
993
|
+
entries.push({
|
|
994
|
+
path: rel,
|
|
995
|
+
childId,
|
|
996
|
+
layer,
|
|
997
|
+
defaultAppliesTo: [`${sliceRoot}/**`],
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return entries;
|
|
1003
|
+
}
|
|
1004
|
+
/** Ids from the effective catalog that belong to the child slice of this file. */
|
|
1005
|
+
export function sliceRuleIdsForFile(filePath, rules, catalog) {
|
|
1006
|
+
const childIds = new Set();
|
|
1007
|
+
for (const rule of rules ?? []) {
|
|
1008
|
+
if (!rule?.childSlices)
|
|
1009
|
+
continue;
|
|
1010
|
+
const universeId = rule.sliceFolders?.length
|
|
1011
|
+
? sliceIdForPath(filePath, rule.sliceFolders, rule.sliceIdentity)
|
|
1012
|
+
: undefined;
|
|
1013
|
+
const resolved = resolveChildSliceId(filePath, universeId, rule.childSlices);
|
|
1014
|
+
if (resolved.childId)
|
|
1015
|
+
childIds.add(resolved.childId);
|
|
1016
|
+
}
|
|
1017
|
+
return catalog
|
|
1018
|
+
.filter((row) => typeof row.childId === 'string' && childIds.has(row.childId))
|
|
1019
|
+
.map((row) => row.id)
|
|
1020
|
+
.sort();
|
|
1021
|
+
}
|
|
724
1022
|
/** Doctor list. Absent when no rule sets sliceAliases, so the key stays off. */
|
|
725
1023
|
export function sliceAliasReport(rules, files) {
|
|
726
1024
|
if (!anySliceAlias(rules))
|
|
727
1025
|
return null;
|
|
728
1026
|
const moves = new Map();
|
|
1027
|
+
const pinned = new Map();
|
|
729
1028
|
for (const rule of rules ?? []) {
|
|
730
1029
|
const child = rule?.childSlices;
|
|
731
1030
|
if (!child?.sliceAliases)
|
|
@@ -734,10 +1033,19 @@ export function sliceAliasReport(rules, files) {
|
|
|
734
1033
|
if (!alias || typeof alias.from !== 'string' || typeof alias.to !== 'string')
|
|
735
1034
|
continue;
|
|
736
1035
|
const key = `${aliasGlobPattern(alias.from)}\0${aliasGlobPattern(alias.to)}`;
|
|
737
|
-
|
|
1036
|
+
const bucket = alias.pinned === true ? pinned : moves;
|
|
1037
|
+
let move = bucket.get(key);
|
|
738
1038
|
if (!move) {
|
|
739
|
-
move = {
|
|
740
|
-
|
|
1039
|
+
move = {
|
|
1040
|
+
from: alias.from,
|
|
1041
|
+
to: alias.to,
|
|
1042
|
+
destination: '',
|
|
1043
|
+
files: [],
|
|
1044
|
+
...(alias.pinned === true && typeof alias.reason === 'string' && alias.reason.length > 0
|
|
1045
|
+
? { reason: alias.reason }
|
|
1046
|
+
: {}),
|
|
1047
|
+
};
|
|
1048
|
+
bucket.set(key, move);
|
|
741
1049
|
}
|
|
742
1050
|
const seen = new Set(move.files);
|
|
743
1051
|
for (const file of files) {
|
|
@@ -750,9 +1058,55 @@ export function sliceAliasReport(rules, files) {
|
|
|
750
1058
|
}
|
|
751
1059
|
}
|
|
752
1060
|
}
|
|
753
|
-
const
|
|
754
|
-
|
|
755
|
-
|
|
1061
|
+
const universes = knownUniverseIds(rules, files);
|
|
1062
|
+
const decorate = (move) => {
|
|
1063
|
+
const target = splitAliasTarget(move.to);
|
|
1064
|
+
const unknown = universes !== null && target !== null && !universes.has(target.universeId);
|
|
1065
|
+
return {
|
|
1066
|
+
...move,
|
|
1067
|
+
files: [...move.files].sort(),
|
|
1068
|
+
...(unknown
|
|
1069
|
+
? {
|
|
1070
|
+
unknownUniverse: true,
|
|
1071
|
+
advisory: `alias target ${move.to} names universe ${target.universeId}, which no file belongs to. Check for a typo.`,
|
|
1072
|
+
}
|
|
1073
|
+
: {}),
|
|
1074
|
+
};
|
|
1075
|
+
};
|
|
1076
|
+
const listed = [...moves.values()].map((move) => decorate(move));
|
|
1077
|
+
const pinnedListed = [...pinned.values()].map((move) => decorate(move));
|
|
1078
|
+
const byAlias = (left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to);
|
|
1079
|
+
listed.sort(byAlias);
|
|
1080
|
+
pinnedListed.sort(byAlias);
|
|
1081
|
+
return {
|
|
1082
|
+
notAScore: true,
|
|
1083
|
+
finished: false,
|
|
1084
|
+
debt: listed.length > 0 ? SLICE_ALIAS_DEBT : PINNED_ALIAS_NOTE,
|
|
1085
|
+
moves: listed,
|
|
1086
|
+
...(pinnedListed.length > 0 ? { pinned: pinnedListed } : {}),
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Universe ids that real (non-aliased) files resolve to, across rules that set
|
|
1091
|
+
* sliceAliases. Null when a rule has no explicit sliceFolders (the universe
|
|
1092
|
+
* would come from layer patterns this report does not see): no claim then.
|
|
1093
|
+
*/
|
|
1094
|
+
function knownUniverseIds(rules, files) {
|
|
1095
|
+
const ids = new Set();
|
|
1096
|
+
for (const rule of rules ?? []) {
|
|
1097
|
+
if (!rule?.childSlices?.sliceAliases?.length)
|
|
1098
|
+
continue;
|
|
1099
|
+
if (!rule.sliceFolders?.length)
|
|
1100
|
+
return null;
|
|
1101
|
+
for (const file of files) {
|
|
1102
|
+
if (typeof file !== 'string')
|
|
1103
|
+
continue;
|
|
1104
|
+
const id = sliceIdForPath(file, rule.sliceFolders, rule.sliceIdentity);
|
|
1105
|
+
if (id)
|
|
1106
|
+
ids.add(id.toLowerCase());
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
return ids;
|
|
756
1110
|
}
|
|
757
1111
|
/**
|
|
758
1112
|
* One human sentence naming which peerIsolation reason fired — so the denial
|
|
@@ -796,32 +1150,23 @@ export function universePairLabel(sliceId) {
|
|
|
796
1150
|
const parts = sliceId.split('/').filter(Boolean);
|
|
797
1151
|
return parts.length > 0 ? parts[parts.length - 1] : undefined;
|
|
798
1152
|
}
|
|
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)
|
|
1153
|
+
function isCommonFolderName(segment, commonFolders) {
|
|
1154
|
+
if (!segment || !commonFolders?.length)
|
|
812
1155
|
return false;
|
|
813
|
-
|
|
1156
|
+
const want = segment.toLowerCase();
|
|
1157
|
+
return commonFolders.some((raw) => typeof raw === 'string' && raw.length > 0 && raw.toLowerCase() === want);
|
|
814
1158
|
}
|
|
815
1159
|
/**
|
|
816
|
-
* Child id for one path. A
|
|
817
|
-
*
|
|
818
|
-
*
|
|
1160
|
+
* Child id for one path. A flat file (the starred id does not grow past the
|
|
1161
|
+
* universe id) is universe common. So is a `commonFolders` directory, but only
|
|
1162
|
+
* at the child position: the segment directly under the universe id
|
|
1163
|
+
* (`features/projects/domain/**`). A folder of the same name inside a child
|
|
1164
|
+
* (`features/projects/rfi/domain/**`) belongs to that child. A child id that
|
|
1165
|
+
* does not extend the universe id is not used; the caller warns.
|
|
819
1166
|
*/
|
|
820
|
-
|
|
1167
|
+
function resolveChildSliceId(relPath, universeId, child) {
|
|
821
1168
|
if (!relPath || !child?.sliceFolders?.length)
|
|
822
1169
|
return {};
|
|
823
|
-
if (pathHasCommonFolder(relPath, child.commonFolders))
|
|
824
|
-
return {};
|
|
825
1170
|
const raw = sliceIdForPath(relPath, child.sliceFolders, child.sliceIdentity);
|
|
826
1171
|
if (!raw || !universeId)
|
|
827
1172
|
return {};
|
|
@@ -829,9 +1174,12 @@ export function resolveChildSliceId(relPath, universeId, child) {
|
|
|
829
1174
|
const universeKey = universeId.toLowerCase();
|
|
830
1175
|
if (childKey === universeKey)
|
|
831
1176
|
return {};
|
|
832
|
-
if (childKey.startsWith(`${universeKey}/`))
|
|
833
|
-
return { childId: raw };
|
|
834
|
-
|
|
1177
|
+
if (!childKey.startsWith(`${universeKey}/`))
|
|
1178
|
+
return { mismatched: { childId: raw, universeId } };
|
|
1179
|
+
const firstChildSegment = childKey.slice(universeKey.length + 1).split('/')[0];
|
|
1180
|
+
if (isCommonFolderName(firstChildSegment, child.commonFolders))
|
|
1181
|
+
return {};
|
|
1182
|
+
return { childId: raw };
|
|
835
1183
|
}
|
|
836
1184
|
function siblingEntryKey(entry) {
|
|
837
1185
|
return trimTrailingSlashes(entry.trim().replace(/\\/g, '/')).toLowerCase();
|
|
@@ -877,7 +1225,7 @@ export function importerInEnforcedSubtree(fromPath, fromChild, entries) {
|
|
|
877
1225
|
* value deny every one. An object denies the importer only when it is listed,
|
|
878
1226
|
* unless `default` is already `deny`.
|
|
879
1227
|
*/
|
|
880
|
-
|
|
1228
|
+
function siblingCrossingAdvisory(siblings, fromPath, fromChild) {
|
|
881
1229
|
if (siblings == null || siblings === 'deny')
|
|
882
1230
|
return false;
|
|
883
1231
|
if (siblings === 'advisory')
|
|
@@ -960,7 +1308,7 @@ function childAllowanceSpansUniverses(from, to) {
|
|
|
960
1308
|
* Universe wall, then the child wall. An allow from the child wall never
|
|
961
1309
|
* overturns a universe deny: that deny returns before the child wall runs.
|
|
962
1310
|
*/
|
|
963
|
-
|
|
1311
|
+
function evaluateNestedSliceWall(input) {
|
|
964
1312
|
const universe = peerIsolationDecision({
|
|
965
1313
|
fromPath: input.fromPath,
|
|
966
1314
|
toPath: input.toPath,
|
|
@@ -970,7 +1318,7 @@ export function evaluateNestedSliceWall(input) {
|
|
|
970
1318
|
fromShared: input.fromShared,
|
|
971
1319
|
toShared: input.toShared,
|
|
972
1320
|
crossSliceAllowed: input.crossSliceAllowed,
|
|
973
|
-
sharedImportsSlice: input.rule.sharedImportsSlice,
|
|
1321
|
+
sharedImportsSlice: sharedImportsSliceMode(input.rule.sharedImportsSlice),
|
|
974
1322
|
});
|
|
975
1323
|
if (universe.denied) {
|
|
976
1324
|
const crossParent = Boolean(input.rule.childSlices) && universe.reason === 'cross-slice';
|
|
@@ -1009,7 +1357,7 @@ export function evaluateNestedSliceWall(input) {
|
|
|
1009
1357
|
toUniverse: input.toSlice,
|
|
1010
1358
|
};
|
|
1011
1359
|
if (fromChild && toChild && fromChild !== toChild) {
|
|
1012
|
-
if (childCrossSliceAllowed(child.allowedCrossSlice, fromChild, toChild)) {
|
|
1360
|
+
if (input.legacyChildAllowed || childCrossSliceAllowed(child.allowedCrossSlice, fromChild, toChild)) {
|
|
1013
1361
|
return allowSameUniverse;
|
|
1014
1362
|
}
|
|
1015
1363
|
const advisory = siblingCrossingAdvisory(child.siblings, input.fromPath, fromChild);
|
|
@@ -1033,27 +1381,45 @@ export function evaluateNestedSliceWall(input) {
|
|
|
1033
1381
|
}
|
|
1034
1382
|
return allowSameUniverse;
|
|
1035
1383
|
}
|
|
1384
|
+
/** Did the child (inner) wall produce this finding, not the universe wall? */
|
|
1385
|
+
export function isChildWallCrossing(verdict) {
|
|
1386
|
+
return verdict?.crossing === 'cross-sibling' || verdict?.crossing === 'parent-imports-child';
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* The consumer text for one slice finding. `rule.message` is the universe-wall
|
|
1390
|
+
* text. `childSlices.message` is the inner-wall text. An inner-wall finding
|
|
1391
|
+
* never falls back to the rule message; undefined means "use ArkGate's default".
|
|
1392
|
+
*/
|
|
1393
|
+
export function sliceConsumerMessage(rule, verdict) {
|
|
1394
|
+
return isChildWallCrossing(verdict) ? rule?.childSlices?.message : rule?.message;
|
|
1395
|
+
}
|
|
1036
1396
|
/**
|
|
1037
1397
|
* Finding message. Import sites always append the explanation. Intent sites
|
|
1038
1398
|
* keep today's wording when there is no reasonId: a plain cross-slice deny
|
|
1039
1399
|
* does not gain a clause, and any other universe-wall reason does.
|
|
1400
|
+
* `ruleMessage` is only used for universe-wall findings; an inner-wall finding
|
|
1401
|
+
* uses `childMessage` or ArkGate's default text.
|
|
1040
1402
|
*/
|
|
1041
1403
|
export function composeSliceDenialMessage(input) {
|
|
1042
1404
|
const explanation = input.verdict.explanation;
|
|
1405
|
+
const inner = isChildWallCrossing(input.verdict);
|
|
1406
|
+
const consumer = inner ? input.childMessage : input.ruleMessage;
|
|
1043
1407
|
if (input.surface === 'intent') {
|
|
1044
1408
|
const defaultMessage = input.defaultMessage ?? `${input.fromLayer} must not reference ${input.toLayer} intent.`;
|
|
1409
|
+
if (inner && explanation)
|
|
1410
|
+
return `${input.childMessage ?? defaultMessage} ${explanation}`;
|
|
1045
1411
|
if (input.verdict.reasonId && explanation)
|
|
1046
1412
|
return `${defaultMessage} ${explanation}`;
|
|
1047
1413
|
if (explanation && input.verdict.peerIsolationReason !== 'cross-slice') {
|
|
1048
1414
|
return `${defaultMessage} ${explanation}`;
|
|
1049
1415
|
}
|
|
1050
|
-
if (
|
|
1051
|
-
return explanation ? `${
|
|
1416
|
+
if (consumer) {
|
|
1417
|
+
return explanation ? `${consumer} (${explanation})` : consumer;
|
|
1052
1418
|
}
|
|
1053
1419
|
return defaultMessage;
|
|
1054
1420
|
}
|
|
1055
|
-
if (
|
|
1056
|
-
return explanation ? `${
|
|
1421
|
+
if (consumer) {
|
|
1422
|
+
return explanation ? `${consumer} (${explanation})` : consumer;
|
|
1057
1423
|
}
|
|
1058
1424
|
if (explanation) {
|
|
1059
1425
|
const kind = input.kind ?? 'import';
|
|
@@ -1117,27 +1483,65 @@ export function sliceCountReport(violations) {
|
|
|
1117
1483
|
};
|
|
1118
1484
|
}
|
|
1119
1485
|
/**
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1486
|
+
* Doctor advisory: a shared file that sits on many CROSS_PARENT_VIA_SHARED
|
|
1487
|
+
* paths is often a composition root. Null below `minFindings` findings or when
|
|
1488
|
+
* no file reaches `minShare` of them. Not a finding and not in the baseline.
|
|
1489
|
+
*/
|
|
1490
|
+
export function crossParentViaSharedHubs(violations, options) {
|
|
1491
|
+
const minFindings = options?.minFindings ?? 10;
|
|
1492
|
+
const minShare = options?.minShare ?? 0.5;
|
|
1493
|
+
let total = 0;
|
|
1494
|
+
const counts = new Map();
|
|
1495
|
+
for (const row of violations ?? []) {
|
|
1496
|
+
if (row?.reasonId !== 'CROSS_PARENT_VIA_SHARED')
|
|
1497
|
+
continue;
|
|
1498
|
+
total += 1;
|
|
1499
|
+
if (!Array.isArray(row.via))
|
|
1500
|
+
continue;
|
|
1501
|
+
const once = new Set(row.via.filter((file) => typeof file === 'string'));
|
|
1502
|
+
for (const file of once)
|
|
1503
|
+
counts.set(file, (counts.get(file) ?? 0) + 1);
|
|
1504
|
+
}
|
|
1505
|
+
if (total < minFindings)
|
|
1506
|
+
return null;
|
|
1507
|
+
const hubs = [...counts.entries()]
|
|
1508
|
+
.map(([file, count]) => ({ file, count, share: Math.round((count / total) * 100) / 100 }))
|
|
1509
|
+
.filter((hub) => hub.count / total >= minShare)
|
|
1510
|
+
.sort((left, right) => right.count - left.count || left.file.localeCompare(right.file));
|
|
1511
|
+
if (hubs.length === 0)
|
|
1512
|
+
return null;
|
|
1513
|
+
const first = hubs[0]?.file ?? '';
|
|
1514
|
+
return {
|
|
1515
|
+
notAScore: true,
|
|
1516
|
+
total,
|
|
1517
|
+
hubs,
|
|
1518
|
+
nextAction: `If ${first} is a composition root, add it to sharedImportsSlice.stopAt. Otherwise these findings are real.`,
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* One warning per directed rule when a path's child id does not extend its
|
|
1523
|
+
* universe id, and one per child allowance that spans universes. The version
|
|
1524
|
+
* floor lives in configVersionFloor.ts and needs pin evidence (#338).
|
|
1122
1525
|
*/
|
|
1123
1526
|
export function childSliceConfigFindings(rules, files) {
|
|
1124
1527
|
const out = [];
|
|
1125
|
-
let versionWarned = false;
|
|
1126
1528
|
const seenMismatch = new Set();
|
|
1127
1529
|
const seenSpan = new Set();
|
|
1128
1530
|
for (const rule of rules ?? []) {
|
|
1531
|
+
if (rule?.peerIsolation && rule.allowed === false)
|
|
1532
|
+
out.push(...legacyStarsIdFindings(rule, files));
|
|
1129
1533
|
if (!rule?.childSlices)
|
|
1130
1534
|
continue;
|
|
1131
|
-
if (!
|
|
1132
|
-
versionWarned = true;
|
|
1535
|
+
if (!rule.peerIsolation || rule.allowed !== false) {
|
|
1133
1536
|
out.push({
|
|
1134
|
-
ruleId: '
|
|
1537
|
+
ruleId: 'CONFIG_CHILD_SLICES_INERT',
|
|
1135
1538
|
failsStrict: false,
|
|
1136
|
-
|
|
1539
|
+
fromLayer: rule.from,
|
|
1540
|
+
toLayer: rule.to,
|
|
1541
|
+
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
1542
|
});
|
|
1138
|
-
}
|
|
1139
|
-
if (!rule.peerIsolation)
|
|
1140
1543
|
continue;
|
|
1544
|
+
}
|
|
1141
1545
|
for (const file of files) {
|
|
1142
1546
|
const universeId = sliceIdForPath(file, rule.sliceFolders, rule.sliceIdentity);
|
|
1143
1547
|
const resolved = resolveChildSliceId(file, universeId, rule.childSlices);
|
|
@@ -1176,35 +1580,215 @@ export function childSliceConfigFindings(rules, files) {
|
|
|
1176
1580
|
}
|
|
1177
1581
|
return out;
|
|
1178
1582
|
}
|
|
1583
|
+
/** Observed new ids per 4.8.23 stars id, from real files (for the warning text). */
|
|
1584
|
+
function observedIdsByLegacy(folders, files) {
|
|
1585
|
+
const byLegacy = new Map();
|
|
1586
|
+
if (!folders?.length)
|
|
1587
|
+
return byLegacy;
|
|
1588
|
+
for (const file of files) {
|
|
1589
|
+
if (typeof file !== 'string')
|
|
1590
|
+
continue;
|
|
1591
|
+
const id = sliceIdForPath(file, [...folders], 'stars');
|
|
1592
|
+
const legacy = legacyStarsSliceIdForPath(file, folders);
|
|
1593
|
+
if (!id || !legacy || id === legacy)
|
|
1594
|
+
continue;
|
|
1595
|
+
const set = byLegacy.get(legacy) ?? new Set();
|
|
1596
|
+
set.add(id);
|
|
1597
|
+
byLegacy.set(legacy, set);
|
|
1598
|
+
}
|
|
1599
|
+
return byLegacy;
|
|
1600
|
+
}
|
|
1601
|
+
function newIdHint(legacyId, shapes, observed, childSegment) {
|
|
1602
|
+
const suffix = childSegment ? `/${childSegment}` : '';
|
|
1603
|
+
const shape = `${shapes[0] ?? legacyId}${suffix}`;
|
|
1604
|
+
const seen = [...(observed.get(legacyId.toLowerCase()) ?? [])].sort().map((id) => `${id}${suffix}`);
|
|
1605
|
+
if (seen.length === 1)
|
|
1606
|
+
return `${seen[0]}`;
|
|
1607
|
+
if (seen.length > 1)
|
|
1608
|
+
return `${shape} (one entry per module: ${seen.join(', ')})`;
|
|
1609
|
+
return shape;
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* CONFIG_SLICE_LEGACY_STARS_ID: under sliceIdentity "stars", a sliceAliases target or an
|
|
1613
|
+
* allowedCrossSlice entry written against the 4.8.23 stars id (star bindings before the
|
|
1614
|
+
* last literal dropped). It keeps its 4.8.23 verdict for one release; the warning names
|
|
1615
|
+
* the new id.
|
|
1616
|
+
*/
|
|
1617
|
+
function legacyStarsIdFindings(rule, files) {
|
|
1618
|
+
const out = [];
|
|
1619
|
+
const push = (message) => out.push({
|
|
1620
|
+
ruleId: 'CONFIG_SLICE_LEGACY_STARS_ID',
|
|
1621
|
+
failsStrict: false,
|
|
1622
|
+
fromLayer: rule.from,
|
|
1623
|
+
toLayer: rule.to,
|
|
1624
|
+
message: `${rule.from} → ${rule.to}: ${message} It keeps its 4.8.23 meaning for this release only.`,
|
|
1625
|
+
});
|
|
1626
|
+
const child = rule.childSlices;
|
|
1627
|
+
if (rule.sliceIdentity === 'stars') {
|
|
1628
|
+
const observed = observedIdsByLegacy(rule.sliceFolders, files);
|
|
1629
|
+
for (const alias of child?.sliceAliases ?? []) {
|
|
1630
|
+
if (!alias || typeof alias.to !== 'string')
|
|
1631
|
+
continue;
|
|
1632
|
+
const target = splitAliasTarget(alias.to);
|
|
1633
|
+
if (!target || legacyAliasUniverse(rule, alias.to) === undefined)
|
|
1634
|
+
continue;
|
|
1635
|
+
const shapes = legacyStarsIdTargets(rule.sliceFolders, target.universeId);
|
|
1636
|
+
const segment = target.childId.split('/').pop();
|
|
1637
|
+
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.`);
|
|
1638
|
+
}
|
|
1639
|
+
for (const edge of rule.allowedCrossSlice ?? []) {
|
|
1640
|
+
if (!edge || typeof edge.from !== 'string' || typeof edge.to !== 'string')
|
|
1641
|
+
continue;
|
|
1642
|
+
const legacySides = [edge.from, edge.to].filter((id) => id.includes('/') && legacyStarsIdTargets(rule.sliceFolders, id).length > 0);
|
|
1643
|
+
if (legacySides.length === 0)
|
|
1644
|
+
continue;
|
|
1645
|
+
const rename = (id) => legacySides.includes(id)
|
|
1646
|
+
? newIdHint(id, legacyStarsIdTargets(rule.sliceFolders, id), observed)
|
|
1647
|
+
: id;
|
|
1648
|
+
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.`);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
if (child?.sliceIdentity === 'stars') {
|
|
1652
|
+
const observed = observedIdsByLegacy(child.sliceFolders, files);
|
|
1653
|
+
for (const edge of child.allowedCrossSlice ?? []) {
|
|
1654
|
+
if (!edge || typeof edge.from !== 'string' || typeof edge.to !== 'string')
|
|
1655
|
+
continue;
|
|
1656
|
+
const legacySides = [edge.from, edge.to].filter((id) => legacyStarsIdTargets(child.sliceFolders, id).length > 0);
|
|
1657
|
+
if (legacySides.length === 0)
|
|
1658
|
+
continue;
|
|
1659
|
+
const rename = (id) => legacySides.includes(id)
|
|
1660
|
+
? newIdHint(id, legacyStarsIdTargets(child.sliceFolders, id), observed)
|
|
1661
|
+
: id;
|
|
1662
|
+
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.`);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
return out;
|
|
1666
|
+
}
|
|
1667
|
+
/** `ratchet: true` → always, `ratchet: false` → never, anything else → auto. */
|
|
1668
|
+
export function siblingRatchetMode(siblings) {
|
|
1669
|
+
if (siblings !== null && typeof siblings === 'object') {
|
|
1670
|
+
const ratchet = siblings.ratchet;
|
|
1671
|
+
if (ratchet === true)
|
|
1672
|
+
return 'always';
|
|
1673
|
+
if (ratchet === false)
|
|
1674
|
+
return 'never';
|
|
1675
|
+
}
|
|
1676
|
+
return 'auto';
|
|
1677
|
+
}
|
|
1179
1678
|
/**
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1183
|
-
* so
|
|
1679
|
+
* How many advisory sibling crossings of one rule the baseline records. A key
|
|
1680
|
+
* still present counts when its current row is in the group. A stale key (the
|
|
1681
|
+
* crossing was removed) counts when the rule still classifies that edge as an
|
|
1682
|
+
* advisory sibling crossing, so replacing a recorded crossing neither disarms
|
|
1683
|
+
* the ratchet nor looks like growth.
|
|
1184
1684
|
*/
|
|
1185
|
-
|
|
1186
|
-
const
|
|
1685
|
+
function recordedAdvisorySiblingCount(input) {
|
|
1686
|
+
const { rule } = input;
|
|
1687
|
+
let recorded = 0;
|
|
1688
|
+
for (const key of input.recordedKeys) {
|
|
1689
|
+
if (input.groupKeys.has(key)) {
|
|
1690
|
+
recorded += 1;
|
|
1691
|
+
continue;
|
|
1692
|
+
}
|
|
1693
|
+
if (!rule || input.currentKeys.has(key))
|
|
1694
|
+
continue;
|
|
1695
|
+
const parts = key.split('|');
|
|
1696
|
+
if (parts.length !== 5)
|
|
1697
|
+
continue;
|
|
1698
|
+
const [ruleId = '', file = '', fromLayer = '', toLayer = '', rawTarget = ''] = parts;
|
|
1699
|
+
if (!input.ruleIds.has(ruleId) || fromLayer !== rule.from || toLayer !== rule.to)
|
|
1700
|
+
continue;
|
|
1701
|
+
const target = rawTarget.replace(/#\d+$/, '');
|
|
1702
|
+
if (!file || !target)
|
|
1703
|
+
continue;
|
|
1704
|
+
const verdict = findDeniedEdgeDecision([rule], fromLayer, toLayer, {
|
|
1705
|
+
fromPath: file,
|
|
1706
|
+
toPath: target,
|
|
1707
|
+
layers: input.layers,
|
|
1708
|
+
})?.sliceVerdict;
|
|
1709
|
+
if (verdict?.reasonId === 'CROSS_SIBLING_SLICE' && verdict.decision === 'advisory')
|
|
1710
|
+
recorded += 1;
|
|
1711
|
+
}
|
|
1712
|
+
return recorded;
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* Advisory sibling crossings past the recorded baseline become blocking, per
|
|
1716
|
+
* directed rule. Only advisory (`failsStrict: false`) crossings count; an
|
|
1717
|
+
* enforced crossing never switches the ratchet on. For each rule:
|
|
1718
|
+
*
|
|
1719
|
+
* - `ratchet` absent (every string form too): on only when the baseline
|
|
1720
|
+
* records at least one advisory sibling crossing of that rule (a key still
|
|
1721
|
+
* present, or a stale key that the rule still classifies as one), and only
|
|
1722
|
+
* when that rule's current advisory count grew past the recorded count.
|
|
1723
|
+
* Swapping a recorded crossing for a new one keeps the count and stays a
|
|
1724
|
+
* warning. An empty baseline, or one frozen before the child wall, promotes
|
|
1725
|
+
* nothing.
|
|
1726
|
+
* - `ratchet: true`: any unrecorded advisory crossing of the rule is promoted.
|
|
1727
|
+
* - `ratchet: false`: never promoted.
|
|
1728
|
+
*
|
|
1729
|
+
* The key is the existing baseline identity (ruleId, file, layers, target);
|
|
1730
|
+
* reasonId is not part of it. Recorded crossings stay advisory. Callers run
|
|
1731
|
+
* this only when a baseline (or an `--against` base) is in use.
|
|
1732
|
+
*/
|
|
1733
|
+
export function applyAdvisorySiblingRatchet(violations, occurrenceKeys, recordedKeys, options) {
|
|
1734
|
+
const groups = new Map();
|
|
1187
1735
|
for (let index = 0; index < violations.length; index += 1) {
|
|
1188
|
-
|
|
1189
|
-
|
|
1736
|
+
const row = violations[index];
|
|
1737
|
+
if (row?.reasonId !== 'CROSS_SIBLING_SLICE' || row.failsStrict !== false)
|
|
1738
|
+
continue;
|
|
1739
|
+
const key = `${row.fromLayer ?? ''}\0${row.toLayer ?? ''}`;
|
|
1740
|
+
const list = groups.get(key);
|
|
1741
|
+
if (list)
|
|
1742
|
+
list.push(index);
|
|
1743
|
+
else
|
|
1744
|
+
groups.set(key, [index]);
|
|
1190
1745
|
}
|
|
1191
|
-
if (
|
|
1746
|
+
if (groups.size === 0)
|
|
1192
1747
|
return [...violations];
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1748
|
+
const currentKeys = new Set(occurrenceKeys);
|
|
1749
|
+
const promote = new Set();
|
|
1750
|
+
for (const indexes of groups.values()) {
|
|
1751
|
+
const first = violations[indexes[0] ?? 0];
|
|
1752
|
+
const rule = (options?.rules ?? []).find((candidate) => candidate &&
|
|
1753
|
+
candidate.from === first?.fromLayer &&
|
|
1754
|
+
candidate.to === first?.toLayer &&
|
|
1755
|
+
candidate.allowed === false &&
|
|
1756
|
+
candidate.peerIsolation === true &&
|
|
1757
|
+
candidate.childSlices != null);
|
|
1758
|
+
const mode = siblingRatchetMode(rule?.childSlices?.siblings);
|
|
1759
|
+
if (mode === 'never')
|
|
1760
|
+
continue;
|
|
1761
|
+
if (mode === 'auto') {
|
|
1762
|
+
const recorded = recordedAdvisorySiblingCount({
|
|
1763
|
+
rule: rule,
|
|
1764
|
+
ruleIds: new Set(indexes.map((index) => String(violations[index]?.ruleId ?? ''))),
|
|
1765
|
+
groupKeys: new Set(indexes.map((index) => occurrenceKeys[index] ?? '')),
|
|
1766
|
+
currentKeys,
|
|
1767
|
+
recordedKeys,
|
|
1768
|
+
layers: options?.layers,
|
|
1769
|
+
});
|
|
1770
|
+
if (recorded === 0 || indexes.length <= recorded)
|
|
1771
|
+
continue;
|
|
1772
|
+
}
|
|
1773
|
+
for (const index of indexes) {
|
|
1774
|
+
if (!recordedKeys.has(occurrenceKeys[index] ?? ''))
|
|
1775
|
+
promote.add(index);
|
|
1776
|
+
}
|
|
1197
1777
|
}
|
|
1198
|
-
if (
|
|
1778
|
+
if (promote.size === 0)
|
|
1199
1779
|
return [...violations];
|
|
1200
1780
|
return violations.map((violation, index) => {
|
|
1201
|
-
if (
|
|
1202
|
-
return violation;
|
|
1203
|
-
if (recordedKeys.has(occurrenceKeys[index] ?? ''))
|
|
1781
|
+
if (!promote.has(index))
|
|
1204
1782
|
return violation;
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1783
|
+
const from = violation.fromLayer ?? '?';
|
|
1784
|
+
const to = violation.toLayer ?? '?';
|
|
1785
|
+
const why = `New advisory sibling crossing past the recorded baseline for ${from} → ${to}. Set childSlices.siblings.ratchet: false to measure only.`;
|
|
1786
|
+
return {
|
|
1787
|
+
...violation,
|
|
1788
|
+
failsStrict: true,
|
|
1789
|
+
severity: 'error',
|
|
1790
|
+
...(typeof violation.message === 'string' ? { message: `${violation.message} ${why}` } : {}),
|
|
1791
|
+
};
|
|
1208
1792
|
});
|
|
1209
1793
|
}
|
|
1210
1794
|
/**
|
|
@@ -1239,8 +1823,9 @@ export function findDeniedEdgeDecision(rules, from, to, options) {
|
|
|
1239
1823
|
const fromPath = options?.fromPath;
|
|
1240
1824
|
const toPath = options?.toPath;
|
|
1241
1825
|
const folders = resolveSliceFolders(rule, from, options?.layers);
|
|
1242
|
-
const
|
|
1243
|
-
const
|
|
1826
|
+
const pair = resolveGovernedSlicePair(rule, folders, fromPath, toPath);
|
|
1827
|
+
const fromPlace = pair.from;
|
|
1828
|
+
const toPlace = pair.to;
|
|
1244
1829
|
const fromSlice = fromPlace.universeId;
|
|
1245
1830
|
const toSlice = toPlace.universeId;
|
|
1246
1831
|
const verdict = evaluateNestedSliceWall({
|
|
@@ -1254,7 +1839,8 @@ export function findDeniedEdgeDecision(rules, from, to, options) {
|
|
|
1254
1839
|
toChild: toPlace.childId,
|
|
1255
1840
|
fromShared: !fromSlice && pathUnderSharedRoot(fromPath, rule.sharedRoots),
|
|
1256
1841
|
toShared: !toSlice && pathUnderSharedRoot(toPath, rule.sharedRoots),
|
|
1257
|
-
crossSliceAllowed:
|
|
1842
|
+
crossSliceAllowed: pair.crossSliceAllowed,
|
|
1843
|
+
legacyChildAllowed: pair.legacyChildAllowed,
|
|
1258
1844
|
});
|
|
1259
1845
|
if (verdict.decision !== 'allow') {
|
|
1260
1846
|
return {
|
|
@@ -1289,7 +1875,7 @@ export function findSharedImportsSliceBridge(rules, from, to, options) {
|
|
|
1289
1875
|
continue;
|
|
1290
1876
|
if (rule.allowed !== false || !rule.peerIsolation)
|
|
1291
1877
|
continue;
|
|
1292
|
-
if (rule.sharedImportsSlice === 'deny')
|
|
1878
|
+
if (sharedImportsSliceMode(rule.sharedImportsSlice) === 'deny')
|
|
1293
1879
|
continue;
|
|
1294
1880
|
const folders = resolveSliceFolders(rule, from, options?.layers);
|
|
1295
1881
|
const fromSlice = resolveGovernedSlice(fromPath, rule, folders).universeId;
|