arkgate 2.6.1 → 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 (46) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +8 -3
  3. package/bin/ark-check.mjs +19 -993
  4. package/bin/ark-layer-match.mjs +148 -171
  5. package/bin/ark-shared.mjs +9 -159
  6. package/bin/lib/architecture-scan.mjs +279 -0
  7. package/bin/lib/ast-scan.mjs +199 -0
  8. package/bin/lib/baseline-key.mjs +23 -0
  9. package/bin/lib/config-warnings.mjs +228 -0
  10. package/bin/lib/graph-cycles.mjs +56 -0
  11. package/bin/lib/remediation.mjs +150 -0
  12. package/bin/lib/scan-files.mjs +69 -0
  13. package/bin/lib/ts-resolve.mjs +215 -0
  14. package/bin/lib/violations.mjs +3 -9
  15. package/dist/eslint/index.cjs +21 -3
  16. package/dist/eslint/index.cjs.map +1 -1
  17. package/dist/eslint/index.d.cts +5 -3
  18. package/dist/eslint/index.d.ts +5 -3
  19. package/dist/eslint/index.js +21 -3
  20. package/dist/eslint/index.js.map +1 -1
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +3 -3
  24. package/dist/index.d.ts +3 -3
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/nestjs/index.cjs +1 -1
  28. package/dist/nestjs/index.cjs.map +1 -1
  29. package/dist/nestjs/index.d.cts +1 -1
  30. package/dist/nestjs/index.d.ts +1 -1
  31. package/dist/nestjs/index.js +1 -1
  32. package/dist/nestjs/index.js.map +1 -1
  33. package/dist/runtime/index.cjs +3080 -0
  34. package/dist/runtime/index.cjs.map +1 -0
  35. package/dist/runtime/index.d.cts +2 -0
  36. package/dist/runtime/index.d.ts +2 -0
  37. package/dist/runtime/index.js +2998 -0
  38. package/dist/runtime/index.js.map +1 -0
  39. package/dist/{types-DpdVN7Lm.d.cts → types-CP3KkwZt.d.cts} +1 -1
  40. package/dist/{types-DpdVN7Lm.d.ts → types-CP3KkwZt.d.ts} +1 -1
  41. package/docs/agent-guide.md +4 -1
  42. package/docs/migrate-from-ark-runtime-kernel.md +4 -2
  43. package/docs/package-surface.md +72 -0
  44. package/docs/production-hardening.md +3 -0
  45. package/package.json +11 -1
  46. package/server.json +2 -2
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }