ark-runtime-kernel 1.4.0 → 1.5.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/README.md +10 -0
- package/bin/ark-check.mjs +141 -33
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- 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.js +1 -1
- package/dist/nestjs/index.js.map +1 -1
- package/docs/agent-guide.md +9 -4
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -205,6 +205,16 @@ npx ark-check --baseline # ratchet mode
|
|
|
205
205
|
- Missing / mismatched publish `source` metadata
|
|
206
206
|
- Forbidden ambient globals per layer (`fetch`, `Date.now`, `Math.random`, ...) — see below
|
|
207
207
|
|
|
208
|
+
**Fast on repeat runs, monorepo-ready:**
|
|
209
|
+
|
|
210
|
+
- Per-file scan cache in `node_modules/.cache/ark-check.json` (keyed by mtime+size and
|
|
211
|
+
the config/manifest contents). Unchanged files skip the TypeScript parse; import edges
|
|
212
|
+
are always re-resolved against the live filesystem, so the cache can never hide a new
|
|
213
|
+
violation. Disable with `--no-cache`.
|
|
214
|
+
- Path aliases resolve against the **nearest** `tsconfig.json` above each source file
|
|
215
|
+
(like `tsc`), so a monorepo with per-package alias maps runs under a single `--root`.
|
|
216
|
+
Pass `--tsconfig <path>` to force one config for every file.
|
|
217
|
+
|
|
208
218
|
Violations come with the layer edge, the resolved target, and a fix hint:
|
|
209
219
|
|
|
210
220
|
```
|
package/bin/ark-check.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import crypto from 'node:crypto';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import {
|
|
@@ -30,6 +31,7 @@ function parseArgs(argv) {
|
|
|
30
31
|
force: false,
|
|
31
32
|
baseline: undefined,
|
|
32
33
|
updateBaseline: false,
|
|
34
|
+
noCache: false,
|
|
33
35
|
};
|
|
34
36
|
for (let i = 2; i < argv.length; i += 1) {
|
|
35
37
|
const arg = argv[i];
|
|
@@ -53,6 +55,7 @@ function parseArgs(argv) {
|
|
|
53
55
|
}
|
|
54
56
|
}
|
|
55
57
|
else if (arg === '--force') args.force = true;
|
|
58
|
+
else if (arg === '--no-cache') args.noCache = true;
|
|
56
59
|
else if (arg === '--baseline' || arg === '--update-baseline') {
|
|
57
60
|
if (arg === '--update-baseline') args.updateBaseline = true;
|
|
58
61
|
// optional path value: consume the next arg only when it isn't another flag
|
|
@@ -71,7 +74,7 @@ function parseArgs(argv) {
|
|
|
71
74
|
|
|
72
75
|
function usage() {
|
|
73
76
|
return [
|
|
74
|
-
'Usage: ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict-config] [--require-gates] [--json] [--baseline [file]]',
|
|
77
|
+
'Usage: ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict-config] [--require-gates] [--json] [--baseline [file]] [--no-cache]',
|
|
75
78
|
' ark-check --init [--force]',
|
|
76
79
|
' ark-check --install-agent-gates [--tools claude,cursor,codex] [--force]',
|
|
77
80
|
' ark-check --update-baseline [file] freeze current violations (default .ark-baseline.json)',
|
|
@@ -91,8 +94,14 @@ function usage() {
|
|
|
91
94
|
'',
|
|
92
95
|
'Resolves relative, tsconfig path-alias, and package imports via the TypeScript',
|
|
93
96
|
'module resolver, then checks each resolved cross-layer import against the rules.',
|
|
94
|
-
'
|
|
95
|
-
'
|
|
97
|
+
'Path aliases resolve against the NEAREST tsconfig.json above each source file, so',
|
|
98
|
+
'monorepo packages with per-package configs work under a single --root. Pass',
|
|
99
|
+
'--tsconfig to force one config for every file. If no tsconfig is found, path',
|
|
100
|
+
'aliases are unavailable but relative/package imports still resolve.',
|
|
101
|
+
'',
|
|
102
|
+
'Parsed files are cached in node_modules/.cache/ark-check.json (keyed by mtime+size',
|
|
103
|
+
'and the config/manifest contents); import edges are always re-resolved against the',
|
|
104
|
+
'live filesystem, so the cache can never hide a new violation. --no-cache disables it.',
|
|
96
105
|
'',
|
|
97
106
|
'Config shape:',
|
|
98
107
|
'{',
|
|
@@ -836,19 +845,91 @@ function createModuleResolutionHost(ts) {
|
|
|
836
845
|
};
|
|
837
846
|
}
|
|
838
847
|
|
|
839
|
-
function
|
|
840
|
-
const configPath = tsconfigArg
|
|
841
|
-
? path.isAbsolute(tsconfigArg)
|
|
842
|
-
? tsconfigArg
|
|
843
|
-
: path.join(root, tsconfigArg)
|
|
844
|
-
: ts.findConfigFile(root, ts.sys.fileExists, 'tsconfig.json');
|
|
845
|
-
if (!configPath || !fs.existsSync(configPath)) return {};
|
|
848
|
+
function parseTsconfig(ts, configPath) {
|
|
846
849
|
const read = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
847
850
|
if (read.error) return {};
|
|
848
851
|
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath));
|
|
849
852
|
return parsed.options;
|
|
850
853
|
}
|
|
851
854
|
|
|
855
|
+
/**
|
|
856
|
+
* Compiler options for a given source file. With --tsconfig every file uses that one
|
|
857
|
+
* config; otherwise each file uses the NEAREST tsconfig.json above it (like tsc does),
|
|
858
|
+
* so monorepo packages with per-package path aliases resolve correctly under one --root.
|
|
859
|
+
*/
|
|
860
|
+
function createCompilerOptionsLookup(ts, root, tsconfigArg) {
|
|
861
|
+
if (tsconfigArg) {
|
|
862
|
+
const configPath = path.isAbsolute(tsconfigArg) ? tsconfigArg : path.join(root, tsconfigArg);
|
|
863
|
+
const options = fs.existsSync(configPath) ? parseTsconfig(ts, configPath) : {};
|
|
864
|
+
return () => options;
|
|
865
|
+
}
|
|
866
|
+
const byDir = new Map();
|
|
867
|
+
const byConfig = new Map();
|
|
868
|
+
return (file) => {
|
|
869
|
+
const dir = path.dirname(file);
|
|
870
|
+
if (byDir.has(dir)) return byDir.get(dir);
|
|
871
|
+
const configPath = ts.findConfigFile(dir, ts.sys.fileExists, 'tsconfig.json');
|
|
872
|
+
let options = {};
|
|
873
|
+
if (configPath) {
|
|
874
|
+
if (!byConfig.has(configPath)) byConfig.set(configPath, parseTsconfig(ts, configPath));
|
|
875
|
+
options = byConfig.get(configPath);
|
|
876
|
+
}
|
|
877
|
+
byDir.set(dir, options);
|
|
878
|
+
return options;
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Per-file scan cache. A cache entry stores the parsed file's content-derived results:
|
|
884
|
+
* content violations (forbidden globals, publish checks, intent references) and the list
|
|
885
|
+
* of module-edge specifiers. Edges are NEVER cached as violations — they are re-resolved
|
|
886
|
+
* against the live filesystem every run, because resolution depends on files and tsconfigs
|
|
887
|
+
* outside the cached file. The whole cache is keyed by the config+manifest contents, so
|
|
888
|
+
* any rule change invalidates everything.
|
|
889
|
+
*/
|
|
890
|
+
function scanCachePath(root) {
|
|
891
|
+
return path.join(root, 'node_modules', '.cache', 'ark-check.json');
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function scanCacheKey(root, args) {
|
|
895
|
+
const read = (p) => {
|
|
896
|
+
try {
|
|
897
|
+
return fs.readFileSync(p, 'utf8');
|
|
898
|
+
} catch {
|
|
899
|
+
return '';
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
const configPath = path.isAbsolute(args.config) ? args.config : path.join(root, args.config);
|
|
903
|
+
const manifestPath = args.manifest
|
|
904
|
+
? path.isAbsolute(args.manifest)
|
|
905
|
+
? args.manifest
|
|
906
|
+
: path.join(root, args.manifest)
|
|
907
|
+
: undefined;
|
|
908
|
+
return crypto
|
|
909
|
+
.createHash('sha1')
|
|
910
|
+
.update(`ark-check-cache-v1\0${read(configPath)}\0${manifestPath ? read(manifestPath) : ''}`)
|
|
911
|
+
.digest('hex');
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function loadScanCache(root, key) {
|
|
915
|
+
try {
|
|
916
|
+
const data = JSON.parse(fs.readFileSync(scanCachePath(root), 'utf8'));
|
|
917
|
+
return data.key === key && data.files && typeof data.files === 'object' ? data.files : undefined;
|
|
918
|
+
} catch {
|
|
919
|
+
return undefined;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function saveScanCache(root, key, files) {
|
|
924
|
+
try {
|
|
925
|
+
const target = scanCachePath(root);
|
|
926
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
927
|
+
fs.writeFileSync(target, JSON.stringify({ key, files }));
|
|
928
|
+
} catch {
|
|
929
|
+
// cache is best-effort: read-only filesystems just re-parse every run
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
852
933
|
/**
|
|
853
934
|
* Fallback resolver for extensionless relative imports whose on-disk target uses an
|
|
854
935
|
* extension `ts.resolveModuleName` won't resolve without a matching tsconfig
|
|
@@ -893,8 +974,9 @@ function resolveRelativeFallback(fromFile, specifier) {
|
|
|
893
974
|
* path RELATIVE TO ROOT either escapes the root (leading `..`) or contains a `node_modules`
|
|
894
975
|
* segment. Using the root-relative path (not an absolute substring) means a project that
|
|
895
976
|
* itself lives under a node_modules segment is still governed, while a broad catch-all
|
|
896
|
-
* pattern (`**`) can't false-flag vendored deps or files outside the project.
|
|
897
|
-
* run
|
|
977
|
+
* pattern (`**`) can't false-flag vendored deps or files outside the project. Monorepos can
|
|
978
|
+
* run under a single --root (per-package tsconfigs are honored via the nearest-tsconfig
|
|
979
|
+
* lookup); edges that resolve outside the root are still skipped.
|
|
898
980
|
*/
|
|
899
981
|
function resolveImport(ts, specifier, containingFile, options, host, root) {
|
|
900
982
|
const res = ts.resolveModuleName(specifier, containingFile, options, host);
|
|
@@ -1160,17 +1242,23 @@ async function main() {
|
|
|
1160
1242
|
const manifest = readManifest(root, args.manifest);
|
|
1161
1243
|
const rules = manifest?.architecture?.rules ?? config.rules;
|
|
1162
1244
|
const manifestIntentLayers = intentLayersFromManifest(manifest);
|
|
1163
|
-
const
|
|
1245
|
+
const compilerOptionsFor = createCompilerOptionsLookup(ts, root, args.tsconfig);
|
|
1164
1246
|
const moduleHost = createModuleResolutionHost(ts);
|
|
1165
1247
|
const files = config.include.flatMap((entry) => walk(path.join(root, entry)));
|
|
1166
1248
|
const violations = [];
|
|
1167
1249
|
const warnings = collectConfigWarnings(root, config, files, rules, manifest);
|
|
1168
|
-
|
|
1169
|
-
|
|
1250
|
+
const cacheKey = args.noCache ? undefined : scanCacheKey(root, args);
|
|
1251
|
+
const cachedFiles = cacheKey ? loadScanCache(root, cacheKey) : undefined;
|
|
1252
|
+
const nextCacheFiles = {};
|
|
1253
|
+
|
|
1254
|
+
// Parses one file and returns its cacheable scan result: violations derived purely from
|
|
1255
|
+
// the file's content (+config/manifest, hashed into the cache key) and the module-edge
|
|
1256
|
+
// specifiers found, which the driver loop below resolves fresh on every run.
|
|
1257
|
+
function scanSourceFile(file, sourceLayer) {
|
|
1170
1258
|
const source = fs.readFileSync(file, 'utf8');
|
|
1171
1259
|
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
|
|
1172
|
-
const
|
|
1173
|
-
|
|
1260
|
+
const violations = [];
|
|
1261
|
+
const edges = [];
|
|
1174
1262
|
|
|
1175
1263
|
const layerConfig = config.layers.find((layer) => layer.name === sourceLayer);
|
|
1176
1264
|
const forbiddenGlobals = Array.isArray(layerConfig?.forbiddenGlobals)
|
|
@@ -1188,22 +1276,7 @@ async function main() {
|
|
|
1188
1276
|
}
|
|
1189
1277
|
|
|
1190
1278
|
const checkModuleEdge = (specifier, node, kind) => {
|
|
1191
|
-
|
|
1192
|
-
const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
|
|
1193
|
-
const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
|
|
1194
|
-
if (rule) {
|
|
1195
|
-
violations.push({
|
|
1196
|
-
ruleId: 'LAYER_IMPORT_VIOLATION',
|
|
1197
|
-
file: normalize(path.relative(root, file)),
|
|
1198
|
-
line: lineOf(sourceFile, node.getStart(sourceFile)),
|
|
1199
|
-
fromLayer: sourceLayer,
|
|
1200
|
-
toLayer: targetLayer,
|
|
1201
|
-
target: normalize(path.relative(root, target)),
|
|
1202
|
-
message:
|
|
1203
|
-
rule.message ??
|
|
1204
|
-
`${sourceLayer} must not ${kind} ${targetLayer}.`,
|
|
1205
|
-
});
|
|
1206
|
-
}
|
|
1279
|
+
edges.push({ specifier, line: lineOf(sourceFile, node.getStart(sourceFile)), kind });
|
|
1207
1280
|
};
|
|
1208
1281
|
|
|
1209
1282
|
const visit = (node) => {
|
|
@@ -1290,8 +1363,43 @@ async function main() {
|
|
|
1290
1363
|
ts.forEachChild(node, visit);
|
|
1291
1364
|
};
|
|
1292
1365
|
visit(sourceFile);
|
|
1366
|
+
return { contentViolations: violations, edges };
|
|
1293
1367
|
}
|
|
1294
1368
|
|
|
1369
|
+
for (const file of files) {
|
|
1370
|
+
const sourceLayer = layerForFile(root, file, config.layers);
|
|
1371
|
+
if (!sourceLayer) continue;
|
|
1372
|
+
const relFile = normalize(path.relative(root, file));
|
|
1373
|
+
const stat = fs.statSync(file);
|
|
1374
|
+
const fileKey = `${stat.mtimeMs}:${stat.size}`;
|
|
1375
|
+
const cached = cachedFiles?.[relFile];
|
|
1376
|
+
const entry =
|
|
1377
|
+
cached && cached.fileKey === fileKey
|
|
1378
|
+
? cached
|
|
1379
|
+
: { fileKey, ...scanSourceFile(file, sourceLayer) };
|
|
1380
|
+
nextCacheFiles[relFile] = entry;
|
|
1381
|
+
|
|
1382
|
+
violations.push(...entry.contentViolations);
|
|
1383
|
+
for (const edge of entry.edges) {
|
|
1384
|
+
const target = resolveImport(ts, edge.specifier, file, compilerOptionsFor(file), moduleHost, root);
|
|
1385
|
+
const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
|
|
1386
|
+
const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
|
|
1387
|
+
if (rule) {
|
|
1388
|
+
violations.push({
|
|
1389
|
+
ruleId: 'LAYER_IMPORT_VIOLATION',
|
|
1390
|
+
file: relFile,
|
|
1391
|
+
line: edge.line,
|
|
1392
|
+
fromLayer: sourceLayer,
|
|
1393
|
+
toLayer: targetLayer,
|
|
1394
|
+
target: normalize(path.relative(root, target)),
|
|
1395
|
+
message: rule.message ?? `${sourceLayer} must not ${edge.kind} ${targetLayer}.`,
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
if (cacheKey) saveScanCache(root, cacheKey, nextCacheFiles);
|
|
1402
|
+
|
|
1295
1403
|
if (args.updateBaseline) {
|
|
1296
1404
|
const { fullPath, count } = writeBaseline(root, args.baseline, violations);
|
|
1297
1405
|
console.log(`Wrote ${fullPath} with ${count} frozen violation key(s).`);
|