arkgate 4.8.19 → 4.8.21

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 (85) hide show
  1. package/CHANGELOG.md +220 -0
  2. package/README.md +107 -117
  3. package/bin/ark-check-runtime.mjs +26 -4
  4. package/bin/ark-layer-match.mjs +236 -12
  5. package/bin/ark.mjs +6 -2
  6. package/bin/lib/analysis-engine.mjs +13 -6
  7. package/bin/lib/config-contract.mjs +6 -0
  8. package/bin/lib/diagnostic-catalog.mjs +1 -0
  9. package/bin/lib/doctor-advisories.mjs +22 -1
  10. package/bin/lib/doctor-human.mjs +11 -9
  11. package/bin/lib/doctor-plan.mjs +17 -14
  12. package/bin/lib/enforcement-honesty.mjs +30 -2
  13. package/bin/lib/first-run-help.mjs +7 -1
  14. package/bin/lib/html-report-advisories.mjs +8 -0
  15. package/bin/lib/html-report-depth.mjs +12 -7
  16. package/bin/lib/invariant-coverage.mjs +406 -54
  17. package/bin/lib/managed-upgrade.mjs +59 -14
  18. package/bin/lib/package-manager.mjs +5 -2
  19. package/bin/lib/physical-cohesion.mjs +68 -51
  20. package/bin/lib/pilot-loop.mjs +183 -37
  21. package/bin/lib/reshape-decisions.mjs +2 -2
  22. package/bin/lib/rules-under-contract.mjs +40 -4
  23. package/bin/lib/start-install-recovery.mjs +156 -0
  24. package/bin/lib/start-preview.mjs +0 -54
  25. package/bin/lib/upgrade-behavior-changes.mjs +83 -0
  26. package/bin/lib/upgrade-command.mjs +69 -31
  27. package/bin/lib/upgrade-outcome.mjs +359 -0
  28. package/bin/lib/violations.mjs +109 -0
  29. package/dist/{configTypes-VD0qcubY.d.ts → configTypes-CTELo87-.d.ts} +9 -0
  30. package/dist/{diagnosticCatalog-D2v0ASN2.d.ts → diagnosticCatalog-B7BXlet6.d.ts} +79 -5
  31. package/dist/eslint/index.cjs +4 -4
  32. package/dist/eslint/index.d.ts +35 -4
  33. package/dist/eslint/index.js +4 -4
  34. package/dist/index.cjs +25 -18
  35. package/dist/index.d.ts +13 -4
  36. package/dist/index.js +27 -20
  37. package/dist/nestjs/index.cjs +9 -9
  38. package/dist/nestjs/index.d.ts +3 -3
  39. package/dist/nestjs/index.js +9 -9
  40. package/dist/runtime/index.cjs +18 -11
  41. package/dist/runtime/index.d.ts +6 -6
  42. package/dist/runtime/index.js +18 -11
  43. package/dist/{types-D5GT5ZT8.d.ts → types-fQ9-KrBj.d.ts} +1 -1
  44. package/dist/{types-BSzRy2X1.d.ts → types-yDNEQ603.d.ts} +1 -1
  45. package/docs/README.md +4 -3
  46. package/docs/agent-guide.md +15 -7
  47. package/docs/brownfield-adoption.md +4 -3
  48. package/docs/configuration.md +46 -8
  49. package/docs/develop.md +1 -1
  50. package/docs/diagnostics.md +12 -2
  51. package/docs/package-surface.md +4 -3
  52. package/docs/product-voice.md +11 -3
  53. package/docs/use.md +1 -1
  54. package/package.json +3 -3
  55. package/schemas/ark.config.schema.json +14 -0
  56. package/server.json +2 -2
  57. package/templates/agent-skills/README.md +1 -1
  58. package/templates/agent-skills/ark-adopt/SKILL.md +18 -1
  59. package/templates/agent-skills/ark-architect/SKILL.md +2 -0
  60. package/templates/agent-skills/ark-autopilot/SKILL.md +6 -2
  61. package/templates/agent-skills/ark-contract/SKILL.md +2 -0
  62. package/templates/agent-skills/ark-coverage/SKILL.md +3 -1
  63. package/templates/agent-skills/ark-explain/SKILL.md +3 -1
  64. package/templates/agent-skills/ark-explore/SKILL.md +15 -1
  65. package/templates/agent-skills/ark-fix/SKILL.md +2 -0
  66. package/templates/agent-skills/ark-loop/SKILL.md +2 -0
  67. package/templates/agent-skills/ark-order/SKILL.md +2 -0
  68. package/templates/agent-skills/ark-place/SKILL.md +2 -0
  69. package/templates/agent-skills/ark-runtime/SKILL.md +2 -0
  70. package/templates/agent-skills/ark-think/SKILL.md +2 -0
  71. package/templates/agent-skills/ark-upgrade/SKILL.md +25 -10
  72. package/templates/skills/ark-adopt.md +18 -1
  73. package/templates/skills/ark-architect.md +2 -0
  74. package/templates/skills/ark-autopilot.md +6 -2
  75. package/templates/skills/ark-contract.md +2 -0
  76. package/templates/skills/ark-coverage.md +3 -1
  77. package/templates/skills/ark-explain.md +3 -1
  78. package/templates/skills/ark-explore.md +15 -1
  79. package/templates/skills/ark-fix.md +2 -0
  80. package/templates/skills/ark-loop.md +2 -0
  81. package/templates/skills/ark-order.md +2 -0
  82. package/templates/skills/ark-place.md +2 -0
  83. package/templates/skills/ark-runtime.md +2 -0
  84. package/templates/skills/ark-think.md +2 -0
  85. package/templates/skills/ark-upgrade.md +25 -10
@@ -215,18 +215,192 @@ export function layerForRelativePath(relPath, layers) {
215
215
  * Includes the parent folder so `features/auth` ≠ `modules/auth`.
216
216
  * Identity is case-normalized for portable results across filesystems.
217
217
  * `src/features/auth/api.ts` + folders `["features"]` → `"features/auth"`.
218
+ *
219
+ * A bare name stays that unanchored one-segment match. The child segment is
220
+ * never the filename, so a flat file under the parent is not its own slice.
221
+ * A starred prefix (`lib` / `features` / `*` / `*`) reuses the shared-root
222
+ * anchor: it must sit at offset 0, or at offset 1 after `src/` or `app/`.
223
+ * With `sliceIdentity` `"path"` (the default) the slice id includes the
224
+ * literal prefix plus the star bindings (`lib/features/projects/rfi`), so
225
+ * parallel trees get different ids. With `"stars"` it is the last literal
226
+ * plus those star bindings. A star never binds the filename; a file directly
227
+ * under the last bound directory keeps that directory as its slice.
218
228
  */
219
- export function sliceIdForPath(relPath, sliceFolders) {
229
+ export function sliceIdForPath(relPath, sliceFolders, sliceIdentity) {
220
230
  if (!sliceFolders?.length)
221
231
  return undefined;
232
+ const identity = sliceIdentity === 'stars' ? 'stars' : 'path';
222
233
  const parts = String(relPath)
223
234
  .split(/[/\\]/)
224
235
  .filter(Boolean);
225
- const folders = new Set(sliceFolders.map((s) => String(s).toLowerCase()));
226
- for (let i = 0; i < parts.length - 1; i += 1) {
227
- if (folders.has(parts[i].toLowerCase())) {
228
- return `${parts[i].toLowerCase()}/${parts[i + 1].toLowerCase()}`;
236
+ if (parts.length === 0)
237
+ return undefined;
238
+ const bare = new Set();
239
+ for (const raw of sliceFolders) {
240
+ if (typeof raw !== 'string' || raw.length === 0)
241
+ continue;
242
+ if (isAnchoredSliceEntry(raw)) {
243
+ const anchored = anchoredSliceId(parts, raw, identity);
244
+ if (anchored)
245
+ return anchored;
246
+ continue;
247
+ }
248
+ if (!raw.includes('/') && !raw.includes('\\') && !raw.includes('*')) {
249
+ bare.add(raw.toLowerCase());
250
+ }
251
+ }
252
+ return bareSliceId(parts, bare);
253
+ }
254
+ /** A starred prefix with a real leading directory, not a bare name and not `*`. */
255
+ function isAnchoredSliceEntry(entry) {
256
+ const segments = entry.split(/[/\\]/).filter((part) => part.length > 0);
257
+ if (segments.length < 2)
258
+ return false;
259
+ if (segments[0] === '*' || segments[0] === '**')
260
+ return false;
261
+ return segments.some((part) => part === '*');
262
+ }
263
+ function anchoredSliceId(parts, raw, identity) {
264
+ const pattern = raw
265
+ .split(/[/\\]/)
266
+ .filter((part) => part.length > 0)
267
+ .map((part) => part.toLowerCase());
268
+ const offsets = anchorOffsets(parts, pattern[0] ?? '');
269
+ for (const offset of offsets) {
270
+ const id = bindAnchoredSlice(parts, pattern, offset, identity);
271
+ if (id)
272
+ return id;
273
+ }
274
+ return undefined;
275
+ }
276
+ /**
277
+ * Walk an anchored pattern from `offset`. Literals must match. Each matching
278
+ * literal is part of the bound segments, and each `*` binds one directory.
279
+ * A star stops before the filename; leftover stars then end the id at the
280
+ * last directory that did bind. Segments `lib` / `features` / `*` / `*`
281
+ * (the pattern `lib/features` plus two stars) therefore yield
282
+ * `lib/features/projects/rfi` under `path`.
283
+ *
284
+ * `path` returns every bound segment (literal prefix plus star bindings), so
285
+ * a parallel tree (`components` / `features` / `*` / `*`) gets a different
286
+ * id. `stars` drops the literal prefix before the last literal and keeps
287
+ * that literal plus the star bindings.
288
+ */
289
+ function bindAnchoredSlice(parts, pattern, offset, identity = 'path') {
290
+ const bound = [];
291
+ let lastLiteralAt = -1;
292
+ let index = offset;
293
+ for (let pi = 0; pi < pattern.length; pi += 1) {
294
+ const segment = pattern[pi];
295
+ if (segment === '*') {
296
+ if (index >= parts.length - 1) {
297
+ for (let rest = pi; rest < pattern.length; rest += 1) {
298
+ if (pattern[rest] !== '*')
299
+ return undefined;
300
+ }
301
+ break;
302
+ }
303
+ bound.push(parts[index].toLowerCase());
304
+ index += 1;
305
+ continue;
306
+ }
307
+ if (segment === '**' || index >= parts.length)
308
+ return undefined;
309
+ if (parts[index].toLowerCase() !== segment)
310
+ return undefined;
311
+ bound.push(parts[index].toLowerCase());
312
+ lastLiteralAt = bound.length - 1;
313
+ index += 1;
314
+ }
315
+ if (bound.length === 0)
316
+ return undefined;
317
+ if (identity === 'stars') {
318
+ if (lastLiteralAt < 0)
319
+ return undefined;
320
+ return bound.slice(lastLiteralAt).join('/');
321
+ }
322
+ return bound.join('/');
323
+ }
324
+ /**
325
+ * Doctor warning input. Silent unless `sliceIdentity` is `"stars"` and two
326
+ * different anchored prefixes share a stem (last literal plus the stars after it).
327
+ */
328
+ export function sliceIdentityCollisions(rules) {
329
+ const out = [];
330
+ for (const rule of rules ?? []) {
331
+ if (!rule || rule.sliceIdentity !== 'stars')
332
+ continue;
333
+ const groups = new Map();
334
+ const seen = new Map();
335
+ for (const raw of rule.sliceFolders ?? []) {
336
+ if (typeof raw !== 'string' || raw.length === 0)
337
+ continue;
338
+ const stem = starsIdentityStem(raw);
339
+ if (!stem)
340
+ continue;
341
+ const key = raw.split(/[/\\]/).filter((part) => part.length > 0).join('/').toLowerCase();
342
+ const groupSeen = seen.get(stem) ?? new Set();
343
+ if (groupSeen.has(key))
344
+ continue;
345
+ groupSeen.add(key);
346
+ seen.set(stem, groupSeen);
347
+ const paths = groups.get(stem) ?? [];
348
+ paths.push(raw);
349
+ groups.set(stem, paths);
229
350
  }
351
+ for (const [stem, paths] of groups) {
352
+ if (paths.length < 2)
353
+ continue;
354
+ out.push({
355
+ from: rule.from,
356
+ to: rule.to,
357
+ stem,
358
+ paths,
359
+ message: formatSliceIdentityCollision(rule.from, rule.to, paths, stem),
360
+ });
361
+ }
362
+ }
363
+ return out;
364
+ }
365
+ /** Last literal plus the stars that follow it. `**` never forms a stem. */
366
+ function starsIdentityStem(raw) {
367
+ if (!isAnchoredSliceEntry(raw))
368
+ return undefined;
369
+ const segments = raw
370
+ .split(/[/\\]/)
371
+ .filter((part) => part.length > 0)
372
+ .map((part) => part.toLowerCase());
373
+ let lastLiteral = -1;
374
+ for (let i = 0; i < segments.length; i += 1) {
375
+ const part = segments[i] ?? '';
376
+ if (part === '**')
377
+ return undefined;
378
+ if (part !== '*')
379
+ lastLiteral = i;
380
+ }
381
+ if (lastLiteral < 0)
382
+ return undefined;
383
+ const tail = segments.slice(lastLiteral);
384
+ if (tail.some((part, index) => index > 0 && part !== '*'))
385
+ return undefined;
386
+ return tail.join('/');
387
+ }
388
+ function formatSliceIdentityCollision(from, to, paths, stem) {
389
+ const named = paths.length === 2
390
+ ? `${paths[0]} and ${paths[1]}`
391
+ : `${paths.slice(0, -1).join(', ')}, and ${paths[paths.length - 1]}`;
392
+ return `${from} → ${to}: sliceIdentity "stars" gives one slice id to ${named}. They bind as ${stem}.`;
393
+ }
394
+ /** Bare names: leftmost folder, plus the next directory. Never the filename. */
395
+ function bareSliceId(parts, names) {
396
+ if (names.size === 0)
397
+ return undefined;
398
+ for (let i = 0; i < parts.length - 1; i += 1) {
399
+ if (!names.has(parts[i].toLowerCase()))
400
+ continue;
401
+ if (i + 1 >= parts.length - 1)
402
+ continue;
403
+ return `${parts[i].toLowerCase()}/${parts[i + 1].toLowerCase()}`;
230
404
  }
231
405
  return undefined;
232
406
  }
@@ -279,6 +453,16 @@ function trimTrailingSlashes(value) {
279
453
  }
280
454
  /** Source folders a declared shared root may sit under without being named. */
281
455
  const SHARED_ROOT_SOURCE_PREFIXES = ['src', 'app'];
456
+ /**
457
+ * Anchor at segment 0, or also at segment 1 when the path opens with `src/` or
458
+ * `app/` and the declaration does not itself start with that folder.
459
+ * Shared roots and starred slice prefixes share this offset.
460
+ */
461
+ function anchorOffsets(parts, rootHead) {
462
+ const head = (parts[0] ?? '').toLowerCase();
463
+ const root = rootHead.toLowerCase();
464
+ return SHARED_ROOT_SOURCE_PREFIXES.includes(head) && root !== head ? [0, 1] : [0];
465
+ }
282
466
  /** A root that would disable the wall wholesale is not a root. */
283
467
  function isBlanketRoot(raw) {
284
468
  const trimmed = trimTrailingSlashes(raw.replace(/^[./]+/, ''));
@@ -287,7 +471,7 @@ function isBlanketRoot(raw) {
287
471
  /**
288
472
  * Is `relPath` under one of the roots the rule declares shared on purpose?
289
473
  *
290
- * **Anchored**, unlike `sliceIdForPath`: the root must start the repo-relative
474
+ * **Anchored**: the root must start the repo-relative
291
475
  * path, optionally after a single conventional source folder, so `ui` covers
292
476
  * `ui/button.tsx` and `src/ui/button.tsx` but NOT `modules/a/ui/x.tsx` — an
293
477
  * unanchored root would exempt a whole tree the author never declared. Deeper
@@ -317,9 +501,7 @@ export function pathUnderSharedRoot(relPath, sharedRoots) {
317
501
  const root = normalizeSegments(raw);
318
502
  if (root.length === 0)
319
503
  continue;
320
- // Anchor at segment 0, or at segment 1 when the path opens with a source
321
- // folder the root does not itself name.
322
- const offsets = SHARED_ROOT_SOURCE_PREFIXES.includes(parts[0]) && root[0] !== parts[0] ? [0, 1] : [0];
504
+ const offsets = anchorOffsets(parts, root[0] ?? '');
323
505
  for (const offset of offsets) {
324
506
  if (offset + root.length > parts.length)
325
507
  continue;
@@ -364,7 +546,8 @@ export function crossSliceEdgeAllowed(allowedCrossSlice, fromSlice, toSlice) {
364
546
  * cross-slice edge, is the repo telling us its design, so it is no longer
365
547
  * "unclassifiable". Order: no fromPath → no slice folders → intent (no toPath):
366
548
  * shared-root allow / slice fail-closed → a side that is neither in a slice nor
367
- * declared shared → same slice → declared cross edge → deny.
549
+ * declared shared → shared root importing a slice (only when the rule denies
550
+ * that hop) → same slice → declared cross edge → deny.
368
551
  *
369
552
  * Intent/event names are not files. Callers pass fromPath only and must not
370
553
  * invent a toPath (that would mis-slice). A declared shared root is classified
@@ -386,6 +569,14 @@ export function peerIsolationDecision(input) {
386
569
  const toClassified = Boolean(input.toSlice) || input.toShared === true;
387
570
  if (!fromClassified || !toClassified)
388
571
  return { denied: true, reason: 'unclassifiable-path' };
572
+ // Shared roots are a sink. The hop is allowed unless the rule opts in.
573
+ // allowedCrossSlice does not excuse it: this is not a slice-to-slice edge.
574
+ if (input.sharedImportsSlice === 'deny' &&
575
+ input.fromShared === true &&
576
+ Boolean(input.toSlice) &&
577
+ !input.fromSlice) {
578
+ return { denied: true, reason: 'shared-imports-slice' };
579
+ }
389
580
  // At least one side is declared shared (and carries no slice id): the repo
390
581
  // said this code belongs to no slice, so there is no cross-slice edge here.
391
582
  if (!input.fromSlice || !input.toSlice)
@@ -416,6 +607,8 @@ export function peerIsolationDenyExplanation(reason, context) {
416
607
  switch (reason) {
417
608
  case 'cross-slice':
418
609
  return `cross-slice edge ${context.fromSlice ?? '?'} → ${context.toSlice ?? '?'}. Extract the shared code, use events/ports across slices, or declare the edge in the rule's allowedCrossSlice.`;
610
+ case 'shared-imports-slice':
611
+ return `shared root imports slice ${context.toSlice ?? '?'} (${context.fromPath ?? '?'} → ${context.toPath ?? '?'}). The wall is direct-only.`;
419
612
  case 'unclassifiable-path': {
420
613
  const unplaced = [
421
614
  context.fromSlice ? undefined : context.fromPath,
@@ -463,8 +656,10 @@ export function findDeniedEdgeDecision(rules, from, to, options) {
463
656
  const fromPath = options?.fromPath;
464
657
  const toPath = options?.toPath;
465
658
  const folders = resolveSliceFolders(rule, from, options?.layers);
466
- const fromSlice = fromPath ? sliceIdForPath(fromPath, folders) : undefined;
467
- const toSlice = toPath ? sliceIdForPath(toPath, folders) : undefined;
659
+ const fromSlice = fromPath
660
+ ? sliceIdForPath(fromPath, folders, rule.sliceIdentity)
661
+ : undefined;
662
+ const toSlice = toPath ? sliceIdForPath(toPath, folders, rule.sliceIdentity) : undefined;
468
663
  const decision = peerIsolationDecision({
469
664
  fromPath,
470
665
  toPath,
@@ -474,6 +669,7 @@ export function findDeniedEdgeDecision(rules, from, to, options) {
474
669
  fromShared: !fromSlice && pathUnderSharedRoot(fromPath, rule.sharedRoots),
475
670
  toShared: !toSlice && pathUnderSharedRoot(toPath, rule.sharedRoots),
476
671
  crossSliceAllowed: crossSliceEdgeAllowed(rule.allowedCrossSlice, fromSlice, toSlice),
672
+ sharedImportsSlice: rule.sharedImportsSlice,
477
673
  });
478
674
  if (decision.denied) {
479
675
  return { rule, peerIsolationReason: decision.reason, fromSlice, toSlice };
@@ -487,6 +683,34 @@ export function findDeniedEdgeDecision(rules, from, to, options) {
487
683
  }
488
684
  return undefined;
489
685
  }
686
+ /**
687
+ * Shared-root → slice edges the check still allows. Listed so a green run
688
+ * says the wall is direct-only. When the rule sets `sharedImportsSlice: "deny"`
689
+ * the same hop is a violation instead, so it is not listed twice.
690
+ */
691
+ export function findSharedImportsSliceBridge(rules, from, to, options) {
692
+ const fromPath = options?.fromPath;
693
+ const toPath = options?.toPath;
694
+ if (!fromPath || !toPath)
695
+ return undefined;
696
+ for (const rule of rules ?? []) {
697
+ if (rule.from !== from || rule.to !== to)
698
+ continue;
699
+ if (rule.allowed !== false || !rule.peerIsolation)
700
+ continue;
701
+ if (rule.sharedImportsSlice === 'deny')
702
+ continue;
703
+ const folders = resolveSliceFolders(rule, from, options?.layers);
704
+ const fromSlice = sliceIdForPath(fromPath, folders, rule.sliceIdentity);
705
+ const toSlice = sliceIdForPath(toPath, folders, rule.sliceIdentity);
706
+ if (fromSlice || !toSlice)
707
+ continue;
708
+ if (!pathUnderSharedRoot(fromPath, rule.sharedRoots))
709
+ continue;
710
+ return { fromPath, toPath, toSlice };
711
+ }
712
+ return undefined;
713
+ }
490
714
  export function isEdgeDenied(rules, from, to, options) {
491
715
  return findDeniedEdgeRule(rules, from, to, options) !== undefined;
492
716
  }
package/bin/ark.mjs CHANGED
@@ -26,11 +26,13 @@ import {
26
26
  applyStartPreview,
27
27
  emitStartSetupBudgetRefuse,
28
28
  evaluateStartSetupBudgetGate,
29
- formatStartPackageInstallFailure,
30
29
  planStart,
31
30
  renderStartPreview,
32
- runStartPackageInstall,
33
31
  } from './lib/start-preview.mjs';
32
+ import {
33
+ formatStartPackageInstallFailure,
34
+ runStartPackageInstall,
35
+ } from './lib/start-install-recovery.mjs';
34
36
  import { runUpgradeCommand } from './lib/upgrade-command.mjs';
35
37
  import { detectActiveAgentHost } from './lib/skill-install.mjs';
36
38
  import { loadArkConfigContract } from './lib/config-contract.mjs';
@@ -509,6 +511,7 @@ async function start(args) {
509
511
  exitStatus: install.status,
510
512
  installCommand,
511
513
  hostOutput: `${install.stdout}\n${install.stderr}`,
514
+ packageVersion: cliVersion(),
512
515
  })
513
516
  );
514
517
  return install.status;
@@ -584,6 +587,7 @@ async function start(args) {
584
587
  exitStatus: installStatus,
585
588
  installCommand: `${command} ${commandArgs.join(' ')}`,
586
589
  hostOutput,
590
+ packageVersion: cliVersion(),
587
591
  })
588
592
  );
589
593
  }