arkgate 4.8.23 → 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 (201) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +41 -91
  4. package/bin/ark-dashboard.mjs +56 -53
  5. package/bin/ark-layer-match.mjs +707 -121
  6. package/bin/ark-mcp-runtime.mjs +448 -169
  7. package/bin/ark-mcp.mjs +68 -4
  8. package/bin/ark-shared.mjs +42 -22
  9. package/bin/ark.mjs +65 -97
  10. package/bin/lib/adapter-contract-types.mjs +5 -0
  11. package/bin/lib/adapter-contract.mjs +15 -0
  12. package/bin/lib/adoption-stance.mjs +2 -2
  13. package/bin/lib/adr-path.mjs +3 -3
  14. package/bin/lib/agent-gates.mjs +0 -61
  15. package/bin/lib/agent-homes.mjs +12 -21
  16. package/bin/lib/agent-projection-command.mjs +9 -5
  17. package/bin/lib/ambient-state.mjs +9 -30
  18. package/bin/lib/analysis-engine.mjs +15 -11
  19. package/bin/lib/architecture-scan.mjs +61 -15
  20. package/bin/lib/ark-order-doctor.mjs +4 -29
  21. package/bin/lib/ark-order-facts.mjs +319 -17
  22. package/bin/lib/ark-order-sensors.mjs +28 -101
  23. package/bin/lib/ark-order-types.mjs +3 -2
  24. package/bin/lib/ark-run-doctor.mjs +17 -28
  25. package/bin/lib/ark-run-facts.mjs +461 -48
  26. package/bin/lib/ark-run-sensors.mjs +6 -4
  27. package/bin/lib/arkgate-pins.mjs +91 -0
  28. package/bin/lib/arkrule-file-hints.mjs +2 -47
  29. package/bin/lib/arkrules-contract.mjs +124 -133
  30. package/bin/lib/arkrules-sensors.mjs +55 -252
  31. package/bin/lib/check-args.mjs +85 -1
  32. package/bin/lib/ci-and-commands.mjs +24 -78
  33. package/bin/lib/ci-merge-boundary.mjs +1 -1
  34. package/bin/lib/class-source-scan.mjs +479 -0
  35. package/bin/lib/clone-detection.mjs +474 -0
  36. package/bin/lib/codex-home.mjs +5 -3
  37. package/bin/lib/config-contract-slices.mjs +739 -0
  38. package/bin/lib/config-contract.mjs +42 -607
  39. package/bin/lib/config-extras.mjs +16 -22
  40. package/bin/lib/config-warnings.mjs +32 -0
  41. package/bin/lib/contract-smells.mjs +33 -15
  42. package/bin/lib/core-ratchet.mjs +0 -2
  43. package/bin/lib/dashboard-cli.mjs +62 -0
  44. package/bin/lib/deep-module-coach.mjs +2 -4
  45. package/bin/lib/deploy-path.mjs +13 -38
  46. package/bin/lib/design-delta.mjs +1 -1
  47. package/bin/lib/design-smells.mjs +2 -2
  48. package/bin/lib/diagnostic-catalog.mjs +23 -9
  49. package/bin/lib/doctor-advisories.mjs +78 -8
  50. package/bin/lib/doctor-green-cite.mjs +1 -1
  51. package/bin/lib/doctor-human.mjs +1 -1
  52. package/bin/lib/doctor-next-actions.mjs +19 -4
  53. package/bin/lib/doctor-plan.mjs +9 -13
  54. package/bin/lib/duplication-io.mjs +572 -0
  55. package/bin/lib/effective-contract-load.mjs +282 -83
  56. package/bin/lib/enforcement-profiles.mjs +40 -4
  57. package/bin/lib/entry-points-io.mjs +424 -0
  58. package/bin/lib/extra-merge-teeth.mjs +36 -0
  59. package/bin/lib/field-install.mjs +38 -14
  60. package/bin/lib/first-run-help.mjs +10 -5
  61. package/bin/lib/flat-parent-importers.mjs +139 -0
  62. package/bin/lib/flat-parent-pilot.mjs +40 -100
  63. package/bin/lib/gate-files.mjs +58 -90
  64. package/bin/lib/git-change-scope.mjs +109 -0
  65. package/bin/lib/github-enforcement.mjs +166 -29
  66. package/bin/lib/golden-pattern.mjs +1 -1
  67. package/bin/lib/hook-templates.mjs +17 -15
  68. package/bin/lib/host-support-matrix.mjs +3 -1
  69. package/bin/lib/html-report-advisories.mjs +4 -0
  70. package/bin/lib/html-report-depth.mjs +2 -2
  71. package/bin/lib/html-report.mjs +2 -2
  72. package/bin/lib/import-graph-projection.mjs +112 -0
  73. package/bin/lib/import-resolve.mjs +13 -1
  74. package/bin/lib/improvement-compass-doctor.mjs +1 -24
  75. package/bin/lib/install-migrate.mjs +50 -73
  76. package/bin/lib/invariant-coverage-io.mjs +4 -3
  77. package/bin/lib/invariant-coverage.mjs +85 -11
  78. package/bin/lib/invariant-probe-cli.mjs +347 -0
  79. package/bin/lib/invariant-probe-io.mjs +165 -0
  80. package/bin/lib/invariant-probe-runner.mjs +268 -0
  81. package/bin/lib/invariant-probe-sites.mjs +174 -0
  82. package/bin/lib/invariant-probe-workspace.mjs +183 -0
  83. package/bin/lib/invariant-probe.mjs +461 -0
  84. package/bin/lib/invariant-tests-path.mjs +16 -22
  85. package/bin/lib/literal-path-drift.mjs +4 -2
  86. package/bin/lib/managed-upgrade.mjs +2 -8
  87. package/bin/lib/mcp-adoption.mjs +13 -6
  88. package/bin/lib/mcp-hook-payload.mjs +403 -63
  89. package/bin/lib/orphan-modules-io.mjs +458 -0
  90. package/bin/lib/orphan-modules.mjs +251 -0
  91. package/bin/lib/outside-importers.mjs +197 -0
  92. package/bin/lib/package-manager.mjs +60 -2
  93. package/bin/lib/parse-health.mjs +1 -1
  94. package/bin/lib/passthrough-commands.mjs +83 -0
  95. package/bin/lib/persistence-write-hint.mjs +106 -0
  96. package/bin/lib/pin-dependent-gates.mjs +117 -0
  97. package/bin/lib/policy-delta-io.mjs +219 -14
  98. package/bin/lib/post-green-path.mjs +1 -1
  99. package/bin/lib/prepare-change.mjs +115 -7
  100. package/bin/lib/prepare-write.mjs +2 -2
  101. package/bin/lib/presets.mjs +59 -73
  102. package/bin/lib/product-copy.mjs +1 -6
  103. package/bin/lib/project-root.mjs +31 -62
  104. package/bin/lib/remediation.mjs +23 -4
  105. package/bin/lib/resolved-candidate-facts.mjs +405 -117
  106. package/bin/lib/rules-inventory.mjs +22 -3
  107. package/bin/lib/rules-under-contract.mjs +247 -9
  108. package/bin/lib/scan-files.mjs +36 -4
  109. package/bin/lib/schema-validation.mjs +130 -0
  110. package/bin/lib/sensor-promote-cli.mjs +2 -11
  111. package/bin/lib/sensor-promote-io.mjs +33 -22
  112. package/bin/lib/sensor-promotion.mjs +1 -1
  113. package/bin/lib/skill-catalog-apply.mjs +1 -1
  114. package/bin/lib/skill-install.mjs +42 -38
  115. package/bin/lib/snippet-analysis.mjs +140 -6
  116. package/bin/lib/source-policy.mjs +34 -0
  117. package/bin/lib/start-install-recovery.mjs +72 -2
  118. package/bin/lib/start-preview.mjs +31 -1
  119. package/bin/lib/states-transitions-presence.mjs +3 -3
  120. package/bin/lib/status-command.mjs +35 -22
  121. package/bin/lib/status-manifest.mjs +50 -30
  122. package/bin/lib/status-transition-catalog.mjs +0 -6
  123. package/bin/lib/suggestions.mjs +3 -3
  124. package/bin/lib/team-parliament-io.mjs +76 -88
  125. package/bin/lib/team-parliament.mjs +21 -10
  126. package/bin/lib/upgrade-behavior-changes.mjs +2 -2
  127. package/bin/lib/upgrade-command.mjs +2 -24
  128. package/bin/lib/upgrade-outcome.mjs +1 -1
  129. package/bin/lib/upstream-report.mjs +21 -10
  130. package/bin/lib/violations.mjs +10 -9
  131. package/bin/lib/weakest-link.mjs +13 -0
  132. package/bin/lib/write-path-capabilities.mjs +6 -4
  133. package/dist/chunk-A5EIY3HI.cjs +5 -0
  134. package/dist/chunk-AHACEKSV.js +16 -0
  135. package/dist/chunk-SVSQVGFJ.js +5 -0
  136. package/dist/chunk-UN5EH4QV.cjs +16 -0
  137. package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-GPvvdleM.d.cts} +30 -4
  138. package/dist/configTypes-GPvvdleM.d.ts +257 -0
  139. package/dist/eslint/index.cjs +18 -5
  140. package/dist/eslint/index.d.cts +368 -0
  141. package/dist/eslint/index.d.ts +139 -156
  142. package/dist/eslint/index.js +11 -5
  143. package/dist/index.cjs +36 -35
  144. package/dist/index.d.cts +5547 -0
  145. package/dist/index.d.ts +3283 -198
  146. package/dist/index.js +36 -35
  147. package/dist/inspectorListen-6DQVJ4DC.js +1 -0
  148. package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
  149. package/dist/nestjs/index.cjs +1 -18
  150. package/dist/nestjs/index.d.cts +22 -0
  151. package/dist/nestjs/index.d.ts +1 -3
  152. package/dist/nestjs/index.js +1 -18
  153. package/dist/order/index.cjs +1 -1
  154. package/dist/order/index.d.cts +155 -0
  155. package/dist/order/index.d.ts +15 -3
  156. package/dist/order/index.js +1 -1
  157. package/dist/runtime/index.cjs +13 -30
  158. package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +828 -276
  159. package/dist/runtime/index.d.ts +2741 -133
  160. package/dist/runtime/index.js +13 -30
  161. package/dist/{types-Bdj32QwA.d.ts → types-CnauMQQL.d.cts} +607 -163
  162. package/dist/types-CnauMQQL.d.ts +1800 -0
  163. package/docs/README.md +3 -1
  164. package/docs/agent-guide.md +217 -16
  165. package/docs/ai-gates.md +174 -26
  166. package/docs/arkorder.md +32 -15
  167. package/docs/brownfield-adoption.md +5 -1
  168. package/docs/configuration.md +230 -31
  169. package/docs/develop.md +2 -1
  170. package/docs/diagnostics.md +212 -19
  171. package/docs/package-surface.md +35 -9
  172. package/docs/product-voice.md +7 -1
  173. package/package.json +69 -19
  174. package/schemas/ark.analysis-result.schema.json +15 -0
  175. package/schemas/ark.config.schema.json +77 -12
  176. package/schemas/ark.invariant-probe.schema.json +368 -0
  177. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  178. package/schemas/ark.status-manifest.schema.json +22 -0
  179. package/server.json +3 -9
  180. package/templates/agent-skills/README.md +1 -1
  181. package/templates/agent-skills/ark-adopt/SKILL.md +5 -1
  182. package/templates/agent-skills/ark-contract/SKILL.md +17 -0
  183. package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
  184. package/templates/agent-skills/ark-explore/SKILL.md +7 -0
  185. package/templates/agent-skills/ark-fix/SKILL.md +20 -0
  186. package/templates/agent-skills/ark-order/SKILL.md +4 -2
  187. package/templates/agent-skills/ark-place/SKILL.md +8 -0
  188. package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
  189. package/templates/arkrules/PersistenceAdapters.json +1 -1
  190. package/templates/arkrules/PresentationAdapters.json +1 -1
  191. package/templates/skills/ark-adopt.md +5 -1
  192. package/templates/skills/ark-contract.md +17 -0
  193. package/templates/skills/ark-coverage.md +2 -0
  194. package/templates/skills/ark-explore.md +7 -0
  195. package/templates/skills/ark-fix.md +20 -0
  196. package/templates/skills/ark-order.md +4 -2
  197. package/templates/skills/ark-place.md +8 -0
  198. package/templates/skills/ark-runtime.md +6 -1
  199. package/bin/lib/ark-order-error.mjs +0 -18
  200. package/bin/lib/ark-order-invariants.mjs +0 -346
  201. package/dist/types-Ik_OakcI.d.ts +0 -183
@@ -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
+ }
@@ -83,9 +83,66 @@ const RUNNER_BY_PM = { pnpm: PNPM_EXEC, yarn: 'yarn', npm: 'npx' };
83
83
  * same detection.
84
84
  */
85
85
  export function execRunner(root) {
86
+ if (!arkgateIsProjectDependency(root)) return pinnedArkgateRunner();
86
87
  return RUNNER_BY_PM[detectPackageManager(root)];
87
88
  }
88
89
 
90
+ /** The exact arkgate version that ships this CLI (the package.json next to bin/). */
91
+ function shippedArkgateVersion() {
92
+ try {
93
+ const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
94
+ return typeof pkg.version === 'string' && pkg.version ? pkg.version : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * True when the project resolves arkgate locally: a `dependencies` / `devDependencies` entry,
102
+ * an installed node_modules/arkgate, or the arkgate package itself (self-host). Only then
103
+ * does a bare `npx ark-check` / `pnpm exec` / `yarn` runner reach this package's bins.
104
+ */
105
+ export function arkgateIsProjectDependency(root) {
106
+ // An installed copy (node_modules/arkgate) also resolves the local runner.
107
+ if (fs.existsSync(path.join(root, 'node_modules', 'arkgate', 'package.json'))) return true;
108
+ const pkg = readPackageJson(root);
109
+ if (!pkg || typeof pkg !== 'object') return false;
110
+ if (pkg.name === 'arkgate') return true;
111
+ return [pkg.dependencies, pkg.devDependencies].some(
112
+ (deps) => deps && typeof deps === 'object' && typeof deps.arkgate === 'string'
113
+ );
114
+ }
115
+
116
+ /** `arkgate@<exact shipped version>` — the package an unpinned project runs through npx. */
117
+ function pinnedArkgateSpec(version = shippedArkgateVersion()) {
118
+ return version ? `arkgate@${version}` : 'arkgate';
119
+ }
120
+
121
+ /**
122
+ * Runner for a project that does not pin arkgate. Bare `npx ark-check` asks npm for a
123
+ * package named `ark-check` (404) or runs a stale global copy; `-p arkgate@<exact>` names the
124
+ * package that owns the bin, and `-y` keeps CI and host hooks from blocking on a prompt.
125
+ */
126
+ export function pinnedArkgateRunner(version) {
127
+ return `npx -y -p ${pinnedArkgateSpec(version)}`;
128
+ }
129
+
130
+ /**
131
+ * Number of npx arguments before the bin: 0 for `npx <bin>`, more for the pinned form
132
+ * (`npx -y -p arkgate@x <bin>`, `--yes`, `--package arkgate@x`, `--package=arkgate@x`).
133
+ */
134
+ export function npxArkgatePrefixLength(args) {
135
+ if (!Array.isArray(args)) return 0;
136
+ let index = 0;
137
+ if (args[index] === '-y' || args[index] === '--yes') index += 1;
138
+ const flag = args[index];
139
+ if ((flag === '-p' || flag === '--package') && /^arkgate(?:@\S+)?$/.test(args[index + 1] ?? '')) {
140
+ return index + 2;
141
+ }
142
+ if (typeof flag === 'string' && /^--package=arkgate(?:@\S+)?$/.test(flag)) return index + 1;
143
+ return 0;
144
+ }
145
+
89
146
  /** Full runnable command string for an installed Ark binary, package-manager aware. */
90
147
  export function arkCommand(root, bin, argsStr = '') {
91
148
  return `${execRunner(root)} ${bin}${argsStr ? ` ${argsStr}` : ''}`;
@@ -116,7 +173,8 @@ export function execCommandParts(root, bin, binArgs = []) {
116
173
  };
117
174
  }
118
175
  if (runner === 'yarn') return { command: 'yarn', args: [bin, ...binArgs] };
119
- return { command: 'npx', args: [bin, ...binArgs] };
176
+ // Pinned form (`npx -y -p arkgate@x`) keeps its prefix as real argv entries.
177
+ return { command: 'npx', args: [...runner.split(' ').slice(1), bin, ...binArgs] };
120
178
  }
121
179
 
122
180
  /**
@@ -141,7 +199,7 @@ export function isNpmYarnWorkspaceRoot(root) {
141
199
  * Normalize a version/range/spec into an installable package argument for arkgate.
142
200
  * Accepts `latest`, `^3.8.2`, `arkgate@latest`, or a full package name.
143
201
  */
144
- export function normalizeArkgateInstallSpec(versionSpec) {
202
+ function normalizeArkgateInstallSpec(versionSpec) {
145
203
  const raw = typeof versionSpec === 'string' && versionSpec.trim() ? versionSpec.trim() : 'latest';
146
204
  if (raw.startsWith('arkgate@') || raw === 'arkgate') return raw === 'arkgate' ? 'arkgate@latest' : raw;
147
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 {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Subcommands of `arkgate` / `ark` that hand their own flags to a sibling bin untouched:
3
+ * `dashboard` (bin/ark-dashboard.mjs) and `mcp` (bin/ark-mcp.mjs).
4
+ *
5
+ * `mcp` exists because `npx arkgate@<version> <args>` always runs the bin named after the
6
+ * package (`arkgate`), never `arkgate-mcp`. The MCP Registry descriptor (`server.json`) is
7
+ * launched exactly that way, so `arkgate mcp --root .` must start the stdio MCP server.
8
+ * `arkgate-mcp` / `ark-mcp` are also accepted as the first argument, so a descriptor or host
9
+ * config that spells the bin name still reaches the server. That does not repair registry
10
+ * entries already published: each pins the arkgate version it was published with, and
11
+ * versions through 4.8.23 have no passthrough. Only a republished descriptor fixes those.
12
+ */
13
+ import { spawn, spawnSync } from 'node:child_process';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const here = path.dirname(fileURLToPath(import.meta.url));
18
+ const arkDashboard = path.join(here, '..', 'ark-dashboard.mjs');
19
+ const arkMcp = path.join(here, '..', 'ark-mcp.mjs');
20
+
21
+ /** First positional arguments that start the MCP server (stdio JSON-RPC). */
22
+ export const MCP_COMMANDS = Object.freeze(['mcp', 'arkgate-mcp', 'ark-mcp']);
23
+
24
+ /** First positional arguments whose remaining argv belongs to another executable. */
25
+ export function isPassthroughCommand(command) {
26
+ return command === 'dashboard' || command === 'report' || MCP_COMMANDS.includes(command);
27
+ }
28
+
29
+ export function isMcpCommand(command) {
30
+ return MCP_COMMANDS.includes(command);
31
+ }
32
+
33
+ export { DASHBOARD_HELP as dashboardHelp } from './dashboard-cli.mjs';
34
+
35
+ export function withDashboardHelp(text, detailed) {
36
+ const extra = detailed
37
+ ? ' arkgate dashboard [--url <inspector-url>] [--interval <ms>] [--once]\n arkgate mcp [--root <project>] [--config <path>]\n arkgate report [--root <project>] [--json] [--title <text>] [--finding <ref>] [--submit] [--i-confirm-submit]\n'
38
+ : '';
39
+ const extraDesc = detailed
40
+ ? ' dashboard ANSI observability TUI (spawns ark-dashboard).\n mcp Stdio MCP server (same as arkgate-mcp; used by npx and the MCP Registry).\n report Draft an upstream GitHub issue for pedroknigge/arkgate. Create needs --submit plus confirm. --yes does not submit.\n'
41
+ : '';
42
+ if (detailed) {
43
+ return text
44
+ .replace(' arkgate agents-md [--root', `${extra} arkgate agents-md [--root`)
45
+ .replace(' agents-md Version-matched', `${extraDesc} agents-md Version-matched`);
46
+ }
47
+ return text.replace(
48
+ ' arkgate-check --doctor status — one next step\n',
49
+ ' arkgate-check --doctor status — one next step\n arkgate dashboard observability TUI (inspector)\n arkgate report draft an upstream GitHub issue\n'
50
+ );
51
+ }
52
+
53
+ export function runDashboard(passthroughArgs) {
54
+ const result = spawnSync(process.execPath, [arkDashboard, ...passthroughArgs], {
55
+ stdio: 'inherit',
56
+ encoding: 'utf8',
57
+ });
58
+ return result.status ?? 1;
59
+ }
60
+
61
+ /**
62
+ * Run bin/ark-mcp.mjs with the caller's stdio. Nothing is written to stdout here: stdout is
63
+ * the JSON-RPC channel. Termination signals are forwarded so the host can stop the server.
64
+ */
65
+ export function runMcp(passthroughArgs) {
66
+ return new Promise((resolve) => {
67
+ const child = spawn(process.execPath, [arkMcp, ...passthroughArgs], { stdio: 'inherit' });
68
+ const forward = (signal) => {
69
+ if (child.exitCode === null) child.kill(signal);
70
+ };
71
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
72
+ for (const signal of signals) process.on(signal, forward);
73
+ const done = (code) => {
74
+ for (const signal of signals) process.off(signal, forward);
75
+ resolve(code);
76
+ };
77
+ child.on('error', (error) => {
78
+ process.stderr.write(`arkgate mcp: ${error.message}\n`);
79
+ done(1);
80
+ });
81
+ child.on('exit', (code, signal) => done(code ?? (signal ? 1 : 0)));
82
+ });
83
+ }