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,139 @@
1
+ /**
2
+ * Importer graph for the flat-parent doctor suggestion.
3
+ *
4
+ * Prefers the importer index the scan projected from resolved facts (ADR 0037),
5
+ * then raw resolved dependency facts (tsconfig paths included). Without either it scans the text and resolves relative
6
+ * specifiers plus tsconfig path aliases, so an `@/…` importer is never
7
+ * invisible. Advisory input only.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { importerGraphFromIndex } from './import-graph-projection.mjs';
12
+ import { readTsconfigAliases, resolveSpecifierToRel } from './import-resolve.mjs';
13
+
14
+ const SPEC_MARKS = [' from "', " from '", ' from `', 'require("', "require('", 'import("', "import('"];
15
+ const EXT = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'];
16
+
17
+ function withoutComments(text) {
18
+ let out = '';
19
+ let i = 0;
20
+ while (i < text.length) {
21
+ const c = text[i];
22
+ const n = text[i + 1];
23
+ if (c === '/' && n === '/') {
24
+ while (i < text.length && text[i] !== '\n') i += 1;
25
+ continue;
26
+ }
27
+ if (c === '/' && n === '*') {
28
+ i += 2;
29
+ while (i + 1 < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
30
+ i = Math.min(text.length, i + 2);
31
+ continue;
32
+ }
33
+ out += c;
34
+ i += 1;
35
+ }
36
+ return out;
37
+ }
38
+
39
+ /** Every string module specifier in `text` (relative and bare). */
40
+ function moduleSpecifiers(text) {
41
+ const specs = [];
42
+ const source = withoutComments(String(text));
43
+ let i = 0;
44
+ while (i < source.length) {
45
+ let at = -1;
46
+ let mark = '';
47
+ for (const candidate of SPEC_MARKS) {
48
+ const found = source.indexOf(candidate, i);
49
+ if (found !== -1 && (at === -1 || found < at)) {
50
+ at = found;
51
+ mark = candidate;
52
+ }
53
+ }
54
+ if (at === -1) break;
55
+ const quote = mark[mark.length - 1];
56
+ const start = at + mark.length;
57
+ const end = source.indexOf(quote, start);
58
+ if (end === -1) break;
59
+ const spec = source.slice(start, end);
60
+ if (spec.length > 0) specs.push(spec);
61
+ i = end + 1;
62
+ }
63
+ return specs;
64
+ }
65
+
66
+ function resolveRelative(fromFile, spec) {
67
+ const base = fromFile.split('/');
68
+ base.pop();
69
+ for (const part of spec.split('/')) {
70
+ if (part === '' || part === '.') continue;
71
+ if (part === '..') {
72
+ if (base.length === 0) return null;
73
+ base.pop();
74
+ continue;
75
+ }
76
+ base.push(part);
77
+ }
78
+ return base.join('/');
79
+ }
80
+
81
+ function probe(joined, fileSet) {
82
+ if (!joined) return null;
83
+ const names = [joined];
84
+ for (const ext of EXT) names.push(`${joined}${ext}`, `${joined}/index${ext}`);
85
+ const stem = joined.replace(/\.(?:m|c)?js$/, '');
86
+ if (stem !== joined) for (const ext of EXT) names.push(`${stem}${ext}`);
87
+ for (const name of names) {
88
+ if (fileSet.has(name)) return name;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ function readText(root, rel) {
94
+ try {
95
+ return fs.readFileSync(path.join(root, rel), 'utf8');
96
+ } catch {
97
+ return '';
98
+ }
99
+ }
100
+
101
+ function addEdge(graph, target, importer) {
102
+ let set = graph.get(target);
103
+ if (!set) {
104
+ set = new Set();
105
+ graph.set(target, set);
106
+ }
107
+ set.add(importer);
108
+ }
109
+
110
+ /**
111
+ * target file → every file that imports it (value and type-only edges; a
112
+ * peerIsolation wall judges both).
113
+ * @param {{ root: string, files: string[], facts?: object, ts?: object }} input
114
+ */
115
+ export function flatParentImporterGraph({ root, files, facts, ts }) {
116
+ if (facts?.importGraph) return importerGraphFromIndex(facts.importGraph);
117
+ const graph = new Map();
118
+ const fileSet = new Set(files);
119
+ const dependencies = Array.isArray(facts?.dependencies) ? facts.dependencies : null;
120
+ if (dependencies && dependencies.length > 0) {
121
+ for (const dep of dependencies) {
122
+ if (dep?.resolution !== 'resolved-project' || typeof dep.target !== 'string') continue;
123
+ if (typeof dep.from !== 'string' || dep.from === dep.target) continue;
124
+ addEdge(graph, dep.target, dep.from);
125
+ }
126
+ return graph;
127
+ }
128
+ const aliases = readTsconfigAliases(ts, root);
129
+ for (const file of files) {
130
+ for (const spec of moduleSpecifiers(readText(root, file))) {
131
+ const joined = spec.startsWith('.')
132
+ ? resolveRelative(file, spec)
133
+ : resolveSpecifierToRel(spec, file, root, aliases);
134
+ const target = probe(joined, fileSet);
135
+ if (target && target !== file) addEdge(graph, target, file);
136
+ }
137
+ }
138
+ return graph;
139
+ }
@@ -2,18 +2,15 @@
2
2
  * Doctor suggestion: a universe-level flat file that one child slice imports.
3
3
  * Advisory only. Not a finding, not a verdict, not a config field.
4
4
  */
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
5
  import {
6
+ findDeniedEdgeDecision,
8
7
  layerForRelativePath,
9
8
  resolveGovernedSlice,
10
9
  sliceAliasDestination,
11
10
  sliceIdForPath,
12
11
  } from '../ark-layer-match.mjs';
13
12
  import { destinationKeepsLayerAndSlice } from './physical-cohesion.mjs';
14
-
15
- const SPEC_MARKS = [' from "', " from '", ' from `', 'require("', "require('", 'import("', "import('"];
16
- const EXT = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'];
13
+ import { flatParentImporterGraph } from './flat-parent-importers.mjs';
17
14
 
18
15
  function ruleKey(rule) {
19
16
  const child = rule?.childSlices;
@@ -45,102 +42,41 @@ function isFlatParent(rel, rule) {
45
42
  return Boolean(universe && raw && raw.toLowerCase() === universe.toLowerCase());
46
43
  }
47
44
 
48
- function withoutComments(text) {
49
- let out = '';
50
- let i = 0;
51
- while (i < text.length) {
52
- const c = text[i];
53
- const n = text[i + 1];
54
- if (c === '/' && n === '/') {
55
- while (i < text.length && text[i] !== '\n') i += 1;
56
- continue;
57
- }
58
- if (c === '/' && n === '*') {
59
- i += 2;
60
- while (i + 1 < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
61
- i = Math.min(text.length, i + 2);
62
- continue;
45
+ /**
46
+ * Importers of each flat file, split by child. A file any non-child imports
47
+ * (universe common, a shared root, another universe, another layer) is marked:
48
+ * moving it into one child would turn that edge into a wall deny.
49
+ */
50
+ function childImporters(flatFiles, graph, rule) {
51
+ const out = new Map();
52
+ for (const target of flatFiles) {
53
+ const universe = resolveGovernedSlice(target, rule).universeId ?? '';
54
+ const ids = new Set();
55
+ let nonChild = false;
56
+ for (const importer of graph.get(target) ?? []) {
57
+ const place = resolveGovernedSlice(importer, rule);
58
+ if (!place.childId || (place.universeId ?? '') !== universe) nonChild = true;
59
+ else ids.add(place.childId);
63
60
  }
64
- out += c;
65
- i += 1;
61
+ out.set(target, { ids, nonChild, importers: [...(graph.get(target) ?? [])] });
66
62
  }
67
63
  return out;
68
64
  }
69
65
 
70
- function relativeSpecifiers(text) {
71
- const specs = [];
72
- const source = withoutComments(String(text));
73
- let i = 0;
74
- while (i < source.length) {
75
- let at = -1;
76
- let mark = '';
77
- for (const candidate of SPEC_MARKS) {
78
- const found = source.indexOf(candidate, i);
79
- if (found !== -1 && (at === -1 || found < at)) {
80
- at = found;
81
- mark = candidate;
82
- }
83
- }
84
- if (at === -1) break;
85
- const quote = mark[mark.length - 1];
86
- const start = at + mark.length;
87
- const end = source.indexOf(quote, start);
88
- if (end === -1) break;
89
- const spec = source.slice(start, end);
90
- if (spec.startsWith('.')) specs.push(spec);
91
- i = end + 1;
92
- }
93
- return specs;
94
- }
95
-
96
- function resolveRelative(fromFile, spec, fileSet) {
97
- const base = fromFile.split('/');
98
- base.pop();
99
- for (const part of spec.split('/')) {
100
- if (part === '' || part === '.') continue;
101
- if (part === '..') {
102
- if (base.length === 0) return null;
103
- base.pop();
104
- continue;
105
- }
106
- base.push(part);
107
- }
108
- const joined = base.join('/');
109
- const names = [joined];
110
- for (const ext of EXT) names.push(`${joined}${ext}`, `${joined}/index${ext}`);
111
- for (const name of names) {
112
- if (fileSet.has(name)) return name;
113
- }
114
- return null;
115
- }
116
-
117
- function readText(root, rel) {
118
- try {
119
- return fs.readFileSync(path.join(root, rel), 'utf8');
120
- } catch {
121
- return '';
122
- }
123
- }
124
-
125
- function childImporters(flatFiles, files, rule, root) {
126
- const flat = new Set(flatFiles);
127
- const known = new Set(files);
128
- const importers = new Map();
129
- for (const file of files) {
130
- const place = resolveGovernedSlice(file, rule);
131
- if (!place.childId) continue;
132
- for (const spec of relativeSpecifiers(readText(root, file))) {
133
- const target = resolveRelative(file, spec, known);
134
- if (!target || !flat.has(target) || target === file) continue;
135
- let ids = importers.get(target);
136
- if (!ids) {
137
- ids = new Set();
138
- importers.set(target, ids);
139
- }
140
- ids.add(place.childId);
141
- }
66
+ /** Would any recorded importer be denied by the rules after the move? */
67
+ function moveBreaksWall(importers, destFile, rules, layers) {
68
+ const toLayer = layerForRelativePath(destFile, layers);
69
+ for (const importer of importers) {
70
+ const fromLayer = layerForRelativePath(importer, layers);
71
+ if (!fromLayer || !toLayer) return true;
72
+ const denied = findDeniedEdgeDecision(rules, fromLayer, toLayer, {
73
+ fromPath: importer,
74
+ toPath: destFile,
75
+ layers,
76
+ });
77
+ if (denied) return true;
142
78
  }
143
- return importers;
79
+ return false;
144
80
  }
145
81
 
146
82
  function basename(rel) {
@@ -149,7 +85,7 @@ function basename(rel) {
149
85
  }
150
86
 
151
87
  /**
152
- * @param {{ root: string, files: string[], rules: object[] | undefined, layers: object[] | undefined }} input
88
+ * @param {{ root: string, files: string[], rules: object[] | undefined, layers: object[] | undefined, facts?: object, ts?: object }} input
153
89
  * @returns {object | null}
154
90
  */
155
91
  export function collectFlatParentPilot(input) {
@@ -161,15 +97,18 @@ export function collectFlatParentPilot(input) {
161
97
  const layers = input.layers;
162
98
  const moves = [];
163
99
  const seen = new Set();
100
+ let graph = null;
164
101
  for (const rule of rules) {
165
102
  const flat = files.filter((file) => isFlatParent(file, rule));
166
103
  if (flat.length === 0) continue;
167
- const importers = childImporters(flat, files, rule, input.root);
104
+ graph ??= flatParentImporterGraph({ root: input.root, files, facts: input.facts, ts: input.ts });
105
+ const importers = childImporters(flat, graph, rule);
168
106
  for (const file of flat) {
169
107
  if (seen.has(file)) continue;
170
- const ids = importers.get(file);
171
- if (!ids || ids.size !== 1) continue;
172
- const importer = [...ids][0];
108
+ const entry = importers.get(file);
109
+ // Exactly one child and nobody else: two children, or any non-child importer, keeps it in place.
110
+ if (!entry || entry.nonChild || entry.ids.size !== 1) continue;
111
+ const importer = [...entry.ids][0];
173
112
  const destination = sliceAliasDestination(file, importer, rule.childSlices);
174
113
  if (!destination) continue;
175
114
  const destFile = `${destination}/${basename(file)}`;
@@ -177,6 +116,7 @@ export function collectFlatParentPilot(input) {
177
116
  if (!layer || layer !== layerForRelativePath(destFile, layers)) continue;
178
117
  const toPlace = resolveGovernedSlice(destFile, rule);
179
118
  if ((toPlace.childId ?? '') !== importer) continue;
119
+ if (moveBreaksWall(entry.importers, destFile, input.rules, layers)) continue;
180
120
  const fromPlace = resolveGovernedSlice(file, rule);
181
121
  const keepsSlice = destinationKeepsLayerAndSlice(file, destFile, layers, [rule]);
182
122
  const wallSame =
@@ -3,12 +3,14 @@
3
3
  */
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import { createRequire } from 'node:module';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import { codexProjectMcpIsValid } from './codex-home.mjs';
8
9
  import { enforcingArkRunText, runsArkCheck } from './github-enforcement.mjs';
10
+ import { parseJsonMergeInputs } from './hook-templates.mjs';
11
+ import { npxArkgatePrefixLength } from './package-manager.mjs';
9
12
 
10
13
  export const __packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
11
- export const __arkCheckCli = path.join(__packageRoot, 'bin', 'ark-check.mjs');
12
14
 
13
15
  export function readJson(file) {
14
16
  return JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -41,13 +43,16 @@ export function packageScriptsHaveTypecheck(scripts) {
41
43
  }
42
44
 
43
45
  /**
44
- * Root package (and shallow nested packages) already have a typecheck script.
45
- * Does not scan CI or framework configs — only package.json scripts.
46
+ * Visit the parsed package.json of each shallow nested package — `root/<dir>/`
47
+ * and `root/<dir>/<child>/`, skipping dot directories and `node_modules` — in
48
+ * directory order. Unreadable or invalid files are skipped. Stops at the first
49
+ * visit that returns true.
50
+ *
46
51
  * @param {string} root
52
+ * @param {(pkg: any, dir: string) => boolean | void} visit
53
+ * @returns {boolean} true when a visit returned true
47
54
  */
48
- export function treeHasTypecheckScript(root) {
49
- const pkg = readPackageJson(root);
50
- if (packageScriptsHaveTypecheck(pkg?.scripts)) return true;
55
+ export function someNestedPackageJson(root, visit) {
51
56
  try {
52
57
  for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
53
58
  if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
@@ -65,8 +70,7 @@ export function treeHasTypecheckScript(root) {
65
70
  const pj = path.join(dir, 'package.json');
66
71
  if (!fs.existsSync(pj)) continue;
67
72
  try {
68
- const nested = JSON.parse(fs.readFileSync(pj, 'utf8'));
69
- if (packageScriptsHaveTypecheck(nested.scripts)) return true;
73
+ if (visit(JSON.parse(fs.readFileSync(pj, 'utf8')), dir) === true) return true;
70
74
  } catch {
71
75
  /* ignore */
72
76
  }
@@ -78,6 +82,17 @@ export function treeHasTypecheckScript(root) {
78
82
  return false;
79
83
  }
80
84
 
85
+ /**
86
+ * Root package (and shallow nested packages) already have a typecheck script.
87
+ * Does not scan CI or framework configs — only package.json scripts.
88
+ * @param {string} root
89
+ */
90
+ export function treeHasTypecheckScript(root) {
91
+ const pkg = readPackageJson(root);
92
+ if (packageScriptsHaveTypecheck(pkg?.scripts)) return true;
93
+ return someNestedPackageJson(root, (nested) => packageScriptsHaveTypecheck(nested.scripts));
94
+ }
95
+
81
96
  /**
82
97
  * Add a conservative `typecheck` script when the host has a TS/JS project config
83
98
  * but no typecheck-like script yet. Never overwrites an existing script.
@@ -86,10 +101,27 @@ export function treeHasTypecheckScript(root) {
86
101
  * @param {{ write?: boolean }} [opts]
87
102
  * @returns {{
88
103
  * changed: boolean,
89
- * reason: 'added' | 'already' | 'no-tsconfig' | 'no-package-json',
104
+ * reason: 'added' | 'already' | 'no-tsconfig' | 'no-package-json' | 'no-typescript',
90
105
  * script?: string,
91
106
  * }}
92
107
  */
108
+ /**
109
+ * True when `typescript` is declared by the project (deps/devDeps/peerDeps) or resolves
110
+ * from its root (e.g. hoisted in a workspace), so a generated `tsc --noEmit` can run.
111
+ */
112
+ function projectHasTypeScript(root, pkg = readPackageJson(root) || {}) {
113
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
114
+ const deps = pkg?.[field];
115
+ if (deps && typeof deps === 'object' && Object.hasOwn(deps, 'typescript')) return true;
116
+ }
117
+ try {
118
+ createRequire(path.join(root, 'package.json')).resolve('typescript/package.json');
119
+ return true;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
93
125
  export function ensureTypecheckScript(root, opts = {}) {
94
126
  const write = opts.write !== false;
95
127
  const hasTsconfig =
@@ -105,6 +137,8 @@ export function ensureTypecheckScript(root, opts = {}) {
105
137
  }
106
138
 
107
139
  const pkg = readPackageJson(root) || {};
140
+ // `tsc` only exists when typescript resolves — otherwise CI fails with `tsc: not found`.
141
+ if (!projectHasTypeScript(root, pkg)) return { changed: false, reason: 'no-typescript' };
108
142
  const scripts =
109
143
  pkg.scripts && typeof pkg.scripts === 'object' ? { ...pkg.scripts } : {};
110
144
  const script = 'tsc --noEmit';
@@ -121,7 +155,7 @@ export const REQUIRED_GATE_FILES = [
121
155
  '.mcp.json',
122
156
  ];
123
157
  export const REQUIRED_GATE_WORKFLOW = '.github/workflows/*.yml running ark-check';
124
- export const CI_NOT_FAIL_CLOSED_ERROR = 'ci-not-fail-closed';
158
+ const CI_NOT_FAIL_CLOSED_ERROR = 'ci-not-fail-closed';
125
159
  const COMPACT_ROUTER = /<!--\s*arkgate:compact-router host=([a-z]+)\s*-->/;
126
160
  const FAIL_CLOSED_ARK_FLAG = /(?:^|\s)--(?:strict|strict-merge|require-gates)(?=\s|$)/;
127
161
 
@@ -194,7 +228,10 @@ function arkMcpArgs(server) {
194
228
  const isArkBin = (value) => /^(?:arkgate-mcp|ark-mcp)(?:\.mjs)?$/.test(executableName(value));
195
229
  if ([server.command, ...args].filter(isArkBin).length !== 1) return null;
196
230
  if (isArkBin(server.command)) return args;
197
- if ((command === 'npx' || command === 'yarn') && isArkBin(args[0])) return args.slice(1);
231
+ const npxPrefix = command === 'npx' ? npxArkgatePrefixLength(args) : 0;
232
+ if ((command === 'npx' || command === 'yarn') && isArkBin(args[npxPrefix])) {
233
+ return args.slice(npxPrefix + 1);
234
+ }
198
235
  if (command === 'pnpm') {
199
236
  const binIndex =
200
237
  args[0] === 'exec'
@@ -285,21 +322,17 @@ export function hasArkMcpRegistration(root, relativePath = '.mcp.json') {
285
322
  */
286
323
  export const MCP_JSON_GATE_FILES = ['.mcp.json', '.cursor/mcp.json', '.agents/mcp_config.json'];
287
324
 
325
+ /** Claude-style hook files: Ark SessionStart/PreToolUse groups are merged, never whole-file replaced. */
326
+ export const CLAUDE_STYLE_HOOK_FILES = ['.claude/settings.json', '.codex/hooks.json'];
327
+
288
328
  /**
289
329
  * Upsert mcpServers.ark into an existing MCP JSON document.
290
330
  * Preserves sibling servers and unknown top-level keys. Returns null if unreadable.
291
331
  */
292
332
  export function mergeArkMcpJson(existingText, generatedText) {
293
- let existing;
294
- let generated;
295
- try {
296
- existing = existingText && existingText.trim() ? JSON.parse(existingText) : {};
297
- generated = JSON.parse(generatedText);
298
- } catch {
299
- return null;
300
- }
301
- if (!existing || typeof existing !== 'object' || Array.isArray(existing)) return null;
302
- if (!generated || typeof generated !== 'object' || Array.isArray(generated)) return null;
333
+ const parsed = parseJsonMergeInputs(existingText, generatedText);
334
+ if (!parsed) return null;
335
+ const { existing, generated } = parsed;
303
336
  const generatedArk = generated.mcpServers?.ark;
304
337
  if (!generatedArk || typeof generatedArk !== 'object' || Array.isArray(generatedArk)) {
305
338
  return null;
@@ -577,71 +610,6 @@ function unquoteYamlScalar(value) {
577
610
  return match ? match[2].trim() : text;
578
611
  }
579
612
 
580
- function jobCondition(lines, job) {
581
- const condition = jobProperty(lines, job, 'if');
582
- if (!condition) return 'default';
583
- const value = unquoteYamlScalar(condition.value);
584
- if (/^(?:\$\{\{\s*)?always\(\)(?:\s*\}\})?$/i.test(value)) return 'always';
585
- if (/^(?:true|\$\{\{\s*true\s*\}\})$/i.test(value)) return 'true';
586
- return 'conditional';
587
- }
588
-
589
- function jobNeeds(lines, job) {
590
- const property = jobProperty(lines, job, 'needs');
591
- if (!property) return { ids: [], indexes: [], valid: true };
592
- const indexes = [property.index];
593
- if (property.value) {
594
- const value = unquoteYamlScalar(property.value);
595
- const raw = value.startsWith('[') && value.endsWith(']')
596
- ? value.slice(1, -1).split(',')
597
- : [value];
598
- const ids = raw.map(unquoteYamlScalar).filter((id) => /^[A-Za-z0-9_-]+$/.test(id));
599
- return { ids, indexes, valid: ids.length === raw.length && ids.length > 0 };
600
- }
601
- const ids = [];
602
- for (let index = property.index + 1; index < job.end; index += 1) {
603
- if (!lines[index].trim() || /^\s*#/.test(lines[index])) {
604
- indexes.push(index);
605
- continue;
606
- }
607
- const indent = lines[index].match(/^\s*/)?.[0].length ?? 0;
608
- if (indent <= job.propertyIndent) break;
609
- indexes.push(index);
610
- const item = lines[index].match(/^\s*-\s*(['"]?)([A-Za-z0-9_-]+)\1\s*(?:#.*)?$/);
611
- if (!item) return { ids: [], indexes, valid: false };
612
- ids.push(item[2]);
613
- }
614
- return { ids, indexes, valid: ids.length > 0 };
615
- }
616
-
617
- function withVerifiedDependencyJobs(content) {
618
- const { lines, jobs } = workflowJobSections(content);
619
- const byId = new Map(jobs.map((job) => [job.id, job]));
620
- const guaranteed = (job, seen = new Set()) => {
621
- if (!job || seen.has(job.id)) return false;
622
- const condition = jobCondition(lines, job);
623
- if (condition === 'conditional') return false;
624
- if (condition === 'always') return true;
625
- const needs = jobNeeds(lines, job);
626
- if (!needs.valid) return false;
627
- const nextSeen = new Set(seen).add(job.id);
628
- return needs.ids.every((id) => guaranteed(byId.get(id), nextSeen));
629
- };
630
- for (const job of jobs) {
631
- const needs = jobNeeds(lines, job);
632
- if (
633
- needs.valid &&
634
- needs.ids.length > 0 &&
635
- needs.ids.every((id) => guaranteed(byId.get(id)))
636
- ) {
637
- // The shared analyzer treats every `needs` as skippable. Hide it only after
638
- // this dependency chain is proven unconditional; keep uncertain/skipped needs visible.
639
- for (const index of needs.indexes) lines[index] = '';
640
- }
641
- }
642
- return lines.join('\n');
643
- }
644
-
645
613
  function isGuaranteedJobCondition(value) {
646
614
  const text = unquoteYamlScalar(value);
647
615
  return (
@@ -705,13 +673,13 @@ export function inspectArkCiGate(root) {
705
673
  } catch {
706
674
  continue;
707
675
  }
708
- const prepared = withVerifiedDependencyJobs(withFailClosedArkActions(content));
676
+ // The shared analyzer proves `needs:` chains (github-enforcement.mjs), so this
677
+ // file-level view and doctor merge-gate evidence cannot disagree.
678
+ const prepared = withFailClosedArkActions(content);
709
679
  const failClosed = FAIL_CLOSED_ARK_FLAG.test(
710
680
  enforcingArkRunText(prepared, failClosedScript)
711
681
  );
712
- const visible = withVerifiedDependencyJobs(
713
- neutralizeSkippableJobControls(withFailClosedArkActions(content))
714
- );
682
+ const visible = neutralizeSkippableJobControls(prepared);
715
683
  const present =
716
684
  failClosed ||
717
685
  runsArkCheck(visible, declaredScript) ||
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Git plumbing for diff-scoped checks (`--changed`, `--local`, `--against`, `--persona`).
3
+ *
4
+ * Every path this module returns or reads is relative to the Ark ROOT (the `-C` directory),
5
+ * never to the repository top level, so a package inside a monorepo sees its own files:
6
+ * - `git diff --relative` strips the package prefix and drops paths outside the root;
7
+ * - `git ls-files --others` is already cwd-relative;
8
+ * - `<ref>:./<path>` makes `git show` resolve from the root instead of the top level.
9
+ * Output is NUL-separated (`-z`) so core.quotePath never C-quotes a non-ASCII name.
10
+ * Every listing is checked: a failed or truncated call is an error, never an empty diff.
11
+ */
12
+ import { spawnSync } from 'node:child_process';
13
+ import path from 'node:path';
14
+
15
+ /** Kill hung git instead of stalling CI. */
16
+ export const SPAWN_TIMEOUT_MS = 8000;
17
+ /** Same ceiling as design-delta: a large untracked tree must not overflow the default 1 MiB. */
18
+ export const GIT_MAX_BUFFER = 64 * 1024 * 1024;
19
+
20
+ /** Refs tried, in order, when no `--base` / `--against` is given. */
21
+ export const TEAM_BASE_CANDIDATES = Object.freeze([
22
+ 'origin/dev',
23
+ 'origin/main',
24
+ 'origin/master',
25
+ 'dev',
26
+ 'main',
27
+ 'master',
28
+ ]);
29
+
30
+ export function runGit(cwd, args) {
31
+ return spawnSync('git', ['-C', cwd, '-c', 'core.quotePath=false', ...args], {
32
+ encoding: 'utf8',
33
+ stdio: ['ignore', 'pipe', 'pipe'],
34
+ timeout: SPAWN_TIMEOUT_MS,
35
+ maxBuffer: GIT_MAX_BUFFER,
36
+ });
37
+ }
38
+
39
+ export function safeGitRef(value) {
40
+ return (
41
+ typeof value === 'string' &&
42
+ /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/.test(value) &&
43
+ !value.includes('..')
44
+ );
45
+ }
46
+
47
+ export function discoverTeamBaseRef(root, preferred) {
48
+ if (safeGitRef(preferred)) return preferred;
49
+ for (const candidate of TEAM_BASE_CANDIDATES) {
50
+ const exists = runGit(root, ['rev-parse', '--verify', `${candidate}^{commit}`]);
51
+ if (exists.status === 0) return candidate;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function gitFailure(label, result) {
57
+ if (result.error) return `git ${label} failed: ${result.error.code || result.error.message}`;
58
+ if (result.status !== 0) {
59
+ const detail = String(result.stderr || '').trim();
60
+ return `git ${label} failed${detail ? `: ${detail}` : ` (status ${result.status})`}`;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Root-relative paths changed vs `baseRef` (committed since the merge base, staged,
67
+ * unstaged, untracked). Fails closed: any git failure returns `ok: false`.
68
+ */
69
+ export function listChangedPaths(root, baseRef) {
70
+ if (!safeGitRef(baseRef)) {
71
+ return { ok: false, paths: [], error: 'Invalid or missing git base ref.' };
72
+ }
73
+ const verify = runGit(root, ['rev-parse', '--verify', `${baseRef}^{commit}`]);
74
+ if (verify.status !== 0) {
75
+ return { ok: false, paths: [], error: `Cannot resolve git ref ${baseRef}.` };
76
+ }
77
+ const calls = [
78
+ [`diff ${baseRef}...HEAD`, ['diff', '--relative', '--name-only', '-z', `${baseRef}...HEAD`]],
79
+ ['diff (unstaged)', ['diff', '--relative', '--name-only', '-z']],
80
+ ['diff --cached', ['diff', '--relative', '--name-only', '-z', '--cached']],
81
+ ['ls-files --others', ['ls-files', '-z', '--others', '--exclude-standard']],
82
+ ];
83
+ const paths = [];
84
+ for (const [label, gitArgs] of calls) {
85
+ const result = runGit(root, gitArgs);
86
+ const failure = gitFailure(label, result);
87
+ if (failure) return { ok: false, paths: [], error: failure };
88
+ paths.push(...String(result.stdout).split('\0'));
89
+ }
90
+ return { ok: true, paths: [...new Set(paths.filter(Boolean))].sort(), error: null };
91
+ }
92
+
93
+ /** Root-relative form of `relPath` for a `<ref>:./<path>` spec; null when outside the root. */
94
+ export function rootRelativeGitPath(root, relPath) {
95
+ if (typeof relPath !== 'string' || !relPath) return null;
96
+ const rel = path.isAbsolute(relPath) ? path.relative(path.resolve(root), relPath) : relPath;
97
+ const normalized = path.posix.normalize(rel.split(path.sep).join('/'));
98
+ if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) return null;
99
+ return normalized.replace(/^\.\//, '');
100
+ }
101
+
102
+ /** File text at `baseRef`, resolved relative to the Ark root (not the repo top level). */
103
+ export function gitShowText(root, baseRef, relPath) {
104
+ const rel = rootRelativeGitPath(root, relPath);
105
+ if (!safeGitRef(baseRef) || !rel) return null;
106
+ const shown = runGit(root, ['show', `${baseRef}:./${rel}`]);
107
+ if (shown.error || shown.status !== 0) return null;
108
+ return shown.stdout;
109
+ }