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 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
- 'If no tsconfig is found, path aliases are unavailable but relative/package imports',
95
- 'still resolve.',
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 loadCompilerOptions(ts, root, tsconfigArg) {
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. For monorepos,
897
- * run ark-check per package rather than reaching across package roots.
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 compilerOptions = loadCompilerOptions(ts, root, args.tsconfig);
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
- for (const file of files) {
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 sourceLayer = layerForFile(root, file, config.layers);
1173
- if (!sourceLayer) continue;
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
- const target = resolveImport(ts, specifier, file, compilerOptions, moduleHost, root);
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).`);
package/dist/index.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  // src/version.ts
4
- var version = "1.4.0";
4
+ var version = "1.5.0";
5
5
 
6
6
  // src/kernel/intent/IntentRegistry.ts
7
7
  var IntentRegistry = class {