arkgate 4.8.22 → 4.8.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +2 -2
- package/bin/ark-check-runtime.mjs +4 -2
- package/bin/ark-layer-match.mjs +606 -11
- package/bin/lib/analysis-engine.mjs +11 -11
- package/bin/lib/config-contract.mjs +492 -1
- package/bin/lib/diagnostic-catalog.mjs +32 -0
- package/bin/lib/doctor-advisories.mjs +60 -1
- package/bin/lib/doctor-plan.mjs +2 -2
- package/bin/lib/enforcement-honesty.mjs +22 -0
- package/bin/lib/flat-parent-pilot.mjs +252 -0
- package/bin/lib/html-report-advisories.mjs +2 -0
- package/bin/lib/html-report-depth.mjs +3 -1
- package/bin/lib/physical-cohesion.mjs +16 -18
- package/bin/lib/pilot-loop.mjs +21 -8
- package/bin/lib/team-parliament-io.mjs +4 -2
- package/dist/{configTypes-CTELo87-.d.ts → configTypes-Bbf_MFGe.d.ts} +36 -2
- package/dist/{diagnosticCatalog-WD2TPSwZ.d.ts → diagnosticCatalog-35mrwAHe.d.ts} +13 -3
- package/dist/eslint/index.cjs +4 -4
- package/dist/eslint/index.d.ts +51 -5
- package/dist/eslint/index.js +4 -4
- package/dist/index.cjs +35 -35
- package/dist/index.d.ts +104 -5
- package/dist/index.js +35 -35
- package/dist/nestjs/index.cjs +9 -9
- package/dist/nestjs/index.d.ts +3 -3
- package/dist/nestjs/index.js +9 -9
- package/dist/runtime/index.cjs +13 -13
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +13 -13
- package/dist/{types-yDNEQ603.d.ts → types-Bdj32QwA.d.ts} +1 -1
- package/dist/{types-fQ9-KrBj.d.ts → types-Ik_OakcI.d.ts} +1 -1
- package/docs/README.md +2 -1
- package/docs/agent-guide.md +9 -1
- package/docs/configuration.md +92 -1
- package/docs/diagnostics.md +32 -0
- package/docs/product-voice.md +1 -1
- package/package.json +1 -1
- package/schemas/ark.config.schema.json +124 -2
- package/server.json +2 -2
- package/templates/agent-skills/README.md +1 -1
|
@@ -233,7 +233,69 @@ export const ARK_CONFIG_SCHEMA = {
|
|
|
233
233
|
},
|
|
234
234
|
},
|
|
235
235
|
},
|
|
236
|
-
sharedImportsSlice: {
|
|
236
|
+
sharedImportsSlice: {
|
|
237
|
+
type: 'string',
|
|
238
|
+
enum: ['deny', 'deny-cross-parent'],
|
|
239
|
+
description: 'deny blocks every shared-root import of a slice. deny-cross-parent leaves that hop as a warning and, in ark-check and CI only, reports a slice that reaches another universe through a shared root. The write hook and ESLint see one edge and do not block it. arkgate 4.8.22 and older reject deny-cross-parent.',
|
|
240
|
+
},
|
|
241
|
+
childSlices: {
|
|
242
|
+
type: 'object',
|
|
243
|
+
additionalProperties: false,
|
|
244
|
+
required: ['sliceFolders'],
|
|
245
|
+
description: 'Optional inner wall under this rule. Absent keeps today\'s universe wall. sliceFolders names children. commonFolders and flat files are universe common. siblings is "deny" (default), "advisory", or { default, enforce }. parentMayImportChild defaults to false. allowedCrossSlice may use a whole-segment *. It clears only a sibling crossing. Cross-parent has no advisory knob. arkgate 4.8.22 and older reject this key. A build that still rejects unknown childSlices fields rejects allowedCrossSlice at config load. A build that still types siblings as a string enum rejects the object at config load.',
|
|
246
|
+
properties: {
|
|
247
|
+
sliceFolders: { ...stringArraySchema, minItems: 1 },
|
|
248
|
+
sliceIdentity: {
|
|
249
|
+
type: 'string',
|
|
250
|
+
enum: ['path', 'stars'],
|
|
251
|
+
},
|
|
252
|
+
commonFolders: { ...stringArraySchema, minItems: 1 },
|
|
253
|
+
siblings: {
|
|
254
|
+
description: '"deny" or "advisory", or { default, enforce } so listed subtrees are errors while the default stays advisory. A string-enum build rejects the object at config load (must be one of deny, advisory).',
|
|
255
|
+
oneOf: [
|
|
256
|
+
{ type: 'string', enum: ['deny', 'advisory'] },
|
|
257
|
+
{
|
|
258
|
+
type: 'object',
|
|
259
|
+
additionalProperties: false,
|
|
260
|
+
required: ['default'],
|
|
261
|
+
properties: {
|
|
262
|
+
default: { type: 'string', enum: ['deny', 'advisory'] },
|
|
263
|
+
enforce: { ...stringArraySchema },
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
parentMayImportChild: { type: 'boolean' },
|
|
269
|
+
sliceAliases: {
|
|
270
|
+
type: 'array',
|
|
271
|
+
minItems: 1,
|
|
272
|
+
description: 'Maps a source path glob onto a child slice id (universe id plus one child segment) so files outside the slice trees take that universe and child for both walls. A bare name, a universe id alone, an unknown universe, and a wildcard in to are rejected. The glob may not overlap a slice folder, and two aliases may not match the same file. Doctor lists each alias as an owed move. A build that rejects unknown childSlices fields fails at config load (unknown field). arkgate 4.8.22 and older reject childSlices.',
|
|
273
|
+
items: {
|
|
274
|
+
type: 'object',
|
|
275
|
+
additionalProperties: false,
|
|
276
|
+
required: ['from', 'to'],
|
|
277
|
+
properties: {
|
|
278
|
+
from: { type: 'string', minLength: 1 },
|
|
279
|
+
to: { type: 'string', minLength: 1 },
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
allowedCrossSlice: {
|
|
284
|
+
type: 'array',
|
|
285
|
+
minItems: 1,
|
|
286
|
+
description: 'Directed child-slice allowances. * matches one whole path segment (features/projects/*). ** and a partial segment are rejected. A bare name is rejected. This list clears only CROSS_SIBLING_SLICE. It cannot clear CROSS_PARENT_SLICE or CROSS_PARENT_VIA_SHARED. The universe allowedCrossSlice still treats * as a literal. A build that rejects unknown childSlices fields fails at config load (unknown field).',
|
|
287
|
+
items: {
|
|
288
|
+
type: 'object',
|
|
289
|
+
additionalProperties: false,
|
|
290
|
+
required: ['from', 'to'],
|
|
291
|
+
properties: {
|
|
292
|
+
from: { type: 'string', minLength: 1 },
|
|
293
|
+
to: { type: 'string', minLength: 1 },
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
},
|
|
237
299
|
},
|
|
238
300
|
},
|
|
239
301
|
safety: {
|
|
@@ -395,6 +457,420 @@ function validateNode(value, schema, path, root, issues) {
|
|
|
395
457
|
}
|
|
396
458
|
}
|
|
397
459
|
}
|
|
460
|
+
const SIBLING_MODES = ['deny', 'advisory'];
|
|
461
|
+
/**
|
|
462
|
+
* String enum builds reject this object before they reach a decision.
|
|
463
|
+
* The message names both forms so a bad value is obvious at config load.
|
|
464
|
+
*/
|
|
465
|
+
const SIBLINGS_FORM_MESSAGE = 'must be "deny", "advisory", or { "default": "deny" | "advisory", "enforce": ["<child id or subtree path>"] }';
|
|
466
|
+
function validateChildSliceSiblings(candidate, issues) {
|
|
467
|
+
const rules = candidate.rules;
|
|
468
|
+
if (!Array.isArray(rules))
|
|
469
|
+
return;
|
|
470
|
+
rules.forEach((rule, index) => {
|
|
471
|
+
if (!isObject(rule))
|
|
472
|
+
return;
|
|
473
|
+
const child = rule.childSlices;
|
|
474
|
+
if (!isObject(child) || child.siblings === undefined)
|
|
475
|
+
return;
|
|
476
|
+
const siblings = child.siblings;
|
|
477
|
+
const path = `$.rules[${index}].childSlices.siblings`;
|
|
478
|
+
if (typeof siblings === 'string') {
|
|
479
|
+
if (!SIBLING_MODES.includes(siblings)) {
|
|
480
|
+
issues.push({ path, message: SIBLINGS_FORM_MESSAGE });
|
|
481
|
+
}
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (!isObject(siblings)) {
|
|
485
|
+
issues.push({ path, message: SIBLINGS_FORM_MESSAGE });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
for (const key of Object.keys(siblings)) {
|
|
489
|
+
if (key !== 'default' && key !== 'enforce') {
|
|
490
|
+
issues.push({ path: propertyPath(path, key), message: 'unknown field' });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (siblings.default === undefined) {
|
|
494
|
+
issues.push({ path: `${path}.default`, message: 'is required' });
|
|
495
|
+
}
|
|
496
|
+
else if (siblings.default !== 'deny' && siblings.default !== 'advisory') {
|
|
497
|
+
issues.push({ path: `${path}.default`, message: 'must be deny or advisory' });
|
|
498
|
+
}
|
|
499
|
+
if (siblings.enforce === undefined)
|
|
500
|
+
return;
|
|
501
|
+
if (!Array.isArray(siblings.enforce)) {
|
|
502
|
+
issues.push({
|
|
503
|
+
path: `${path}.enforce`,
|
|
504
|
+
message: 'must be an array of child slice ids or subtree paths',
|
|
505
|
+
});
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const seen = new Set();
|
|
509
|
+
siblings.enforce.forEach((entry, entryIndex) => {
|
|
510
|
+
const entryPath = `${path}.enforce[${entryIndex}]`;
|
|
511
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) {
|
|
512
|
+
issues.push({ path: entryPath, message: 'must be a non-empty child slice id or subtree path' });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const normalized = trimTrailingSlashes(entry.trim().replace(/\\/g, '/'));
|
|
516
|
+
if (normalized.split('/').some((part) => part.length === 0 || part === '.' || part === '..')) {
|
|
517
|
+
issues.push({ path: entryPath, message: 'must be a child slice id or subtree path without . or ..' });
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (normalized.includes('*')) {
|
|
521
|
+
issues.push({
|
|
522
|
+
path: entryPath,
|
|
523
|
+
message: 'must be a child slice id or subtree path. * is not a wildcard on siblings.enforce',
|
|
524
|
+
});
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (seen.has(normalized)) {
|
|
528
|
+
issues.push({ path: entryPath, message: 'duplicate enforce entry' });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
seen.add(normalized);
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
const BARE_CHILD_CROSS_SLICE = 'must be a slice id with a slash. A bare name is ambiguous across universes.';
|
|
536
|
+
const WHOLE_SEGMENT_STAR = '* matches one whole path segment. ** and a partial segment are not wildcards.';
|
|
537
|
+
/**
|
|
538
|
+
* Whole-segment `*` is legal only on this list. A bare name is rejected.
|
|
539
|
+
* The universe allowedCrossSlice is not checked here and still treats `*` as a literal.
|
|
540
|
+
*/
|
|
541
|
+
function childCrossSlicePatternIssue(raw) {
|
|
542
|
+
const trimmed = trimTrailingSlashes(raw.trim().replace(/\\/g, '/'));
|
|
543
|
+
if (trimmed.length === 0)
|
|
544
|
+
return 'must be a non-empty slice id';
|
|
545
|
+
if (!trimmed.includes('/'))
|
|
546
|
+
return BARE_CHILD_CROSS_SLICE;
|
|
547
|
+
const parts = trimmed.split('/');
|
|
548
|
+
for (const part of parts) {
|
|
549
|
+
if (part.length === 0 || part === '.' || part === '..') {
|
|
550
|
+
return 'must be a slice id without empty, . or .. segments';
|
|
551
|
+
}
|
|
552
|
+
if (part.includes('*') && part !== '*')
|
|
553
|
+
return WHOLE_SEGMENT_STAR;
|
|
554
|
+
}
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
function childCrossSlicePatternKey(raw) {
|
|
558
|
+
return trimTrailingSlashes(raw.trim().replace(/\\/g, '/')).toLowerCase();
|
|
559
|
+
}
|
|
560
|
+
function validateChildSliceAllowedCrossSlice(candidate, issues) {
|
|
561
|
+
const rules = candidate.rules;
|
|
562
|
+
if (!Array.isArray(rules))
|
|
563
|
+
return;
|
|
564
|
+
rules.forEach((rule, index) => {
|
|
565
|
+
if (!isObject(rule))
|
|
566
|
+
return;
|
|
567
|
+
const child = rule.childSlices;
|
|
568
|
+
if (!isObject(child) || child.allowedCrossSlice === undefined)
|
|
569
|
+
return;
|
|
570
|
+
const edges = child.allowedCrossSlice;
|
|
571
|
+
const path = `$.rules[${index}].childSlices.allowedCrossSlice`;
|
|
572
|
+
if (!Array.isArray(edges)) {
|
|
573
|
+
issues.push({ path, message: 'must be an array of { from, to } child slice ids' });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const seen = new Set();
|
|
577
|
+
edges.forEach((edge, edgeIndex) => {
|
|
578
|
+
const edgePath = `${path}[${edgeIndex}]`;
|
|
579
|
+
if (!isObject(edge)) {
|
|
580
|
+
issues.push({ path: edgePath, message: 'must be an object with from and to' });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
for (const key of Object.keys(edge)) {
|
|
584
|
+
if (key !== 'from' && key !== 'to') {
|
|
585
|
+
issues.push({ path: propertyPath(edgePath, key), message: 'unknown field' });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
for (const side of ['from', 'to']) {
|
|
589
|
+
const value = edge[side];
|
|
590
|
+
const sidePath = `${edgePath}.${side}`;
|
|
591
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
592
|
+
issues.push({ path: sidePath, message: 'must be a non-empty slice id' });
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const issue = childCrossSlicePatternIssue(value);
|
|
596
|
+
if (issue)
|
|
597
|
+
issues.push({ path: sidePath, message: issue });
|
|
598
|
+
}
|
|
599
|
+
if (typeof edge.from !== 'string' || typeof edge.to !== 'string')
|
|
600
|
+
return;
|
|
601
|
+
const key = `${childCrossSlicePatternKey(edge.from)}\0${childCrossSlicePatternKey(edge.to)}`;
|
|
602
|
+
if (!key.startsWith('\0') && !key.endsWith('\0')) {
|
|
603
|
+
if (seen.has(key)) {
|
|
604
|
+
issues.push({ path: edgePath, message: 'duplicate child slice allowance' });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
seen.add(key);
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
const ALIAS_TARGET_MESSAGE = 'must be a child of an existing universe (the universe id this rule already names, plus one child segment). A bare name, a universe id alone, an unknown universe, and a wildcard are rejected.';
|
|
613
|
+
const ALIAS_OVERLAP_MESSAGE = 'overlaps a slice folder. An alias covers only files outside the slice trees.';
|
|
614
|
+
const ALIAS_SAME_FILE_MESSAGE = 'two slice aliases match the same file';
|
|
615
|
+
function aliasPathSegments(raw) {
|
|
616
|
+
return trimTrailingSlashes(raw.trim().replace(/\\/g, '/')).toLowerCase().split('/').filter((part) => part.length > 0);
|
|
617
|
+
}
|
|
618
|
+
function concreteGlobPrefix(segments) {
|
|
619
|
+
const prefix = [];
|
|
620
|
+
for (const segment of segments) {
|
|
621
|
+
if (segment.includes('*'))
|
|
622
|
+
break;
|
|
623
|
+
prefix.push(segment);
|
|
624
|
+
}
|
|
625
|
+
return prefix;
|
|
626
|
+
}
|
|
627
|
+
function segmentsArePrefix(prefix, full) {
|
|
628
|
+
if (prefix.length > full.length)
|
|
629
|
+
return false;
|
|
630
|
+
for (let index = 0; index < prefix.length; index += 1) {
|
|
631
|
+
if (prefix[index] !== full[index])
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
function anchoredSlicePrefix(entry) {
|
|
637
|
+
const segments = entry.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
|
|
638
|
+
if (segments.length < 2 || segments[0] === '*' || segments[0] === '**')
|
|
639
|
+
return null;
|
|
640
|
+
if (!segments.some((part) => part === '*'))
|
|
641
|
+
return null;
|
|
642
|
+
const prefix = [];
|
|
643
|
+
for (const segment of segments) {
|
|
644
|
+
if (segment === '*' || segment === '**')
|
|
645
|
+
break;
|
|
646
|
+
if (segment.includes('*'))
|
|
647
|
+
return null;
|
|
648
|
+
prefix.push(segment);
|
|
649
|
+
}
|
|
650
|
+
return prefix.length > 0 ? prefix : null;
|
|
651
|
+
}
|
|
652
|
+
/** Trailing ** does not invent a slice-folder name the glob does not already reach. */
|
|
653
|
+
function globOverlapsAnchored(glob, patternPrefix) {
|
|
654
|
+
const prefix = concreteGlobPrefix(glob);
|
|
655
|
+
const wild = prefix.length < glob.length;
|
|
656
|
+
const rooted = prefix[0] === 'src' || prefix[0] === 'app' ? prefix.slice(1) : prefix;
|
|
657
|
+
if (segmentsArePrefix(patternPrefix, prefix) || segmentsArePrefix(patternPrefix, rooted))
|
|
658
|
+
return true;
|
|
659
|
+
if (!wild)
|
|
660
|
+
return false;
|
|
661
|
+
return ((segmentsArePrefix(prefix, patternPrefix) && prefix.length < patternPrefix.length) ||
|
|
662
|
+
(segmentsArePrefix(prefix, ['src', ...patternPrefix]) && prefix.length < patternPrefix.length + 1) ||
|
|
663
|
+
(segmentsArePrefix(prefix, ['app', ...patternPrefix]) && prefix.length < patternPrefix.length + 1));
|
|
664
|
+
}
|
|
665
|
+
function globOverlapsBare(glob, name) {
|
|
666
|
+
for (let index = 0; index < glob.length; index += 1) {
|
|
667
|
+
if (glob[index] !== name && glob[index] !== '*')
|
|
668
|
+
continue;
|
|
669
|
+
const rest = glob.slice(index + 1);
|
|
670
|
+
if (rest.length >= 2)
|
|
671
|
+
return true;
|
|
672
|
+
if (rest.some((segment) => segment === '*' || segment === '**'))
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
const prefix = concreteGlobPrefix(glob);
|
|
676
|
+
return (prefix.length < glob.length &&
|
|
677
|
+
(prefix.length === 0 || (prefix.length === 1 && (prefix[0] === 'src' || prefix[0] === 'app'))));
|
|
678
|
+
}
|
|
679
|
+
function globOverlapsSliceEntry(glob, entry) {
|
|
680
|
+
const segments = entry.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
|
|
681
|
+
if (segments.length === 1 && segments[0] && !segments[0].includes('*')) {
|
|
682
|
+
return globOverlapsBare(glob, segments[0]);
|
|
683
|
+
}
|
|
684
|
+
const prefix = anchoredSlicePrefix(entry);
|
|
685
|
+
return prefix ? globOverlapsAnchored(glob, prefix) : false;
|
|
686
|
+
}
|
|
687
|
+
function aliasGlobIssue(raw) {
|
|
688
|
+
const trimmed = trimTrailingSlashes(raw.trim().replace(/\\/g, '/'));
|
|
689
|
+
if (trimmed.length === 0)
|
|
690
|
+
return 'must be a non-empty path glob';
|
|
691
|
+
if (trimmed === '*' || trimmed === '**')
|
|
692
|
+
return 'must not cover the whole tree';
|
|
693
|
+
for (const part of trimmed.split('/')) {
|
|
694
|
+
if (part.length === 0 || part === '.' || part === '..') {
|
|
695
|
+
return 'must be a path glob without empty, . or .. segments';
|
|
696
|
+
}
|
|
697
|
+
if (part === '*' || part === '**')
|
|
698
|
+
continue;
|
|
699
|
+
if (part.includes('*') || part.includes('?') || part.includes('{') || part.includes('[')) {
|
|
700
|
+
return '* matches one whole path segment. ** is a whole segment. A partial segment is not a wildcard.';
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
function universeShapes(folders, identity) {
|
|
706
|
+
const shapes = [];
|
|
707
|
+
if (!Array.isArray(folders))
|
|
708
|
+
return shapes;
|
|
709
|
+
const stars = identity === 'stars';
|
|
710
|
+
for (const raw of folders) {
|
|
711
|
+
if (typeof raw !== 'string' || raw.length === 0)
|
|
712
|
+
continue;
|
|
713
|
+
const segments = raw.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
|
|
714
|
+
if (segments.length === 1 && segments[0] && !segments[0].includes('*')) {
|
|
715
|
+
shapes.push({ literals: [segments[0], null] });
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (segments.some((part) => part === '**' || (part.includes('*') && part !== '*')))
|
|
719
|
+
continue;
|
|
720
|
+
if (!segments.includes('*'))
|
|
721
|
+
continue;
|
|
722
|
+
if (stars) {
|
|
723
|
+
let lastLiteral = -1;
|
|
724
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
725
|
+
if (segments[index] !== '*')
|
|
726
|
+
lastLiteral = index;
|
|
727
|
+
}
|
|
728
|
+
if (lastLiteral < 0)
|
|
729
|
+
continue;
|
|
730
|
+
shapes.push({
|
|
731
|
+
literals: segments.slice(lastLiteral).map((part) => (part === '*' ? null : part)),
|
|
732
|
+
});
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
shapes.push({ literals: segments.map((part) => (part === '*' ? null : part)) });
|
|
736
|
+
}
|
|
737
|
+
return shapes;
|
|
738
|
+
}
|
|
739
|
+
function shapeMatches(shape, segments) {
|
|
740
|
+
if (shape.literals.length !== segments.length)
|
|
741
|
+
return false;
|
|
742
|
+
return shape.literals.every((literal, index) => literal === null || literal === segments[index]);
|
|
743
|
+
}
|
|
744
|
+
function aliasTargetIssue(raw, shapes) {
|
|
745
|
+
const trimmed = trimTrailingSlashes(raw.trim().replace(/\\/g, '/'));
|
|
746
|
+
if (trimmed.length === 0 || trimmed.split('/').some((part) => part.length === 0 || part === '.' || part === '..')) {
|
|
747
|
+
return ALIAS_TARGET_MESSAGE;
|
|
748
|
+
}
|
|
749
|
+
if (trimmed.includes('*') || !trimmed.includes('/'))
|
|
750
|
+
return ALIAS_TARGET_MESSAGE;
|
|
751
|
+
const parts = trimmed.split('/').map((part) => part.toLowerCase());
|
|
752
|
+
if (shapes.length === 0)
|
|
753
|
+
return ALIAS_TARGET_MESSAGE;
|
|
754
|
+
if (shapes.some((shape) => shapeMatches(shape, parts)))
|
|
755
|
+
return ALIAS_TARGET_MESSAGE;
|
|
756
|
+
if (shapes.some((shape) => shapeMatches(shape, parts.slice(0, -1))))
|
|
757
|
+
return null;
|
|
758
|
+
return ALIAS_TARGET_MESSAGE;
|
|
759
|
+
}
|
|
760
|
+
/** A leading src/ or app/ is optional at match time, so both spellings are one glob. */
|
|
761
|
+
function aliasGlobVariants(segments) {
|
|
762
|
+
const head = segments[0];
|
|
763
|
+
if ((head === 'src' || head === 'app') && segments.length > 1)
|
|
764
|
+
return [segments, segments.slice(1)];
|
|
765
|
+
return [segments];
|
|
766
|
+
}
|
|
767
|
+
function aliasesCanMatchSameFile(left, right) {
|
|
768
|
+
const leftVariants = aliasGlobVariants(left);
|
|
769
|
+
const rightVariants = aliasGlobVariants(right);
|
|
770
|
+
for (const a of leftVariants) {
|
|
771
|
+
if (globsCanMatchSame(a, right, 0, 0, new Map()))
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
for (const b of rightVariants) {
|
|
775
|
+
if (globsCanMatchSame(left, b, 0, 0, new Map()))
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
function globsCanMatchSame(left, right, i, j, memo) {
|
|
781
|
+
const key = `${i}:${j}`;
|
|
782
|
+
const cached = memo.get(key);
|
|
783
|
+
if (cached !== undefined)
|
|
784
|
+
return cached;
|
|
785
|
+
let matched = false;
|
|
786
|
+
if (i === left.length && j === right.length)
|
|
787
|
+
matched = true;
|
|
788
|
+
else if (i < left.length && left[i] === '**') {
|
|
789
|
+
matched =
|
|
790
|
+
globsCanMatchSame(left, right, i + 1, j, memo) ||
|
|
791
|
+
(j < right.length && globsCanMatchSame(left, right, i, j + 1, memo));
|
|
792
|
+
}
|
|
793
|
+
else if (j < right.length && right[j] === '**') {
|
|
794
|
+
matched =
|
|
795
|
+
globsCanMatchSame(left, right, i, j + 1, memo) ||
|
|
796
|
+
(i < left.length && globsCanMatchSame(left, right, i + 1, j, memo));
|
|
797
|
+
}
|
|
798
|
+
else if (i < left.length && j < right.length) {
|
|
799
|
+
const a = left[i];
|
|
800
|
+
const b = right[j];
|
|
801
|
+
if (a === b || a === '*' || b === '*')
|
|
802
|
+
matched = globsCanMatchSame(left, right, i + 1, j + 1, memo);
|
|
803
|
+
}
|
|
804
|
+
memo.set(key, matched);
|
|
805
|
+
return matched;
|
|
806
|
+
}
|
|
807
|
+
function validateChildSliceAliases(candidate, issues) {
|
|
808
|
+
const rules = candidate.rules;
|
|
809
|
+
if (!Array.isArray(rules))
|
|
810
|
+
return;
|
|
811
|
+
rules.forEach((rule, index) => {
|
|
812
|
+
if (!isObject(rule))
|
|
813
|
+
return;
|
|
814
|
+
const child = rule.childSlices;
|
|
815
|
+
if (!isObject(child) || child.sliceAliases === undefined)
|
|
816
|
+
return;
|
|
817
|
+
const aliases = child.sliceAliases;
|
|
818
|
+
const path = `$.rules[${index}].childSlices.sliceAliases`;
|
|
819
|
+
if (!Array.isArray(aliases)) {
|
|
820
|
+
issues.push({ path, message: 'must be an array of { from, to } slice aliases' });
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const shapes = universeShapes(rule.sliceFolders, rule.sliceIdentity);
|
|
824
|
+
const sliceEntries = [
|
|
825
|
+
...(Array.isArray(rule.sliceFolders) ? rule.sliceFolders : []),
|
|
826
|
+
...(Array.isArray(child.sliceFolders) ? child.sliceFolders : []),
|
|
827
|
+
].filter((entry) => typeof entry === 'string' && entry.length > 0);
|
|
828
|
+
const globs = [];
|
|
829
|
+
aliases.forEach((alias, aliasIndex) => {
|
|
830
|
+
const aliasPath = `${path}[${aliasIndex}]`;
|
|
831
|
+
if (!isObject(alias)) {
|
|
832
|
+
issues.push({ path: aliasPath, message: 'must be an object with from and to' });
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
for (const key of Object.keys(alias)) {
|
|
836
|
+
if (key !== 'from' && key !== 'to') {
|
|
837
|
+
issues.push({ path: propertyPath(aliasPath, key), message: 'unknown field' });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
const from = alias.from;
|
|
841
|
+
const to = alias.to;
|
|
842
|
+
if (typeof from !== 'string' || from.trim().length === 0) {
|
|
843
|
+
issues.push({ path: `${aliasPath}.from`, message: 'must be a non-empty path glob' });
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
const issue = aliasGlobIssue(from);
|
|
847
|
+
if (issue)
|
|
848
|
+
issues.push({ path: `${aliasPath}.from`, message: issue });
|
|
849
|
+
else {
|
|
850
|
+
const segments = aliasPathSegments(from);
|
|
851
|
+
if (sliceEntries.some((entry) => globOverlapsSliceEntry(segments, entry))) {
|
|
852
|
+
issues.push({ path: `${aliasPath}.from`, message: ALIAS_OVERLAP_MESSAGE });
|
|
853
|
+
}
|
|
854
|
+
for (const previous of globs) {
|
|
855
|
+
if (aliasesCanMatchSameFile(previous, segments)) {
|
|
856
|
+
issues.push({ path: aliasPath, message: ALIAS_SAME_FILE_MESSAGE });
|
|
857
|
+
break;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
globs.push(segments);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
if (typeof to !== 'string' || to.trim().length === 0) {
|
|
864
|
+
issues.push({ path: `${aliasPath}.to`, message: ALIAS_TARGET_MESSAGE });
|
|
865
|
+
}
|
|
866
|
+
else {
|
|
867
|
+
const issue = aliasTargetIssue(to, shapes);
|
|
868
|
+
if (issue)
|
|
869
|
+
issues.push({ path: `${aliasPath}.to`, message: issue });
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
}
|
|
398
874
|
function validateLayerOwners(candidate, issues) {
|
|
399
875
|
const layers = candidate.layers;
|
|
400
876
|
if (!Array.isArray(layers))
|
|
@@ -525,6 +1001,9 @@ export function loadArkConfigContract(input, source = 'ark.config.json') {
|
|
|
525
1001
|
validateArkRunExtra(candidate, issues);
|
|
526
1002
|
validateArkOrderExtra(candidate, issues);
|
|
527
1003
|
validateLayerOwners(candidate, issues);
|
|
1004
|
+
validateChildSliceSiblings(candidate, issues);
|
|
1005
|
+
validateChildSliceAllowedCrossSlice(candidate, issues);
|
|
1006
|
+
validateChildSliceAliases(candidate, issues);
|
|
528
1007
|
if (issues.length > 0)
|
|
529
1008
|
throw new ArkConfigValidationError(source, issues);
|
|
530
1009
|
return { config: candidate, migratedFrom };
|
|
@@ -557,3 +1036,15 @@ export function withArkConfigMetadata(config) {
|
|
|
557
1036
|
}
|
|
558
1037
|
return result;
|
|
559
1038
|
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Trim trailing slashes without a regex.
|
|
1041
|
+
*
|
|
1042
|
+
* `/\/+$/` is a polynomial ReDoS on a value that comes from the repo's own
|
|
1043
|
+
* contract but is still library input. A scan is linear and says the same thing.
|
|
1044
|
+
*/
|
|
1045
|
+
function trimTrailingSlashes(value) {
|
|
1046
|
+
let end = value.length;
|
|
1047
|
+
while (end > 0 && value[end - 1] === '/')
|
|
1048
|
+
end -= 1;
|
|
1049
|
+
return value.slice(0, end);
|
|
1050
|
+
}
|
|
@@ -121,6 +121,9 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
|
|
|
121
121
|
entry('CONFIG_UNCLASSIFIED_FILES', 'config', 'Unclassified included files', 'Included source files match no layer pattern; import rules will not enforce on them.', 'Extend layer patterns or narrow include so every governed file is classified.', { oftenAdvisory: true }),
|
|
122
122
|
entry('CONFIG_LAYER_MISSING_OWNER', 'config', 'Layer missing owner', 'requireLayerOwners is on and this layer has no owners. Writes to that house fail closed, like a folder with no name on the door.', 'Add a GitHub handle or email to that layer’s owners in ark.config.json (/ark-adopt), then re-run. Reserved/allowEmpty houses may stay unnamed. Turn the flag off only if you no longer want owners required.'),
|
|
123
123
|
entry('CONFIG_SLICE_IDENTITY_COLLISION', 'config', 'Starred slice prefixes share one id', 'sliceIdentity is stars and two different sliceFolders prefixes bind as the same slice id. Parallel trees of one feature do that on purpose; unrelated trees that share the last folder name merge by accident.', 'Keep stars when those prefixes are one feature. Otherwise use path, or rename the last literal so the prefixes do not bind as the same id. Doctor names both paths.', { oftenAdvisory: true }),
|
|
124
|
+
entry('CONFIG_CHILD_SLICES_VERSION', 'config', 'childSlices needs a newer arkgate', 'The rule schema sets additionalProperties to false. arkgate 4.8.22 and older reject a config that contains childSlices instead of ignoring the key.', 'Pin arkgate newer than 4.8.22 before shipping a config that sets childSlices. This warning does not fail the check.', { oftenAdvisory: true }),
|
|
125
|
+
entry('CONFIG_CHILD_SLICE_EXTENDS', 'config', 'Child slice id does not extend its universe', 'A path resolved a child id that is not the universe id plus a further segment. The child wall does not treat that id as a child, so the edge is universe common until the folders line up.', 'Point childSlices.sliceFolders at children under the same universe id the outer sliceFolders already names. Doctor names the path.', { oftenAdvisory: true }),
|
|
126
|
+
entry('CONFIG_CHILD_SLICE_CROSS_UNIVERSE', 'config', 'Child slice allowance cannot cross the universe wall', 'childSlices.allowedCrossSlice names a pattern that can match two universes. That list clears only a sibling crossing inside one universe. The universe wall still denies the edge.', 'Narrow the pattern so both sides share one universe prefix (features/projects/* to features/projects/d2d-item), or remove the entry. This warning does not fail the check.', { oftenAdvisory: true }),
|
|
124
127
|
// ── literal path drift ───────────────────────────────────────────────────
|
|
125
128
|
entry('LITERAL_PATH_DRIFT', 'drift', 'Literal path moved by a rename', 'A repo path written inside a string, a comment or a docstring no longer resolves, and the rename set says where it went. Nothing in the gate sees this class: `tsc` resolves imports, not strings, and ESLint does not either, so the rename compiles green and the reference lies afterwards. It appears in four forms — the tsconfig alias, a relative literal, a path written without the include-root prefix, and prose — and a hand sweep reliably covers one of them.', 'Apply the suggested replacement, or re-run `npx arkgate-check --path-drift --base-ref <ref> --write` to apply every writable anchored replacement at once. The rewrite is mechanical and one-directional: the destination comes from the rename, it must itself resolve and be path-shaped, and the token is rewritten in the form the author wrote it in. A destination that leaves the alias root of the literal is reported with the target only and must be rewritten by hand.'),
|
|
126
129
|
entry('LITERAL_PATH_UNRESOLVED', 'drift', 'Literal path does not resolve', 'A literal that looks like a repo path does not resolve under this root, and no rename explains where it went. Unlike LITERAL_PATH_DRIFT this is a candidate, not a verdict: with nothing to anchor it, ArkGate cannot tell a dead reference from an illustrative path in a comment, an example in documentation, or a path belonging to another tree.', 'Read the candidate and decide: fix the path, or leave it. Advisory only — it never fails a run and is never rewritten by --write, because there is no destination to propose. Run `--path-drift --all` to list the sweep.', { oftenAdvisory: true }),
|
|
@@ -183,3 +186,32 @@ export function catalogFixForRuleId(ruleId) {
|
|
|
183
186
|
export function catalogWhyForRuleId(ruleId) {
|
|
184
187
|
return getDiagnosticCatalogEntry(ruleId)?.why;
|
|
185
188
|
}
|
|
189
|
+
export const SLICE_REASON_HINTS = Object.freeze([
|
|
190
|
+
Object.freeze({
|
|
191
|
+
reasonId: 'CROSS_PARENT_SLICE',
|
|
192
|
+
ruleId: 'LAYER_IMPORT_VIOLATION',
|
|
193
|
+
title: 'Cross-universe slice',
|
|
194
|
+
why: 'This import crosses two universe slices. The child wall cannot allow another universe.',
|
|
195
|
+
fix: 'Keep the import inside one universe, or move the shared code into a folder both universes may use.',
|
|
196
|
+
}),
|
|
197
|
+
Object.freeze({
|
|
198
|
+
reasonId: 'CROSS_SIBLING_SLICE',
|
|
199
|
+
ruleId: 'LAYER_IMPORT_VIOLATION',
|
|
200
|
+
title: 'Cross-sibling slice',
|
|
201
|
+
why: 'This import crosses two feature slices inside one universe.',
|
|
202
|
+
fix: 'Import universe-common code, or move the shared piece into the feature that owns it. An enforce list makes those importer subtrees errors. Other sibling crossings stay warnings, and a new one past the baseline still fails.',
|
|
203
|
+
}),
|
|
204
|
+
Object.freeze({
|
|
205
|
+
reasonId: 'CROSS_PARENT_VIA_SHARED',
|
|
206
|
+
ruleId: 'LAYER_IMPORT_VIOLATION',
|
|
207
|
+
title: 'Cross-universe through a shared root',
|
|
208
|
+
why: 'A slice reaches another universe only through a shared root. ark-check and CI report this path. The write hook and ESLint see one edge at a time and do not block it.',
|
|
209
|
+
fix: 'Stop the shared root from importing the other universe, or move that import into the universe that owns it. A shared hop that stays inside one universe is allowed.',
|
|
210
|
+
}),
|
|
211
|
+
]);
|
|
212
|
+
const SLICE_REASON_BY_ID = new Map(SLICE_REASON_HINTS.map((hint) => [hint.reasonId, hint]));
|
|
213
|
+
export function sliceReasonHint(reasonId) {
|
|
214
|
+
if (typeof reasonId !== 'string' || reasonId.length === 0)
|
|
215
|
+
return undefined;
|
|
216
|
+
return SLICE_REASON_BY_ID.get(reasonId);
|
|
217
|
+
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
computePhysicalCohesion,
|
|
12
12
|
printPhysicalCohesionSection,
|
|
13
13
|
} from './physical-cohesion.mjs';
|
|
14
|
+
import { collectFlatParentPilot, printFlatParentPilot } from './flat-parent-pilot.mjs';
|
|
14
15
|
import {
|
|
15
16
|
computeDecisionAwareReshapePilot,
|
|
16
17
|
computeReshapeDecisionMemory,
|
|
@@ -37,7 +38,7 @@ import {
|
|
|
37
38
|
} from './ark-order-doctor.mjs';
|
|
38
39
|
import { composeMergePlanesHonesty } from './extra-merge-teeth.mjs';
|
|
39
40
|
import { collectPrototypeShortcutsResidual } from './prototype-shortcuts.mjs';
|
|
40
|
-
import { sliceIdentityCollisions } from '../ark-layer-match.mjs';
|
|
41
|
+
import { sliceAliasReport, sliceCountReport, sliceIdentityCollisions } from '../ark-layer-match.mjs';
|
|
41
42
|
|
|
42
43
|
export function attachExtraDoctorSections(rulesUnderContract, config, classification, findings) {
|
|
43
44
|
const arkRulesMerge = {
|
|
@@ -130,6 +131,12 @@ export function printCompactExtraDoctorLines(advisories, io) {
|
|
|
130
131
|
io.line(io.warn, collision.message);
|
|
131
132
|
}
|
|
132
133
|
}
|
|
134
|
+
const flatMoves = advisories?.flatParentPilot?.moves;
|
|
135
|
+
if (Array.isArray(flatMoves) && flatMoves.length > 0 && typeof flatMoves[0]?.evidence === 'string') {
|
|
136
|
+
console.log('');
|
|
137
|
+
io.line(io.warn, flatMoves[0].evidence);
|
|
138
|
+
if (flatMoves.length > 1) io.line(' ', `${flatMoves.length - 1} more flat-parent suggestion(s) in --doctor --all`);
|
|
139
|
+
}
|
|
133
140
|
const noDomain = advisories?.noDomainFrontend;
|
|
134
141
|
if (noDomain?.ask) {
|
|
135
142
|
console.log('');
|
|
@@ -189,6 +196,31 @@ export function printCompactExtraDoctorLines(advisories, io) {
|
|
|
189
196
|
}
|
|
190
197
|
}
|
|
191
198
|
|
|
199
|
+
function stripEdgeSlashes(value, edge) {
|
|
200
|
+
let start = 0;
|
|
201
|
+
let end = value.length;
|
|
202
|
+
if (edge !== 'end') {
|
|
203
|
+
while (start < end && value[start] === '/') start += 1;
|
|
204
|
+
}
|
|
205
|
+
if (edge !== 'start') {
|
|
206
|
+
while (end > start && value[end - 1] === '/') end -= 1;
|
|
207
|
+
}
|
|
208
|
+
return value.slice(start, end);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Governed file lists are absolute. Alias globs are project-relative. */
|
|
212
|
+
function aliasScanPath(root, entry) {
|
|
213
|
+
const raw = typeof entry === 'string' ? entry : typeof entry?.path === 'string' ? entry.path : '';
|
|
214
|
+
if (!raw) return null;
|
|
215
|
+
const norm = raw.replace(/\\/g, '/');
|
|
216
|
+
const base = stripEdgeSlashes(String(root ?? '').replace(/\\/g, '/'), 'end');
|
|
217
|
+
const rel =
|
|
218
|
+
base && (norm === base || norm.startsWith(`${base}/`))
|
|
219
|
+
? stripEdgeSlashes(norm.slice(base.length), 'start')
|
|
220
|
+
: stripEdgeSlashes(norm, 'start');
|
|
221
|
+
return rel.length > 0 ? rel : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
192
224
|
function classificationFromCoverage(cov) {
|
|
193
225
|
return {
|
|
194
226
|
governedPercent: cov?.governed?.percent ?? null,
|
|
@@ -256,7 +288,23 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
|
|
|
256
288
|
files,
|
|
257
289
|
});
|
|
258
290
|
const sliceIdentityHits = sliceIdentityCollisions(rules ?? config?.rules);
|
|
291
|
+
const slices = sliceCountReport(activeViolations);
|
|
292
|
+
const aliasFiles = [];
|
|
293
|
+
for (const entry of Array.isArray(files) ? files : []) {
|
|
294
|
+
const rel = aliasScanPath(root, entry);
|
|
295
|
+
if (rel) aliasFiles.push(rel);
|
|
296
|
+
}
|
|
297
|
+
const sliceAliases = sliceAliasReport(rules ?? config?.rules, aliasFiles);
|
|
298
|
+
const flatParentPilot = collectFlatParentPilot({
|
|
299
|
+
root,
|
|
300
|
+
files: aliasFiles,
|
|
301
|
+
rules: rules ?? config?.rules,
|
|
302
|
+
layers: config?.layers,
|
|
303
|
+
});
|
|
259
304
|
return {
|
|
305
|
+
...(slices ? { slices } : {}),
|
|
306
|
+
...(sliceAliases ? { sliceAliases } : {}),
|
|
307
|
+
...(flatParentPilot ? { flatParentPilot } : {}),
|
|
260
308
|
...(prototypeShortcuts ? { prototypeShortcuts } : {}),
|
|
261
309
|
...(sliceIdentityHits.length > 0
|
|
262
310
|
? { sliceIdentity: { notAScore: true, collisions: sliceIdentityHits } }
|
|
@@ -277,6 +325,17 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
|
|
|
277
325
|
}
|
|
278
326
|
|
|
279
327
|
export function printDoctorAdvisories(advisories, io) {
|
|
328
|
+
printFlatParentPilot(advisories?.flatParentPilot, io);
|
|
329
|
+
const sliceAliases = advisories?.sliceAliases;
|
|
330
|
+
if (sliceAliases && sliceAliases.notAScore === true && Array.isArray(sliceAliases.moves)) {
|
|
331
|
+
console.log('');
|
|
332
|
+
console.log(io.color.bold('Slice aliases (owed move)'));
|
|
333
|
+
io.line(io.warn, sliceAliases.debt);
|
|
334
|
+
for (const move of sliceAliases.moves) {
|
|
335
|
+
io.line(io.warn, `${move.from} → ${move.to}. Move these files to ${move.destination}. They are not finished.`);
|
|
336
|
+
for (const file of move.files ?? []) io.line(' ', file);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
280
339
|
const sliceIdentity = advisories?.sliceIdentity;
|
|
281
340
|
if (Array.isArray(sliceIdentity?.collisions) && sliceIdentity.collisions.length > 0) {
|
|
282
341
|
console.log('');
|