arkgate 4.8.24 → 4.8.25

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 +85 -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 +55 -8
  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 +31 -62
  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-AHACEKSV.js +16 -0
  115. package/dist/chunk-UN5EH4QV.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 +2 -1
  139. package/docs/agent-guide.md +134 -2
  140. package/docs/ai-gates.md +15 -1
  141. package/docs/configuration.md +40 -11
  142. package/docs/diagnostics.md +118 -2
  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
@@ -48,13 +48,14 @@ export function scanDemandsInvariantTestsPath(root, args = {}) {
48
48
  }
49
49
 
50
50
  /**
51
- * First concrete path segment exists, or every declared glob is wildcard-only.
51
+ * True when some declared glob's concrete prefix (text before the first `*`/`?`)
52
+ * exists under root, or when every declared glob is wildcard-only. Prefixes that
53
+ * escape root are skipped. No declarations → false; no root → true.
52
54
  *
53
55
  * @param {string} root
54
- * @param {{ testGlobs?: unknown, coverageRoots?: unknown } | null | undefined} coverage
56
+ * @param {string[]} declared
55
57
  */
56
- export function declaredInvariantTestsPathPresent(root, coverage) {
57
- const declared = configuredInvariantTestsPaths(coverage);
58
+ function firstConcreteGlobPrefixPresent(root, declared) {
58
59
  if (declared.length === 0) return false;
59
60
  if (typeof root !== 'string' || root.length === 0) return true;
60
61
  let sawConcrete = false;
@@ -74,6 +75,16 @@ export function declaredInvariantTestsPathPresent(root, coverage) {
74
75
  return !sawConcrete;
75
76
  }
76
77
 
78
+ /**
79
+ * First concrete path segment exists, or every declared glob is wildcard-only.
80
+ *
81
+ * @param {string} root
82
+ * @param {{ testGlobs?: unknown, coverageRoots?: unknown } | null | undefined} coverage
83
+ */
84
+ export function declaredInvariantTestsPathPresent(root, coverage) {
85
+ return firstConcreteGlobPrefixPresent(root, configuredInvariantTestsPaths(coverage));
86
+ }
87
+
77
88
  /**
78
89
  * Doctor residual when adopted + invariants + missing/empty tests path.
79
90
  *
@@ -131,24 +142,7 @@ export function collectInvariantTestsPathResidual(input = {}) {
131
142
  * @param {{ coverageRoots?: unknown } | null | undefined} coverage
132
143
  */
133
144
  export function declaredCoverageRootsPresent(root, coverage) {
134
- const declared = configuredCoverageRoots(coverage);
135
- if (declared.length === 0) return false;
136
- if (typeof root !== 'string' || root.length === 0) return true;
137
- let sawConcrete = false;
138
- for (const entry of declared) {
139
- const prefix = entry.split(/[*?]/)[0].replace(/\/$/, '');
140
- if (!prefix) continue;
141
- sawConcrete = true;
142
- const abs = path.resolve(root, prefix);
143
- const rel = path.relative(path.resolve(root), abs).replace(/\\/g, '/');
144
- if (!rel || rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) continue;
145
- try {
146
- if (fs.existsSync(abs)) return true;
147
- } catch {
148
- continue;
149
- }
150
- }
151
- return !sawConcrete;
145
+ return firstConcreteGlobPrefixPresent(root, configuredCoverageRoots(coverage));
152
146
  }
153
147
 
154
148
  function residualHasEnforcedInvariant(input) {
@@ -85,8 +85,10 @@ const MAX_TOKEN_LENGTH = 200;
85
85
  * Alias map used when the caller declares none: the `@/` convention over `src/`.
86
86
  * The CLI side derives the real map from the project's tsconfig `paths`.
87
87
  */
88
- export const DEFAULT_ALIASES = Object.freeze({ '@/': 'src/' });
89
- /** Include roots assumed when the contract declares none. */
88
+ const DEFAULT_ALIASES = Object.freeze({ '@/': 'src/' });
89
+ /**
90
+ * Include roots assumed when the contract declares none.
91
+ */
90
92
  export const DEFAULT_INCLUDE_ROOTS = Object.freeze(['src']);
91
93
  /**
92
94
  * A replacement must look like a path before it may be written into a file.
@@ -31,17 +31,11 @@ import {
31
31
  } from './skill-install.mjs';
32
32
 
33
33
  export {
34
- formatManagedUpgradeSelfServiceHonesty,
35
34
  projectHostWritePathActivation,
36
35
  projectManagedUpgradeSelfServiceHonesty,
37
36
  } from './managed-upgrade-honesty.mjs';
38
- export {
39
- buildUpgradeWhatsNewSuggestions,
40
- formatUpgradeWhatsNewSuggestions,
41
- UPGRADE_WHATS_NEW_SCHEMA_VERSION,
42
- } from './upgrade-whats-new.mjs';
43
37
 
44
- export const MANAGED_MANIFEST_PATH = 'ark.managed.json';
38
+ const MANAGED_MANIFEST_PATH = 'ark.managed.json';
45
39
  const MANIFEST_VERSION = '1.0';
46
40
  const AFTER_CONTENT = Symbol('managed-after-content');
47
41
  // Exact normalized identities shipped by the last pre-manifest release. They are
@@ -438,7 +432,7 @@ function summaryFor(assets, manifestChanged) {
438
432
  };
439
433
  }
440
434
 
441
- export function managedPlanDigest(plan) {
435
+ function managedPlanDigest(plan) {
442
436
  const payload = {
443
437
  schemaVersion: plan.schemaVersion,
444
438
  root: plan.root,
@@ -42,14 +42,13 @@ export const COMMAND_GATE_JSON_FILES = [
42
42
  // Primary CLI names (product) + one-major aliases. migrate-commands must strip ALL of these
43
43
  // before re-emitting a single preferred bin — otherwise a partial rename leaves
44
44
  // args: ["ark-mcp", "arkgate-mcp", ...] which breaks stdio MCP hosts.
45
- export const ARK_MCP_BINS = new Set(['arkgate-mcp', 'ark-mcp']);
46
- export const ARK_CHECK_BINS = new Set(['arkgate-check', 'ark-check']);
47
- export const ARK_CLI_BINS = new Set(['arkgate', 'ark']);
45
+ const ARK_MCP_BINS = new Set(['arkgate-mcp', 'ark-mcp']);
46
+ const ARK_CHECK_BINS = new Set(['arkgate-check', 'ark-check']);
47
+ const ARK_CLI_BINS = new Set(['arkgate', 'ark']);
48
48
  // PREFERRED_MCP_BIN lives in hook-templates.mjs (re-exported above).
49
49
  export const PREFERRED_CHECK_BIN = 'arkgate-check';
50
- export const PREFERRED_CLI_BIN = 'arkgate';
51
50
  // Runner argv noise that is not a bin argument (pnpm exec form).
52
- export const MCP_RUNNER_ARGV = new Set(['exec', '--config.verify-deps-before-run=false']);
51
+ const MCP_RUNNER_ARGV = new Set(['exec', '--config.verify-deps-before-run=false']);
53
52
  // The runner token immediately before an ark command in a text command string.
54
53
  // Matches npm/yarn runners and both pnpm forms (legacy `pnpm exec` + verify-deps-safe form).
55
54
  // Longer bin names first so `arkgate-check` is not partially matched as `ark`.
@@ -201,7 +201,7 @@ function seekSequence(source, pattern, start, eof) {
201
201
  * `*** End of File` may close a chunk; a bare empty line is an empty context line.
202
202
  * Returns null for any line outside the Codex apply_patch grammar.
203
203
  */
204
- export function parseCodexUpdateChunks(lines) {
204
+ function parseCodexUpdateChunks(lines) {
205
205
  const body = [...lines];
206
206
  while (body.length > 0 && body[body.length - 1] === '') body.pop();
207
207
  const chunks = [];
@@ -412,13 +412,13 @@ export function proposedSource(toolName, toolInput) {
412
412
  }
413
413
 
414
414
  /** Antigravity PreToolUse requires stdout `decision` on every response (allow included). */
415
- export function emitAntigravityAllow(output, antigravityStyle) {
415
+ function emitAntigravityAllow(output, antigravityStyle) {
416
416
  if (!antigravityStyle) return;
417
417
  output.stdout(`${JSON.stringify({ decision: 'allow' })}\n`);
418
418
  }
419
419
 
420
420
  /** Cursor preToolUse accepts explicit allow; exit 0 alone also works. */
421
- export function emitCursorAllow(output, cursorStyle) {
421
+ function emitCursorAllow(output, cursorStyle) {
422
422
  if (!cursorStyle) return;
423
423
  output.stdout(`${JSON.stringify({ permission: 'allow' })}\n`);
424
424
  }
@@ -662,7 +662,7 @@ function patchTargets(patch) {
662
662
  }
663
663
 
664
664
  /** File paths the hook payload would write, or null for non-file tools. */
665
- export function hookPayloadTargets(normalized) {
665
+ function hookPayloadTargets(normalized) {
666
666
  const { toolName, toolInput } = normalized;
667
667
  if (toolName === 'ApplyPatch') {
668
668
  return patchTargets(
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Files nothing imports — Tooling (ADR 0037).
3
+ *
4
+ * Reads the importer index the scan projected from resolved facts, the entry
5
+ * evidence, and the importers that live outside the governed graph: test
6
+ * files, non-governed project source, path literals that name a file, and
7
+ * lexical dynamic reach (`import(\`./x/${…}\`)`, `import.meta.glob`,
8
+ * `new URL(…, import.meta.url)`, `require.context`). The pure decision lives
9
+ * in `orphan-modules.mjs` (generated from src/domain/orphanModules.ts).
10
+ *
11
+ * Bounded: the outside pass reads at most 5000 files of 256 KB each and runs
12
+ * only while a file without an importer remains. Advisory only — never a gate
13
+ * input, never in the write hook, MCP write tools, ESLint or --strict-merge.
14
+ */
15
+ import path from 'node:path';
16
+ import { globToRegExp, layerForRelativePath } from '../ark-layer-match.mjs';
17
+ import { ambientEntries, collectEntryPoints, entriesBySource } from './entry-points-io.mjs';
18
+ import { graphScanLimit } from './graph-blind.mjs';
19
+ import {
20
+ ORPHAN_MODULE_NEXT,
21
+ deferredUnusedExports,
22
+ findOrphanModules,
23
+ findUnusedExports,
24
+ moduleStem,
25
+ orphanModuleLine,
26
+ unavailableOrphanModules,
27
+ unusedExportLine,
28
+ } from './orphan-modules.mjs';
29
+ import {
30
+ MAX_EXTERNAL_IMPORTER_FILES,
31
+ createResolver,
32
+ dirOf,
33
+ fileImports,
34
+ readSource,
35
+ stripExt,
36
+ testPathMatcher,
37
+ walkExternalSources,
38
+ } from './outside-importers.mjs';
39
+
40
+ const MAX_EXPORT_PARSE_FILES = 3000;
41
+ const PRINT_CAP = 10;
42
+ const QUOTED = /(['"`])([^'"`\n]{1,300})\1/g;
43
+ const TEMPLATE_HEAD = /\b(?:import|require)\s*\(\s*(?:`([^`$]*)\$\{|(['"])([^'"\n]+)\2\s*\+)/g;
44
+ const META_GLOB = /import\.meta\.glob(?:Eager)?\s*(?:<[^>]*>)?\s*\(\s*(\[[^\]]*\]|(['"`])[^'"`\n]+\2)/g;
45
+ const NEW_URL = /new\s+URL\s*\(\s*(['"`])([^'"`\n]+)\1\s*,\s*import\.meta\.url/g;
46
+ const REQUIRE_CONTEXT = /require\.context\s*\(\s*(['"`])([^'"`\n]+)\1/g;
47
+ const REACH_GATE = /import\s*\(|require\s*\(|import\.meta\.glob|new\s+URL|require\.context/;
48
+ const GENERATED_MARK = /GENERATED FILE|@generated|DO NOT EDIT|\bgenerated (?:from|by)\b/i;
49
+ const GENERATED_HEADER_CHARS = 1200;
50
+ const PATH_IN_HEADER = /[\w@.-][\w@./-]*\.[cm]?[jt]sx?(?![\w/])/g;
51
+
52
+ /** `from`'s directory joined with a relative literal; null for bare / escaping. */
53
+ function relativeTo(fromRel, literal) {
54
+ if (typeof literal !== 'string') return null;
55
+ const raw = literal.startsWith('/') ? literal.slice(1) : literal;
56
+ const base = literal.startsWith('/') ? '' : dirOf(fromRel);
57
+ if (!literal.startsWith('.') && !literal.startsWith('/')) return null;
58
+ const joined = path.posix.normalize(base ? `${base}/${raw}` : raw);
59
+ if (joined === '..' || joined.startsWith('../')) return null;
60
+ return joined.replace(/^\.\//, '');
61
+ }
62
+
63
+ /** A generated copy names the file it was generated from in its header. */
64
+ function generatedSources(rel, text, governed) {
65
+ const header = text.slice(0, GENERATED_HEADER_CHARS);
66
+ if (!GENERATED_MARK.test(header)) return null;
67
+ const dir = dirOf(rel);
68
+ const sources = [];
69
+ for (const token of header.match(PATH_IN_HEADER) ?? []) {
70
+ const hit = governed.has(token) ? token : dir && governed.has(`${dir}/${token}`) ? `${dir}/${token}` : null;
71
+ if (hit) sources.push(hit);
72
+ }
73
+ return sources;
74
+ }
75
+
76
+ /** Heuristic: a match on a line that is (or has turned into) a comment is prose, not code. */
77
+ function inComment(text, index) {
78
+ const prefix = text.slice(text.lastIndexOf('\n', index - 1) + 1, index);
79
+ return /^\s*(?:\*|\/\*|\/\/)/.test(prefix) || /(?:^|[^:'"`])\/\//.test(prefix);
80
+ }
81
+
82
+ /** Exported names, or null when not knowable (`export *`, `export =`, destructuring). */
83
+ function exportedNames(ts, sourceFile) {
84
+ const names = [];
85
+ for (const statement of sourceFile.statements) {
86
+ if (ts.isExportDeclaration(statement)) {
87
+ const clause = statement.exportClause;
88
+ if (!clause) return null;
89
+ if (ts.isNamespaceExport?.(clause)) names.push(clause.name.text);
90
+ else for (const element of clause.elements) names.push(element.name.text);
91
+ continue;
92
+ }
93
+ if (ts.isExportAssignment(statement)) {
94
+ if (statement.isExportEquals) return null;
95
+ names.push('default');
96
+ continue;
97
+ }
98
+ const modifiers = statement.modifiers ?? [];
99
+ if (!modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue;
100
+ if (modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)) {
101
+ names.push('default');
102
+ } else if (ts.isVariableStatement(statement)) {
103
+ for (const declaration of statement.declarationList.declarations) {
104
+ if (!ts.isIdentifier(declaration.name)) return null;
105
+ names.push(declaration.name.text);
106
+ }
107
+ } else if (statement.name && ts.isIdentifier(statement.name)) {
108
+ names.push(statement.name.text);
109
+ } else {
110
+ return null;
111
+ }
112
+ }
113
+ return names;
114
+ }
115
+
116
+ /**
117
+ * Where a file may be loaded without a static import. Returns one sentence per
118
+ * reached candidate, from lexical evidence in `rel`'s text.
119
+ */
120
+ function reachFrom(rel, text, candidates, byBase) {
121
+ const hits = new Map();
122
+ const mark = (file, why) => {
123
+ if (!hits.has(file)) hits.set(file, why);
124
+ };
125
+ for (const match of text.matchAll(QUOTED)) {
126
+ if (inComment(text, match.index)) continue;
127
+ const literal = match[2];
128
+ const base = literal.slice(literal.lastIndexOf('/') + 1);
129
+ for (const file of byBase.get(base) ?? []) {
130
+ const exact = literal === file || literal.endsWith(`/${file}`) || relativeTo(rel, literal) === file;
131
+ const bare = literal === base && !base.startsWith('index.');
132
+ if (exact || bare) mark(file, `${rel} names it by path`);
133
+ }
134
+ }
135
+ if (!REACH_GATE.test(text)) return hits;
136
+ for (const match of text.matchAll(TEMPLATE_HEAD)) {
137
+ if (inComment(text, match.index)) continue;
138
+ const prefix = relativeTo(rel, match[1] ?? match[3]);
139
+ if (!prefix) continue;
140
+ for (const file of candidates) {
141
+ if (file.startsWith(prefix)) mark(file, `a dynamic import in ${rel} can load files under ${prefix}`);
142
+ }
143
+ }
144
+ for (const match of text.matchAll(META_GLOB)) {
145
+ if (inComment(text, match.index)) continue;
146
+ const literals = [...match[1].matchAll(/(['"`])([^'"`\n]+)\1/g)].map((item) => item[2]);
147
+ for (const literal of literals) {
148
+ if (literal.startsWith('!')) continue;
149
+ const pattern = relativeTo(rel, literal);
150
+ if (!pattern) continue;
151
+ const re = globToRegExp(pattern);
152
+ for (const file of candidates) if (re.test(file)) mark(file, `import.meta.glob in ${rel} matches it`);
153
+ }
154
+ }
155
+ for (const match of text.matchAll(NEW_URL)) {
156
+ if (inComment(text, match.index)) continue;
157
+ const target = relativeTo(rel, match[2]);
158
+ if (!target) continue;
159
+ for (const file of candidates) {
160
+ if (file === target || stripExt(file) === stripExt(target)) mark(file, `new URL(…, import.meta.url) in ${rel} points at it`);
161
+ }
162
+ }
163
+ for (const match of text.matchAll(REQUIRE_CONTEXT)) {
164
+ if (inComment(text, match.index)) continue;
165
+ const prefix = relativeTo(rel, match[2]);
166
+ if (!prefix) continue;
167
+ for (const file of candidates) {
168
+ if (file.startsWith(`${prefix.replace(/\/$/, '')}/`)) mark(file, `require.context in ${rel} can load it`);
169
+ }
170
+ }
171
+ return hits;
172
+ }
173
+
174
+ function addUse(namedUse, file, names) {
175
+ const previous = namedUse.get(file);
176
+ if (previous === '*') return;
177
+ if (names === '*') {
178
+ namedUse.set(file, '*');
179
+ return;
180
+ }
181
+ const set = previous ?? new Set();
182
+ for (const name of names) set.add(name);
183
+ namedUse.set(file, set);
184
+ }
185
+
186
+ /**
187
+ * Importers outside the governed graph plus lexical reach.
188
+ * Compact runs resolve only specifiers whose stem matches a candidate.
189
+ */
190
+ function scanOutside({ root, ts, config, governed, candidates, details, governedScan }) {
191
+ const isTest = testPathMatcher(config);
192
+ const candidateSet = new Set(candidates);
193
+ const stems = new Set();
194
+ const byBase = new Map();
195
+ for (const file of candidates) {
196
+ stems.add(moduleStem(file));
197
+ const segments = file.split('/');
198
+ if (moduleStem(file) === 'index' && segments.length > 1) stems.add(segments[segments.length - 2]);
199
+ const base = segments[segments.length - 1];
200
+ byBase.set(base, [...(byBase.get(base) ?? []), file]);
201
+ }
202
+ const resolve = createResolver(ts, root, governed);
203
+ const testImporters = new Map();
204
+ const outsideImporters = new Map();
205
+ const dynamicReach = new Map();
206
+ const namedUse = new Map();
207
+ let oversize = 0;
208
+ const bump = (map, file) => map.set(file, (map.get(file) ?? 0) + 1);
209
+ const { files, capped } = walkExternalSources(root, governed);
210
+ for (const rel of files) {
211
+ const read = readSource(path.join(root, rel));
212
+ if (read.oversize) oversize += 1;
213
+ if (read.text == null) continue;
214
+ const test = isTest(rel);
215
+ for (const { specifier, names } of fileImports(ts, rel, read.text, details)) {
216
+ const stem = moduleStem(specifier);
217
+ const last = specifier.split('/').pop() ?? '';
218
+ if (!details && !stems.has(stem) && !stems.has(last)) continue;
219
+ const target = resolve(specifier, rel);
220
+ if (!target) continue;
221
+ if (candidateSet.has(target)) bump(test ? testImporters : outsideImporters, target);
222
+ if (details) addUse(namedUse, target, names);
223
+ }
224
+ for (const [file, why] of reachFrom(rel, read.text, candidates, byBase)) {
225
+ if (test) bump(testImporters, file);
226
+ else if (!dynamicReach.has(file)) dynamicReach.set(file, why);
227
+ }
228
+ }
229
+ // Governed files: lexical reach, plus generated copies (the header names the source).
230
+ const generated = new Set();
231
+ const generatedFrom = new Map();
232
+ for (const rel of governedScan ?? []) {
233
+ const read = readSource(path.join(root, rel));
234
+ if (read.text == null) continue;
235
+ const sources = generatedSources(rel, read.text, governed);
236
+ if (sources) {
237
+ generated.add(rel);
238
+ for (const source of sources) if (source !== rel && !generatedFrom.has(source)) generatedFrom.set(source, rel);
239
+ }
240
+ if (candidates.length === 0) continue;
241
+ for (const [file, why] of reachFrom(rel, read.text, candidates, byBase)) {
242
+ if (file !== rel && !dynamicReach.has(file)) dynamicReach.set(file, why);
243
+ }
244
+ }
245
+ for (const source of generatedFrom.keys()) if (candidateSet.has(source)) bump(outsideImporters, source);
246
+ return { testImporters, outsideImporters, dynamicReach, namedUse, generated, generatedFrom, capped, oversize };
247
+ }
248
+
249
+ function partialReasonsFor(importGraph, outside, reachDeferred, unmapped) {
250
+ const reasons = [];
251
+ if (importGraph.completeness !== 'complete') {
252
+ reasons.push('The import analysis was incomplete, so some importers may be missing.');
253
+ }
254
+ if (importGraph.invalidFiles > 0) {
255
+ reasons.push(`${importGraph.invalidFiles} governed file(s) did not parse, so their imports are missing.`);
256
+ }
257
+ const followed = [];
258
+ if (importGraph.dynamicSites > 0) followed.push(`${importGraph.dynamicSites} dynamic`);
259
+ if (importGraph.unresolvedTotal > 0) followed.push(`${importGraph.unresolvedTotal} unresolved`);
260
+ if (followed.length > 0) {
261
+ reasons.push(`Some imports could not be followed (${followed.join(', ')}). These files may still be used.`);
262
+ }
263
+ if (unmapped.length > 0) {
264
+ const sample = unmapped.slice(0, 3).map((row) => row.declared).join(', ');
265
+ reasons.push(`${unmapped.length} package.json entry path(s) map to no governed source file (${sample}).`);
266
+ }
267
+ if (outside && outside.capped > 0) {
268
+ reasons.push(`The outside-importer pass stopped at ${MAX_EXTERNAL_IMPORTER_FILES} files; ${outside.capped} were not read.`);
269
+ }
270
+ if (outside && outside.oversize > 0) {
271
+ reasons.push(`${outside.oversize} file(s) over 256 KB were not read for imports.`);
272
+ }
273
+ if (reachDeferred) {
274
+ reasons.push(`Dynamic loading was not scanned at this size (${importGraph.files.length} governed files).`);
275
+ }
276
+ return reasons;
277
+ }
278
+
279
+ function unusedExportsFor({ root, ts, importGraph, entries, outside, partialReasons, skip }) {
280
+ const namedUse = new Map();
281
+ importGraph.namedUse.forEach((names, index) => addUse(namedUse, importGraph.files[index], names));
282
+ outside.namedUse.forEach((names, file) => addUse(namedUse, file, names));
283
+ // A generated copy's importers use the source it was generated from.
284
+ for (const [source, copy] of outside.generatedFrom) {
285
+ const use = namedUse.get(copy);
286
+ if (use !== undefined) addUse(namedUse, source, use);
287
+ }
288
+ const exportsByFile = new Map();
289
+ let parsed = 0;
290
+ let unparsed = 0;
291
+ for (const [file, use] of [...namedUse].sort(([left], [right]) => (left < right ? -1 : 1))) {
292
+ if (use === '*' || entries.has(file) || skip.has(file) || outside.generated.has(file)) continue;
293
+ if (parsed >= MAX_EXPORT_PARSE_FILES) {
294
+ unparsed += 1;
295
+ continue;
296
+ }
297
+ const read = readSource(path.join(root, file));
298
+ if (read.text == null) {
299
+ exportsByFile.set(file, null);
300
+ continue;
301
+ }
302
+ parsed += 1;
303
+ exportsByFile.set(file, exportedNames(ts, ts.createSourceFile(file, read.text, ts.ScriptTarget.Latest, false)));
304
+ }
305
+ const reasons = [...partialReasons];
306
+ if (unparsed > 0) reasons.push(`${unparsed} file(s) past the ${MAX_EXPORT_PARSE_FILES}-file parse cap were not checked.`);
307
+ return findUnusedExports({ exportsByFile, namedUse, entries: new Set(entries.keys()), partialReasons: reasons });
308
+ }
309
+
310
+ /**
311
+ * @param {{ root: string, config?: object, ts?: object, importGraph?: ReturnType<import('./import-graph-projection.mjs').projectImporterIndex>, details?: boolean, today?: string }} input
312
+ */
313
+ export function computeOrphanModules(input) {
314
+ const { root, config, ts, importGraph } = input;
315
+ const details = input.details === true;
316
+ if (!importGraph || !ts || typeof ts.preProcessFile !== 'function') {
317
+ return {
318
+ ...unavailableOrphanModules(
319
+ !importGraph
320
+ ? 'This run had no import facts. Run arkgate-check --doctor to list files nothing imports.'
321
+ : 'TypeScript was not available, so files nothing imports were not checked.'
322
+ ),
323
+ unusedExports: deferredUnusedExports(),
324
+ };
325
+ }
326
+ const governed = new Set(importGraph.files);
327
+ const today = input.today ?? new Date().toISOString().slice(0, 10);
328
+ const evidence = collectEntryPoints(root, { governed: importGraph.files, config, ts, today });
329
+ const zero = importGraph.files.filter(
330
+ (file, index) => importGraph.importerCount[index] === 0 && !evidence.entries.has(file)
331
+ );
332
+ for (const file of ambientEntries(root, zero)) evidence.entries.set(file, 'ambient');
333
+ const candidates = zero.filter((file) => !evidence.entries.has(file));
334
+ const reachLimit = graphScanLimit(importGraph.files.length);
335
+ const reachDeferred = candidates.length > 0 && importGraph.files.length > reachLimit;
336
+ const outside =
337
+ candidates.length > 0 || details
338
+ ? scanOutside({
339
+ root,
340
+ ts,
341
+ config,
342
+ governed,
343
+ candidates,
344
+ details,
345
+ governedScan: importGraph.files.length > reachLimit ? null : importGraph.files,
346
+ })
347
+ : null;
348
+ const partialReasons = partialReasonsFor(importGraph, outside, reachDeferred, evidence.unmapped);
349
+ const unresolvedStems = new Set(importGraph.unresolved.map((row) => moduleStem(row.specifier)));
350
+ const result = findOrphanModules({
351
+ files: importGraph.files,
352
+ importerCount: importGraph.importerCount,
353
+ entries: evidence.entries,
354
+ testImporters: outside?.testImporters ?? new Map(),
355
+ outsideImporters: outside?.outsideImporters ?? new Map(),
356
+ dynamicReach: outside?.dynamicReach ?? new Map(),
357
+ unresolvedStems,
358
+ annotations: evidence.expiredByPath,
359
+ frameworks: evidence.frameworks,
360
+ entryEvidence: { bySource: entriesBySource(evidence.entries), unmapped: evidence.unmapped.length },
361
+ partialReasons,
362
+ notes: evidence.sidecar.note ? [evidence.sidecar.note] : [],
363
+ });
364
+ for (const item of result.orphans) {
365
+ const layer = layerForRelativePath(item.path, config?.layers);
366
+ if (layer) item.layer = layer;
367
+ }
368
+ result.unusedExports = details
369
+ ? unusedExportsFor({
370
+ root,
371
+ ts,
372
+ importGraph,
373
+ entries: evidence.entries,
374
+ outside,
375
+ partialReasons: [
376
+ ...partialReasons.filter((reason) => !reason.startsWith('Dynamic loading')),
377
+ ...(importGraph.files.length > reachLimit
378
+ ? [`Generated copies were not detected at this size (${importGraph.files.length} governed files).`]
379
+ : []),
380
+ ],
381
+ // Listed files are tier 1. Files only tests or outside code import stay in tier 2.
382
+ skip: new Set(
383
+ candidates.filter((file) => !outside?.testImporters.has(file) && !outside?.outsideImporters.has(file))
384
+ ),
385
+ })
386
+ : deferredUnusedExports();
387
+ return result;
388
+ }
389
+
390
+ /** Compact view: one dim count line, or silence. */
391
+ export function printOrphanModulesCompactLine(section, io) {
392
+ const listed = Number(section?.totals?.listed) || 0;
393
+ if (section?.notAScore !== true || listed === 0) return;
394
+ console.log('');
395
+ // Tier 2 ran only under --all / --report; then the Details section follows, so no pointer.
396
+ const detailsRan = section?.unusedExports?.status !== undefined && section.unusedExports.status !== 'deferred';
397
+ const hint = detailsRan ? '' : ' Details: arkgate-check --doctor --all';
398
+ io.line(' ', io.color.dim(`${listed} file${listed === 1 ? '' : 's'} nothing imports.${hint}`));
399
+ }
400
+
401
+ /** Details view. */
402
+ export function printOrphanModulesSection(section, io) {
403
+ if (!section || section.notAScore !== true) return;
404
+ const listed = Number(section.totals?.listed) || 0;
405
+ const testOnly = Number(section.testOnly?.count) || 0;
406
+ const unused = section.unusedExports;
407
+ const unusedRows = Array.isArray(unused?.files) ? unused.files : [];
408
+ if (section.status === 'complete' && listed === 0 && testOnly === 0 && unusedRows.length === 0) return;
409
+ console.log('');
410
+ console.log(io.color.bold('Files nothing imports (not a score)'));
411
+ if (section.status === 'unavailable') {
412
+ io.line(' ', io.color.dim(section.honesty?.[0] ?? section.headline));
413
+ return;
414
+ }
415
+ io.line(listed > 0 ? io.warn : ' ', section.headline);
416
+ for (const item of section.orphans.slice(0, PRINT_CAP)) io.line(io.warn, orphanModuleLine(item));
417
+ const more = listed - Math.min(listed, PRINT_CAP, section.orphans.length);
418
+ if (more > 0) io.line(' ', io.color.dim(`…(+${more} more in doctor JSON)`));
419
+ if (listed > 0) io.line(' ', `Next: ${ORPHAN_MODULE_NEXT}`);
420
+ if (testOnly > 0) {
421
+ io.line(' ', io.color.dim(`${testOnly} file(s) only tests import (for example ${section.testOnly.sample[0]}). Not listed.`));
422
+ }
423
+ for (const text of section.honesty ?? []) io.line(' ', io.color.dim(text));
424
+ if (unusedRows.length > 0) {
425
+ io.line(' ', 'Exports nothing imports by name:');
426
+ for (const row of unusedRows.slice(0, PRINT_CAP)) io.line(io.warn, unusedExportLine(row));
427
+ const extra = unusedRows.length + (unused.truncated ?? 0) - Math.min(unusedRows.length, PRINT_CAP);
428
+ if (extra > 0) io.line(' ', io.color.dim(`…(+${extra} more in doctor JSON)`));
429
+ }
430
+ io.line(' ', io.color.dim('advisory only — nothing imports these files; the check verdict is unchanged'));
431
+ }
432
+
433
+ /** HTML report section (report parity: `data-advisory="orphanModules"`). */
434
+ export function orphanModulesHtml(section, esc = (value) => String(value)) {
435
+ if (!section || section.notAScore !== true) return '';
436
+ const orphans = Array.isArray(section.orphans) ? section.orphans : [];
437
+ const items = orphans
438
+ .map((item) => `<li><span class="tag warn">${esc(item.certainty)}</span> ${esc(orphanModuleLine(item))}</li>`)
439
+ .join('');
440
+ const more = section.truncated > 0 ? `<p class="muted">…(+${section.truncated} more in doctor JSON)</p>` : '';
441
+ const next = orphans.length > 0 ? `<p>Next: ${esc(ORPHAN_MODULE_NEXT)}</p>` : '';
442
+ const testOnly =
443
+ section.testOnly?.count > 0
444
+ ? `<p class="muted">${section.testOnly.count} file(s) only tests import. Not listed.</p>`
445
+ : '';
446
+ const honesty = (section.honesty ?? []).map((text) => `<p class="muted">${esc(text)}</p>`).join('');
447
+ const unused = section.unusedExports;
448
+ const unusedRows = Array.isArray(unused?.files) ? unused.files : [];
449
+ const unusedHtml =
450
+ unusedRows.length > 0
451
+ ? `<h3>Exports nothing imports by name</h3><ul>${unusedRows
452
+ .map((row) => `<li><code>${esc(row.path)}</code>: ${esc(row.exports.join(', '))}</li>`)
453
+ .join('')}</ul>`
454
+ : unused?.status === 'deferred'
455
+ ? `<p class="muted">Unused exports: run <code>${esc(unused.next ?? '')}</code>.</p>`
456
+ : '';
457
+ return `<section class="section card" data-advisory="orphanModules"><h2>Files nothing imports <span class="muted">(advisory — not a score; the verdict is unchanged)</span></h2><p>${esc(section.headline ?? '')}</p><ul>${items}</ul>${more}${next}${testOnly}${honesty}${unusedHtml}</section>`;
458
+ }