ark-runtime-kernel 1.0.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/LICENSE +21 -0
- package/README.md +328 -0
- package/bin/ark-check.mjs +773 -0
- package/bin/ark-mcp.mjs +407 -0
- package/bin/ark-shared.mjs +219 -0
- package/dist/eslint/index.cjs +149 -0
- package/dist/eslint/index.cjs.map +1 -0
- package/dist/eslint/index.d.cts +37 -0
- package/dist/eslint/index.d.ts +37 -0
- package/dist/eslint/index.js +141 -0
- package/dist/eslint/index.js.map +1 -0
- package/dist/index.cjs +2853 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1496 -0
- package/dist/index.d.ts +1496 -0
- package/dist/index.js +2801 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_INTENT_PREFIXES,
|
|
6
|
+
DEFAULT_LAYER_DIRECTORIES,
|
|
7
|
+
DEFAULT_RULES,
|
|
8
|
+
createElevenLayerConfig,
|
|
9
|
+
globToRegExp,
|
|
10
|
+
layerForFile,
|
|
11
|
+
looksLikeIntent,
|
|
12
|
+
resolveIntentLayer,
|
|
13
|
+
} from './ark-shared.mjs';
|
|
14
|
+
|
|
15
|
+
function parseArgs(argv) {
|
|
16
|
+
const args = {
|
|
17
|
+
root: process.cwd(),
|
|
18
|
+
config: 'ark.config.json',
|
|
19
|
+
manifest: undefined,
|
|
20
|
+
printConfig: undefined,
|
|
21
|
+
tsconfig: undefined,
|
|
22
|
+
json: false,
|
|
23
|
+
strictConfig: false,
|
|
24
|
+
init: false,
|
|
25
|
+
force: false,
|
|
26
|
+
};
|
|
27
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
28
|
+
const arg = argv[i];
|
|
29
|
+
if (arg === '--json') args.json = true;
|
|
30
|
+
else if (arg === '--strict-config') args.strictConfig = true;
|
|
31
|
+
else if (arg === '--init') args.init = true;
|
|
32
|
+
else if (arg === '--force') args.force = true;
|
|
33
|
+
else if (arg === '--root') args.root = path.resolve(argv[++i]);
|
|
34
|
+
else if (arg === '--config') args.config = argv[++i];
|
|
35
|
+
else if (arg === '--manifest') args.manifest = argv[++i];
|
|
36
|
+
else if (arg === '--print-config') args.printConfig = argv[++i];
|
|
37
|
+
else if (arg === '--tsconfig') args.tsconfig = argv[++i];
|
|
38
|
+
else if (arg === '--help' || arg === '-h') args.help = true;
|
|
39
|
+
}
|
|
40
|
+
return args;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function usage() {
|
|
44
|
+
return [
|
|
45
|
+
'Usage: ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict-config] [--json]',
|
|
46
|
+
' ark-check --init [--force]',
|
|
47
|
+
' ark-check --print-config eleven-layer',
|
|
48
|
+
'',
|
|
49
|
+
'--init scans the project for the built-in layer directory conventions (src/domain,',
|
|
50
|
+
'src/application, src/adapters/persistence, ...) and writes an ark.config.json covering',
|
|
51
|
+
'only the layers that actually exist, with the default rules filtered to those layers.',
|
|
52
|
+
'',
|
|
53
|
+
'Resolves relative, tsconfig path-alias, and package imports via the TypeScript',
|
|
54
|
+
'module resolver, then checks each resolved cross-layer import against the rules.',
|
|
55
|
+
'If no tsconfig is found, path aliases are unavailable but relative/package imports',
|
|
56
|
+
'still resolve.',
|
|
57
|
+
'',
|
|
58
|
+
'Config shape:',
|
|
59
|
+
'{',
|
|
60
|
+
' "include": ["src"],',
|
|
61
|
+
' "layers": [',
|
|
62
|
+
' { "name": "DomainModel", "patterns": ["src/domain/**"], "intentPrefixes": ["Domain."] }',
|
|
63
|
+
' ],',
|
|
64
|
+
' "rules": [{ "from": "DomainModel", "to": "PersistenceAdapters", "allowed": false }]',
|
|
65
|
+
'}',
|
|
66
|
+
'',
|
|
67
|
+
'Config warnings are advisory by default and are included in JSON output.',
|
|
68
|
+
'Use --strict-config to make config warnings fail the check.',
|
|
69
|
+
'',
|
|
70
|
+
'Generate a starter 11-layer config:',
|
|
71
|
+
' ark-check --print-config eleven-layer > ark.config.json',
|
|
72
|
+
].join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readJson(file) {
|
|
76
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readConfig(root, configPath) {
|
|
80
|
+
const fullPath = path.isAbsolute(configPath)
|
|
81
|
+
? configPath
|
|
82
|
+
: path.join(root, configPath);
|
|
83
|
+
if (!fs.existsSync(fullPath)) {
|
|
84
|
+
return {
|
|
85
|
+
include: ['src'],
|
|
86
|
+
layers: [],
|
|
87
|
+
rules: DEFAULT_RULES,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const raw = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
|
91
|
+
return {
|
|
92
|
+
include: raw.include ?? ['src'],
|
|
93
|
+
layers: raw.layers ?? [],
|
|
94
|
+
rules: raw.rules ?? DEFAULT_RULES,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Infer an ark.config.json from the directories that actually exist in the project,
|
|
100
|
+
* using the same layer→directory conventions as the eleven-layer template. A directory
|
|
101
|
+
* only counts when it contains at least one source file, so an empty scaffold dir can't
|
|
102
|
+
* produce a layer whose pattern matches nothing (which --strict-config would fail).
|
|
103
|
+
*/
|
|
104
|
+
function detectConfig(root) {
|
|
105
|
+
const srcDir = fs.existsSync(path.join(root, 'src')) ? 'src' : '.';
|
|
106
|
+
const layers = [];
|
|
107
|
+
|
|
108
|
+
for (const entry of DEFAULT_INTENT_PREFIXES) {
|
|
109
|
+
const directories = (DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? []).filter(
|
|
110
|
+
(directory) => walk(path.join(root, srcDir, directory)).length > 0
|
|
111
|
+
);
|
|
112
|
+
if (directories.length === 0) continue;
|
|
113
|
+
layers.push({
|
|
114
|
+
name: entry.layer,
|
|
115
|
+
patterns: directories.map((directory) => `${normalize(path.join(srcDir, directory))}/**`),
|
|
116
|
+
intentPrefixes: entry.prefixes,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const names = new Set(layers.map((layer) => layer.name));
|
|
121
|
+
const rules = DEFAULT_RULES.filter((rule) => names.has(rule.from) && names.has(rule.to));
|
|
122
|
+
|
|
123
|
+
return { srcDir, config: { include: [srcDir], layers, rules } };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Top-level directories under srcDir not covered by any detected layer pattern. */
|
|
127
|
+
function uncoveredDirectories(root, srcDir, layers) {
|
|
128
|
+
const base = path.join(root, srcDir);
|
|
129
|
+
if (!fs.existsSync(base)) return [];
|
|
130
|
+
return fs
|
|
131
|
+
.readdirSync(base, { withFileTypes: true })
|
|
132
|
+
.filter(
|
|
133
|
+
(entry) =>
|
|
134
|
+
entry.isDirectory() &&
|
|
135
|
+
entry.name !== 'node_modules' &&
|
|
136
|
+
entry.name !== 'dist' &&
|
|
137
|
+
!entry.name.startsWith('.')
|
|
138
|
+
)
|
|
139
|
+
.map((entry) => entry.name)
|
|
140
|
+
.filter((name) => {
|
|
141
|
+
const prefix = `${normalize(path.join(srcDir, name))}/`;
|
|
142
|
+
return !layers.some((layer) =>
|
|
143
|
+
layer.patterns.some((pattern) => pattern.startsWith(prefix))
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function runInit(args) {
|
|
149
|
+
const configPath = path.isAbsolute(args.config)
|
|
150
|
+
? args.config
|
|
151
|
+
: path.join(args.root, args.config);
|
|
152
|
+
|
|
153
|
+
if (fs.existsSync(configPath) && !args.force) {
|
|
154
|
+
console.error(`${configPath} already exists. Re-run with --force to overwrite it.`);
|
|
155
|
+
process.exitCode = 2;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { srcDir, config } = detectConfig(args.root);
|
|
160
|
+
if (config.layers.length === 0) {
|
|
161
|
+
console.error(
|
|
162
|
+
[
|
|
163
|
+
'No conventional layer directories found (looked for src/domain, src/application,',
|
|
164
|
+
'src/adapters/persistence, ...). Generate the full template instead and adapt the',
|
|
165
|
+
'patterns to your layout:',
|
|
166
|
+
' ark-check --print-config eleven-layer > ark.config.json',
|
|
167
|
+
].join('\n')
|
|
168
|
+
);
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
174
|
+
|
|
175
|
+
console.log(`Wrote ${configPath}`);
|
|
176
|
+
console.log('');
|
|
177
|
+
console.log('Detected layers:');
|
|
178
|
+
for (const layer of config.layers) {
|
|
179
|
+
console.log(` ${layer.name}: ${layer.patterns.join(', ')}`);
|
|
180
|
+
}
|
|
181
|
+
const uncovered = uncoveredDirectories(args.root, srcDir, config.layers);
|
|
182
|
+
if (uncovered.length > 0) {
|
|
183
|
+
console.log('');
|
|
184
|
+
console.log(
|
|
185
|
+
`Not covered by any layer (add patterns for these or they stay ungoverned): ${uncovered.join(', ')}`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
console.log('');
|
|
189
|
+
console.log('Next steps:');
|
|
190
|
+
console.log(' 1. CI gate: npx ark-check --root . --config ark.config.json --strict-config');
|
|
191
|
+
console.log(' 2. AI write gate: npx ark-mcp --root . --config ark.config.json');
|
|
192
|
+
console.log(' (bind its validate_code tool to your agent\'s pre-write hook — see README)');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function readManifest(root, manifestPath) {
|
|
196
|
+
if (!manifestPath) return undefined;
|
|
197
|
+
const fullPath = path.isAbsolute(manifestPath)
|
|
198
|
+
? manifestPath
|
|
199
|
+
: path.join(root, manifestPath);
|
|
200
|
+
if (!fs.existsSync(fullPath)) {
|
|
201
|
+
throw new Error(`Manifest not found: ${fullPath}`);
|
|
202
|
+
}
|
|
203
|
+
return readJson(fullPath);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function walk(dir, files = []) {
|
|
207
|
+
if (!fs.existsSync(dir)) return files;
|
|
208
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
209
|
+
const full = path.join(dir, entry.name);
|
|
210
|
+
if (entry.isDirectory()) {
|
|
211
|
+
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
|
|
212
|
+
walk(full, files);
|
|
213
|
+
} else if (/\.[cm]?[tj]sx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
|
|
214
|
+
files.push(full);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return files;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function normalize(value) {
|
|
221
|
+
return value.split(path.sep).join('/');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function intentLayersFromManifest(manifest) {
|
|
225
|
+
const layers = manifest?.architecture?.layers;
|
|
226
|
+
if (!Array.isArray(layers)) return undefined;
|
|
227
|
+
return layers
|
|
228
|
+
.filter((layer) => Array.isArray(layer.prefixes) && layer.prefixes.length > 0)
|
|
229
|
+
.map((layer) => ({ name: layer.name, prefixes: layer.prefixes }));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function layerForIntent(intent, layers, manifestIntentLayers) {
|
|
233
|
+
// Use only layers that declare intent prefixes; fall back to the built-in defaults when
|
|
234
|
+
// none do (mirrors the write-gate). resolveIntentLayer applies the library's exact
|
|
235
|
+
// longest-prefix + trailing-dot semantics so CI and the MCP gate classify identically.
|
|
236
|
+
const configured =
|
|
237
|
+
manifestIntentLayers ??
|
|
238
|
+
layers
|
|
239
|
+
.filter((layer) => (layer.intentPrefixes ?? []).length > 0)
|
|
240
|
+
.map((layer) => ({ name: layer.name, prefixes: layer.intentPrefixes }));
|
|
241
|
+
const source =
|
|
242
|
+
configured.length > 0
|
|
243
|
+
? configured
|
|
244
|
+
: DEFAULT_INTENT_PREFIXES.map((entry) => ({ name: entry.layer, prefixes: entry.prefixes }));
|
|
245
|
+
return resolveIntentLayer(intent, source);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function isBlocked(rules, from, to) {
|
|
249
|
+
return rules.find((rule) => !rule.allowed && rule.from === from && rule.to === to);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function configWarning(ruleId, message, extra = {}) {
|
|
253
|
+
return { ruleId, message, ...extra };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function collectConfigWarnings(root, config, files, rules, manifest) {
|
|
257
|
+
const warnings = [];
|
|
258
|
+
const layers = Array.isArray(config.layers) ? config.layers : [];
|
|
259
|
+
const manifestLayers = Array.isArray(manifest?.architecture?.layers)
|
|
260
|
+
? manifest.architecture.layers
|
|
261
|
+
: [];
|
|
262
|
+
const knownLayers = new Set([
|
|
263
|
+
...layers.map((layer) => layer.name).filter(Boolean),
|
|
264
|
+
...manifestLayers.map((layer) => layer.name).filter(Boolean),
|
|
265
|
+
]);
|
|
266
|
+
|
|
267
|
+
if (layers.length === 0) {
|
|
268
|
+
warnings.push(
|
|
269
|
+
configWarning(
|
|
270
|
+
'CONFIG_NO_LAYERS',
|
|
271
|
+
'No file layers are configured; ark-check cannot classify files for import-boundary enforcement.'
|
|
272
|
+
)
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const seenLayers = new Set();
|
|
277
|
+
const duplicateLayers = new Set();
|
|
278
|
+
for (const layer of layers) {
|
|
279
|
+
if (!layer.name) {
|
|
280
|
+
warnings.push(
|
|
281
|
+
configWarning('CONFIG_LAYER_WITHOUT_NAME', 'A configured layer is missing a name.')
|
|
282
|
+
);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (seenLayers.has(layer.name)) duplicateLayers.add(layer.name);
|
|
286
|
+
seenLayers.add(layer.name);
|
|
287
|
+
|
|
288
|
+
const patterns = Array.isArray(layer.patterns) ? layer.patterns : [];
|
|
289
|
+
if (patterns.length === 0) {
|
|
290
|
+
warnings.push(
|
|
291
|
+
configWarning(
|
|
292
|
+
'CONFIG_LAYER_WITHOUT_PATTERNS',
|
|
293
|
+
`Layer "${layer.name}" has no file patterns and will never classify files.`,
|
|
294
|
+
{ layer: layer.name }
|
|
295
|
+
)
|
|
296
|
+
);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (const pattern of patterns) {
|
|
301
|
+
let re;
|
|
302
|
+
try {
|
|
303
|
+
re = globToRegExp(pattern);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
warnings.push(
|
|
306
|
+
configWarning(
|
|
307
|
+
'CONFIG_INVALID_LAYER_PATTERN',
|
|
308
|
+
`Layer "${layer.name}" has an invalid pattern "${pattern}": ${
|
|
309
|
+
err instanceof Error ? err.message : String(err)
|
|
310
|
+
}`,
|
|
311
|
+
{ layer: layer.name, pattern }
|
|
312
|
+
)
|
|
313
|
+
);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const matched = files.some((file) => {
|
|
318
|
+
const rel = normalize(path.relative(root, file));
|
|
319
|
+
return re.test(rel);
|
|
320
|
+
});
|
|
321
|
+
if (!matched && !layer.optional) {
|
|
322
|
+
warnings.push(
|
|
323
|
+
configWarning(
|
|
324
|
+
'CONFIG_LAYER_PATTERN_NO_MATCHES',
|
|
325
|
+
`Layer "${layer.name}" pattern "${pattern}" matched no included files.`,
|
|
326
|
+
{ layer: layer.name, pattern }
|
|
327
|
+
)
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
for (const name of duplicateLayers) {
|
|
334
|
+
warnings.push(
|
|
335
|
+
configWarning(
|
|
336
|
+
'CONFIG_DUPLICATE_LAYER',
|
|
337
|
+
`Layer "${name}" is configured more than once.`,
|
|
338
|
+
{ layer: name }
|
|
339
|
+
)
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (knownLayers.size > 0) {
|
|
344
|
+
for (const rule of rules ?? []) {
|
|
345
|
+
if (rule.from && !knownLayers.has(rule.from)) {
|
|
346
|
+
warnings.push(
|
|
347
|
+
configWarning(
|
|
348
|
+
'CONFIG_RULE_UNKNOWN_FROM_LAYER',
|
|
349
|
+
`Rule references unknown source layer "${rule.from}".`,
|
|
350
|
+
{ fromLayer: rule.from, toLayer: rule.to }
|
|
351
|
+
)
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
if (rule.to && !knownLayers.has(rule.to)) {
|
|
355
|
+
warnings.push(
|
|
356
|
+
configWarning(
|
|
357
|
+
'CONFIG_RULE_UNKNOWN_TO_LAYER',
|
|
358
|
+
`Rule references unknown target layer "${rule.to}".`,
|
|
359
|
+
{ fromLayer: rule.from, toLayer: rule.to }
|
|
360
|
+
)
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const unclassified = files.filter((file) => !layerForFile(root, file, layers));
|
|
367
|
+
if (unclassified.length > 0) {
|
|
368
|
+
warnings.push(
|
|
369
|
+
configWarning(
|
|
370
|
+
'CONFIG_UNCLASSIFIED_FILES',
|
|
371
|
+
`${unclassified.length} included source file(s) are not matched by any configured layer; ark-check will not enforce import rules for those source files.`,
|
|
372
|
+
{
|
|
373
|
+
count: unclassified.length,
|
|
374
|
+
samples: unclassified.slice(0, 5).map((file) => normalize(path.relative(root, file))),
|
|
375
|
+
}
|
|
376
|
+
)
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return warnings;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function createModuleResolutionHost(ts) {
|
|
384
|
+
return {
|
|
385
|
+
fileExists: (f) => ts.sys.fileExists(f),
|
|
386
|
+
readFile: (f) => ts.sys.readFile(f),
|
|
387
|
+
directoryExists: ts.sys.directoryExists ? (d) => ts.sys.directoryExists(d) : undefined,
|
|
388
|
+
getCurrentDirectory: () => ts.sys.getCurrentDirectory(),
|
|
389
|
+
getDirectories: ts.sys.getDirectories ? (d) => ts.sys.getDirectories(d) : undefined,
|
|
390
|
+
realpath: ts.sys.realpath ? (p) => ts.sys.realpath(p) : undefined,
|
|
391
|
+
useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function loadCompilerOptions(ts, root, tsconfigArg) {
|
|
396
|
+
const configPath = tsconfigArg
|
|
397
|
+
? path.isAbsolute(tsconfigArg)
|
|
398
|
+
? tsconfigArg
|
|
399
|
+
: path.join(root, tsconfigArg)
|
|
400
|
+
: ts.findConfigFile(root, ts.sys.fileExists, 'tsconfig.json');
|
|
401
|
+
if (!configPath || !fs.existsSync(configPath)) return {};
|
|
402
|
+
const read = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
403
|
+
if (read.error) return {};
|
|
404
|
+
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath));
|
|
405
|
+
return parsed.options;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Fallback resolver for extensionless relative imports whose on-disk target uses an
|
|
410
|
+
* extension `ts.resolveModuleName` won't resolve without a matching tsconfig
|
|
411
|
+
* (notably `.mts`/`.cts`). Mirrors the classic candidate list.
|
|
412
|
+
*/
|
|
413
|
+
function isFile(candidate) {
|
|
414
|
+
try {
|
|
415
|
+
return fs.statSync(candidate).isFile();
|
|
416
|
+
} catch {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function resolveRelativeFallback(fromFile, specifier) {
|
|
422
|
+
const base = path.resolve(path.dirname(fromFile), specifier);
|
|
423
|
+
const candidates = [
|
|
424
|
+
base, // only used when the specifier already carries an extension (isFile filters dirs)
|
|
425
|
+
`${base}.ts`,
|
|
426
|
+
`${base}.tsx`,
|
|
427
|
+
`${base}.mts`,
|
|
428
|
+
`${base}.cts`,
|
|
429
|
+
`${base}.js`,
|
|
430
|
+
`${base}.jsx`,
|
|
431
|
+
`${base}.mjs`,
|
|
432
|
+
`${base}.cjs`,
|
|
433
|
+
path.join(base, 'index.ts'),
|
|
434
|
+
path.join(base, 'index.tsx'),
|
|
435
|
+
path.join(base, 'index.mts'),
|
|
436
|
+
path.join(base, 'index.cts'),
|
|
437
|
+
];
|
|
438
|
+
// isFile (not existsSync) so a directory named like the specifier never shadows the
|
|
439
|
+
// real module file — e.g. `./foo` must not resolve to a `foo/` directory before `foo.mts`.
|
|
440
|
+
return candidates.find(isFile);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Resolve any import specifier (relative, tsconfig path-alias, or package) to a source
|
|
445
|
+
* file using TypeScript's module resolver, returning the resolved file (or undefined for
|
|
446
|
+
* unresolved / declaration-only targets).
|
|
447
|
+
*
|
|
448
|
+
* ark-check governs one project rooted at --root. A resolved target is skipped when its
|
|
449
|
+
* path RELATIVE TO ROOT either escapes the root (leading `..`) or contains a `node_modules`
|
|
450
|
+
* segment. Using the root-relative path (not an absolute substring) means a project that
|
|
451
|
+
* itself lives under a node_modules segment is still governed, while a broad catch-all
|
|
452
|
+
* pattern (`**`) can't false-flag vendored deps or files outside the project. For monorepos,
|
|
453
|
+
* run ark-check per package rather than reaching across package roots.
|
|
454
|
+
*/
|
|
455
|
+
function resolveImport(ts, specifier, containingFile, options, host, root) {
|
|
456
|
+
const res = ts.resolveModuleName(specifier, containingFile, options, host);
|
|
457
|
+
let file = res.resolvedModule?.resolvedFileName;
|
|
458
|
+
if (!file && specifier.startsWith('.')) {
|
|
459
|
+
file = resolveRelativeFallback(containingFile, specifier);
|
|
460
|
+
}
|
|
461
|
+
if (!file) return undefined;
|
|
462
|
+
if (file.endsWith('.d.ts')) return undefined;
|
|
463
|
+
const abs = path.resolve(file);
|
|
464
|
+
const segments = path.relative(root, abs).split(path.sep);
|
|
465
|
+
if (segments[0] === '..' || segments.includes('node_modules')) return undefined;
|
|
466
|
+
return abs;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function lineOf(sourceFile, pos) {
|
|
470
|
+
return sourceFile.getLineAndCharacterOfPosition(pos).line + 1;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function textOfModuleSpecifier(node) {
|
|
474
|
+
return node.moduleSpecifier && typeof node.moduleSpecifier.text === 'string'
|
|
475
|
+
? node.moduleSpecifier.text
|
|
476
|
+
: undefined;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function propertyName(ts, node) {
|
|
480
|
+
if (!node) return undefined;
|
|
481
|
+
if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) return node.text;
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function objectProperty(ts, node, name) {
|
|
486
|
+
if (!node || !ts.isObjectLiteralExpression(node)) return undefined;
|
|
487
|
+
return node.properties.find((property) => {
|
|
488
|
+
if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
return propertyName(ts, property.name) === name;
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function objectHasProperty(ts, node, name) {
|
|
496
|
+
return objectProperty(ts, node, name) !== undefined;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function objectPropertyValue(ts, node, name) {
|
|
500
|
+
const property = objectProperty(ts, node, name);
|
|
501
|
+
return property && ts.isPropertyAssignment(property)
|
|
502
|
+
? property.initializer
|
|
503
|
+
: undefined;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function objectHasMetadataSource(ts, node) {
|
|
507
|
+
const metadata = objectPropertyValue(ts, node, 'metadata');
|
|
508
|
+
return objectHasProperty(ts, metadata, 'source');
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function stringLiteralText(ts, node) {
|
|
512
|
+
return node && ts.isStringLiteralLike(node) ? node.text : undefined;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function isPublishCall(ts, node) {
|
|
516
|
+
if (!ts.isCallExpression(node)) return false;
|
|
517
|
+
const expression = node.expression;
|
|
518
|
+
if (ts.isPropertyAccessExpression(expression)) {
|
|
519
|
+
return expression.name.text === 'publish';
|
|
520
|
+
}
|
|
521
|
+
return ts.isIdentifier(expression) && expression.text === 'publish';
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function looksLikeIntentCreatorExpression(ts, node) {
|
|
525
|
+
if (!node) return false;
|
|
526
|
+
if (ts.isIdentifier(node)) {
|
|
527
|
+
return /^[A-Z]/.test(node.text);
|
|
528
|
+
}
|
|
529
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
530
|
+
return looksLikeIntentCreatorExpression(ts, node.name);
|
|
531
|
+
}
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function isArkPublishCandidate(ts, node) {
|
|
536
|
+
if (!ts.isCallExpression(node)) return false;
|
|
537
|
+
const firstArg = node.arguments[0];
|
|
538
|
+
const rawIntent = stringLiteralText(ts, firstArg);
|
|
539
|
+
return (
|
|
540
|
+
(rawIntent !== undefined && looksLikeIntent(rawIntent)) ||
|
|
541
|
+
objectHasProperty(ts, firstArg, 'intent') ||
|
|
542
|
+
looksLikeIntentCreatorExpression(ts, firstArg)
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function publishSourceLiteral(ts, node) {
|
|
547
|
+
if (!ts.isCallExpression(node)) return undefined;
|
|
548
|
+
const [firstArg, secondArg, thirdArg] = node.arguments;
|
|
549
|
+
const rawMetadata = objectPropertyValue(ts, firstArg, 'metadata');
|
|
550
|
+
return (
|
|
551
|
+
stringLiteralText(ts, objectPropertyValue(ts, rawMetadata, 'source')) ??
|
|
552
|
+
stringLiteralText(ts, objectPropertyValue(ts, secondArg, 'source')) ??
|
|
553
|
+
stringLiteralText(ts, objectPropertyValue(ts, thirdArg, 'source'))
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function publishHasSource(ts, node) {
|
|
558
|
+
if (!ts.isCallExpression(node)) return false;
|
|
559
|
+
const [firstArg, secondArg, thirdArg] = node.arguments;
|
|
560
|
+
return (
|
|
561
|
+
objectHasMetadataSource(ts, firstArg) ||
|
|
562
|
+
objectHasProperty(ts, secondArg, 'source') ||
|
|
563
|
+
objectHasProperty(ts, thirdArg, 'source')
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function moduleSpecifierFromCall(ts, node) {
|
|
568
|
+
if (!ts.isCallExpression(node)) return undefined;
|
|
569
|
+
|
|
570
|
+
if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
571
|
+
const first = node.arguments[0];
|
|
572
|
+
const value = stringLiteralText(ts, first);
|
|
573
|
+
return value ? { value, kind: 'dynamic-import' } : undefined;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (ts.isIdentifier(node.expression) && node.expression.text === 'require') {
|
|
577
|
+
const first = node.arguments[0];
|
|
578
|
+
const value = stringLiteralText(ts, first);
|
|
579
|
+
return value ? { value, kind: 'require' } : undefined;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return undefined;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function main() {
|
|
586
|
+
const args = parseArgs(process.argv);
|
|
587
|
+
if (args.help) {
|
|
588
|
+
console.log(usage());
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (args.init) {
|
|
592
|
+
runInit(args);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (args.printConfig) {
|
|
596
|
+
if (args.printConfig !== 'eleven-layer') {
|
|
597
|
+
console.error(`Unknown config profile: ${args.printConfig}`);
|
|
598
|
+
process.exitCode = 2;
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
console.log(JSON.stringify(createElevenLayerConfig(), null, 2));
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
let ts;
|
|
606
|
+
try {
|
|
607
|
+
ts = await import('typescript');
|
|
608
|
+
} catch {
|
|
609
|
+
console.error('ark-check requires TypeScript. Install it with: npm install -D typescript');
|
|
610
|
+
process.exitCode = 2;
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const root = args.root;
|
|
615
|
+
const config = readConfig(root, args.config);
|
|
616
|
+
const manifest = readManifest(root, args.manifest);
|
|
617
|
+
const rules = manifest?.architecture?.rules ?? config.rules;
|
|
618
|
+
const manifestIntentLayers = intentLayersFromManifest(manifest);
|
|
619
|
+
const compilerOptions = loadCompilerOptions(ts, root, args.tsconfig);
|
|
620
|
+
const moduleHost = createModuleResolutionHost(ts);
|
|
621
|
+
const files = config.include.flatMap((entry) => walk(path.join(root, entry)));
|
|
622
|
+
const violations = [];
|
|
623
|
+
const warnings = collectConfigWarnings(root, config, files, rules, manifest);
|
|
624
|
+
|
|
625
|
+
for (const file of files) {
|
|
626
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
627
|
+
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
|
|
628
|
+
const sourceLayer = layerForFile(root, file, config.layers);
|
|
629
|
+
if (!sourceLayer) continue;
|
|
630
|
+
|
|
631
|
+
const checkModuleEdge = (specifier, node, kind) => {
|
|
632
|
+
const target = resolveImport(ts, specifier, file, compilerOptions, moduleHost, root);
|
|
633
|
+
const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
|
|
634
|
+
const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
|
|
635
|
+
if (rule) {
|
|
636
|
+
violations.push({
|
|
637
|
+
ruleId: 'LAYER_IMPORT_VIOLATION',
|
|
638
|
+
file: normalize(path.relative(root, file)),
|
|
639
|
+
line: lineOf(sourceFile, node.getStart(sourceFile)),
|
|
640
|
+
fromLayer: sourceLayer,
|
|
641
|
+
toLayer: targetLayer,
|
|
642
|
+
target: normalize(path.relative(root, target)),
|
|
643
|
+
message:
|
|
644
|
+
rule.message ??
|
|
645
|
+
`${sourceLayer} must not ${kind} ${targetLayer}.`,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const visit = (node) => {
|
|
651
|
+
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
|
652
|
+
const specifier = textOfModuleSpecifier(node);
|
|
653
|
+
if (specifier) {
|
|
654
|
+
checkModuleEdge(specifier, node, ts.isImportDeclaration(node) ? 'import' : 'export');
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (ts.isCallExpression(node)) {
|
|
659
|
+
const moduleCall = moduleSpecifierFromCall(ts, node);
|
|
660
|
+
if (moduleCall) {
|
|
661
|
+
checkModuleEdge(moduleCall.value, node, moduleCall.kind);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (isPublishCall(ts, node)) {
|
|
665
|
+
const firstArg = node.arguments[0];
|
|
666
|
+
const rawIntent = stringLiteralText(ts, firstArg);
|
|
667
|
+
if (
|
|
668
|
+
(rawIntent && looksLikeIntent(rawIntent)) ||
|
|
669
|
+
objectHasProperty(ts, firstArg, 'intent')
|
|
670
|
+
) {
|
|
671
|
+
violations.push({
|
|
672
|
+
ruleId: 'RAW_EVENT_PUBLISH',
|
|
673
|
+
file: normalize(path.relative(root, file)),
|
|
674
|
+
line: lineOf(sourceFile, node.getStart(sourceFile)),
|
|
675
|
+
message:
|
|
676
|
+
'Publish through a registered intent creator; raw event objects or intent strings bypass Ark contracts and tooling.',
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (isArkPublishCandidate(ts, node) && !publishHasSource(ts, node)) {
|
|
681
|
+
violations.push({
|
|
682
|
+
ruleId: 'PUBLISH_MISSING_SOURCE',
|
|
683
|
+
file: normalize(path.relative(root, file)),
|
|
684
|
+
line: lineOf(sourceFile, node.getStart(sourceFile)),
|
|
685
|
+
fromLayer: sourceLayer,
|
|
686
|
+
message: 'Strict Ark publish calls must include metadata.source.',
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const sourceIntent = publishSourceLiteral(ts, node);
|
|
691
|
+
if (sourceIntent && looksLikeIntent(sourceIntent)) {
|
|
692
|
+
const sourceIntentLayer = layerForIntent(
|
|
693
|
+
sourceIntent,
|
|
694
|
+
config.layers,
|
|
695
|
+
manifestIntentLayers
|
|
696
|
+
);
|
|
697
|
+
if (sourceIntentLayer && sourceIntentLayer !== sourceLayer) {
|
|
698
|
+
violations.push({
|
|
699
|
+
ruleId: 'PUBLISH_SOURCE_LAYER_MISMATCH',
|
|
700
|
+
file: normalize(path.relative(root, file)),
|
|
701
|
+
line: lineOf(sourceFile, node.getStart(sourceFile)),
|
|
702
|
+
fromLayer: sourceLayer,
|
|
703
|
+
toLayer: sourceIntentLayer,
|
|
704
|
+
target: sourceIntent,
|
|
705
|
+
message:
|
|
706
|
+
`Publish source "${sourceIntent}" resolves to ${sourceIntentLayer}, but the publishing file is classified as ${sourceLayer}.`,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (ts.isStringLiteralLike(node) && looksLikeIntent(node.text)) {
|
|
714
|
+
const targetLayer = layerForIntent(node.text, config.layers, manifestIntentLayers);
|
|
715
|
+
const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
|
|
716
|
+
if (rule) {
|
|
717
|
+
violations.push({
|
|
718
|
+
ruleId: 'LAYER_INTENT_REFERENCE_VIOLATION',
|
|
719
|
+
file: normalize(path.relative(root, file)),
|
|
720
|
+
line: lineOf(sourceFile, node.getStart(sourceFile)),
|
|
721
|
+
fromLayer: sourceLayer,
|
|
722
|
+
toLayer: targetLayer,
|
|
723
|
+
target: node.text,
|
|
724
|
+
message:
|
|
725
|
+
rule.message ??
|
|
726
|
+
`${sourceLayer} must not reference ${targetLayer} intent ${node.text}.`,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
ts.forEachChild(node, visit);
|
|
732
|
+
};
|
|
733
|
+
visit(sourceFile);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (args.json) {
|
|
737
|
+
console.log(JSON.stringify({
|
|
738
|
+
ok: violations.length === 0 && (!args.strictConfig || warnings.length === 0),
|
|
739
|
+
violations,
|
|
740
|
+
warnings,
|
|
741
|
+
}, null, 2));
|
|
742
|
+
} else if (violations.length === 0) {
|
|
743
|
+
for (const warning of warnings) {
|
|
744
|
+
console.error(`warning ${warning.ruleId} ${warning.message}`);
|
|
745
|
+
}
|
|
746
|
+
if (warnings.length === 0) {
|
|
747
|
+
console.log('Ark check passed.');
|
|
748
|
+
} else if (args.strictConfig) {
|
|
749
|
+
console.error(`Ark check failed with ${warnings.length} config warning(s).`);
|
|
750
|
+
} else {
|
|
751
|
+
console.log(`Ark check passed with ${warnings.length} config warning(s).`);
|
|
752
|
+
}
|
|
753
|
+
} else {
|
|
754
|
+
for (const warning of warnings) {
|
|
755
|
+
console.error(`warning ${warning.ruleId} ${warning.message}`);
|
|
756
|
+
}
|
|
757
|
+
for (const violation of violations) {
|
|
758
|
+
console.error(
|
|
759
|
+
`${violation.file}:${violation.line} ${violation.ruleId} ${violation.message}`
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
process.exitCode =
|
|
765
|
+
violations.length === 0 && (!args.strictConfig || warnings.length === 0)
|
|
766
|
+
? 0
|
|
767
|
+
: 1;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
main().catch((error) => {
|
|
771
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
772
|
+
process.exitCode = 2;
|
|
773
|
+
});
|