arkgate 2.6.0 → 2.7.0

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 (57) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +98 -70
  3. package/bin/ark-check.mjs +240 -1001
  4. package/bin/ark-layer-match.mjs +153 -147
  5. package/bin/ark-mcp.mjs +102 -5
  6. package/bin/ark-shared.mjs +304 -165
  7. package/bin/ark.mjs +44 -34
  8. package/bin/lib/agent-gates.mjs +448 -15
  9. package/bin/lib/architecture-scan.mjs +279 -0
  10. package/bin/lib/ast-scan.mjs +199 -0
  11. package/bin/lib/baseline-key.mjs +23 -0
  12. package/bin/lib/config-warnings.mjs +228 -0
  13. package/bin/lib/doctor-plan.mjs +11 -4
  14. package/bin/lib/graph-cycles.mjs +56 -0
  15. package/bin/lib/presets.mjs +75 -4
  16. package/bin/lib/remediation.mjs +150 -0
  17. package/bin/lib/scan-files.mjs +69 -0
  18. package/bin/lib/ts-resolve.mjs +215 -0
  19. package/bin/lib/violations.mjs +3 -9
  20. package/dist/eslint/index.cjs +21 -3
  21. package/dist/eslint/index.cjs.map +1 -1
  22. package/dist/eslint/index.d.cts +5 -3
  23. package/dist/eslint/index.d.ts +5 -3
  24. package/dist/eslint/index.js +21 -3
  25. package/dist/eslint/index.js.map +1 -1
  26. package/dist/index.cjs +1 -1
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +3 -3
  29. package/dist/index.d.ts +3 -3
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/nestjs/index.cjs +1 -1
  33. package/dist/nestjs/index.cjs.map +1 -1
  34. package/dist/nestjs/index.d.cts +1 -1
  35. package/dist/nestjs/index.d.ts +1 -1
  36. package/dist/nestjs/index.js +1 -1
  37. package/dist/nestjs/index.js.map +1 -1
  38. package/dist/runtime/index.cjs +3080 -0
  39. package/dist/runtime/index.cjs.map +1 -0
  40. package/dist/runtime/index.d.cts +2 -0
  41. package/dist/runtime/index.d.ts +2 -0
  42. package/dist/runtime/index.js +2998 -0
  43. package/dist/runtime/index.js.map +1 -0
  44. package/dist/{types-DpdVN7Lm.d.cts → types-CP3KkwZt.d.cts} +1 -1
  45. package/dist/{types-DpdVN7Lm.d.ts → types-CP3KkwZt.d.ts} +1 -1
  46. package/docs/agent-guide.md +67 -1
  47. package/docs/migrate-from-ark-runtime-kernel.md +4 -2
  48. package/docs/package-surface.md +72 -0
  49. package/docs/production-hardening.md +3 -0
  50. package/package.json +11 -1
  51. package/server.json +2 -2
  52. package/templates/skills/ark-adopt.md +43 -87
  53. package/templates/skills/ark-autopilot.md +39 -77
  54. package/templates/skills/ark-contract.md +43 -84
  55. package/templates/skills/ark-coverage.md +62 -83
  56. package/templates/skills/ark-fix.md +45 -90
  57. package/templates/skills/ark-loop.md +44 -66
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Config validation warnings + intent layer helpers for ark-check.
3
+ * Extracted from ark-check entry (R3).
4
+ */
5
+ import path from 'node:path';
6
+ import {
7
+ DEFAULT_INTENT_PREFIXES,
8
+ globToRegExp,
9
+ layerForFile,
10
+ patternSpecificity,
11
+ resolveIntentLayer,
12
+ } from '../ark-shared.mjs';
13
+ import { normalize } from './scan-files.mjs';
14
+
15
+ export function intentLayersFromManifest(manifest) {
16
+ const layers = manifest?.architecture?.layers;
17
+ if (!Array.isArray(layers)) return undefined;
18
+ return layers
19
+ .filter((layer) => Array.isArray(layer.prefixes) && layer.prefixes.length > 0)
20
+ .map((layer) => ({ name: layer.name, prefixes: layer.prefixes }));
21
+ }
22
+
23
+ export function layerForIntent(intent, layers, manifestIntentLayers) {
24
+ // Use only layers that declare intent prefixes; fall back to the built-in defaults when
25
+ // none do (mirrors the write-gate). resolveIntentLayer applies the library's exact
26
+ // longest-prefix + trailing-dot semantics so CI and the MCP gate classify identically.
27
+ const configured =
28
+ manifestIntentLayers ??
29
+ layers
30
+ .filter((layer) => (layer.intentPrefixes ?? []).length > 0)
31
+ .map((layer) => ({ name: layer.name, prefixes: layer.intentPrefixes }));
32
+ const source =
33
+ configured.length > 0
34
+ ? configured
35
+ : DEFAULT_INTENT_PREFIXES.map((entry) => ({ name: entry.layer, prefixes: entry.prefixes }));
36
+ return resolveIntentLayer(intent, source);
37
+ }
38
+
39
+ export function isBlocked(rules, from, to) {
40
+ return rules.find((rule) => !rule.allowed && rule.from === from && rule.to === to);
41
+ }
42
+
43
+ export function configWarning(ruleId, message, extra = {}) {
44
+ return { ruleId, message, ...extra };
45
+ }
46
+
47
+ export function collectConfigWarnings(root, config, files, rules, manifest) {
48
+ const warnings = [];
49
+ const layers = Array.isArray(config.layers) ? config.layers : [];
50
+ const manifestLayers = Array.isArray(manifest?.architecture?.layers)
51
+ ? manifest.architecture.layers
52
+ : [];
53
+ const knownLayers = new Set([
54
+ ...layers.map((layer) => layer.name).filter(Boolean),
55
+ ...manifestLayers.map((layer) => layer.name).filter(Boolean),
56
+ ]);
57
+
58
+ if (layers.length === 0) {
59
+ warnings.push(
60
+ configWarning(
61
+ 'CONFIG_NO_LAYERS',
62
+ 'No file layers are configured; ark-check cannot classify files for import-boundary enforcement.'
63
+ )
64
+ );
65
+ }
66
+
67
+ const seenLayers = new Set();
68
+ const duplicateLayers = new Set();
69
+ for (const layer of layers) {
70
+ if (!layer.name) {
71
+ warnings.push(
72
+ configWarning('CONFIG_LAYER_WITHOUT_NAME', 'A configured layer is missing a name.')
73
+ );
74
+ continue;
75
+ }
76
+ if (seenLayers.has(layer.name)) duplicateLayers.add(layer.name);
77
+ seenLayers.add(layer.name);
78
+
79
+ if (
80
+ layer.forbiddenGlobals !== undefined &&
81
+ (!Array.isArray(layer.forbiddenGlobals) ||
82
+ layer.forbiddenGlobals.some((entry) => typeof entry !== 'string'))
83
+ ) {
84
+ warnings.push(
85
+ configWarning(
86
+ 'CONFIG_INVALID_FORBIDDEN_GLOBALS',
87
+ `Layer "${layer.name}" has an invalid forbiddenGlobals value; expected an array of strings (e.g. ["fetch", "Date.now"]). The entry is ignored.`,
88
+ { layer: layer.name }
89
+ )
90
+ );
91
+ }
92
+
93
+ const patterns = Array.isArray(layer.patterns) ? layer.patterns : [];
94
+ if (patterns.length === 0) {
95
+ warnings.push(
96
+ configWarning(
97
+ 'CONFIG_LAYER_WITHOUT_PATTERNS',
98
+ `Layer "${layer.name}" has no file patterns and will never classify files.`,
99
+ { layer: layer.name }
100
+ )
101
+ );
102
+ continue;
103
+ }
104
+
105
+ for (const pattern of patterns) {
106
+ let re;
107
+ try {
108
+ re = globToRegExp(pattern);
109
+ } catch (err) {
110
+ warnings.push(
111
+ configWarning(
112
+ 'CONFIG_INVALID_LAYER_PATTERN',
113
+ `Layer "${layer.name}" has an invalid pattern "${pattern}": ${
114
+ err instanceof Error ? err.message : String(err)
115
+ }`,
116
+ { layer: layer.name, pattern }
117
+ )
118
+ );
119
+ continue;
120
+ }
121
+
122
+ const matched = files.some((file) => {
123
+ const rel = normalize(path.relative(root, file));
124
+ return re.test(rel);
125
+ });
126
+ if (!matched && !layer.optional) {
127
+ // Advisory only under --strict-config: monorepo/Next presets ship many optional-looking
128
+ // globs (e.g. src/layouts/**, app/**) that never match when include is ["frontend"].
129
+ // Failing the release gate on dead preset globs caused false CI red while architecture
130
+ // edges were clean (deer-flow host validation). Real safety is import violations +
131
+ // CONFIG_UNCLASSIFIED_FILES / invalid patterns.
132
+ warnings.push(
133
+ configWarning(
134
+ 'CONFIG_LAYER_PATTERN_NO_MATCHES',
135
+ `Layer "${layer.name}" pattern "${pattern}" matched no included files.`,
136
+ { layer: layer.name, pattern, failsStrict: false }
137
+ )
138
+ );
139
+ }
140
+ }
141
+ }
142
+
143
+ for (const name of duplicateLayers) {
144
+ warnings.push(
145
+ configWarning(
146
+ 'CONFIG_DUPLICATE_LAYER',
147
+ `Layer "${name}" is configured more than once.`,
148
+ { layer: name }
149
+ )
150
+ );
151
+ }
152
+
153
+ if (knownLayers.size > 0) {
154
+ for (const rule of rules ?? []) {
155
+ if (rule.from && !knownLayers.has(rule.from)) {
156
+ warnings.push(
157
+ configWarning(
158
+ 'CONFIG_RULE_UNKNOWN_FROM_LAYER',
159
+ `Rule references unknown source layer "${rule.from}".`,
160
+ { fromLayer: rule.from, toLayer: rule.to }
161
+ )
162
+ );
163
+ }
164
+ if (rule.to && !knownLayers.has(rule.to)) {
165
+ warnings.push(
166
+ configWarning(
167
+ 'CONFIG_RULE_UNKNOWN_TO_LAYER',
168
+ `Rule references unknown target layer "${rule.to}".`,
169
+ { fromLayer: rule.from, toLayer: rule.to }
170
+ )
171
+ );
172
+ }
173
+ }
174
+ }
175
+
176
+ // Ambiguous overlap: a file matched by two different layers at the SAME top specificity.
177
+ // layerForFile breaks the tie by declaration order, but the config is genuinely undecided
178
+ // (unlike a facade split, where the surface pattern is strictly more specific and wins
179
+ // cleanly). Surface the layer pairs so the author disambiguates instead of relying on order.
180
+ const ambiguousPairs = new Set();
181
+ if (layers.length > 1) {
182
+ for (const file of files) {
183
+ const rel = normalize(path.relative(root, file));
184
+ let topScore = -1;
185
+ let topLayers = [];
186
+ for (const layer of layers) {
187
+ for (const pattern of layer.patterns ?? []) {
188
+ if (!globToRegExp(pattern).test(rel)) continue;
189
+ const score = patternSpecificity(pattern);
190
+ if (score > topScore) {
191
+ topScore = score;
192
+ topLayers = [layer.name];
193
+ } else if (score === topScore && !topLayers.includes(layer.name)) {
194
+ topLayers.push(layer.name);
195
+ }
196
+ }
197
+ }
198
+ if (topLayers.length > 1) {
199
+ ambiguousPairs.add([...topLayers].sort().join(' + '));
200
+ }
201
+ }
202
+ }
203
+ if (ambiguousPairs.size > 0) {
204
+ warnings.push(
205
+ configWarning(
206
+ 'CONFIG_AMBIGUOUS_LAYERS',
207
+ `Some files match multiple layers at equal specificity; classification falls back to declaration order. Disambiguate the overlapping patterns: ${[...ambiguousPairs].join(', ')}.`,
208
+ { pairs: [...ambiguousPairs] }
209
+ )
210
+ );
211
+ }
212
+
213
+ const unclassified = files.filter((file) => !layerForFile(root, file, layers));
214
+ if (unclassified.length > 0) {
215
+ warnings.push(
216
+ configWarning(
217
+ 'CONFIG_UNCLASSIFIED_FILES',
218
+ `${unclassified.length} included source file(s) are not matched by any configured layer; ark-check will not enforce import rules for those source files.`,
219
+ {
220
+ count: unclassified.length,
221
+ samples: unclassified.slice(0, 5).map((file) => normalize(path.relative(root, file))),
222
+ }
223
+ )
224
+ );
225
+ }
226
+
227
+ return warnings;
228
+ }
@@ -351,11 +351,15 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
351
351
  });
352
352
  console.log('');
353
353
  console.log(color.bold('Operating mode'));
354
+ // Modes are detected states, not user-picked settings. Plain-language "what you do next".
354
355
  const modeMark = mode === 'enforce' ? ok : mode === 'adapt' ? warn : warn;
355
356
  const modeHelp = {
356
- suggest: 'starter shape / thin tree — expand layers as you grow',
357
- adapt: 'contract still needs to match real layout or raise coverage',
358
- enforce: 'contract governs enough code; gates can honestly hold the line',
357
+ suggest:
358
+ 'Setup — Ark proposes a starting architecture shape. You do not pick this mode; it means the tree is thin or new. Next: accept the shape (ark start / ark init) and add real layers as you grow.',
359
+ adapt:
360
+ 'Align — contract and folders still disagree, or coverage is weak / debt is open. You do not pick this mode. Next: classify ungoverned dirs (/ark-contract, /ark-adopt), run the plan (/ark-autopilot or /ark-loop). Gates do not fully protect you yet.',
361
+ enforce:
362
+ 'Guard — contract governs enough real code and edges are clean enough for gates to protect you. You do not pick this mode; you arrived here. Next: keep CI/write gates on; only NEW violations should fail.',
359
363
  };
360
364
  line(modeMark, `${mode.toUpperCase()} — ${modeHelp[mode]}`);
361
365
  if (emptyScope) {
@@ -466,7 +470,10 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
466
470
  console.log('');
467
471
  console.log(color.bold('Adoption (separate from fitness score)'));
468
472
  if (adoption.gaps.length === 0 && !adoption.layerBalance) {
469
- line(ok, 'Hosts, MCP argv, core optionality, origin report, and baseline policy look complete');
473
+ line(
474
+ ok,
475
+ 'Hosts, MCP argv, core optionality, origin report, baseline policy, and deploy-path lint/types look complete'
476
+ );
470
477
  } else {
471
478
  for (const gap of adoption.gaps) {
472
479
  const mark = gap.severity === 'warn' ? warn : gap.severity === 'info' ? warn : bad;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Import-graph cycle detection (Tarjan) for ark-check.
3
+ * Extracted from ark-check entry (R3).
4
+ */
5
+ export function detectCycles(graph) {
6
+ let index = 0;
7
+ const indices = new Map();
8
+ const low = new Map();
9
+ const onStack = new Set();
10
+ const stack = [];
11
+ const components = [];
12
+
13
+ // ponytail: recursive Tarjan; make it iterative only if a real repo blows the stack.
14
+ const strongconnect = (v) => {
15
+ indices.set(v, index);
16
+ low.set(v, index);
17
+ index += 1;
18
+ stack.push(v);
19
+ onStack.add(v);
20
+ for (const w of [...(graph.get(v) ?? [])].sort()) {
21
+ if (!graph.has(w)) continue;
22
+ if (!indices.has(w)) {
23
+ strongconnect(w);
24
+ low.set(v, Math.min(low.get(v), low.get(w)));
25
+ } else if (onStack.has(w)) {
26
+ low.set(v, Math.min(low.get(v), indices.get(w)));
27
+ }
28
+ }
29
+ if (low.get(v) === indices.get(v)) {
30
+ const comp = [];
31
+ let w;
32
+ do {
33
+ w = stack.pop();
34
+ onStack.delete(w);
35
+ comp.push(w);
36
+ } while (w !== v);
37
+ if (comp.length > 1) components.push(comp.sort());
38
+ }
39
+ };
40
+
41
+ for (const v of [...graph.keys()].sort()) {
42
+ if (!indices.has(v)) strongconnect(v);
43
+ }
44
+
45
+ return components
46
+ .sort((a, b) => a[0].localeCompare(b[0]))
47
+ .map((members) => ({
48
+ ruleId: 'CIRCULAR_DEPENDENCY',
49
+ file: members[0],
50
+ line: 1,
51
+ target: members.join(' → '),
52
+ message: `Circular dependency among ${members.length} files: ${members.join(' → ')} → ${members[0]}.`,
53
+ // Graph is value/runtime edges only (type-only imports omitted).
54
+ cycleKind: 'value',
55
+ }));
56
+ }
@@ -6,6 +6,7 @@ import {
6
6
  createElevenLayerConfig,
7
7
  DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
8
8
  DEFAULT_INTENT_PREFIXES,
9
+ resolveIncludeRoots,
9
10
  } from '../ark-shared.mjs';
10
11
 
11
12
  export function denyUpward(names) {
@@ -177,16 +178,25 @@ export const ARCHITECTURE_PRESETS = {
177
178
  // anywhere in the tree (`**/domain/**` hits packages/x/domain AND apps/y/src/domain),
178
179
  // so one profile governs every package. include defaults to the detected workspace
179
180
  // roots (falls back to packages+apps). Naming varies by repo — adjust and re-check.
180
- monorepo: (includeDirs, root) =>
181
- presetWithOverlays(
181
+ monorepo: (includeDirs, root) => {
182
+ let include =
183
+ includeDirs && includeDirs.length > 0 ? [...includeDirs] : [];
184
+ if (root) {
185
+ const resolved = resolveIncludeRoots(root);
186
+ if (resolved.length > 0) include = resolved;
187
+ }
188
+ if (include.length === 0) include = ['packages', 'apps'];
189
+ return presetWithOverlays(
182
190
  {
183
- include: includeDirs && includeDirs.length > 0 ? includeDirs : ['packages', 'apps'],
191
+ include,
184
192
  layers: [
185
193
  {
186
194
  name: 'DomainModel',
187
195
  description:
188
196
  'Pure business rules and entities, in any package. No I/O, no framework, no ambient globals.',
189
- patterns: ['**/domain/**', '**/entities/**'],
197
+ // Domain by intentional folders only — NOT bare **/types.ts (that mis-classifies
198
+ // application bags like frontend/src/core/**/types.ts as Domain and creates false edges).
199
+ patterns: ['**/domain/**', '**/entities/**', '**/cinematic/types.ts'],
190
200
  forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
191
201
  optional: true,
192
202
  },
@@ -206,6 +216,8 @@ export const ARCHITECTURE_PRESETS = {
206
216
  '**/controllers/**',
207
217
  '**/http/**',
208
218
  '**/routes/**',
219
+ '**/hooks/**',
220
+ '**/lib/**',
209
221
  ],
210
222
  optional: true,
211
223
  },
@@ -231,6 +243,65 @@ export const ARCHITECTURE_PRESETS = {
231
243
  ],
232
244
  },
233
245
  root
246
+ );
247
+ },
248
+
249
+ /**
250
+ * UI / Vite / Remotion-style surface: presentation-heavy trees with hooks, lib, routes,
251
+ * components. Use when the TS surface is mostly UI (no deep domain folders yet).
252
+ */
253
+ 'ui-surface': (_workspaces, root) =>
254
+ presetWithOverlays(
255
+ {
256
+ include: (() => {
257
+ if (!root) return ['src'];
258
+ try {
259
+ const roots = resolveIncludeRoots(root);
260
+ return roots.length > 0 ? roots : ['src'];
261
+ } catch {
262
+ return ['src'];
263
+ }
264
+ })(),
265
+ layers: [
266
+ {
267
+ name: 'DomainModel',
268
+ description: 'Shared types and pure view-models (optional on UI-first trees).',
269
+ // Avoid bare **/types.ts — see monorepo DomainModel note (false Domain on core/**/types.ts).
270
+ patterns: ['**/domain/**', '**/cinematic/types.ts'],
271
+ exclude: FRAMEWORK_INTERNAL_EXCLUDE,
272
+ forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
273
+ optional: true,
274
+ },
275
+ {
276
+ name: 'PresentationAdapters',
277
+ description: 'UI, routes, hooks, components, compositions.',
278
+ patterns: [
279
+ '**/src/**',
280
+ '**/components/**',
281
+ '**/hooks/**',
282
+ '**/lib/**',
283
+ '**/routes/**',
284
+ '**/app/**',
285
+ '**/pages/**',
286
+ ],
287
+ exclude: FRAMEWORK_INTERNAL_EXCLUDE,
288
+ optional: true,
289
+ },
290
+ {
291
+ name: 'PersistenceAdapters',
292
+ description: 'Client data access and external API adapters (when present).',
293
+ patterns: ['**/infrastructure/**', '**/adapters/**', '**/repositories/**'],
294
+ exclude: FRAMEWORK_INTERNAL_EXCLUDE,
295
+ optional: true,
296
+ },
297
+ ],
298
+ rules: [
299
+ { from: 'DomainModel', to: 'PresentationAdapters', allowed: false },
300
+ { from: 'DomainModel', to: 'PersistenceAdapters', allowed: false },
301
+ { from: 'PresentationAdapters', to: 'PersistenceAdapters', allowed: false },
302
+ ],
303
+ },
304
+ root
234
305
  ),
235
306
  };
236
307
 
@@ -0,0 +1,150 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/remediation.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/remediation.mjs). Zero Node I/O.
9
+ */
10
+
11
+ export const REMEDIATION_CLASSES = [
12
+ 'mechanical-safe',
13
+ 'judgment',
14
+ 'deferred',
15
+ ];
16
+ /**
17
+ * Co-pilot work classifier — the TRUST BOUNDARY for auto-apply.
18
+ * Biased toward 'judgment': false mechanical-safe is worse than an extra human approval.
19
+ */
20
+ export function classifyRemediation(violation) {
21
+ const ruleId = violation?.ruleId;
22
+ if (ruleId === 'LAYER_IMPORT_VIOLATION') {
23
+ if (violation?.typeOnly && violation?.sourcePureTypeModule) {
24
+ return {
25
+ class: 'mechanical-safe',
26
+ confidence: 0.88,
27
+ remediationKind: 'pure-type-file-relocate',
28
+ rationale: 'Whole source file is type-only surface (no runtime statements) with a type-only cross-layer edge: relocate the file to the owning layer (or extract the type there). Behavior-preserving; gate verifies.',
29
+ };
30
+ }
31
+ if (violation?.typeOnly) {
32
+ return {
33
+ class: 'mechanical-safe',
34
+ confidence: 0.9,
35
+ remediationKind: 'type-only-import-move',
36
+ rationale: 'Type-only import (erased at runtime): move the type to the layer that owns it and re-export for back-compat. Behavior-preserving, and the gate verifies it.',
37
+ };
38
+ }
39
+ if (violation?.targetTypeOnlyExports) {
40
+ const kind = violation.edgeKind;
41
+ if (kind === 'require' || kind === 'dynamic-import') {
42
+ return {
43
+ class: 'judgment',
44
+ confidence: 0.75,
45
+ rationale: 'Runtime module load (require/import()) of a type-only module still executes the target file — not auto-safe; rewrite to a static import type if appropriate.',
46
+ };
47
+ }
48
+ return {
49
+ class: 'mechanical-safe',
50
+ confidence: 0.85,
51
+ remediationKind: 'import-type-from-pure-type-module',
52
+ rationale: 'Static import targets a pure type-only module: convert to `import type` (erased at runtime) and place the type in a shared/owning layer. No runtime coupling; gate verifies.',
53
+ };
54
+ }
55
+ return {
56
+ class: 'judgment',
57
+ confidence: 0.7,
58
+ rationale: 'Value import — real runtime coupling. Relocating it (e.g. a route reaching the DB → a repository) is a refactor whose organization is a human choice.',
59
+ };
60
+ }
61
+ if (ruleId === 'FORBIDDEN_GLOBAL') {
62
+ return {
63
+ class: 'judgment',
64
+ confidence: 0.8,
65
+ rationale: 'Ambient global in a pure layer: inject the capability through a port (Clock, Config, Http). Introducing the port is a design decision.',
66
+ };
67
+ }
68
+ if (ruleId === 'CIRCULAR_DEPENDENCY') {
69
+ return {
70
+ class: 'judgment',
71
+ confidence: 0.7,
72
+ rationale: 'Dependency cycle: breaking it means deciding which side owns the shared abstraction.',
73
+ };
74
+ }
75
+ if (typeof ruleId === 'string' && ruleId.length > 0) {
76
+ return {
77
+ class: 'judgment',
78
+ confidence: 0.6,
79
+ rationale: 'Needs a human decision on how to satisfy the contract without weakening the gate.',
80
+ };
81
+ }
82
+ return {
83
+ class: 'deferred',
84
+ confidence: 0.3,
85
+ rationale: 'Unrecognized violation shape — a human should look before anything is changed.',
86
+ };
87
+ }
88
+ /**
89
+ * Deterministic fix-class labels for JSON output (English, shared with skills/reports).
90
+ */
91
+ export function enrichViolationWithFixClass(violation) {
92
+ const enriched = { ...violation };
93
+ switch (violation.ruleId) {
94
+ case 'LAYER_IMPORT_VIOLATION':
95
+ if (violation.typeOnly || violation.targetTypeOnlyExports) {
96
+ enriched.fixClass = 'file-move';
97
+ enriched.effort = 'small';
98
+ enriched.enthusiastHint = violation.targetTypeOnlyExports
99
+ ? 'The imported module only exports types — use `import type` and place the type in a layer both sides may share.'
100
+ : 'This is a type-only import — move the type to a layer both sides may share, or relocate the file to match its role.';
101
+ }
102
+ else {
103
+ enriched.fixClass = 'port-inversion';
104
+ enriched.effort = 'medium';
105
+ enriched.enthusiastHint = `${violation.fromLayer ?? 'This layer'} must not import ${violation.toLayer ?? 'that layer'} directly. Define an interface (port) where you need the capability and inject the implementation from the outer layer.`;
106
+ }
107
+ break;
108
+ case 'FORBIDDEN_GLOBAL':
109
+ enriched.fixClass = 'inject-port';
110
+ enriched.effort = 'small';
111
+ enriched.enthusiastHint = `Do not call "${violation.target ?? 'that global'}" here. Pass the capability in through a small interface (for example a Clock, HttpPort, or Config provider).`;
112
+ break;
113
+ case 'RAW_EVENT_PUBLISH':
114
+ enriched.fixClass = 'registered-intent';
115
+ enriched.effort = 'small';
116
+ enriched.enthusiastHint =
117
+ 'Register the event intent first, then publish through the creator returned by the registry — not a raw string or object.';
118
+ break;
119
+ case 'PUBLISH_MISSING_SOURCE':
120
+ enriched.fixClass = 'add-source-metadata';
121
+ enriched.effort = 'small';
122
+ enriched.enthusiastHint =
123
+ 'Add metadata.source to the publish call so Ark knows which layer is publishing the event.';
124
+ break;
125
+ case 'PUBLISH_SOURCE_LAYER_MISMATCH':
126
+ enriched.fixClass = 'fix-source-layer';
127
+ enriched.effort = 'small';
128
+ enriched.enthusiastHint =
129
+ 'Use a source intent that belongs to the same layer as this file, or move the publish call to the layer that owns the source.';
130
+ break;
131
+ case 'LAYER_INTENT_REFERENCE_VIOLATION':
132
+ enriched.fixClass = 'intent-relocation';
133
+ enriched.effort = 'small';
134
+ enriched.enthusiastHint =
135
+ 'Reference that intent from a layer allowed to know about it — usually an adapter or application layer, not the domain core.';
136
+ break;
137
+ case 'CIRCULAR_DEPENDENCY':
138
+ enriched.fixClass = 'break-cycle';
139
+ enriched.effort = 'medium';
140
+ enriched.enthusiastHint =
141
+ 'Two modules import each other in a loop. Extract shared code, invert one dependency behind a port, or merge them if they are really one unit.';
142
+ break;
143
+ default:
144
+ enriched.fixClass = 'review-contract';
145
+ enriched.effort = 'small';
146
+ enriched.enthusiastHint =
147
+ 'Read the violation message and the layer rules in ark.config.json, then adjust imports or move code to the correct layer.';
148
+ }
149
+ return enriched;
150
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Governed source file walk / collection for ark-check.
3
+ * Extracted from ark-check entry (R3).
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { isScanExcludedRelative } from '../ark-shared.mjs';
8
+
9
+ export const SOURCE_FILE_NAME = /\.[cm]?[tj]sx?$/;
10
+
11
+ /** Unit/e2e test files are not architecture surface — agents and Nest put them next
12
+ * to production code (*.spec.ts). Counting them as ungoverned forces false
13
+ * CONFIG_UNCLASSIFIED_FILES under --strict-config on every starter. */
14
+ export const TEST_FILE_NAME =
15
+ /\.(spec|test)\.(tsx?|jsx?|mts|cts)$/i;
16
+
17
+ export function isGovernableSourceFile(name) {
18
+ return SOURCE_FILE_NAME.test(name) && !name.endsWith('.d.ts') && !TEST_FILE_NAME.test(name);
19
+ }
20
+
21
+ export function isSkippedSourceDir(name) {
22
+ return (
23
+ name === 'node_modules' ||
24
+ name === 'dist' ||
25
+ name === 'coverage' ||
26
+ name === '__tests__' ||
27
+ name === '__mocks__' ||
28
+ name === 'e2e' ||
29
+ // Top-level style Nest/Jest folders (not "testing" helpers inside src)
30
+ name === 'test' ||
31
+ name === 'tests'
32
+ );
33
+ }
34
+
35
+ export function walk(dir, files = []) {
36
+ const stat = fs.statSync(dir, { throwIfNoEntry: false });
37
+ if (!stat) return files;
38
+ // An `include` entry may be a single file (e.g. a root-level "middleware.ts"),
39
+ // not just a directory — govern it directly instead of trying to scandir it
40
+ // (which threw ENOTDIR). The extension filter still applies.
41
+ if (stat.isFile()) {
42
+ if (isGovernableSourceFile(path.basename(dir))) files.push(dir);
43
+ return files;
44
+ }
45
+ if (!stat.isDirectory()) return files;
46
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
47
+ const full = path.join(dir, entry.name);
48
+ if (entry.isDirectory()) {
49
+ if (isSkippedSourceDir(entry.name)) continue;
50
+ walk(full, files);
51
+ } else if (isGovernableSourceFile(entry.name)) {
52
+ files.push(full);
53
+ }
54
+ }
55
+ return files;
56
+ }
57
+
58
+ /** Walk include roots then drop codegen / config.exclude (universal scan filter). */
59
+ export function collectGovernedFiles(root, config) {
60
+ const raw = (config.include ?? []).flatMap((entry) => walk(path.join(root, entry)));
61
+ return raw.filter((abs) => {
62
+ const rel = normalize(path.relative(root, abs));
63
+ return !isScanExcludedRelative(rel, config);
64
+ });
65
+ }
66
+
67
+ export function normalize(value) {
68
+ return value.split(path.sep).join('/');
69
+ }