arkgate 4.8.2 → 4.8.4

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 (73) hide show
  1. package/CHANGELOG.md +257 -3
  2. package/README.md +47 -9
  3. package/bin/ark-check-runtime.mjs +340 -5
  4. package/bin/ark-layer-match.mjs +170 -13
  5. package/bin/ark-mcp-runtime.mjs +9 -2
  6. package/bin/lib/analysis-completeness.mjs +86 -0
  7. package/bin/lib/analysis-engine.mjs +6 -6
  8. package/bin/lib/architecture-scan.mjs +2 -0
  9. package/bin/lib/ark-order-facts.mjs +59 -0
  10. package/bin/lib/ark-order-sensors.mjs +31 -2
  11. package/bin/lib/arkrule-file-hints.mjs +6 -2
  12. package/bin/lib/arkrules-contract.mjs +9 -1
  13. package/bin/lib/arkrules-sensors.mjs +22 -2
  14. package/bin/lib/check-args.mjs +66 -0
  15. package/bin/lib/config-contract.mjs +26 -0
  16. package/bin/lib/config-extras.mjs +2 -0
  17. package/bin/lib/design-smells.mjs +85 -0
  18. package/bin/lib/diagnostic-catalog.mjs +8 -2
  19. package/bin/lib/first-run-help.mjs +12 -0
  20. package/bin/lib/invariant-coverage-io.mjs +175 -19
  21. package/bin/lib/invariant-coverage.mjs +110 -7
  22. package/bin/lib/literal-path-drift-io.mjs +569 -0
  23. package/bin/lib/literal-path-drift.mjs +761 -0
  24. package/bin/lib/policy-delta-io.mjs +5 -0
  25. package/bin/lib/remediation.mjs +24 -1
  26. package/bin/lib/resolved-candidate-facts.mjs +31 -0
  27. package/bin/lib/rules-under-contract.mjs +5 -0
  28. package/bin/lib/scan-files.mjs +54 -0
  29. package/bin/lib/sensor-promote-cli.mjs +372 -0
  30. package/bin/lib/sensor-promote-io.mjs +246 -0
  31. package/bin/lib/sensor-promotion.mjs +363 -0
  32. package/dist/{configTypes-BdCe_gvv.d.ts → configTypes-dy5PfTqS.d.ts} +36 -0
  33. package/dist/{diagnosticCatalog-CPzH-MLN.d.ts → diagnosticCatalog-DgTs0abp.d.ts} +169 -11
  34. package/dist/eslint/index.cjs +5 -5
  35. package/dist/eslint/index.d.ts +34 -1
  36. package/dist/eslint/index.js +5 -5
  37. package/dist/index.cjs +31 -31
  38. package/dist/index.d.ts +85 -7
  39. package/dist/index.js +31 -31
  40. package/dist/nestjs/index.cjs +5 -5
  41. package/dist/nestjs/index.d.ts +3 -3
  42. package/dist/nestjs/index.js +5 -5
  43. package/dist/runtime/index.cjs +13 -13
  44. package/dist/runtime/index.d.ts +6 -6
  45. package/dist/runtime/index.js +13 -13
  46. package/dist/{types-DCSlrRnV.d.ts → types-BuM8WNqe.d.ts} +1 -1
  47. package/dist/{types-C9KApBzX.d.ts → types-D95drJ3_.d.ts} +1 -1
  48. package/docs/README.md +4 -4
  49. package/docs/agent-guide.md +182 -0
  50. package/docs/configuration.md +89 -9
  51. package/docs/develop.md +24 -2
  52. package/docs/diagnostics.md +79 -1
  53. package/docs/enthusiast/README.md +6 -4
  54. package/docs/package-surface.md +36 -4
  55. package/docs/product-voice.md +15 -5
  56. package/docs/use.md +8 -5
  57. package/package.json +2 -2
  58. package/schemas/ark.arkrules.schema.json +1 -0
  59. package/schemas/ark.config.schema.json +72 -0
  60. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  61. package/server.json +3 -3
  62. package/templates/agent-skills/README.md +1 -1
  63. package/templates/agent-skills/ark-adopt/SKILL.md +13 -3
  64. package/templates/agent-skills/ark-autopilot/SKILL.md +1 -1
  65. package/templates/agent-skills/ark-contract/SKILL.md +4 -0
  66. package/templates/agent-skills/ark-coverage/SKILL.md +1 -0
  67. package/templates/agent-skills/ark-place/SKILL.md +6 -2
  68. package/templates/arkrules/ApplicationOrchestration.json +6 -0
  69. package/templates/skills/ark-adopt.md +13 -3
  70. package/templates/skills/ark-autopilot.md +1 -1
  71. package/templates/skills/ark-contract.md +4 -0
  72. package/templates/skills/ark-coverage.md +1 -0
  73. package/templates/skills/ark-place.md +6 -2
@@ -97,10 +97,20 @@ import {
97
97
  import { loadArkConfigContract, parseArkConfigJson } from './lib/config-contract.mjs';
98
98
  import { checkUsage, checkUsageAll } from './lib/first-run-help.mjs';
99
99
  import { createAdapterResult } from './lib/adapter-contract.mjs';
100
- import { collectGovernedFiles, normalize, walk } from './lib/scan-files.mjs';
100
+ import {
101
+ UNGOVERNED_PROBE_CAP,
102
+ collectGovernedFiles,
103
+ countUngovernedSourceFiles,
104
+ normalize,
105
+ walk,
106
+ } from './lib/scan-files.mjs';
101
107
  import { configWarning } from './lib/config-warnings.mjs';
102
108
  import { runArchitectureScan } from './lib/architecture-scan.mjs';
103
- import { ANALYSIS_COMPLETENESS, analysisIncompleteStatement } from './lib/analysis-completeness.mjs';
109
+ import {
110
+ ANALYSIS_COMPLETENESS,
111
+ analysisIncompleteStatement,
112
+ emptyAnalysisRefusal,
113
+ } from './lib/analysis-completeness.mjs';
104
114
  import { reportUnavailableAnalysis } from './lib/unavailable-analysis.mjs';
105
115
  import { validateHardWriteRequest } from './lib/enforcement-profiles.mjs';
106
116
  import {
@@ -574,6 +584,201 @@ function runAdoptContract(args) {
574
584
  }
575
585
  }
576
586
 
587
+ /**
588
+ * Literal path drift (LPD) — repo paths that live inside strings, comments and
589
+ * docstrings, and no longer resolve.
590
+ *
591
+ * `tsc` resolves imports, not strings, and ESLint does not either, so this
592
+ * whole class compiles green. Report only by default (house convention:
593
+ * plan-by-default, `--write` to mutate) and only the ANCHORED findings are ever
594
+ * written — an unanchored one has no destination to propose.
595
+ */
596
+ async function runPathDrift(args) {
597
+ const root = args.root;
598
+ const { scanLiteralPathDrift, writeLiteralPathDrift } = await import(
599
+ './lib/literal-path-drift-io.mjs'
600
+ );
601
+ let config;
602
+ try {
603
+ config = readConfig(root, args.config);
604
+ } catch {
605
+ // The scan reads text, not the contract's rules: a contract too broken to
606
+ // parse must not hide the drift. `include` only widens the roots.
607
+ config = { include: ['src'] };
608
+ }
609
+ const baseRef = resolveDesignDeltaBaseRef(root, args.baseRef);
610
+ const report = scanLiteralPathDrift(root, config, { baseRef, tsconfig: args.tsconfig });
611
+ const written = args.write ? writeLiteralPathDrift(root, report.anchored) : null;
612
+ // After a write the findings that were applied no longer exist on disk, so
613
+ // reporting them as findings would describe a tree that is already gone.
614
+ const remainingAnchored = written
615
+ ? report.anchored.filter((finding) => !wasApplied(finding, written))
616
+ : report.anchored;
617
+ // Both sinks describe the tree as it now stands: after a write the applied
618
+ // findings are gone from disk, and printing them as findings would contradict
619
+ // the "wrote ..." lines directly underneath.
620
+ const shown = { ...report, anchored: remainingAnchored };
621
+
622
+ // The unanchored sweep is opt-in (`--all`). On a repo that WRITES about paths
623
+ // it produced 4085 candidates out of 9536 literals, almost all of them
624
+ // illustrative paths in prose and help text — that is ArkGate's inability to
625
+ // resolve a string reported as a fact about the user's code, the same defect
626
+ // class as the coverage budget. The count is always printed, so opting out of
627
+ // the list is never opting out of knowing.
628
+ const payload = {
629
+ ...report,
630
+ anchored: remainingAnchored,
631
+ unanchoredCount: report.unanchoredCount,
632
+ unanchored: args.all ? report.unanchored : [],
633
+ unanchoredListed: args.all,
634
+ };
635
+ if (args.json) {
636
+ console.log(JSON.stringify({ pathDrift: payload, ...(written ? { written } : {}) }, null, 2));
637
+ } else {
638
+ printPathDrift(root, args, shown, written);
639
+ }
640
+ // Anchored drift is a fact about the tree: the source is gone and a rename
641
+ // says where it went. Unanchored drift is advisory — ArkGate cannot tell a
642
+ // dead reference from one it simply cannot resolve.
643
+ // Three outcomes, and CI must be able to tell them apart from the exit code
644
+ // alone — a tick the terminal withholds is no use to a pipeline that only
645
+ // reads the status:
646
+ // 0 anchored mode ran and found nothing left
647
+ // 1 anchored drift remains
648
+ // 2 anchored mode could not run (no usable base ref) — this run proved
649
+ // nothing, and exiting 0 here would be the false green one level down.
650
+ if (!report.renameSet.available) process.exitCode = 2;
651
+ else process.exitCode = remainingAnchored.length > 0 ? 1 : 0;
652
+ }
653
+
654
+ /**
655
+ * True when THIS finding was one of the replacements written.
656
+ *
657
+ * Matched by identity, not by file: a file holding two findings where only one
658
+ * still matched its token must keep the other one in the remaining set.
659
+ */
660
+ function wasApplied(finding, written) {
661
+ const entry = written.written.find((row) => row.file === finding.file);
662
+ if (!entry) return false;
663
+ return (entry.appliedFindings ?? []).some(
664
+ (applied) =>
665
+ applied.line === finding.line &&
666
+ applied.column === finding.column &&
667
+ applied.token === finding.token
668
+ );
669
+ }
670
+
671
+ /**
672
+ * A path from git or from the filesystem is raw bytes, and it is about to be
673
+ * printed to a terminal. A control character there can repaint or erase the
674
+ * findings above it.
675
+ */
676
+ function renderPath(value) {
677
+ return String(value).replace(/[\u0000-\u001f\u007f]/g, (ch) =>
678
+ `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`
679
+ );
680
+ }
681
+
682
+ function printPathDrift(root, args, report, written) {
683
+ console.log(color.bold('Literal path drift'));
684
+ console.log(
685
+ color.dim(
686
+ ` scanned ${report.scannedFiles} text file(s), ${report.candidates} path-shaped literal(s)`
687
+ )
688
+ );
689
+ if (!report.renameSet.available) {
690
+ console.log(
691
+ color.yellow(
692
+ ` No rename set (${report.renameSet.reason}) — anchored mode is OFF, so nothing below carries a suggested replacement. Pass --base-ref <git-ref> to enable it.`
693
+ )
694
+ );
695
+ } else {
696
+ console.log(
697
+ color.dim(
698
+ ` rename set vs ${report.baseRef}: ${report.renameSet.renames} rename(s), ${report.anchorsConsidered} usable anchor(s)`
699
+ )
700
+ );
701
+ }
702
+ if (report.ambiguousAnchors.length > 0) {
703
+ console.log(
704
+ color.dim(
705
+ ` ${report.ambiguousAnchors.length} rename source(s) map to more than one destination and anchor nothing (e.g. ${renderPath(report.ambiguousAnchors[0])})`
706
+ )
707
+ );
708
+ }
709
+ const discarded = report.scan.discarded;
710
+ const dropped = Object.values(discarded).reduce((sum, n) => sum + n, 0);
711
+ if (dropped > 0) {
712
+ console.log(
713
+ color.dim(
714
+ ` not read: ${discarded.generated} generated, ${discarded.oversize} oversize, ${discarded.budget} past the ${report.scan.maxFiles}-file budget, ${discarded.byteBudget} past the ${Math.round(report.scan.maxTotalBytes / (1024 * 1024))}MB total budget, ${discarded.unreadable} unreadable, ${discarded.depthLimited} past the depth limit, ${discarded.symlink} symlinked file(s), ${discarded.symlinkDir} symlinked director(ies)`
715
+ )
716
+ );
717
+ }
718
+
719
+ for (const finding of report.anchored) {
720
+ console.log(
721
+ `${color.red('\u2716')} ${finding.ruleId} ${renderPath(finding.file)}:${finding.line} [${finding.form}]`
722
+ );
723
+ console.log(
724
+ finding.suggestedToken === null
725
+ ? ` ${renderPath(finding.token)} -> ${renderPath(finding.suggestedTarget)} (outside the alias root of this literal — rewrite by hand)`
726
+ : ` ${renderPath(finding.token)} -> ${renderPath(finding.suggestedToken)}`
727
+ );
728
+ }
729
+ if (report.unanchoredCount > 0) {
730
+ if (args.all) {
731
+ for (const finding of report.unanchored) {
732
+ console.log(
733
+ `${color.yellow('warning')} ${finding.ruleId} ${renderPath(finding.file)}:${finding.line} [${finding.form}] ${renderPath(finding.token)}`
734
+ );
735
+ }
736
+ }
737
+ console.log(
738
+ color.yellow(
739
+ ` ${report.unanchoredCount} unanchored candidate(s)${args.all ? ` listed above${report.truncated.unanchored ? ` (first ${report.unanchored.length}; the list is capped at ${report.findingCap}, the count is not)` : ''}` : ' not listed (--all)'} — literals that look like a repo path and do not resolve. Advisory only: with no rename to anchor them ArkGate cannot tell a dead reference from an illustrative one, so read them, do not gate on them.`
740
+ )
741
+ );
742
+ }
743
+
744
+ if (written) {
745
+ for (const entry of written.written) {
746
+ console.log(color.green(` wrote ${renderPath(entry.file)} (${entry.applied})`));
747
+ }
748
+ for (const entry of written.skipped) {
749
+ console.log(color.yellow(` skipped ${renderPath(entry.file)}: ${entry.reason} (${entry.count})`));
750
+ }
751
+ }
752
+
753
+ if (report.anchored.length === 0) {
754
+ if (!report.renameSet.available) {
755
+ // No tick. A green mark over a check that never ran is the false green
756
+ // this whole patch exists to remove.
757
+ console.log(
758
+ color.yellow(
759
+ '\u25CB Anchored mode did not run — no rename set. This says nothing about drift.'
760
+ )
761
+ );
762
+ } else {
763
+ console.log(color.green('\u2714 No anchored literal path drift.'));
764
+ console.log(
765
+ color.dim(
766
+ ' Every literal explained by the rename set resolves. This is a text match over strings and comments: it proves no scanned literal is stale against those renames, not that every path in the repo is live.'
767
+ )
768
+ );
769
+ }
770
+ }
771
+ if (!written && report.anchored.length > 0) {
772
+ const writable = report.anchored.filter((finding) => finding.suggestedToken !== null).length;
773
+ const byHand = report.anchored.length - writable;
774
+ console.log(
775
+ color.dim(
776
+ ` Report only. Re-run with --write to apply ${writable} of the ${report.anchored.length} anchored replacement(s)${byHand > 0 ? `; ${byHand} must be rewritten by hand` : ''}. Unanchored findings are never written.`
777
+ )
778
+ );
779
+ }
780
+ }
781
+
577
782
  /**
578
783
  * Additive P0-A contract retrofit: inject high-spec app/api → Application when missing.
579
784
  * Does not remove existing patterns or weaken rules (DL-P0A-RETROFIT).
@@ -849,6 +1054,9 @@ function applyConfigRootWalkUp(args) {
849
1054
  return args;
850
1055
  }
851
1056
  const writeMode = isMutatingCliCommand(args);
1057
+ // The root the caller asked for, before any walk-up adopts the config's directory.
1058
+ // An empty analysis must be able to say which of the two it actually walked.
1059
+ args.requestedRoot = path.resolve(args.root);
852
1060
  const effective = resolveEffectiveProjectRoot(args.root, {
853
1061
  configName: args.config,
854
1062
  writeMode,
@@ -924,6 +1132,17 @@ async function main() {
924
1132
  return;
925
1133
  }
926
1134
 
1135
+ if (args.pathDrift) {
1136
+ await runPathDrift(args);
1137
+ return;
1138
+ }
1139
+
1140
+ if (args.sensors) {
1141
+ const { runSensors } = await import('./lib/sensor-promote-cli.mjs');
1142
+ await runSensors(args, readConfig);
1143
+ return;
1144
+ }
1145
+
927
1146
  if (args.recommend) {
928
1147
  try {
929
1148
  const recommendation = buildArchitectureRecommendation(args.root);
@@ -964,6 +1183,59 @@ async function main() {
964
1183
  return;
965
1184
  }
966
1185
 
1186
+ // Empty analysis is a refusal: on zero governed files every later gate reports on
1187
+ // nothing. It outranks --require-gates, so the caller hears the real reason instead
1188
+ // of "Ark gates are not installed" in whatever directory the contract happened to
1189
+ // live in — but it is evaluated LAZILY, so the cheap exits (a --changed run whose
1190
+ // diff touches no product path, --require-gates with the gates present) still pay
1191
+ // nothing for a filesystem walk they never needed.
1192
+ //
1193
+ // Report modes are exempt: --plan, --coverage and --doctor are how a user sees and
1194
+ // fixes an empty scope (they already carry the `empty-scope` adoption gap), so
1195
+ // refusing there would remove the only surface that explains the refusal.
1196
+ const root = args.root;
1197
+ const verdictPath = !args.plan && !args.coverage && !args.doctor;
1198
+ let configCache = null;
1199
+ const loadConfig = () => (configCache ??= readConfig(root, args.config));
1200
+ let governedCache = null;
1201
+ const loadGovernedFiles = () => (governedCache ??= collectGovernedFiles(root, loadConfig()));
1202
+ const emptyAnalysisRefusalNow = () => {
1203
+ const governedCount = loadGovernedFiles().length;
1204
+ return emptyAnalysisRefusal({
1205
+ governedFileCount: governedCount,
1206
+ // Probed only when nothing is governed, and never through the contract's own
1207
+ // exclude: the config under suspicion must not get to answer the question about
1208
+ // itself (`exclude: ["**"]` would otherwise read as greenfield and pass).
1209
+ ungovernedSourceCount: governedCount === 0 ? countUngovernedSourceFiles(root) : 0,
1210
+ ungovernedSourceCap: UNGOVERNED_PROBE_CAP,
1211
+ root,
1212
+ requestedRoot: args.requestedRoot,
1213
+ configPath: path.isAbsolute(args.config) ? args.config : path.join(root, args.config),
1214
+ configWalkedUp: args.configWalkedUp === true,
1215
+ });
1216
+ };
1217
+ const reportEmptyAnalysis = (refusal) => {
1218
+ if (args.json) {
1219
+ console.log(
1220
+ JSON.stringify(
1221
+ {
1222
+ ok: false,
1223
+ error: refusal.ruleId,
1224
+ completeness: ANALYSIS_COMPLETENESS.unavailable,
1225
+ message: refusal.message,
1226
+ nextAction: refusal.nextAction,
1227
+ },
1228
+ null,
1229
+ 2
1230
+ )
1231
+ );
1232
+ } else {
1233
+ console.error(`${color.red('\u2716')} ${refusal.ruleId} ${refusal.message}`);
1234
+ console.error(`Next: ${refusal.nextAction}`);
1235
+ }
1236
+ process.exitCode = 1;
1237
+ };
1238
+
967
1239
  if (args.requireGates || args.requireWriteHook) {
968
1240
  let writeRequest = null;
969
1241
  if (args.requireWriteHook) {
@@ -994,6 +1266,24 @@ async function main() {
994
1266
  missing.push(`${writeRequest.host} hard-write hook`);
995
1267
  }
996
1268
  if (missing.length > 0) {
1269
+ // "Gates not installed" is the wrong reason for a run that would have analyzed
1270
+ // nothing: it sends the user to `ark init` for a problem they do not have. Only
1271
+ // here do we pay for the walk — with the gates present we fall through and the
1272
+ // verdict path below checks at its usual point. A contract that cannot even be
1273
+ // read is not evidence of an empty analysis, so that throw falls back to the
1274
+ // gate report instead of masking it.
1275
+ let refusal = null;
1276
+ if (verdictPath) {
1277
+ try {
1278
+ refusal = emptyAnalysisRefusalNow();
1279
+ } catch {
1280
+ refusal = null;
1281
+ }
1282
+ }
1283
+ if (refusal) {
1284
+ reportEmptyAnalysis(refusal);
1285
+ return;
1286
+ }
997
1287
  const payload = {
998
1288
  ok: false,
999
1289
  error: 'missing-gates',
@@ -1037,10 +1327,9 @@ async function main() {
1037
1327
  }
1038
1328
  }
1039
1329
 
1040
- const root = args.root;
1041
1330
  const bound = bindTeamBaseRefs(args, root);
1042
1331
  Object.assign(args, bound.args);
1043
- const config = readConfig(root, args.config);
1332
+ const config = loadConfig();
1044
1333
  const policyDelta = analyzePolicyTransition({
1045
1334
  root,
1046
1335
  configPath: args.config,
@@ -1088,7 +1377,14 @@ async function main() {
1088
1377
  }
1089
1378
  const manifest = readManifest(root, args.manifest);
1090
1379
  const rules = manifest?.architecture?.rules ?? config.rules;
1091
- const allGovernedFiles = collectGovernedFiles(root, config);
1380
+ const allGovernedFiles = loadGovernedFiles();
1381
+ if (verdictPath) {
1382
+ const refusal = emptyAnalysisRefusalNow();
1383
+ if (refusal) {
1384
+ reportEmptyAnalysis(refusal);
1385
+ return;
1386
+ }
1387
+ }
1092
1388
  if (args.failUngoverned && teamParliament?.changeSet?.productPaths?.length) {
1093
1389
  const governedRel = new Set(
1094
1390
  allGovernedFiles.map((abs) => normalize(path.relative(root, abs)))
@@ -1164,6 +1460,31 @@ async function main() {
1164
1460
  populatedLayerCount,
1165
1461
  });
1166
1462
 
1463
+ // --promote reuses the analysis that just ran rather than running its own:
1464
+ // the advisory findings it counts are already in `violations` + `warnings`,
1465
+ // stamped with the rule that produced them. Placed before the design-delta
1466
+ // check so a preview does not pay for a base-ref diff it never reads.
1467
+ if (args.promote) {
1468
+ // The floor the merge gate itself applies: below it every enforced
1469
+ // extra-plane finding is demoted to a warning, so a promotion made here
1470
+ // buys a label and not a tooth. `violations` above was already demoted by
1471
+ // it; the preview has to know, or it sells teeth the gate then removes.
1472
+ const { extraMergeTeethAllowed } = await import('./lib/extra-merge-teeth.mjs');
1473
+ const teethDemotedByFloor = !extraMergeTeethAllowed({
1474
+ governedPercent: preCov.governed?.percent ?? null,
1475
+ populatedLayerCount,
1476
+ });
1477
+ const { runPromote } = await import('./lib/sensor-promote-cli.mjs');
1478
+ await runPromote(root, config, args, {
1479
+ files,
1480
+ all: [...violations, ...(warnings ?? [])],
1481
+ completeness,
1482
+ completenessReasons,
1483
+ teethDemotedByFloor,
1484
+ });
1485
+ return;
1486
+ }
1487
+
1167
1488
  const createdPathsOnly = Boolean(args.strictMerge && !args.failOnNewSmells);
1168
1489
  const designCheck = createDesignDeltaCheck({
1169
1490
  enabled: args.failOnNewSmells || args.strictMerge,
@@ -1670,6 +1991,20 @@ async function main() {
1670
1991
  `${color.green('✔')} Ark check passed with ${warnings.length} config warning(s).${baselineNote}`
1671
1992
  );
1672
1993
  }
1994
+ // `--plan` is where the design bets live and a green run never named it.
1995
+ // Not on the strict-config branch above (it printed a failure), not on
1996
+ // `--changed` (a partial scan would print one slice's count as the tree's),
1997
+ // not on `--watch` (a line that repeats every save is a line nobody reads).
1998
+ if (!(args.strictConfig && strictWarnings.length > 0) && !args.changed && !args.watch) {
1999
+ const { greenPlanPointer } = await import('./lib/design-smells.mjs');
2000
+ const pointer = greenPlanPointer({
2001
+ root, config, files, coverage: preCov,
2002
+ blockingViolations: blockingViolations.length,
2003
+ suppressedCount: suppressed.length,
2004
+ planCommand: arkCommand(root, 'ark-check', '--plan'),
2005
+ });
2006
+ if (pointer) console.log(color.dim(pointer));
2007
+ }
1673
2008
  } else {
1674
2009
  console.error(
1675
2010
  blockingViolations.length > 0
@@ -258,20 +258,165 @@ function resolveSliceFolders(rule, layerName, layers) {
258
258
  const layer = (layers ?? []).find((l) => l.name === layerName);
259
259
  return inferSliceFoldersFromPatterns(layer?.patterns);
260
260
  }
261
+ function normalizeSegments(value) {
262
+ return String(value)
263
+ .split(/[/\\]/)
264
+ .filter((part) => Boolean(part) && part !== '.')
265
+ .map((part) => part.toLowerCase());
266
+ }
261
267
  /**
262
- * PeerIsolation deny decision given resolved path/slice evidence (DF04 pure core).
268
+ * Trim trailing slashes without a regex.
263
269
  *
264
- * Fail-closed: missing path, no classifiable folders, or unclassifiable either
265
- * side deny. Same-slice allow (return false). Cross-slice deny.
270
+ * `/\/+$/` is a polynomial ReDoS on a value that comes from the repo's own
271
+ * contract but is still library input: a root of many slashes makes the engine
272
+ * retry from every start position. A scan is linear and says the same thing.
266
273
  */
267
- export function peerIsolationMustDeny(input) {
268
- if (!input.fromPath || !input.toPath)
274
+ function trimTrailingSlashes(value) {
275
+ let end = value.length;
276
+ while (end > 0 && value[end - 1] === '/')
277
+ end -= 1;
278
+ return value.slice(0, end);
279
+ }
280
+ /** Source folders a declared shared root may sit under without being named. */
281
+ const SHARED_ROOT_SOURCE_PREFIXES = ['src', 'app'];
282
+ /** A root that would disable the wall wholesale is not a root. */
283
+ function isBlanketRoot(raw) {
284
+ const trimmed = trimTrailingSlashes(raw.replace(/^[./]+/, ''));
285
+ return trimmed === '*' || trimmed === '**';
286
+ }
287
+ /**
288
+ * Is `relPath` under one of the roots the rule declares shared on purpose?
289
+ *
290
+ * **Anchored**, unlike `sliceIdForPath`: the root must start the repo-relative
291
+ * path, optionally after a single conventional source folder, so `ui` covers
292
+ * `ui/button.tsx` and `src/ui/button.tsx` but NOT `modules/a/ui/x.tsx` — an
293
+ * unanchored root would exempt a whole tree the author never declared. Deeper
294
+ * or monorepo roots are written out (`packages/web/src/ui`) or globbed
295
+ * (`packages/*​/src/ui`). Matching is case-insensitive; a root containing `*`
296
+ * is matched as a glob (also case-insensitively) against the whole path, and a
297
+ * bare `*` / `**` is refused because it would disable fail-closed wholesale.
298
+ */
299
+ export function pathUnderSharedRoot(relPath, sharedRoots) {
300
+ if (!relPath || !sharedRoots?.length)
301
+ return false;
302
+ const rel = String(relPath).split(/[/\\]/).join('/');
303
+ const lowerRel = rel.toLowerCase();
304
+ const parts = normalizeSegments(rel);
305
+ for (const raw of sharedRoots) {
306
+ if (typeof raw !== 'string' || raw.length === 0)
307
+ continue;
308
+ if (isBlanketRoot(raw))
309
+ continue;
310
+ if (raw.includes('*')) {
311
+ const glob = trimTrailingSlashes(raw.toLowerCase());
312
+ if (globToRegExp(glob).test(lowerRel) || globToRegExp(`${glob}/**`).test(lowerRel)) {
313
+ return true;
314
+ }
315
+ continue;
316
+ }
317
+ const root = normalizeSegments(raw);
318
+ if (root.length === 0)
319
+ 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];
323
+ for (const offset of offsets) {
324
+ if (offset + root.length > parts.length)
325
+ continue;
326
+ let hit = true;
327
+ for (let j = 0; j < root.length; j += 1) {
328
+ if (parts[offset + j] !== root[j]) {
329
+ hit = false;
330
+ break;
331
+ }
332
+ }
333
+ if (hit)
334
+ return true;
335
+ }
336
+ }
337
+ return false;
338
+ }
339
+ function sliceMatchesDeclaration(declared, sliceId) {
340
+ const want = String(declared).split(/[/\\]/).filter(Boolean).join('/').toLowerCase();
341
+ if (!want)
342
+ return false;
343
+ const have = sliceId.toLowerCase();
344
+ if (want === have)
269
345
  return true;
346
+ // Bare slice name: `auth` matches `features/auth`.
347
+ return !want.includes('/') && have.endsWith(`/${want}`);
348
+ }
349
+ /** Has the rule declared this directed slice→slice edge? */
350
+ export function crossSliceEdgeAllowed(allowedCrossSlice, fromSlice, toSlice) {
351
+ if (!allowedCrossSlice?.length || !fromSlice || !toSlice)
352
+ return false;
353
+ return allowedCrossSlice.some((edge) => edge &&
354
+ typeof edge.from === 'string' &&
355
+ typeof edge.to === 'string' &&
356
+ sliceMatchesDeclaration(edge.from, fromSlice) &&
357
+ sliceMatchesDeclaration(edge.to, toSlice));
358
+ }
359
+ /**
360
+ * PeerIsolation deny decision with the reason that fired (DF04 pure core).
361
+ *
362
+ * Fail-closed stays fail-closed: absent evidence denies. What changed in 4.8.4
363
+ * is what counts as evidence — a declared shared root, or a declared directed
364
+ * cross-slice edge, is the repo telling us its design, so it is no longer
365
+ * "unclassifiable". Order: no paths → no slice folders → a side that is neither
366
+ * in a slice nor declared shared → same slice → declared cross edge → deny.
367
+ */
368
+ export function peerIsolationDecision(input) {
369
+ if (!input.fromPath || !input.toPath)
370
+ return { denied: true, reason: 'missing-path' };
270
371
  if (input.folderCount <= 0)
271
- return true;
372
+ return { denied: true, reason: 'no-slice-folders' };
373
+ const fromClassified = Boolean(input.fromSlice) || input.fromShared === true;
374
+ const toClassified = Boolean(input.toSlice) || input.toShared === true;
375
+ if (!fromClassified || !toClassified)
376
+ return { denied: true, reason: 'unclassifiable-path' };
377
+ // At least one side is declared shared (and carries no slice id): the repo
378
+ // said this code belongs to no slice, so there is no cross-slice edge here.
272
379
  if (!input.fromSlice || !input.toSlice)
273
- return true;
274
- return input.fromSlice !== input.toSlice;
380
+ return { denied: false };
381
+ if (input.fromSlice === input.toSlice)
382
+ return { denied: false };
383
+ if (input.crossSliceAllowed)
384
+ return { denied: false };
385
+ return { denied: true, reason: 'cross-slice' };
386
+ }
387
+ /**
388
+ * Boolean face of {@link peerIsolationDecision}, kept for parity consumers.
389
+ *
390
+ * Fail-closed: missing path, no classifiable folders, or unclassifiable either
391
+ * side → deny. Same-slice → allow (return false). Cross-slice → deny unless the
392
+ * rule declared that directed edge.
393
+ */
394
+ export function peerIsolationMustDeny(input) {
395
+ return peerIsolationDecision(input).denied;
396
+ }
397
+ /**
398
+ * One human sentence naming which peerIsolation reason fired — so the denial
399
+ * reports a fact about their code (`cross-slice`) or a fact about our evidence
400
+ * (everything else), never one dressed as the other.
401
+ */
402
+ export function peerIsolationDenyExplanation(reason, context) {
403
+ switch (reason) {
404
+ case 'cross-slice':
405
+ 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.`;
406
+ case 'unclassifiable-path': {
407
+ const unplaced = [
408
+ context.fromSlice ? undefined : context.fromPath,
409
+ context.toSlice ? undefined : context.toPath,
410
+ ].filter((path) => Boolean(path));
411
+ const which = unplaced.length > 0 ? ` (${unplaced.join(', ')})` : '';
412
+ return `unclassifiable path${which} — ArkGate cannot place it in a slice, so it cannot prove this is not a cross-slice edge. Move it into a slice, or declare its root in the rule's sharedRoots.`;
413
+ }
414
+ case 'no-slice-folders':
415
+ return 'no slice folders — peerIsolation is on but no slice folder resolves from the rule or the layer patterns. Set sliceFolders on the rule.';
416
+ case 'missing-path':
417
+ default:
418
+ return 'no path evidence for this edge — peerIsolation needs the importer and importee paths.';
419
+ }
275
420
  }
276
421
  /**
277
422
  * Find the first denying rule for a layer edge.
@@ -286,6 +431,14 @@ export function peerIsolationMustDeny(input) {
286
431
  * allow a possible cross-slice edge.
287
432
  */
288
433
  export function findDeniedEdgeRule(rules, from, to, options) {
434
+ return findDeniedEdgeDecision(rules, from, to, options)?.rule;
435
+ }
436
+ /**
437
+ * {@link findDeniedEdgeRule} with the denial reason attached, so adapters can
438
+ * say *why* a peerIsolation rule fired instead of emitting one opaque message
439
+ * for a real cross-slice import and for a file we simply could not place.
440
+ */
441
+ export function findDeniedEdgeDecision(rules, from, to, options) {
289
442
  for (const rule of rules ?? []) {
290
443
  if (rule.from !== from || rule.to !== to)
291
444
  continue;
@@ -297,21 +450,25 @@ export function findDeniedEdgeRule(rules, from, to, options) {
297
450
  const folders = resolveSliceFolders(rule, from, options?.layers);
298
451
  const fromSlice = fromPath && toPath ? sliceIdForPath(fromPath, folders) : undefined;
299
452
  const toSlice = fromPath && toPath ? sliceIdForPath(toPath, folders) : undefined;
300
- if (peerIsolationMustDeny({
453
+ const decision = peerIsolationDecision({
301
454
  fromPath,
302
455
  toPath,
303
456
  folderCount: folders.length,
304
457
  fromSlice,
305
458
  toSlice,
306
- })) {
307
- return rule;
459
+ fromShared: !fromSlice && pathUnderSharedRoot(fromPath, rule.sharedRoots),
460
+ toShared: !toSlice && pathUnderSharedRoot(toPath, rule.sharedRoots),
461
+ crossSliceAllowed: crossSliceEdgeAllowed(rule.allowedCrossSlice, fromSlice, toSlice),
462
+ });
463
+ if (decision.denied) {
464
+ return { rule, peerIsolationReason: decision.reason, fromSlice, toSlice };
308
465
  }
309
- continue; // same slice: this peerIsolation rule does not deny
466
+ continue; // same slice, declared shared, or declared cross edge: no denial
310
467
  }
311
468
  // Classic deny — same-layer always allowed without peerIsolation
312
469
  if (from === to)
313
470
  continue;
314
- return rule;
471
+ return { rule };
315
472
  }
316
473
  return undefined;
317
474
  }
@@ -260,14 +260,21 @@ async function loadArk() {
260
260
  const url = new URL('../dist/index.js', import.meta.url);
261
261
  if (!fs.existsSync(url)) {
262
262
  throw new Error(
263
- 'ark-mcp requires the built library at dist/index.js. Run "npm run build" first.'
263
+ 'ark-mcp requires the built library at dist/index.js, and this install does not have it. ' +
264
+ 'The npm tarball ships dist/; a git install (git+https://…/arkgate) ships only the ' +
265
+ 'committed sources, so arkgate/arkgate-check work but ark-mcp and every "import arkgate*" ' +
266
+ 'do not. Install from npm (npm i arkgate); building is only possible in a clone of the ' +
267
+ 'repository, not in this node_modules copy, which ships no devDependencies. ' +
268
+ 'See docs/package-surface.md, "Installing from git".'
264
269
  );
265
270
  }
266
271
  try {
267
272
  return await import('../dist/index.js');
268
273
  } catch (err) {
269
274
  throw new Error(
270
- `ark-mcp failed to load dist/index.js (rebuild with "npm run build"): ${
275
+ `ark-mcp failed to load dist/index.js it exists but will not import, so this is a ` +
276
+ `broken or partial build rather than a git install (rebuild with "npm run build" in a ` +
277
+ `clone of the repository): ${
271
278
  err instanceof Error ? err.message : String(err)
272
279
  }`
273
280
  );