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.
- package/CHANGELOG.md +97 -0
- package/README.md +98 -70
- package/bin/ark-check.mjs +240 -1001
- package/bin/ark-layer-match.mjs +153 -147
- package/bin/ark-mcp.mjs +102 -5
- package/bin/ark-shared.mjs +304 -165
- package/bin/ark.mjs +44 -34
- package/bin/lib/agent-gates.mjs +448 -15
- package/bin/lib/architecture-scan.mjs +279 -0
- package/bin/lib/ast-scan.mjs +199 -0
- package/bin/lib/baseline-key.mjs +23 -0
- package/bin/lib/config-warnings.mjs +228 -0
- package/bin/lib/doctor-plan.mjs +11 -4
- package/bin/lib/graph-cycles.mjs +56 -0
- package/bin/lib/presets.mjs +75 -4
- package/bin/lib/remediation.mjs +150 -0
- package/bin/lib/scan-files.mjs +69 -0
- package/bin/lib/ts-resolve.mjs +215 -0
- package/bin/lib/violations.mjs +3 -9
- package/dist/eslint/index.cjs +21 -3
- package/dist/eslint/index.cjs.map +1 -1
- package/dist/eslint/index.d.cts +5 -3
- package/dist/eslint/index.d.ts +5 -3
- package/dist/eslint/index.js +21 -3
- package/dist/eslint/index.js.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +1 -1
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.d.cts +1 -1
- package/dist/nestjs/index.d.ts +1 -1
- package/dist/nestjs/index.js +1 -1
- package/dist/nestjs/index.js.map +1 -1
- package/dist/runtime/index.cjs +3080 -0
- package/dist/runtime/index.cjs.map +1 -0
- package/dist/runtime/index.d.cts +2 -0
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +2998 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/{types-DpdVN7Lm.d.cts → types-CP3KkwZt.d.cts} +1 -1
- package/dist/{types-DpdVN7Lm.d.ts → types-CP3KkwZt.d.ts} +1 -1
- package/docs/agent-guide.md +67 -1
- package/docs/migrate-from-ark-runtime-kernel.md +4 -2
- package/docs/package-surface.md +72 -0
- package/docs/production-hardening.md +3 -0
- package/package.json +11 -1
- package/server.json +2 -2
- package/templates/skills/ark-adopt.md +43 -87
- package/templates/skills/ark-autopilot.md +39 -77
- package/templates/skills/ark-contract.md +43 -84
- package/templates/skills/ark-coverage.md +62 -83
- package/templates/skills/ark-fix.md +45 -90
- 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
|
+
}
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -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:
|
|
357
|
-
|
|
358
|
-
|
|
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(
|
|
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
|
+
}
|
package/bin/lib/presets.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|