arkgate 4.8.24 → 4.8.26

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 (166) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +2 -2
  3. package/bin/ark-check-runtime.mjs +17 -14
  4. package/bin/ark-layer-match.mjs +115 -13
  5. package/bin/ark-mcp-runtime.mjs +36 -31
  6. package/bin/ark-shared.mjs +4 -23
  7. package/bin/lib/adoption-stance.mjs +2 -2
  8. package/bin/lib/adr-path.mjs +3 -3
  9. package/bin/lib/agent-gates.mjs +0 -61
  10. package/bin/lib/agent-homes.mjs +12 -21
  11. package/bin/lib/agent-projection-command.mjs +4 -4
  12. package/bin/lib/ambient-state.mjs +9 -30
  13. package/bin/lib/analysis-engine.mjs +14 -14
  14. package/bin/lib/architecture-scan.mjs +11 -9
  15. package/bin/lib/ark-order-doctor.mjs +3 -28
  16. package/bin/lib/ark-order-facts.mjs +6 -99
  17. package/bin/lib/ark-order-types.mjs +1 -2
  18. package/bin/lib/ark-run-doctor.mjs +3 -28
  19. package/bin/lib/ark-run-facts.mjs +30 -40
  20. package/bin/lib/ark-run-sensors.mjs +1 -1
  21. package/bin/lib/arkrule-file-hints.mjs +2 -47
  22. package/bin/lib/arkrules-contract.mjs +124 -133
  23. package/bin/lib/arkrules-sensors.mjs +7 -5
  24. package/bin/lib/check-args.mjs +63 -1
  25. package/bin/lib/ci-and-commands.mjs +8 -72
  26. package/bin/lib/ci-merge-boundary.mjs +1 -1
  27. package/bin/lib/class-source-scan.mjs +5 -5
  28. package/bin/lib/clone-detection.mjs +474 -0
  29. package/bin/lib/codex-home.mjs +1 -1
  30. package/bin/lib/config-contract-slices.mjs +44 -6
  31. package/bin/lib/config-contract.mjs +40 -117
  32. package/bin/lib/config-extras.mjs +16 -22
  33. package/bin/lib/contract-smells.mjs +33 -15
  34. package/bin/lib/core-ratchet.mjs +0 -2
  35. package/bin/lib/dashboard-cli.mjs +2 -2
  36. package/bin/lib/deep-module-coach.mjs +2 -4
  37. package/bin/lib/deploy-path.mjs +13 -38
  38. package/bin/lib/design-smells.mjs +2 -2
  39. package/bin/lib/diagnostic-catalog.mjs +8 -1
  40. package/bin/lib/doctor-advisories.mjs +55 -9
  41. package/bin/lib/doctor-green-cite.mjs +1 -1
  42. package/bin/lib/doctor-human.mjs +1 -1
  43. package/bin/lib/doctor-next-actions.mjs +2 -2
  44. package/bin/lib/doctor-plan.mjs +4 -6
  45. package/bin/lib/duplication-io.mjs +572 -0
  46. package/bin/lib/effective-contract-load.mjs +181 -44
  47. package/bin/lib/entry-points-io.mjs +424 -0
  48. package/bin/lib/extra-merge-teeth.mjs +36 -0
  49. package/bin/lib/field-install.mjs +38 -14
  50. package/bin/lib/first-run-help.mjs +7 -3
  51. package/bin/lib/flat-parent-importers.mjs +5 -3
  52. package/bin/lib/gate-files.mjs +30 -89
  53. package/bin/lib/github-enforcement.mjs +165 -28
  54. package/bin/lib/golden-pattern.mjs +1 -1
  55. package/bin/lib/hook-templates.mjs +16 -16
  56. package/bin/lib/html-report-advisories.mjs +4 -0
  57. package/bin/lib/html-report-depth.mjs +2 -2
  58. package/bin/lib/html-report.mjs +2 -2
  59. package/bin/lib/import-graph-projection.mjs +112 -0
  60. package/bin/lib/improvement-compass-doctor.mjs +1 -24
  61. package/bin/lib/invariant-coverage-io.mjs +4 -3
  62. package/bin/lib/invariant-coverage.mjs +26 -0
  63. package/bin/lib/invariant-probe-cli.mjs +347 -0
  64. package/bin/lib/invariant-probe-io.mjs +165 -0
  65. package/bin/lib/invariant-probe-runner.mjs +268 -0
  66. package/bin/lib/invariant-probe-sites.mjs +174 -0
  67. package/bin/lib/invariant-probe-workspace.mjs +183 -0
  68. package/bin/lib/invariant-probe.mjs +461 -0
  69. package/bin/lib/invariant-tests-path.mjs +16 -22
  70. package/bin/lib/literal-path-drift.mjs +4 -2
  71. package/bin/lib/managed-upgrade.mjs +2 -8
  72. package/bin/lib/mcp-adoption.mjs +4 -5
  73. package/bin/lib/mcp-hook-payload.mjs +4 -4
  74. package/bin/lib/orphan-modules-io.mjs +458 -0
  75. package/bin/lib/orphan-modules.mjs +251 -0
  76. package/bin/lib/outside-importers.mjs +197 -0
  77. package/bin/lib/package-manager.mjs +3 -3
  78. package/bin/lib/parse-health.mjs +1 -1
  79. package/bin/lib/persistence-write-hint.mjs +33 -8
  80. package/bin/lib/pin-dependent-gates.mjs +2 -2
  81. package/bin/lib/policy-delta-io.mjs +60 -12
  82. package/bin/lib/post-green-path.mjs +1 -1
  83. package/bin/lib/prepare-change.mjs +3 -9
  84. package/bin/lib/prepare-write.mjs +1 -2
  85. package/bin/lib/presets.mjs +56 -68
  86. package/bin/lib/product-copy.mjs +1 -6
  87. package/bin/lib/project-root.mjs +43 -85
  88. package/bin/lib/resolved-candidate-facts.mjs +90 -62
  89. package/bin/lib/rules-under-contract.mjs +72 -8
  90. package/bin/lib/scan-files.mjs +4 -4
  91. package/bin/lib/schema-validation.mjs +130 -0
  92. package/bin/lib/sensor-promote-cli.mjs +2 -11
  93. package/bin/lib/sensor-promote-io.mjs +33 -22
  94. package/bin/lib/sensor-promotion.mjs +1 -1
  95. package/bin/lib/skill-catalog-apply.mjs +1 -1
  96. package/bin/lib/skill-install.mjs +42 -38
  97. package/bin/lib/snippet-analysis.mjs +1 -1
  98. package/bin/lib/start-install-recovery.mjs +1 -1
  99. package/bin/lib/start-preview.mjs +1 -1
  100. package/bin/lib/states-transitions-presence.mjs +3 -3
  101. package/bin/lib/status-command.mjs +2 -18
  102. package/bin/lib/status-manifest.mjs +18 -26
  103. package/bin/lib/status-transition-catalog.mjs +0 -6
  104. package/bin/lib/suggestions.mjs +3 -3
  105. package/bin/lib/team-parliament-io.mjs +3 -17
  106. package/bin/lib/team-parliament.mjs +15 -9
  107. package/bin/lib/upgrade-behavior-changes.mjs +2 -2
  108. package/bin/lib/upgrade-command.mjs +2 -24
  109. package/bin/lib/upgrade-outcome.mjs +1 -1
  110. package/bin/lib/upstream-report.mjs +21 -10
  111. package/bin/lib/violations.mjs +10 -9
  112. package/bin/lib/weakest-link.mjs +13 -0
  113. package/bin/lib/write-path-capabilities.mjs +2 -2
  114. package/dist/chunk-G3JRBJGH.js +16 -0
  115. package/dist/chunk-R3AJ5RSZ.cjs +16 -0
  116. package/dist/{configTypes-BJbaeoj4.d.cts → configTypes-GPvvdleM.d.cts} +12 -1
  117. package/dist/{configTypes-BJbaeoj4.d.ts → configTypes-GPvvdleM.d.ts} +12 -1
  118. package/dist/eslint/index.cjs +11 -11
  119. package/dist/eslint/index.d.cts +10 -1
  120. package/dist/eslint/index.d.ts +10 -1
  121. package/dist/eslint/index.js +11 -11
  122. package/dist/index.cjs +34 -34
  123. package/dist/index.d.cts +587 -515
  124. package/dist/index.d.ts +587 -515
  125. package/dist/index.js +34 -34
  126. package/dist/nestjs/index.cjs +1 -1
  127. package/dist/nestjs/index.d.cts +1 -1
  128. package/dist/nestjs/index.d.ts +1 -1
  129. package/dist/nestjs/index.js +1 -1
  130. package/dist/order/index.cjs +1 -1
  131. package/dist/order/index.js +1 -1
  132. package/dist/runtime/index.cjs +12 -12
  133. package/dist/runtime/index.d.cts +174 -136
  134. package/dist/runtime/index.d.ts +174 -136
  135. package/dist/runtime/index.js +12 -12
  136. package/dist/{types-DRinG-vT.d.cts → types-CnauMQQL.d.cts} +33 -25
  137. package/dist/{types-DRinG-vT.d.ts → types-CnauMQQL.d.ts} +33 -25
  138. package/docs/README.md +3 -1
  139. package/docs/agent-guide.md +140 -6
  140. package/docs/ai-gates.md +15 -1
  141. package/docs/configuration.md +40 -11
  142. package/docs/diagnostics.md +119 -3
  143. package/docs/package-surface.md +4 -1
  144. package/docs/product-voice.md +7 -1
  145. package/package.json +11 -2
  146. package/schemas/ark.config.schema.json +31 -3
  147. package/schemas/ark.invariant-probe.schema.json +368 -0
  148. package/server.json +2 -2
  149. package/templates/agent-skills/README.md +1 -1
  150. package/templates/agent-skills/ark-adopt/SKILL.md +1 -0
  151. package/templates/agent-skills/ark-contract/SKILL.md +17 -0
  152. package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
  153. package/templates/agent-skills/ark-explore/SKILL.md +7 -0
  154. package/templates/agent-skills/ark-fix/SKILL.md +20 -0
  155. package/templates/agent-skills/ark-place/SKILL.md +8 -0
  156. package/templates/skills/ark-adopt.md +1 -0
  157. package/templates/skills/ark-contract.md +17 -0
  158. package/templates/skills/ark-coverage.md +2 -0
  159. package/templates/skills/ark-explore.md +7 -0
  160. package/templates/skills/ark-fix.md +20 -0
  161. package/templates/skills/ark-place.md +8 -0
  162. package/bin/lib/ark-order-error.mjs +0 -18
  163. package/bin/lib/ark-order-invariants.mjs +0 -388
  164. package/bin/lib/stable-hash.mjs +0 -90
  165. package/dist/chunk-MBXCFLO4.js +0 -16
  166. package/dist/chunk-RNQHRDCQ.cjs +0 -16
@@ -13,7 +13,7 @@ import { REQUIRED_GATE_WORKFLOW } from './gate-files.mjs';
13
13
  export const PACKAGE_UNRESOLVED_NEXT_ACTION =
14
14
  'Install arkgate in this project first (npx --package=arkgate, or pnpm add -D arkgate -w / yarn add -D arkgate -W at a workspace root), then re-run --doctor';
15
15
 
16
- export function packageUnresolvedNextAction(ctx) {
16
+ function packageUnresolvedNextAction(ctx) {
17
17
  if (ctx?.packageInstalled !== false || ctx?.selfHost === true) return null;
18
18
  const code = ctx?.packageVersionTruth?.code;
19
19
  if (code === 'PACKAGE_PIN_SELF_HOST' || code === 'PACKAGE_PIN_ABSENT') return null;
@@ -101,7 +101,7 @@ function domainSampleIsGlobLeak(row, cov) {
101
101
  }
102
102
 
103
103
  /** Overlapping roots from matching Domain wildcards, else sample path prefixes. */
104
- export function overlappingRootsFromCoverage(cov) {
104
+ function overlappingRootsFromCoverage(cov) {
105
105
  const samples = Array.isArray(cov?.dualMembership?.samples) ? cov.dualMembership.samples : [];
106
106
  const domainPatterns = layerPatterns(cov, 'DomainModel');
107
107
  const fromPatterns = [];
@@ -10,7 +10,6 @@ import {
10
10
  shouldShowNewHereNudge,
11
11
  } from '../ark-shared.mjs';
12
12
  import * as arkShared from '../ark-shared.mjs';
13
- import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
14
13
  import { describePackageVersionDualTruth } from './field-install.mjs';
15
14
  import { withPinDependentGateFiles } from './pin-dependent-gates.mjs';
16
15
  import { detectAgentHomeGaps } from './agent-homes.mjs';
@@ -22,10 +21,9 @@ import { collectStatesTransitionsResidual } from './states-transitions-presence.
22
21
  import { collectStatusTransitionCatalogResidual } from './status-transition-catalog.mjs';
23
22
  import { collectNoDomainFrontendResidual } from './no-domain-frontend.mjs';
24
23
  import { collectInvariantCoverageResiduals } from './invariant-tests-path.mjs';
25
- import { anyChildWallAdvisory, anySliceAlias, applyAdvisorySiblingRatchet } from '../ark-layer-match.mjs';
24
+ import { anyChildWallAdvisory, applyAdvisorySiblingRatchet, sliceAliasOwesMove } from '../ark-layer-match.mjs';
26
25
  export { printAdrPresenceHint };
27
26
  export { printDoctorCompactHuman, printDoctorDetailsHuman };
28
- export { summarizeRulesUnderContract };
29
27
  /** Optional S3 dual-match classifier when ark-shared exports it (soft dep for S5 landing). */
30
28
  const matchingLayersForRelativePath =
31
29
  typeof arkShared.matchingLayersForRelativePath === 'function'
@@ -610,7 +608,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
610
608
  options.ts,
611
609
  options.parseHealth,
612
610
  options.facts ?? options.architectureFacts,
613
- activeViolations
611
+ activeViolations,
612
+ { details: options.all === true, changed: options.changed === true }
614
613
  );
615
614
  // After physical cohesion so a reshape pilot is a candidate (#309).
616
615
  const pilotLoop = summarizePilotLoop(
@@ -692,7 +691,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
692
691
  github: githubForBoundary,
693
692
  adoptionStance: stanceFile,
694
693
  stewardNudge: doctorAdvisories.stewardNudge,
695
- physicalCohesionResidual: physicalCohesionResidualRemains(doctorAdvisories.physicalCohesion), childWallAdvisory: anyChildWallAdvisory(config?.rules), sliceAliasDebt: anySliceAlias(config?.rules),
694
+ physicalCohesionResidual: physicalCohesionResidualRemains(doctorAdvisories.physicalCohesion), childWallAdvisory: anyChildWallAdvisory(config?.rules), sliceAliasDebt: sliceAliasOwesMove(config?.rules),
696
695
  });
697
696
 
698
697
  // Improvement compass: projection only — never feeds ok/valid/goal.met.
@@ -974,4 +973,3 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
974
973
  printDoctorCompactHuman(humanView);
975
974
  if (options.all) printDoctorDetailsHuman(humanView);
976
975
  }
977
-
@@ -0,0 +1,572 @@
1
+ /**
2
+ * Copies across a wall — Tooling (ADR 0038).
3
+ *
4
+ * Builds an AST-kind token stream per governed file from the TypeScript module
5
+ * doctor already loaded (`ts.createSourceFile`, one parse per file, a pre-order
6
+ * node walk that is JSX-safe), fingerprints it with the pure core in
7
+ * `clone-detection.mjs` (generated from src/domain/cloneDetection.ts), and
8
+ * classifies each candidate file pair with the gate's own classifier
9
+ * (`findDeniedEdgeDecision`, both directions) before any verification.
10
+ *
11
+ * Two phases, bounded memory. Phase 1 streams: read, parse, fingerprint, append
12
+ * to typed arrays, release the text and the tree. Phase 2 re-parses only the
13
+ * files of crossing pairs (a cache of eight) to extend matches and compare
14
+ * names. Every cap makes the section `partial` with counts.
15
+ *
16
+ * Runs only in status details (`--doctor --all`) and `--report`. Never in facts,
17
+ * `factsHash`, the verdict, the write hook, MCP, ESLint, compact status,
18
+ * `--changed` or `--strict-merge`. Always `notAScore`.
19
+ */
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import {
23
+ DEFAULT_GENERATED_FILE_GLOBS,
24
+ findDeniedEdgeDecision,
25
+ globToRegExp,
26
+ isEdgeDenied,
27
+ layerForRelativePath,
28
+ pathUnderSharedRoot,
29
+ } from '../ark-layer-match.mjs';
30
+ import {
31
+ CLONE_MIN_LINES,
32
+ DUPLICATION_COMMAND,
33
+ TOKEN_IDENT,
34
+ TOKEN_KIND_OFFSET,
35
+ TOKEN_LITERAL,
36
+ buildDuplicationAdvisory,
37
+ candidatePairs,
38
+ classifyCrossing,
39
+ emptyDuplicationTotals,
40
+ extendMatch,
41
+ fingerprint,
42
+ groupFamilies,
43
+ isGeneratedHeader,
44
+ isListedCrossing,
45
+ namesAgree,
46
+ notRunDuplication,
47
+ primaryPair,
48
+ sameNames,
49
+ unavailableDuplication,
50
+ } from './clone-detection.mjs';
51
+ import { graphScanLimit } from './graph-blind.mjs';
52
+
53
+ const MAX_FILE_BYTES = 256 * 1024;
54
+ const MAX_FINGERPRINTS = 1_000_000;
55
+ /** Calibrated (ADR 0038): the mother repo has 459 crossing file pairs; all compare in ~0.3 s. */
56
+ const MAX_VERIFY_PAIRS = 1000;
57
+ const VERIFY_CACHE = 8;
58
+ const PRINT_CAP = 8;
59
+ const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
60
+ const DECLARATION_FILE = /\.d\.[cm]?ts$/;
61
+ const TEST_FILE = /\.(?:test|spec)(?:-d)?\.[cm]?[jt]sx?$|(?:^|\/)(?:tests?|__tests__|__mocks__|e2e)\//i;
62
+ const GENERATED_GLOBS = DEFAULT_GENERATED_FILE_GLOBS.map((glob) => globToRegExp(glob));
63
+ /** Order a crossing pair is verified in when the verify cap bites. */
64
+ const VERIFY_ORDER = ['cross-slice', 'cross-parent', 'cross-sibling', 'cross-layer-walled', 'cross-layer'];
65
+
66
+ function scriptKindFor(ts, rel) {
67
+ const lower = rel.toLowerCase();
68
+ if (lower.endsWith('.tsx')) return ts.ScriptKind.TSX;
69
+ if (lower.endsWith('.jsx')) return ts.ScriptKind.JSX;
70
+ if (/\.[cm]?js$/.test(lower)) return ts.ScriptKind.JS;
71
+ return ts.ScriptKind.TS;
72
+ }
73
+
74
+ function literalKinds(ts) {
75
+ const K = ts.SyntaxKind;
76
+ return new Set(
77
+ [
78
+ K.StringLiteral,
79
+ K.NumericLiteral,
80
+ K.BigIntLiteral,
81
+ K.NoSubstitutionTemplateLiteral,
82
+ K.RegularExpressionLiteral,
83
+ K.TemplateHead,
84
+ K.TemplateMiddle,
85
+ K.TemplateTail,
86
+ K.JsxText,
87
+ ].filter((kind) => typeof kind === 'number')
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Pre-order node walk → token kinds. Identifiers → 0, literals and JSX text → 1,
93
+ * every other node kind → `2 + kind`; unary operators (not child nodes) are
94
+ * emitted after their expression node. Imports, `import x = require()` and
95
+ * `export … from` are skipped; comments are trivia and never tokens.
96
+ * `detail` also keeps, per token, the node, the end of its subtree in tokens
97
+ * (for line ranges) and the identifier text.
98
+ */
99
+ export function tokenStream(ts, rel, text, detail = false) {
100
+ const sourceFile = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, false, scriptKindFor(ts, rel));
101
+ const K = ts.SyntaxKind;
102
+ const literals = literalKinds(ts);
103
+ const kinds = [];
104
+ const nodes = detail ? [] : null;
105
+ const names = detail ? [] : null;
106
+ const subtreeEnd = detail ? [] : null;
107
+ const emit = (value, node) => {
108
+ kinds.push(value);
109
+ if (detail) {
110
+ nodes.push(node);
111
+ names.push(value === TOKEN_IDENT ? String(node.text ?? '') : '');
112
+ subtreeEnd.push(kinds.length);
113
+ }
114
+ };
115
+ const visit = (node) => {
116
+ const kind = node.kind;
117
+ if (kind === K.ImportDeclaration || kind === K.ImportEqualsDeclaration || kind === K.EndOfFileToken) return;
118
+ if (kind === K.ExportDeclaration && node.moduleSpecifier) return;
119
+ if (kind === K.Identifier || kind === K.PrivateIdentifier) {
120
+ emit(TOKEN_IDENT, node);
121
+ return;
122
+ }
123
+ if (literals.has(kind)) {
124
+ if (kind === K.JsxText && node.containsOnlyTriviaWhiteSpaces) return;
125
+ emit(TOKEN_LITERAL, node);
126
+ return;
127
+ }
128
+ const at = kinds.length;
129
+ emit(TOKEN_KIND_OFFSET + kind, node);
130
+ if (kind === K.PrefixUnaryExpression || kind === K.PostfixUnaryExpression) {
131
+ emit(TOKEN_KIND_OFFSET + node.operator, node);
132
+ }
133
+ ts.forEachChild(node, visit);
134
+ if (detail) subtreeEnd[at] = kinds.length;
135
+ };
136
+ ts.forEachChild(sourceFile, visit);
137
+ return { kinds: Uint16Array.from(kinds), nodes, names, subtreeEnd, sourceFile };
138
+ }
139
+
140
+ function isGeneratedPath(rel) {
141
+ return GENERATED_GLOBS.some((re) => re.test(rel));
142
+ }
143
+
144
+ /** Layers named by any deny rule: a copy there is most likely to cross a wall. */
145
+ function walledLayers(rules) {
146
+ const out = new Set();
147
+ for (const rule of Array.isArray(rules) ? rules : []) {
148
+ if (rule?.allowed !== false) continue;
149
+ if (typeof rule.from === 'string') out.add(rule.from);
150
+ if (typeof rule.to === 'string') out.add(rule.to);
151
+ }
152
+ return out;
153
+ }
154
+
155
+ function eligibleFiles(relFiles, config, rules) {
156
+ const skipped = { test: 0, declaration: 0, generated: 0, oversize: 0, unreadable: 0, parseError: 0, cap: 0 };
157
+ const walled = walledLayers(rules);
158
+ const first = [];
159
+ const rest = [];
160
+ for (const rel of [...new Set(relFiles)].sort()) {
161
+ if (!SOURCE_FILE.test(rel)) continue;
162
+ if (DECLARATION_FILE.test(rel)) skipped.declaration += 1;
163
+ else if (TEST_FILE.test(rel)) skipped.test += 1;
164
+ else if (isGeneratedPath(rel)) skipped.generated += 1;
165
+ else (walled.has(layerForRelativePath(rel, config?.layers)) ? first : rest).push(rel);
166
+ }
167
+ const ordered = [...first, ...rest];
168
+ const limit = graphScanLimit(ordered.length);
169
+ skipped.cap = Math.max(0, ordered.length - limit);
170
+ return { files: ordered.slice(0, limit), eligible: ordered.length, skipped };
171
+ }
172
+
173
+ function readEligible(root, rel, skipped) {
174
+ try {
175
+ const absolute = path.join(root, rel);
176
+ const stat = fs.statSync(absolute);
177
+ if (!stat.isFile()) {
178
+ skipped.unreadable += 1;
179
+ return null;
180
+ }
181
+ if (stat.size > MAX_FILE_BYTES) {
182
+ skipped.oversize += 1;
183
+ return null;
184
+ }
185
+ const text = fs.readFileSync(absolute, 'utf8');
186
+ if (isGeneratedHeader(text)) {
187
+ skipped.generated += 1;
188
+ return null;
189
+ }
190
+ return text;
191
+ } catch {
192
+ skipped.unreadable += 1;
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /** Growable typed-array table of `(hash, file, offset)` rows. */
198
+ function createTable() {
199
+ let capacity = 4096;
200
+ let hashes = new Uint32Array(capacity);
201
+ let files = new Uint32Array(capacity);
202
+ let offsets = new Uint32Array(capacity);
203
+ let count = 0;
204
+ return {
205
+ get count() {
206
+ return count;
207
+ },
208
+ append(fileIndex, print) {
209
+ const need = count + print.hashes.length;
210
+ if (need > capacity) {
211
+ capacity = Math.max(need, capacity * 2);
212
+ const grow = (old) => {
213
+ const next = new Uint32Array(capacity);
214
+ next.set(old.subarray(0, count));
215
+ return next;
216
+ };
217
+ hashes = grow(hashes);
218
+ files = grow(files);
219
+ offsets = grow(offsets);
220
+ }
221
+ hashes.set(print.hashes, count);
222
+ offsets.set(print.offsets, count);
223
+ files.fill(fileIndex, count, need);
224
+ count = need;
225
+ },
226
+ view() {
227
+ return { hashes, files, offsets, count };
228
+ },
229
+ };
230
+ }
231
+
232
+ /** Phase 1: stream every eligible file once; keep only fingerprints. */
233
+ function fingerprintFiles(root, ts, files, skipped) {
234
+ const table = createTable();
235
+ const fingerprinted = new Array(files.length).fill(false);
236
+ let capped = 0;
237
+ for (let index = 0; index < files.length; index += 1) {
238
+ const rel = files[index];
239
+ const text = readEligible(root, rel, skipped);
240
+ if (text === null) continue;
241
+ let print;
242
+ try {
243
+ print = fingerprint(tokenStream(ts, rel, text).kinds);
244
+ } catch {
245
+ skipped.parseError += 1;
246
+ continue;
247
+ }
248
+ if (table.count + print.hashes.length > MAX_FINGERPRINTS) {
249
+ capped = files.length - index;
250
+ break;
251
+ }
252
+ table.append(index, print);
253
+ fingerprinted[index] = true;
254
+ }
255
+ return { table: table.view(), fingerprinted, capped };
256
+ }
257
+
258
+ function wallDecision(decision) {
259
+ if (!decision) return undefined;
260
+ return {
261
+ ...(decision.sliceVerdict?.crossing ? { crossing: decision.sliceVerdict.crossing } : {}),
262
+ childSlices: Boolean(decision.rule?.childSlices),
263
+ };
264
+ }
265
+
266
+ function decisionsFor(relA, relB, config, rules) {
267
+ const layers = config?.layers;
268
+ const layerA = layerForRelativePath(relA, layers);
269
+ const layerB = layerForRelativePath(relB, layers);
270
+ if (!layerA || !layerB) return { layerA, layerB };
271
+ return {
272
+ layerA,
273
+ layerB,
274
+ forward: findDeniedEdgeDecision(rules, layerA, layerB, { fromPath: relA, toPath: relB, layers }),
275
+ backward: findDeniedEdgeDecision(rules, layerB, layerA, { fromPath: relB, toPath: relA, layers }),
276
+ };
277
+ }
278
+
279
+ /** Classify every candidate file pair before any expensive verification. */
280
+ function classifyPairs(pairs, files, config, rules, totals) {
281
+ const crossing = [];
282
+ for (const pair of pairs) {
283
+ const relA = files[pair.a];
284
+ const relB = files[pair.b];
285
+ const placed = decisionsFor(relA, relB, config, rules);
286
+ const outcome = classifyCrossing({
287
+ layerA: placed.layerA,
288
+ layerB: placed.layerB,
289
+ forward: wallDecision(placed.forward),
290
+ backward: wallDecision(placed.backward),
291
+ });
292
+ if (outcome === 'same-slice') totals.pairsSameSliceUnexamined += 1;
293
+ else if (outcome === 'fail-closed') totals.pairsUnclassifiable += 1;
294
+ else if (outcome === 'unplaced') totals.pairsUnplaced += 1;
295
+ else if (isListedCrossing(outcome)) crossing.push({ pair, relA, relB, crossing: outcome, ...placed });
296
+ }
297
+ totals.pairsCrossBoundary = crossing.length;
298
+ crossing.sort(
299
+ (left, right) =>
300
+ VERIFY_ORDER.indexOf(left.crossing) - VERIFY_ORDER.indexOf(right.crossing) ||
301
+ right.pair.shared - left.pair.shared ||
302
+ left.pair.a - right.pair.a ||
303
+ left.pair.b - right.pair.b
304
+ );
305
+ return crossing;
306
+ }
307
+
308
+ /** Phase 2 cache: at most `VERIFY_CACHE` parsed files at once. */
309
+ function createDetailCache(root, ts) {
310
+ const cache = new Map();
311
+ return (rel) => {
312
+ if (cache.has(rel)) {
313
+ const hit = cache.get(rel);
314
+ cache.delete(rel);
315
+ cache.set(rel, hit);
316
+ return hit;
317
+ }
318
+ let entry = null;
319
+ try {
320
+ const text = fs.readFileSync(path.join(root, rel), 'utf8');
321
+ entry = tokenStream(ts, rel, text, true);
322
+ } catch {
323
+ entry = null;
324
+ }
325
+ cache.set(rel, entry);
326
+ if (cache.size > VERIFY_CACHE) cache.delete(cache.keys().next().value);
327
+ return entry;
328
+ };
329
+ }
330
+
331
+ function lineOf(stream, position) {
332
+ return stream.sourceFile.getLineAndCharacterOfPosition(position).line + 1;
333
+ }
334
+
335
+ /** First line of a region: where its first token starts. */
336
+ function startLineOf(stream, start) {
337
+ return lineOf(stream, stream.nodes[start].getStart(stream.sourceFile));
338
+ }
339
+
340
+ /** Last line of a region: the end of the last node whose whole subtree is inside it. */
341
+ function endLineOf(stream, start, end) {
342
+ let last = stream.nodes[end - 1].end;
343
+ for (let i = start; i < end; i += 1) {
344
+ if (stream.subtreeEnd[i] <= end && stream.nodes[i].end > last) last = stream.nodes[i].end;
345
+ }
346
+ return lineOf(stream, Math.max(0, last - 1));
347
+ }
348
+
349
+ function namesIn(stream, start, end) {
350
+ const out = [];
351
+ for (let i = start; i < end; i += 1) if (stream.kinds[i] === TOKEN_IDENT) out.push(stream.names[i]);
352
+ return out;
353
+ }
354
+
355
+ function verifyPair(row, load) {
356
+ const a = load(row.relA);
357
+ const b = load(row.relB);
358
+ if (!a || !b) return [];
359
+ const found = [];
360
+ const seeds = row.pair.seeds;
361
+ for (let i = 0; i < seeds.length; i += 2) {
362
+ const seedA = seeds[i];
363
+ const seedB = seeds[i + 1];
364
+ const inside = found.some(
365
+ (m) => seedA >= m.aStart && seedA < m.aEnd && seedB >= m.bStart && seedB < m.bEnd
366
+ );
367
+ if (inside) continue;
368
+ const match = extendMatch(a.kinds, b.kinds, seedA, seedB);
369
+ if (match) found.push(match);
370
+ }
371
+ const verified = [];
372
+ for (const match of found) {
373
+ const span = (stream, rel, layer, start, end) => ({
374
+ path: rel,
375
+ start,
376
+ end,
377
+ startLine: startLineOf(stream, start),
378
+ endLine: endLineOf(stream, start, end),
379
+ ...(layer ? { layer } : {}),
380
+ });
381
+ const left = span(a, row.relA, row.layerA, match.aStart, match.aEnd);
382
+ const right = span(b, row.relB, row.layerB, match.bStart, match.bEnd);
383
+ if (left.endLine - left.startLine + 1 < CLONE_MIN_LINES) continue;
384
+ if (right.endLine - right.startLine + 1 < CLONE_MIN_LINES) continue;
385
+ const names = sameNames(namesIn(a, match.aStart, match.aEnd), namesIn(b, match.bStart, match.bEnd));
386
+ if (!namesAgree(names)) continue;
387
+ verified.push({ a: left, b: right, crossing: row.crossing, names });
388
+ }
389
+ return verified;
390
+ }
391
+
392
+ function segmentsOf(rel) {
393
+ return rel.split('/').filter(Boolean);
394
+ }
395
+
396
+ function dirSegments(rel) {
397
+ return segmentsOf(rel).slice(0, -1);
398
+ }
399
+
400
+ /**
401
+ * A declared shared root as a folder next to the member, anchored the way the
402
+ * wall anchors it (the repo root, or one conventional source folder in).
403
+ */
404
+ function sharedRootPath(sharedRoots, relA) {
405
+ const head = segmentsOf(relA)[0];
406
+ for (const raw of Array.isArray(sharedRoots) ? sharedRoots : []) {
407
+ if (typeof raw !== 'string' || raw.includes('*')) continue;
408
+ const root = segmentsOf(raw.replace(/\\/g, '/').replace(/^\.\//, '')).join('/');
409
+ if (!root) continue;
410
+ for (const candidate of [head ? `${head}/${root}` : '', root]) {
411
+ if (candidate && pathUnderSharedRoot(`${candidate}/x.ts`, [raw])) return `${candidate}/`;
412
+ }
413
+ }
414
+ return null;
415
+ }
416
+
417
+ function commonDir(relA, relB) {
418
+ const left = dirSegments(relA);
419
+ const right = dirSegments(relB);
420
+ const out = [];
421
+ for (let i = 0; i < Math.min(left.length, right.length) && left[i] === right[i]; i += 1) out.push(left[i]);
422
+ return out.join('/');
423
+ }
424
+
425
+ function sharedLayer(config, rules, members) {
426
+ const own = new Set(members.map((member) => member.layer));
427
+ for (const layer of Array.isArray(config?.layers) ? config.layers : []) {
428
+ const name = layer?.name;
429
+ if (typeof name !== 'string' || own.has(name)) continue;
430
+ const reachable = members.every(
431
+ (member) => !isEdgeDenied(rules, member.layer, name, { fromPath: member.path, layers: config.layers })
432
+ );
433
+ if (reachable) return name;
434
+ }
435
+ return null;
436
+ }
437
+
438
+ /** Plain destination hint for a family (never applied). */
439
+ function destinationFor(family, config, rules) {
440
+ const pair = primaryPair(family);
441
+ const a = family.members[pair?.a ?? 0];
442
+ const b = family.members[pair?.b ?? 1];
443
+ if (!pair || !a || !b) return { kind: 'ask-place' };
444
+ const placed = decisionsFor(a.path, b.path, config, rules);
445
+ const wall = placed.forward?.rule?.peerIsolation ? placed.forward.rule : placed.backward?.rule;
446
+ switch (pair.crossing) {
447
+ case 'cross-slice':
448
+ case 'cross-parent': {
449
+ const shared = sharedRootPath(wall?.sharedRoots, a.path);
450
+ return shared ? { kind: 'shared-root', path: shared } : { kind: 'ask-place' };
451
+ }
452
+ case 'cross-sibling': {
453
+ const common = commonDir(a.path, b.path);
454
+ const folder = wall?.childSlices?.commonFolders?.find((name) => typeof name === 'string' && name.length > 0);
455
+ if (!common) return { kind: 'ask-place' };
456
+ return { kind: 'universe-common', path: `${common}/${folder ? `${folder}/` : ''}` };
457
+ }
458
+ case 'cross-layer':
459
+ if (placed.forward && !placed.backward) return { kind: 'lower-layer', layer: a.layer };
460
+ if (placed.backward && !placed.forward) return { kind: 'lower-layer', layer: b.layer };
461
+ return { kind: 'either-layer', layers: [a.layer, b.layer].sort() };
462
+ default: {
463
+ const layer = sharedLayer(config, rules, [a, b]);
464
+ return layer ? { kind: 'shared-layer', layer } : { kind: 'ask-place' };
465
+ }
466
+ }
467
+ }
468
+
469
+ function partialReasonsFor(totals, capped, seedTruncated, unverified) {
470
+ const reasons = [];
471
+ if (totals.filesSkipped.cap > 0) {
472
+ reasons.push(`${totals.filesSkipped.cap} file(s) were not checked: the file cap was reached.`);
473
+ }
474
+ if (capped > 0) reasons.push(`${capped} file(s) were not checked: the fingerprint cap was reached.`);
475
+ if (totals.filesSkipped.parseError > 0) {
476
+ reasons.push(`${totals.filesSkipped.parseError} file(s) could not be parsed.`);
477
+ }
478
+ if (seedTruncated) reasons.push('Some shared fingerprints were not paired: the pair cap was reached.');
479
+ if (unverified > 0) reasons.push(`${unverified} crossing file pair(s) were not compared: the compare cap was reached.`);
480
+ return reasons;
481
+ }
482
+
483
+ /**
484
+ * @param {{ root: string, config: object, rules?: object[], ts?: object, files: string[], details?: boolean, changed?: boolean }} input
485
+ */
486
+ export function computeCrossWallDuplication(input) {
487
+ if (input.changed === true) {
488
+ return notRunDuplication('Status ran on changed files only. Copies across a wall need the whole tree.');
489
+ }
490
+ if (input.details !== true) return notRunDuplication();
491
+ const ts = input.ts;
492
+ if (typeof ts?.createSourceFile !== 'function' || typeof ts?.forEachChild !== 'function') {
493
+ return unavailableDuplication('No TypeScript parser was loaded, so no token stream could be built.');
494
+ }
495
+ const config = input.config ?? {};
496
+ const rules = input.rules ?? config.rules;
497
+ const { files, eligible, skipped } = eligibleFiles(input.files ?? [], config, rules);
498
+ const totals = { ...emptyDuplicationTotals(), filesEligible: eligible, filesSkipped: skipped };
499
+ const phase1 = fingerprintFiles(input.root, ts, files, skipped);
500
+ totals.filesFingerprinted = phase1.fingerprinted.filter(Boolean).length;
501
+ totals.fingerprints = phase1.table.count;
502
+ const candidates = candidatePairs(phase1.table);
503
+ totals.seedPairs = candidates.seedPairs;
504
+ totals.bucketsSkipped = candidates.bucketsSkipped;
505
+ totals.candidateFilePairs = candidates.pairs.length;
506
+ const crossing = classifyPairs(candidates.pairs, files, config, rules, totals);
507
+ const load = createDetailCache(input.root, ts);
508
+ const verified = [];
509
+ // Priority picks which pairs fit the cap; file order keeps the parse cache warm.
510
+ const toVerify = crossing.slice(0, MAX_VERIFY_PAIRS);
511
+ const byFile = [...toVerify].sort((left, right) => left.pair.a - right.pair.a || left.pair.b - right.pair.b);
512
+ for (const row of byFile) verified.push(...verifyPair(row, load));
513
+ totals.pairsVerified = toVerify.length;
514
+ totals.clonePairs = verified.length;
515
+ const families = groupFamilies(verified).map((family) => ({
516
+ ...family,
517
+ destination: destinationFor(family, config, rules),
518
+ }));
519
+ return buildDuplicationAdvisory({
520
+ families,
521
+ totals,
522
+ partialReasons: partialReasonsFor(totals, phase1.capped, candidates.truncated, crossing.length - toVerify.length),
523
+ });
524
+ }
525
+
526
+ function memberLine(member) {
527
+ return `${member.path} lines ${member.startLine}–${member.endLine}`;
528
+ }
529
+
530
+ /** Details view. Silent when nothing crosses and nothing was cut. */
531
+ export function printCrossWallDuplicationSection(section, io) {
532
+ if (!section || section.notAScore !== true || section.status === 'not-run') return;
533
+ const families = Array.isArray(section.families) ? section.families : [];
534
+ if (section.status === 'complete' && families.length === 0) return;
535
+ console.log('');
536
+ console.log(io.color.bold('Copies across a wall (not a score)'));
537
+ if (section.status === 'unavailable') {
538
+ io.line(' ', io.color.dim(section.honesty?.[0] ?? section.headline));
539
+ return;
540
+ }
541
+ io.line(families.length > 0 ? io.warn : ' ', section.headline);
542
+ for (const family of families.slice(0, PRINT_CAP)) {
543
+ io.line(io.warn, family.line);
544
+ for (const member of family.members) io.line(' ', io.color.dim(memberLine(member)));
545
+ }
546
+ if (section.truncated > 0) io.line(' ', io.color.dim(`…(+${section.truncated} more in doctor JSON)`));
547
+ for (const text of section.honesty ?? []) io.line(' ', io.color.dim(text));
548
+ io.line(' ', io.color.dim('advisory only — a copy is not an import; the check verdict is unchanged'));
549
+ }
550
+
551
+ /** HTML report section (report parity: `data-advisory="crossWallDuplication"`). */
552
+ export function crossWallDuplicationHtml(section, esc = (value) => String(value)) {
553
+ if (!section || section.notAScore !== true) return '';
554
+ const families = Array.isArray(section.families) ? section.families : [];
555
+ let body;
556
+ if (section.status === 'not-run') {
557
+ body = `<p class="muted">Not run here. It runs in <code>${esc(section.next ?? DUPLICATION_COMMAND)}</code> and in the report.</p>`;
558
+ } else {
559
+ const items = families
560
+ .map(
561
+ (family) =>
562
+ `<li><span class="tag warn">${esc(family.crossing)}</span> ${esc(family.line)}<ul>${family.members
563
+ .map((member) => `<li><code>${esc(member.path)}</code> lines ${member.startLine}–${member.endLine}</li>`)
564
+ .join('')}</ul></li>`
565
+ )
566
+ .join('');
567
+ const more = section.truncated > 0 ? `<p class="muted">…(+${section.truncated} more in doctor JSON)</p>` : '';
568
+ const honesty = (section.honesty ?? []).map((text) => `<p class="muted">${esc(text)}</p>`).join('');
569
+ body = `<p>${esc(section.headline ?? '')}</p>${items ? `<ul>${items}</ul>` : ''}${more}${honesty}`;
570
+ }
571
+ return `<section class="section card" data-advisory="crossWallDuplication"><h2>Copies across a wall <span class="muted">(advisory — not a score; the verdict is unchanged)</span></h2>${body}</section>`;
572
+ }