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
@@ -0,0 +1,251 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/orphanModules.ts
5
+ * Regenerate: node scripts/generate-cli-pure.mjs
6
+ * Drift check: node scripts/generate-cli-pure.mjs --check
7
+ *
8
+ * Pure CLI helper (bin/lib/orphan-modules.mjs). Zero Node I/O.
9
+ */
10
+
11
+ const ARK_ORPHAN_MODULES_SCHEMA_VERSION = '1.0';
12
+ /** Listed files per tier (agent-legible). The totals always carry the full count. */
13
+ export const ORPHAN_LIST_CAP = 20;
14
+ /** List cut when entry points were not recognised (ADR 0037 risk). */
15
+ export const ORPHAN_UNRECOGNISED_LIST_CAP = 5;
16
+ /** Share of governed files listed above which unrecognised entry points are assumed. */
17
+ const UNRECOGNISED_SHARE = 0.25;
18
+ /** A tiny tree with a few listed files is readable as is. */
19
+ const UNRECOGNISED_MIN_FILES = 5;
20
+ const TEST_ONLY_SAMPLE = 5;
21
+ export const ORPHAN_MODULE_NEXT = 'Delete it through the write gate, or add it to .ark/entry-points.json if a framework loads it.';
22
+ export const UNUSED_EXPORTS_COMMAND = 'arkgate-check --doctor --all';
23
+ const CERTAINTY_ORDER = {
24
+ 'no-importer': 0,
25
+ 'maybe-unresolved': 1,
26
+ 'maybe-dynamic': 2,
27
+ };
28
+ function byPath(left, right) {
29
+ return left < right ? -1 : left > right ? 1 : 0;
30
+ }
31
+ /** Last path segment without its extensions (`a/b/foo.test.ts` → `foo`). */
32
+ export function moduleStem(rel) {
33
+ const segments = String(rel).replace(/\\/g, '/').split('/');
34
+ const last = segments[segments.length - 1] ?? '';
35
+ const dot = last.indexOf('.');
36
+ return dot > 0 ? last.slice(0, dot) : last;
37
+ }
38
+ function stemsFor(rel) {
39
+ const stem = moduleStem(rel);
40
+ if (stem !== 'index')
41
+ return [stem];
42
+ const segments = rel.split('/');
43
+ return segments.length > 1 ? [stem, segments[segments.length - 2] ?? stem] : [stem];
44
+ }
45
+ /** One human line per listed file. Shared by the terminal and the report. */
46
+ export function orphanModuleLine(item) {
47
+ if (item.certainty === 'maybe-dynamic') {
48
+ const why = item.evidence[0] ?? 'a dynamic import may load it';
49
+ return `No static import reaches ${item.path}, but ${why}. It may still be loaded.`;
50
+ }
51
+ if (item.certainty === 'maybe-unresolved') {
52
+ return `Nothing imports ${item.path}, but an import that could not be followed has the same name. It may still be used.`;
53
+ }
54
+ return `Nothing imports ${item.path}, and no entry point covers it.`;
55
+ }
56
+ function unrecognisedFlood(listed, governed, frameworks) {
57
+ return (frameworks.length === 0 &&
58
+ listed >= UNRECOGNISED_MIN_FILES &&
59
+ governed > 0 &&
60
+ listed / governed > UNRECOGNISED_SHARE);
61
+ }
62
+ /**
63
+ * Tier 1: governed files with zero importers that no entry covers.
64
+ * Test-only and outside-only files are counted, never listed.
65
+ */
66
+ export function findOrphanModules(input) {
67
+ const files = [...input.files];
68
+ const count = (index) => Number(input.importerCount[index] ?? 0) || 0;
69
+ let imported = 0;
70
+ let entries = 0;
71
+ const testOnly = [];
72
+ let outsideOnly = 0;
73
+ const listed = [];
74
+ for (let index = 0; index < files.length; index += 1) {
75
+ const path = files[index];
76
+ if (count(index) > 0) {
77
+ imported += 1;
78
+ continue;
79
+ }
80
+ if (input.entries.has(path)) {
81
+ entries += 1;
82
+ continue;
83
+ }
84
+ if ((input.testImporters.get(path) ?? 0) > 0) {
85
+ testOnly.push(path);
86
+ continue;
87
+ }
88
+ if ((input.outsideImporters.get(path) ?? 0) > 0) {
89
+ outsideOnly += 1;
90
+ continue;
91
+ }
92
+ const annotations = [...(input.annotations?.get(path) ?? [])];
93
+ const reach = input.dynamicReach.get(path);
94
+ if (reach) {
95
+ listed.push({
96
+ ruleId: 'ORPHAN_MODULE',
97
+ path,
98
+ certainty: 'maybe-dynamic',
99
+ evidence: [reach, ...annotations],
100
+ });
101
+ continue;
102
+ }
103
+ if (stemsFor(path).some((stem) => input.unresolvedStems.has(stem))) {
104
+ listed.push({
105
+ ruleId: 'ORPHAN_MODULE',
106
+ path,
107
+ certainty: 'maybe-unresolved',
108
+ evidence: ['an import that could not be followed has the same name', ...annotations],
109
+ });
110
+ continue;
111
+ }
112
+ listed.push({
113
+ ruleId: 'ORPHAN_MODULE',
114
+ path,
115
+ certainty: 'no-importer',
116
+ evidence: ['no governed file, test or project script imports it', ...annotations],
117
+ });
118
+ }
119
+ listed.sort((left, right) => CERTAINTY_ORDER[left.certainty] - CERTAINTY_ORDER[right.certainty] || byPath(left.path, right.path));
120
+ testOnly.sort(byPath);
121
+ const maybeDynamic = listed.filter((item) => item.certainty === 'maybe-dynamic').length;
122
+ const maybeUnresolved = listed.filter((item) => item.certainty === 'maybe-unresolved').length;
123
+ const honesty = [...input.partialReasons];
124
+ const flood = unrecognisedFlood(listed.length, files.length, input.frameworks);
125
+ if (flood) {
126
+ honesty.unshift(`${listed.length} of ${files.length} governed files have no importer and no framework was recognised. Entry points were probably not recognised, so the list may be wrong.`);
127
+ }
128
+ const partial = honesty.length > 0 || maybeDynamic > 0 || maybeUnresolved > 0;
129
+ const cap = flood ? ORPHAN_UNRECOGNISED_LIST_CAP : ORPHAN_LIST_CAP;
130
+ const shown = listed.slice(0, cap);
131
+ const headline = listed.length === 0
132
+ ? partial
133
+ ? 'No file without an importer was found, but some imports could not be followed.'
134
+ : 'Every governed file has an importer or an entry point.'
135
+ : flood
136
+ ? 'Entry points not recognised. The list of files nothing imports may be wrong.'
137
+ : `${listed.length} governed file${listed.length === 1 ? '' : 's'} nothing imports.`;
138
+ return {
139
+ schemaVersion: ARK_ORPHAN_MODULES_SCHEMA_VERSION,
140
+ advisory: true,
141
+ notAScore: true,
142
+ status: partial ? 'partial' : 'complete',
143
+ headline,
144
+ orphans: shown,
145
+ truncated: listed.length - shown.length,
146
+ testOnly: { count: testOnly.length, sample: testOnly.slice(0, TEST_ONLY_SAMPLE) },
147
+ totals: {
148
+ governed: files.length,
149
+ entries,
150
+ imported,
151
+ testOnly: testOnly.length,
152
+ outsideOnly,
153
+ listed: listed.length,
154
+ maybeDynamic,
155
+ maybeUnresolved,
156
+ },
157
+ entryEvidence: {
158
+ bySource: sortedRecord(input.entryEvidence.bySource),
159
+ unmapped: input.entryEvidence.unmapped,
160
+ },
161
+ frameworks: [...input.frameworks].sort(byPath),
162
+ honesty: [...honesty, ...(input.notes ?? [])],
163
+ ...(listed.length > 0 ? { next: ORPHAN_MODULE_NEXT } : {}),
164
+ };
165
+ }
166
+ function sortedRecord(record) {
167
+ const out = {};
168
+ for (const key of Object.keys(record).sort(byPath))
169
+ out[key] = record[key];
170
+ return out;
171
+ }
172
+ /** Doctor without an importer index (no TypeScript, or a caller without facts). */
173
+ export function unavailableOrphanModules(reason) {
174
+ return {
175
+ schemaVersion: ARK_ORPHAN_MODULES_SCHEMA_VERSION,
176
+ advisory: true,
177
+ notAScore: true,
178
+ status: 'unavailable',
179
+ headline: 'Files nothing imports were not checked.',
180
+ orphans: [],
181
+ truncated: 0,
182
+ testOnly: { count: 0, sample: [] },
183
+ totals: {
184
+ governed: 0,
185
+ entries: 0,
186
+ imported: 0,
187
+ testOnly: 0,
188
+ outsideOnly: 0,
189
+ listed: 0,
190
+ maybeDynamic: 0,
191
+ maybeUnresolved: 0,
192
+ },
193
+ entryEvidence: { bySource: {}, unmapped: 0 },
194
+ frameworks: [],
195
+ honesty: [reason],
196
+ };
197
+ }
198
+ /** Tier 2 in the compact view: named, not run. */
199
+ export function deferredUnusedExports() {
200
+ return {
201
+ schemaVersion: ARK_ORPHAN_MODULES_SCHEMA_VERSION,
202
+ notAScore: true,
203
+ status: 'deferred',
204
+ files: [],
205
+ truncated: 0,
206
+ totals: { filesChecked: 0, filesSkipped: 0, unusedExports: 0 },
207
+ honesty: ['Unused exports are listed in the details view only.'],
208
+ next: UNUSED_EXPORTS_COMMAND,
209
+ };
210
+ }
211
+ /**
212
+ * Tier 2: exports no governed file or test imports by name.
213
+ * Entry files are exempt. A file with no named use at all is tier 1, not here.
214
+ */
215
+ export function findUnusedExports(input) {
216
+ const rows = [];
217
+ let filesChecked = 0;
218
+ let filesSkipped = 0;
219
+ const paths = [...input.exportsByFile.keys()].sort(byPath);
220
+ for (const path of paths) {
221
+ const exported = input.exportsByFile.get(path);
222
+ const used = input.namedUse.get(path);
223
+ if (input.entries.has(path) || used === undefined || used === '*' || exported == null) {
224
+ filesSkipped += 1;
225
+ continue;
226
+ }
227
+ filesChecked += 1;
228
+ const unused = [...new Set(exported)].filter((name) => !used.has(name)).sort(byPath);
229
+ if (unused.length > 0)
230
+ rows.push({ ruleId: 'UNUSED_EXPORT', path, exports: unused });
231
+ }
232
+ const shown = rows.slice(0, ORPHAN_LIST_CAP);
233
+ return {
234
+ schemaVersion: ARK_ORPHAN_MODULES_SCHEMA_VERSION,
235
+ notAScore: true,
236
+ status: input.partialReasons.length > 0 ? 'partial' : 'complete',
237
+ files: shown,
238
+ truncated: rows.length - shown.length,
239
+ totals: {
240
+ filesChecked,
241
+ filesSkipped,
242
+ unusedExports: rows.reduce((sum, row) => sum + row.exports.length, 0),
243
+ },
244
+ honesty: [...input.partialReasons],
245
+ };
246
+ }
247
+ /** One human line per file with unused exports. */
248
+ export function unusedExportLine(row) {
249
+ const names = row.exports.join(', ');
250
+ return `${row.path} exports ${names}, and nothing imports ${row.exports.length === 1 ? 'it' : 'them'} by name.`;
251
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Importers that live outside the governed graph — Tooling.
3
+ *
4
+ * Shared by files nothing imports (ADR 0037), which counts test and outside
5
+ * importers, and the invariant mutation probe (ADR 0039), which needs the
6
+ * test files that import a symbol file to pick covering tests. One walk, one
7
+ * test classifier, one lexical resolver, so the two never disagree on which
8
+ * test imports what.
9
+ *
10
+ * Bounded: at most 5000 non-governed files of 256 KB each, depth 8.
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { namedModuleBindings } from './ast-scan.mjs';
15
+ import { probeGoverned } from './entry-points-io.mjs';
16
+ import { readTsconfigAliases, resolveSpecifierToRel } from './import-resolve.mjs';
17
+ import { coverageOptionsFromConfig, matchSimpleGlob } from './invariant-coverage-io.mjs';
18
+
19
+ export const MAX_EXTERNAL_IMPORTER_FILES = 5000;
20
+ const MAX_FILE_BYTES = 256 * 1024;
21
+ const MAX_WALK_DEPTH = 8;
22
+ const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
23
+ const DECLARATION_FILE = /\.d\.[cm]?ts$/;
24
+ const TEST_FILE = /\.(?:test|spec)(?:-d)?\.[cm]?[jt]sx?$|(?:^|\/)(?:tests?|__tests__|__mocks__|e2e)\//i;
25
+ /** Data trees and build output: never importers of product source. */
26
+ const SKIP_DIRS = new Set([
27
+ 'node_modules',
28
+ 'dist',
29
+ 'build',
30
+ 'out',
31
+ 'coverage',
32
+ 'fixture',
33
+ 'fixtures',
34
+ '__fixtures__',
35
+ 'testdata',
36
+ 'docs',
37
+ 'documentation',
38
+ ]);
39
+
40
+ export function readSource(absolute) {
41
+ try {
42
+ const stat = fs.statSync(absolute);
43
+ if (!stat.isFile()) return { skip: true };
44
+ if (stat.size > MAX_FILE_BYTES) return { oversize: true };
45
+ return { text: fs.readFileSync(absolute, 'utf8') };
46
+ } catch {
47
+ return { skip: true };
48
+ }
49
+ }
50
+
51
+ export function dirOf(rel) {
52
+ const at = rel.lastIndexOf('/');
53
+ return at === -1 ? '' : rel.slice(0, at);
54
+ }
55
+
56
+ export function stripExt(rel) {
57
+ return rel.replace(DECLARATION_FILE, '').replace(SOURCE_FILE, '');
58
+ }
59
+
60
+ /** The default test-path heuristic plus the project's `coverage.testGlobs`. */
61
+ export function testPathMatcher(config) {
62
+ const testGlobs = coverageOptionsFromConfig(config).testGlobs ?? [];
63
+ return (rel) => TEST_FILE.test(rel) || testGlobs.some((glob) => matchSimpleGlob(glob, rel));
64
+ }
65
+
66
+ /** Non-governed project files (tests and other source), sorted, bounded. */
67
+ export function walkExternalSources(root, governed) {
68
+ const files = [];
69
+ let capped = 0;
70
+ const visit = (dirRel, depth) => {
71
+ if (depth > MAX_WALK_DEPTH) return;
72
+ let entries;
73
+ try {
74
+ entries = fs.readdirSync(dirRel ? path.join(root, dirRel) : root, { withFileTypes: true });
75
+ } catch {
76
+ return;
77
+ }
78
+ entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
79
+ for (const entry of entries) {
80
+ if (entry.name.startsWith('.')) continue;
81
+ const rel = dirRel ? `${dirRel}/${entry.name}` : entry.name;
82
+ if (entry.isDirectory()) {
83
+ if (!SKIP_DIRS.has(entry.name)) visit(rel, depth + 1);
84
+ continue;
85
+ }
86
+ if (!entry.isFile() || !SOURCE_FILE.test(entry.name) || DECLARATION_FILE.test(entry.name)) continue;
87
+ if (governed.has(rel)) continue;
88
+ if (files.length >= MAX_EXTERNAL_IMPORTER_FILES) capped += 1;
89
+ else files.push(rel);
90
+ }
91
+ };
92
+ visit('', 0);
93
+ return { files, capped };
94
+ }
95
+
96
+ /** Module specifiers of a file. With bindings, named imports keep their names. */
97
+ export function fileImports(ts, rel, text, withBindings) {
98
+ const imports = [];
99
+ // Static declarations per specifier. preProcessFile also lists dynamic
100
+ // import() / require() sites: any occurrence past the static ones uses the
101
+ // whole module, so it counts as '*'.
102
+ const statics = new Map();
103
+ if (withBindings) {
104
+ const sourceFile = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, false);
105
+ for (const statement of sourceFile.statements) {
106
+ const specifier = statement.moduleSpecifier;
107
+ if (!specifier || !ts.isStringLiteralLike(specifier)) continue;
108
+ if (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) continue;
109
+ imports.push({ specifier: specifier.text, names: namedModuleBindings(ts, statement) ?? '*' });
110
+ statics.set(specifier.text, (statics.get(specifier.text) ?? 0) + 1);
111
+ }
112
+ }
113
+ const pre = ts.preProcessFile(text, true, true);
114
+ for (const item of [...pre.importedFiles, ...pre.referencedFiles]) {
115
+ const left = statics.get(item.fileName) ?? 0;
116
+ if (left > 0) statics.set(item.fileName, left - 1);
117
+ else imports.push({ specifier: item.fileName, names: '*' });
118
+ }
119
+ return imports;
120
+ }
121
+
122
+ /**
123
+ * Relative and tsconfig-alias specifiers → a governed file (same lexical
124
+ * resolver as the flat-parent graph and the write hook). Bare packages are
125
+ * never project files.
126
+ */
127
+ export function createResolver(ts, root, governed) {
128
+ const aliases = readTsconfigAliases(ts, root);
129
+ const memo = new Map();
130
+ return (specifier, fromRel) => {
131
+ const key = `${dirOf(fromRel)}\0${specifier}`;
132
+ if (memo.has(key)) return memo.get(key);
133
+ let hit = null;
134
+ try {
135
+ const rel = resolveSpecifierToRel(specifier, fromRel, root, aliases);
136
+ hit = rel ? (governed.has(rel) ? rel : probeGoverned(stripExt(rel), governed)) : null;
137
+ } catch {
138
+ hit = null;
139
+ }
140
+ memo.set(key, hit);
141
+ return hit;
142
+ };
143
+ }
144
+
145
+ /** Governed files a barrel re-exports from (`export … from`), resolved. */
146
+ function reExportTargets(ts, root, rel, resolve) {
147
+ const read = readSource(path.join(root, rel));
148
+ if (read.text == null) return [];
149
+ const sourceFile = ts.createSourceFile(rel, read.text, ts.ScriptTarget.Latest, false);
150
+ const out = [];
151
+ for (const statement of sourceFile.statements) {
152
+ if (!ts.isExportDeclaration(statement)) continue;
153
+ const specifier = statement.moduleSpecifier;
154
+ if (!specifier || !ts.isStringLiteralLike(specifier)) continue;
155
+ const target = resolve(specifier.text, rel);
156
+ if (target) out.push(target);
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /**
162
+ * Test files that import each target directly, or through one re-exporting
163
+ * barrel. Same walk, test classifier and resolver as the files-nothing-imports
164
+ * pass. Returns target → sorted test paths (targets nothing imports map to []).
165
+ *
166
+ * @param {{ root: string, ts: object, config?: object, governed: Set<string>, targets: string[] }} input
167
+ * @returns {{ byTarget: Map<string, string[]>, capped: number }}
168
+ */
169
+ export function testImportersOf({ root, ts, config, governed, targets }) {
170
+ const byTarget = new Map(targets.map((target) => [target, new Set()]));
171
+ if (!ts || typeof ts.preProcessFile !== 'function' || targets.length === 0) {
172
+ return { byTarget: new Map(targets.map((target) => [target, []])), capped: 0 };
173
+ }
174
+ const isTest = testPathMatcher(config);
175
+ const resolve = createResolver(ts, root, governed);
176
+ const barrels = new Map();
177
+ const { files, capped } = walkExternalSources(root, governed);
178
+ for (const rel of files) {
179
+ if (!isTest(rel)) continue;
180
+ const read = readSource(path.join(root, rel));
181
+ if (read.text == null) continue;
182
+ for (const { specifier } of fileImports(ts, rel, read.text, false)) {
183
+ const hit = resolve(specifier, rel);
184
+ if (!hit) continue;
185
+ if (byTarget.has(hit)) {
186
+ byTarget.get(hit).add(rel);
187
+ continue;
188
+ }
189
+ if (!barrels.has(hit)) barrels.set(hit, reExportTargets(ts, root, hit, resolve));
190
+ for (const target of barrels.get(hit)) byTarget.get(target)?.add(rel);
191
+ }
192
+ }
193
+ return {
194
+ byTarget: new Map([...byTarget].map(([target, tests]) => [target, [...tests].sort()])),
195
+ capped,
196
+ };
197
+ }
@@ -88,7 +88,7 @@ export function execRunner(root) {
88
88
  }
89
89
 
90
90
  /** The exact arkgate version that ships this CLI (the package.json next to bin/). */
91
- export function shippedArkgateVersion() {
91
+ function shippedArkgateVersion() {
92
92
  try {
93
93
  const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
94
94
  return typeof pkg.version === 'string' && pkg.version ? pkg.version : null;
@@ -114,7 +114,7 @@ export function arkgateIsProjectDependency(root) {
114
114
  }
115
115
 
116
116
  /** `arkgate@<exact shipped version>` — the package an unpinned project runs through npx. */
117
- export function pinnedArkgateSpec(version = shippedArkgateVersion()) {
117
+ function pinnedArkgateSpec(version = shippedArkgateVersion()) {
118
118
  return version ? `arkgate@${version}` : 'arkgate';
119
119
  }
120
120
 
@@ -199,7 +199,7 @@ export function isNpmYarnWorkspaceRoot(root) {
199
199
  * Normalize a version/range/spec into an installable package argument for arkgate.
200
200
  * Accepts `latest`, `^3.8.2`, `arkgate@latest`, or a full package name.
201
201
  */
202
- export function normalizeArkgateInstallSpec(versionSpec) {
202
+ function normalizeArkgateInstallSpec(versionSpec) {
203
203
  const raw = typeof versionSpec === 'string' && versionSpec.trim() ? versionSpec.trim() : 'latest';
204
204
  if (raw.startsWith('arkgate@') || raw === 'arkgate') return raw === 'arkgate' ? 'arkgate@latest' : raw;
205
205
  if (raw.includes('/') || raw.startsWith('file:') || raw.startsWith('link:')) return raw;
@@ -4,7 +4,7 @@
4
4
  * governed files to a partial analysis verdict.
5
5
  */
6
6
 
7
- export const PARSE_HEALTH_FILE_CAP = 12;
7
+ const PARSE_HEALTH_FILE_CAP = 12;
8
8
 
9
9
  function unavailableParseHealth(scannedFiles = 0) {
10
10
  return {
@@ -9,13 +9,13 @@
9
9
  */
10
10
 
11
11
  /** IO / ORM import evidence. postgres and drizzle-orm include package subpaths. */
12
- export const IO_IMPORT_HINT_RE = /\bfrom\s+['"](?:@?prisma\/client|@supabase\/|drizzle-orm(?:\/[^'"]+)?|postgres(?:\/[^'"]+)?|typeorm|knex|mongodb|pg|mysql2|mongoose|better-sqlite3|ioredis|redis|kysely|sequelize)['"]|require\(\s*['"](?:@?prisma\/client|pg|postgres(?:\/[^'"]+)?|drizzle-orm(?:\/[^'"]+)?|knex|typeorm|mongoose)/;
12
+ const IO_IMPORT_HINT_RE = /\bfrom\s+['"](?:@?prisma\/client|@supabase\/|drizzle-orm(?:\/[^'"]+)?|postgres(?:\/[^'"]+)?|typeorm|knex|mongodb|pg|mysql2|mongoose|better-sqlite3|ioredis|redis|kysely|sequelize)['"]|require\(\s*['"](?:@?prisma\/client|pg|postgres(?:\/[^'"]+)?|drizzle-orm(?:\/[^'"]+)?|knex|typeorm|mongoose)/;
13
13
  /** Path-alias / local db module (`@/lib/db`) without resolving tsconfig. */
14
- export const IO_ALIAS_IMPORT_RE = /\bfrom\s+['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle|orm)(?:\.[cm]?[jt]sx?)?['"]|require\(\s*['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle|orm)/;
14
+ const IO_ALIAS_IMPORT_RE = /\bfrom\s+['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle|orm)(?:\.[cm]?[jt]sx?)?['"]|require\(\s*['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle|orm)/;
15
15
  /** Named imports from a local persistence module (same module names as IO_ALIAS_IMPORT_RE). */
16
16
  const LOCAL_CLIENT_IMPORT_RE = /\bimport\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle|orm)(?:\.[cm]?[jt]sx?)?['"]/g;
17
17
  /** Conventional client identifiers (PrismaClient included via the /i flag). */
18
- export const PERSISTENCE_CLIENT_NAMES = [
18
+ const PERSISTENCE_CLIENT_NAMES = [
19
19
  'db',
20
20
  'tx',
21
21
  'client',
@@ -24,7 +24,10 @@ export const PERSISTENCE_CLIENT_NAMES = [
24
24
  'drizzle',
25
25
  ];
26
26
  const WRITE_VERB_SOURCE = '(?:insert(?:One|Many)?|update(?:One|Many)?|upsert|delete(?:One|Many)?|createMany|create|replaceOne|findOneAnd(?:Update|Delete|Replace))';
27
- const SQL_WRITE_SOURCE = '\\bINSERT\\s+INTO\\b|\\bUPDATE\\s+[A-Za-z_][\\w.]*\\s+SET\\b|\\bDELETE\\s+FROM\\b';
27
+ const SQL_WRITE_RE = /\b(?:INSERT\s+INTO|(?<!\b(?:FOR(?:\s+NO\s+KEY)?|DO)\s)UPDATE(?:\s+ONLY)?(?!\s+(?:OF|SET)\b)|DELETE\s+FROM(?:\s+ONLY)?|MERGE\s+INTO|TRUNCATE(?:\s+TABLE)?(?:\s+ONLY)?)\s+(?:(?:(?:"[^"]*"|[A-Za-z_][\w$]*)\s*\.\s*)?(?:"[^"]*"|[A-Za-z_][\w$]*)|\$\{[^}]+\})/i;
28
+ const NOT_A_SQL_TAG = /^(?:await|return|throw|yield|new|typeof|void|delete|if|else|case|of|in|instanceof|async|function|class|const|let|var|import|export|from|as|default|type)$/;
29
+ const TAGGED_SQL_RE = /(^|[^\w$])([A-Za-z_$][\w$]*)\s*`((?:[^`\\]|\\[\s\S])*)`/g;
30
+ const UPSERT_RE = /\bINSERT\s+INTO\b[\s\S]*?\bON\s+CONFLICT\b[\s\S]*?\bDO\s+UPDATE\s+SET\b[^;`]*/gi;
28
31
  /** Call arguments with one level of nested parentheses: `({ log: fn() })`. */
29
32
  const CALL_ARGS_SOURCE = '\\((?:[^()]|\\([^()]*\\))*\\)';
30
33
  /** Receivers built inline: `new PrismaClient(...)` / `drizzle(...)`. */
@@ -55,7 +58,7 @@ function collectFirstGroup(re, content, names) {
55
58
  * driver constructor, annotated with a driver client type, or imported by name from a
56
59
  * local persistence module.
57
60
  */
58
- export function persistenceClientBindings(content) {
61
+ function persistenceClientBindings(content) {
59
62
  const names = new Set();
60
63
  collectFirstGroup(DRIVER_BINDING_RE, content, names);
61
64
  collectFirstGroup(DRIVER_ANNOTATION_RE, content, names);
@@ -71,11 +74,33 @@ export function persistenceClientBindings(content) {
71
74
  }
72
75
  return [...names].sort();
73
76
  }
77
+ function blankPersistenceComments(content) {
78
+ return content.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|`(?:[^`\\]|\\[\s\S])*`|'(?:[^'\\]|\\[\s\S])*'|"(?:[^"\\]|\\[\s\S])*"/g, (token) => token.charCodeAt(0) === 47
79
+ ? token.replace(/[^\n]/g, ' ')
80
+ : token.charCodeAt(0) === 96
81
+ ? token.replace(/--[^\n`]*/g, (comment) => ' '.repeat(comment.length))
82
+ : token);
83
+ }
84
+ export function sourceHasTaggedSqlWrite(content) {
85
+ const text = blankPersistenceComments(content);
86
+ TAGGED_SQL_RE.lastIndex = 0;
87
+ let match;
88
+ while ((match = TAGGED_SQL_RE.exec(text)) !== null) {
89
+ if (NOT_A_SQL_TAG.test(match[2] ?? ''))
90
+ continue;
91
+ if (SQL_WRITE_RE.test((match[3] ?? '').replace(UPSERT_RE, ' ')))
92
+ return true;
93
+ }
94
+ return false;
95
+ }
74
96
  /** Receiver-bound write evidence (ORM verb on a persistence client, or raw SQL write). */
75
97
  export function sourceHasPersistenceWrite(content) {
76
- const receivers = [...PERSISTENCE_CLIENT_NAMES, ...persistenceClientBindings(content)]
98
+ const text = blankPersistenceComments(content);
99
+ const receivers = [...PERSISTENCE_CLIENT_NAMES, ...persistenceClientBindings(text)]
77
100
  .map(escapeRegExp)
78
101
  .join('|');
79
- const re = new RegExp(`(?:\\b(?:${receivers})(?![\\w$])|${INLINE_CLIENT_SOURCE})(?:\\s*\\.\\s*[A-Za-z_]\\w*)*\\s*\\.\\s*${WRITE_VERB_SOURCE}\\s*\\(|${SQL_WRITE_SOURCE}`, 'i');
80
- return re.test(content);
102
+ const re = new RegExp(`(?:\\b(?:${receivers})(?![\\w$])|${INLINE_CLIENT_SOURCE})(?:\\s*\\.\\s*[A-Za-z_]\\w*)*\\s*\\.\\s*${WRITE_VERB_SOURCE}\\s*\\(`, 'i');
103
+ if (re.test(text))
104
+ return true;
105
+ return SQL_WRITE_RE.test(text);
81
106
  }
@@ -16,7 +16,7 @@ const LOCAL_RUNNER_TEXT =
16
16
  const TOML_SERVER = /command\s*=\s*"([^"]*)"\s*\r?\nargs\s*=\s*\[([^\]]*)\]/g;
17
17
 
18
18
  /** Host hooks, package scripts, and MCP registrations Ark generates. */
19
- export const PIN_DEPENDENT_CANDIDATES = [
19
+ const PIN_DEPENDENT_CANDIDATES = [
20
20
  '.claude/settings.json',
21
21
  '.codex/hooks.json',
22
22
  '.cursor/hooks.json',
@@ -40,7 +40,7 @@ function readText(file) {
40
40
  }
41
41
 
42
42
  /** True when this argv runs an Ark bin through a runner that needs the local pin. */
43
- export function argvDependsOnArkgatePin(command, args) {
43
+ function argvDependsOnArkgatePin(command, args) {
44
44
  if (typeof command !== 'string' || !Array.isArray(args)) return false;
45
45
  const runner = path.basename(command.trim().replace(/\\/g, '/')).replace(/\.(?:cmd|exe)$/i, '');
46
46
  if (runner === 'npx') return npxArkgatePrefixLength(args) === 0 && ARK_BIN.test(args[0] ?? '');