arkgate 4.8.23 → 4.8.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +24 -77
  4. package/bin/ark-dashboard.mjs +56 -53
  5. package/bin/ark-layer-match.mjs +596 -112
  6. package/bin/ark-mcp-runtime.mjs +412 -138
  7. package/bin/ark-mcp.mjs +68 -4
  8. package/bin/ark-shared.mjs +39 -0
  9. package/bin/ark.mjs +65 -97
  10. package/bin/lib/adapter-contract-types.mjs +5 -0
  11. package/bin/lib/adapter-contract.mjs +15 -0
  12. package/bin/lib/agent-projection-command.mjs +5 -1
  13. package/bin/lib/analysis-engine.mjs +15 -11
  14. package/bin/lib/architecture-scan.mjs +50 -6
  15. package/bin/lib/ark-order-doctor.mjs +1 -1
  16. package/bin/lib/ark-order-facts.mjs +410 -15
  17. package/bin/lib/ark-order-invariants.mjs +51 -9
  18. package/bin/lib/ark-order-sensors.mjs +28 -101
  19. package/bin/lib/ark-order-types.mjs +2 -0
  20. package/bin/lib/ark-run-doctor.mjs +14 -0
  21. package/bin/lib/ark-run-facts.mjs +434 -11
  22. package/bin/lib/ark-run-sensors.mjs +5 -3
  23. package/bin/lib/arkgate-pins.mjs +91 -0
  24. package/bin/lib/arkrules-sensors.mjs +50 -249
  25. package/bin/lib/check-args.mjs +23 -1
  26. package/bin/lib/ci-and-commands.mjs +16 -6
  27. package/bin/lib/class-source-scan.mjs +479 -0
  28. package/bin/lib/codex-home.mjs +4 -2
  29. package/bin/lib/config-contract-slices.mjs +701 -0
  30. package/bin/lib/config-contract.mjs +5 -493
  31. package/bin/lib/config-warnings.mjs +32 -0
  32. package/bin/lib/dashboard-cli.mjs +62 -0
  33. package/bin/lib/design-delta.mjs +1 -1
  34. package/bin/lib/diagnostic-catalog.mjs +16 -9
  35. package/bin/lib/doctor-advisories.mjs +25 -1
  36. package/bin/lib/doctor-next-actions.mjs +17 -2
  37. package/bin/lib/doctor-plan.mjs +6 -8
  38. package/bin/lib/effective-contract-load.mjs +121 -59
  39. package/bin/lib/enforcement-profiles.mjs +40 -4
  40. package/bin/lib/first-run-help.mjs +3 -2
  41. package/bin/lib/flat-parent-importers.mjs +137 -0
  42. package/bin/lib/flat-parent-pilot.mjs +40 -100
  43. package/bin/lib/gate-files.mjs +29 -2
  44. package/bin/lib/git-change-scope.mjs +109 -0
  45. package/bin/lib/github-enforcement.mjs +1 -1
  46. package/bin/lib/hook-templates.mjs +18 -16
  47. package/bin/lib/host-support-matrix.mjs +3 -1
  48. package/bin/lib/import-resolve.mjs +13 -1
  49. package/bin/lib/install-migrate.mjs +50 -73
  50. package/bin/lib/invariant-coverage.mjs +59 -11
  51. package/bin/lib/mcp-adoption.mjs +9 -1
  52. package/bin/lib/mcp-hook-payload.mjs +401 -61
  53. package/bin/lib/package-manager.mjs +59 -1
  54. package/bin/lib/passthrough-commands.mjs +83 -0
  55. package/bin/lib/persistence-write-hint.mjs +81 -0
  56. package/bin/lib/pin-dependent-gates.mjs +117 -0
  57. package/bin/lib/policy-delta-io.mjs +168 -10
  58. package/bin/lib/prepare-change.mjs +120 -6
  59. package/bin/lib/prepare-write.mjs +1 -0
  60. package/bin/lib/presets.mjs +3 -5
  61. package/bin/lib/remediation.mjs +23 -4
  62. package/bin/lib/resolved-candidate-facts.mjs +315 -55
  63. package/bin/lib/rules-inventory.mjs +22 -3
  64. package/bin/lib/rules-under-contract.mjs +179 -5
  65. package/bin/lib/scan-files.mjs +32 -0
  66. package/bin/lib/snippet-analysis.mjs +140 -6
  67. package/bin/lib/source-policy.mjs +34 -0
  68. package/bin/lib/stable-hash.mjs +90 -0
  69. package/bin/lib/start-install-recovery.mjs +71 -1
  70. package/bin/lib/start-preview.mjs +30 -0
  71. package/bin/lib/status-command.mjs +33 -4
  72. package/bin/lib/status-manifest.mjs +32 -4
  73. package/bin/lib/team-parliament-io.mjs +77 -75
  74. package/bin/lib/team-parliament.mjs +6 -1
  75. package/bin/lib/write-path-capabilities.mjs +4 -2
  76. package/dist/chunk-A5EIY3HI.cjs +5 -0
  77. package/dist/chunk-MBXCFLO4.js +16 -0
  78. package/dist/chunk-RNQHRDCQ.cjs +16 -0
  79. package/dist/chunk-SVSQVGFJ.js +5 -0
  80. package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-BJbaeoj4.d.cts} +18 -3
  81. package/dist/configTypes-BJbaeoj4.d.ts +246 -0
  82. package/dist/eslint/index.cjs +17 -4
  83. package/dist/eslint/index.d.cts +359 -0
  84. package/dist/eslint/index.d.ts +130 -156
  85. package/dist/eslint/index.js +10 -4
  86. package/dist/index.cjs +35 -34
  87. package/dist/index.d.cts +5475 -0
  88. package/dist/index.d.ts +3312 -299
  89. package/dist/index.js +35 -34
  90. package/dist/inspectorListen-6DQVJ4DC.js +1 -0
  91. package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
  92. package/dist/nestjs/index.cjs +1 -18
  93. package/dist/nestjs/index.d.cts +22 -0
  94. package/dist/nestjs/index.d.ts +1 -3
  95. package/dist/nestjs/index.js +1 -18
  96. package/dist/order/index.cjs +1 -1
  97. package/dist/order/index.d.cts +155 -0
  98. package/dist/order/index.d.ts +15 -3
  99. package/dist/order/index.js +1 -1
  100. package/dist/runtime/index.cjs +13 -30
  101. package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +804 -290
  102. package/dist/runtime/index.d.ts +2678 -108
  103. package/dist/runtime/index.js +13 -30
  104. package/dist/{types-Bdj32QwA.d.ts → types-DRinG-vT.d.cts} +576 -140
  105. package/dist/types-DRinG-vT.d.ts +1792 -0
  106. package/docs/README.md +2 -1
  107. package/docs/agent-guide.md +84 -15
  108. package/docs/ai-gates.md +160 -26
  109. package/docs/arkorder.md +32 -15
  110. package/docs/brownfield-adoption.md +5 -1
  111. package/docs/configuration.md +196 -26
  112. package/docs/develop.md +2 -1
  113. package/docs/diagnostics.md +96 -19
  114. package/docs/package-surface.md +31 -8
  115. package/docs/product-voice.md +1 -1
  116. package/package.json +59 -18
  117. package/schemas/ark.analysis-result.schema.json +15 -0
  118. package/schemas/ark.config.schema.json +47 -10
  119. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  120. package/schemas/ark.status-manifest.schema.json +22 -0
  121. package/server.json +3 -9
  122. package/templates/agent-skills/README.md +1 -1
  123. package/templates/agent-skills/ark-adopt/SKILL.md +4 -1
  124. package/templates/agent-skills/ark-order/SKILL.md +4 -2
  125. package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
  126. package/templates/arkrules/PersistenceAdapters.json +1 -1
  127. package/templates/arkrules/PresentationAdapters.json +1 -1
  128. package/templates/skills/ark-adopt.md +4 -1
  129. package/templates/skills/ark-order.md +4 -2
  130. package/templates/skills/ark-runtime.md +6 -1
  131. package/dist/types-Ik_OakcI.d.ts +0 -183
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { ARK_ORDER_SCHEMA_DEF, ARK_RUN_SCHEMA_DEF, defaultedArkOrder, defaultedArkRun, validateArkOrderExtra, validateArkRunExtra, } from './config-extras.mjs';
12
+ import { CHILD_SLICES_SCHEMA_DEF, SHARED_IMPORTS_SLICE_SCHEMA_DEF, validateSliceContract, } from './config-contract-slices.mjs';
12
13
  import { canonicalStewardId } from './team-parliament.mjs';
13
14
  /** Current published ark.config.json schema version (ADR 0027: 1.3 adds optional arkOrder). */
14
15
  export const ARK_CONFIG_SCHEMA_VERSION = '1.3';
@@ -217,7 +218,7 @@ export const ARK_CONFIG_SCHEMA = {
217
218
  sliceIdentity: {
218
219
  type: 'string',
219
220
  enum: ['path', 'stars'],
220
- description: 'How a starred sliceFolders prefix is named. Absent and path keep today\'s ids (every literal and star binding). stars keeps the last literal plus the star bindings, so parallel trees share one feature id. Bare names are unchanged.',
221
+ description: 'How a starred sliceFolders prefix is named. Absent and path keep today\'s ids (every literal and star binding). stars keeps the last literal plus every star binding (a star before the last literal is kept too), so parallel trees share one feature id. Bare names are unchanged.',
221
222
  },
222
223
  sharedRoots: { ...stringArraySchema, minItems: 1 },
223
224
  allowedCrossSlice: {
@@ -233,69 +234,8 @@ export const ARK_CONFIG_SCHEMA = {
233
234
  },
234
235
  },
235
236
  },
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
+ sharedImportsSlice: SHARED_IMPORTS_SLICE_SCHEMA_DEF,
238
+ childSlices: CHILD_SLICES_SCHEMA_DEF,
299
239
  },
300
240
  },
301
241
  safety: {
@@ -457,420 +397,6 @@ function validateNode(value, schema, path, root, issues) {
457
397
  }
458
398
  }
459
399
  }
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
- }
874
400
  function validateLayerOwners(candidate, issues) {
875
401
  const layers = candidate.layers;
876
402
  if (!Array.isArray(layers))
@@ -1001,9 +527,7 @@ export function loadArkConfigContract(input, source = 'ark.config.json') {
1001
527
  validateArkRunExtra(candidate, issues);
1002
528
  validateArkOrderExtra(candidate, issues);
1003
529
  validateLayerOwners(candidate, issues);
1004
- validateChildSliceSiblings(candidate, issues);
1005
- validateChildSliceAllowedCrossSlice(candidate, issues);
1006
- validateChildSliceAliases(candidate, issues);
530
+ validateSliceContract(candidate, issues);
1007
531
  if (issues.length > 0)
1008
532
  throw new ArkConfigValidationError(source, issues);
1009
533
  return { config: candidate, migratedFrom };
@@ -1036,15 +560,3 @@ export function withArkConfigMetadata(config) {
1036
560
  }
1037
561
  return result;
1038
562
  }
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
- }
@@ -59,3 +59,35 @@ export function collectConfigWarnings(root, config, files, rules, manifest) {
59
59
  files: files.map((file) => normalize(path.relative(root, file))),
60
60
  });
61
61
  }
62
+
63
+ /**
64
+ * Machine-readable refusal for an invalid contract under `--json` / `--doctor --json`.
65
+ * The human message still goes to stderr; stdout must never be empty for JSON consumers.
66
+ */
67
+
68
+ /**
69
+ * @param {unknown} error
70
+ * @param {string[]} argv
71
+ * @returns {string|null} JSON line for stdout, or null when not a config-contract error / not --json
72
+ */
73
+ export function configInvalidJsonEnvelope(error, argv) {
74
+ if (!argv.includes('--json')) return null;
75
+ const e = /** @type {{ name?: string, issues?: Array<{path: string, message: string}>, source?: string }} */ (error);
76
+ if (e?.name !== 'ArkConfigValidationError' || !Array.isArray(e.issues)) {
77
+ // Any other fatal error still answers --json with JSON (exit 2 stays the caller's job),
78
+ // so a CI step that parses stdout never sees an empty or plain-text body.
79
+ const message = error instanceof Error ? error.message : String(error);
80
+ const policyBase = /^(Cannot (read|resolve) policy base|Policy base )/.test(message);
81
+ return JSON.stringify({
82
+ ok: false,
83
+ error: policyBase ? 'POLICY_BASE_UNREADABLE' : 'CHECK_ERROR',
84
+ message,
85
+ });
86
+ }
87
+ return JSON.stringify({
88
+ ok: false,
89
+ error: 'CONFIG_INVALID',
90
+ configPath: e.source,
91
+ messages: e.issues.map((issue) => `${issue.path}: ${issue.message}`),
92
+ });
93
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Shared, pure helpers for the observability dashboard CLI (`ark-dashboard`,
3
+ * `arkgate-dashboard`, and the `ark dashboard` passthrough). No I/O here.
4
+ */
5
+
6
+ export const DASHBOARD_DEFAULT_INTERVAL_MS = 2000;
7
+ export const DASHBOARD_DEFAULT_TIMEOUT_MS = 5000;
8
+ export const DASHBOARD_DEFAULT_URL = 'http://127.0.0.1:3000/snapshot';
9
+ export const DASHBOARD_MIN_MS = 200;
10
+ export const DASHBOARD_MAX_MS = 60_000;
11
+
12
+ export const DASHBOARD_HELP = `arkgate-dashboard (alias ark-dashboard, ark dashboard) — observability TUI.
13
+ Usage: arkgate-dashboard [--url <inspector-url>] [--interval <ms>] [--timeout <ms>] [--once]
14
+ Polls an ArkRun inspector; it does not start the kernel.
15
+
16
+ -u, --url <url> Inspector URL: handle.url (root) or handle.snapshotUrl.
17
+ Default: $ARK_DASHBOARD_URL, else ${DASHBOARD_DEFAULT_URL}.
18
+ startInspector() binds a random port unless you pass
19
+ { port }, so pass the URL your app printed.
20
+ -i, --interval <ms> Poll interval, clamped to ${DASHBOARD_MIN_MS}-${DASHBOARD_MAX_MS} (default ${DASHBOARD_DEFAULT_INTERVAL_MS}).
21
+ -t, --timeout <ms> Fetch timeout, clamped to ${DASHBOARD_MIN_MS}-${DASHBOARD_MAX_MS} (default ${DASHBOARD_DEFAULT_TIMEOUT_MS}).
22
+ --once Render one frame and exit (non-zero when the kernel is unreachable).
23
+ -h, --help Show this help.
24
+ -v, --version Print the arkgate version.`;
25
+
26
+ export const DASHBOARD_EPHEMERAL_PORT_HINT =
27
+ 'Hint: startInspector() binds a random port by default (port 0). Pass --url <handle.url> ' +
28
+ '(printed by your app), set ARK_DASHBOARD_URL, or start the inspector with startInspector({ port: 3000 }).';
29
+
30
+ /**
31
+ * Clamp a millisecond flag into [min, max]. Non-numeric input uses the default.
32
+ * @param {string | undefined} raw
33
+ * @param {number} fallback
34
+ */
35
+ export function clampDashboardMs(raw, fallback) {
36
+ if (raw === undefined || raw === null || String(raw).trim() === '') return fallback;
37
+ const parsed = Number.parseInt(String(raw), 10);
38
+ if (!Number.isFinite(parsed)) return fallback;
39
+ return Math.min(DASHBOARD_MAX_MS, Math.max(DASHBOARD_MIN_MS, parsed));
40
+ }
41
+
42
+ /**
43
+ * Derive a sibling inspector route (`/outbox`, `/workflows`) from the URL the
44
+ * user passed. Accepts the inspector root (`handle.url`, with or without a
45
+ * trailing slash), `/snapshot`, `/snapshot/`, and prefix mounts (`/x/snapshot`).
46
+ * @param {string} inspectorUrl
47
+ * @param {string} suffix e.g. '/outbox'
48
+ * @returns {string | null}
49
+ */
50
+ export function dashboardSiblingUrl(inspectorUrl, suffix) {
51
+ try {
52
+ const u = new URL(inspectorUrl);
53
+ let basePath = u.pathname.replace(/\/+$/, '');
54
+ if (basePath.endsWith('/snapshot')) basePath = basePath.slice(0, -'/snapshot'.length);
55
+ u.pathname = `${basePath}${suffix}`;
56
+ u.search = '';
57
+ u.hash = '';
58
+ return u.toString();
59
+ } catch {
60
+ return null;
61
+ }
62
+ }