arkgate 2.6.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +8 -3
  3. package/bin/ark-check.mjs +19 -993
  4. package/bin/ark-layer-match.mjs +148 -171
  5. package/bin/ark-shared.mjs +9 -159
  6. package/bin/lib/agent-gates.mjs +48 -228
  7. package/bin/lib/architecture-scan.mjs +299 -0
  8. package/bin/lib/ast-scan.mjs +427 -0
  9. package/bin/lib/baseline-key.mjs +23 -0
  10. package/bin/lib/codex-home.mjs +320 -0
  11. package/bin/lib/config-warnings.mjs +228 -0
  12. package/bin/lib/doctor-plan.mjs +2 -0
  13. package/bin/lib/graph-cycles.mjs +56 -0
  14. package/bin/lib/remediation.mjs +182 -0
  15. package/bin/lib/scan-files.mjs +69 -0
  16. package/bin/lib/ts-resolve.mjs +216 -0
  17. package/bin/lib/violations.mjs +3 -9
  18. package/dist/eslint/index.cjs +21 -3
  19. package/dist/eslint/index.cjs.map +1 -1
  20. package/dist/eslint/index.d.cts +5 -3
  21. package/dist/eslint/index.d.ts +5 -3
  22. package/dist/eslint/index.js +21 -3
  23. package/dist/eslint/index.js.map +1 -1
  24. package/dist/index.cjs +1 -1
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +3 -3
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/nestjs/index.cjs +1 -1
  31. package/dist/nestjs/index.cjs.map +1 -1
  32. package/dist/nestjs/index.d.cts +1 -1
  33. package/dist/nestjs/index.d.ts +1 -1
  34. package/dist/nestjs/index.js +1 -1
  35. package/dist/nestjs/index.js.map +1 -1
  36. package/dist/runtime/index.cjs +3080 -0
  37. package/dist/runtime/index.cjs.map +1 -0
  38. package/dist/runtime/index.d.cts +2 -0
  39. package/dist/runtime/index.d.ts +2 -0
  40. package/dist/runtime/index.js +2998 -0
  41. package/dist/runtime/index.js.map +1 -0
  42. package/dist/{types-DpdVN7Lm.d.cts → types-CP3KkwZt.d.cts} +1 -1
  43. package/dist/{types-DpdVN7Lm.d.ts → types-CP3KkwZt.d.ts} +1 -1
  44. package/docs/agent-guide.md +5 -2
  45. package/docs/ai-gates.md +41 -7
  46. package/docs/brownfield-adoption.md +7 -0
  47. package/docs/demos/03-copilot-autopilot.md +3 -2
  48. package/docs/enthusiast/reference-commands.md +2 -2
  49. package/docs/migrate-from-ark-runtime-kernel.md +4 -2
  50. package/docs/package-surface.md +72 -0
  51. package/docs/production-hardening.md +3 -0
  52. package/package.json +12 -1
  53. package/server.json +2 -2
  54. package/templates/skills/ark-explain.md +3 -2
  55. package/templates/skills/ark-loop.md +2 -1
package/bin/ark-check.mjs CHANGED
@@ -1,8 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from 'node:child_process';
3
- import crypto from 'node:crypto';
4
3
  import fs from 'node:fs';
5
- import os from 'node:os';
6
4
  import path from 'node:path';
7
5
  import { fileURLToPath } from 'node:url';
8
6
 
@@ -15,7 +13,6 @@ import {
15
13
  DEFAULT_RULES,
16
14
  applyFrameworkLayoutOverlays,
17
15
  arkCommand,
18
- collectForbiddenGlobalUses,
19
16
  ADOPTION_PLAN_FILENAME,
20
17
  buildArchitectureRecommendation,
21
18
  createElevenLayerConfig,
@@ -23,47 +20,27 @@ import {
23
20
  listPolicyPackIds,
24
21
  loadPolicyPackMeta,
25
22
  writeAdoptionPlan,
26
- classifyRemediation,
27
- detectPackageManager,
28
23
  detectWorkspaces,
29
24
  detectTsPackageRoots,
30
25
  resolveIncludeRoots,
31
- execCommandParts,
32
- execRunner,
33
26
  formatArchitectureRecommendationHuman,
34
- globToRegExp,
35
27
  installDevHint,
36
- isScanExcludedRelative,
37
- presentLockfiles,
38
28
  layerForFile,
39
- looksLikeIntent,
40
- patternSpecificity,
41
- resolveIntentLayer,
42
- resolveOperatingMode,
43
- shouldShowNewHereNudge,
44
- usableTypescript,
45
- typescriptUsabilityHint,
46
29
  } from './ark-shared.mjs';
47
30
 
48
31
  import {
49
32
  runInstallAgentGates,
50
- runMigrateCommands,
51
33
  loadTypeScript,
52
- collectAdoptionGaps,
53
34
  detectSkillGaps,
54
35
  detectCodexHomeGap,
55
36
  missingGates,
56
37
  staleRunnerGateFiles,
57
38
  brokenMcpGateFiles,
58
39
  readJson,
59
- readPackageJson,
60
40
  hasCheckArchitectureScript,
61
- hasArkWorkflow,
62
41
  checkArchitectureScriptSnippet,
63
42
  arkCheckCommand,
64
43
  arkPackageVersion,
65
- agentInstructions,
66
- packageManager,
67
44
  REQUIRED_GATE_FILES,
68
45
  codexPromptsDir,
69
46
  } from './lib/agent-gates.mjs';
@@ -82,33 +59,35 @@ import {
82
59
  runCoverage,
83
60
  runPlan,
84
61
  runDoctor,
85
- buildRemediationPlan,
86
62
  } from './lib/doctor-plan.mjs';
87
63
  import {
88
64
  baselineKey,
89
65
  readBaseline,
90
66
  summarizeViolations,
91
- violationEdge,
92
67
  writeBaseline,
93
68
  printViolation,
94
69
  printViolationBreakdown,
95
70
  CONCENTRATION_MIN_VIOLATIONS,
96
71
  } from './lib/violations.mjs';
97
72
  import {
98
- buildUnclassifiedSuggestions,
99
73
  suggestLayerForDir,
100
- suggestLayerForPath,
101
74
  detectBestFitModel,
102
75
  dirSegmentsFromGlob,
103
76
  } from './lib/suggestions.mjs';
104
77
  import {
105
78
  ARCHITECTURE_PRESETS,
106
- CANONICAL_LAYER_NAMES,
107
- denyUpward,
108
- presetWithOverlays,
109
- FRAMEWORK_INTERNAL_EXCLUDE,
110
79
  } from './lib/presets.mjs';
111
80
 
81
+ import {
82
+ collectGovernedFiles,
83
+ normalize,
84
+ walk,
85
+ } from './lib/scan-files.mjs';
86
+ import {
87
+ configWarning,
88
+ } from './lib/config-warnings.mjs';
89
+ import { runArchitectureScan } from './lib/architecture-scan.mjs';
90
+
112
91
 
113
92
  function parseArgs(argv) {
114
93
  const args = {
@@ -299,7 +278,6 @@ function usage() {
299
278
  ].join('\n');
300
279
  }
301
280
 
302
-
303
281
  function readConfig(root, configPath) {
304
282
  const fullPath = path.isAbsolute(configPath)
305
283
  ? configPath
@@ -864,7 +842,6 @@ function runInit(args) {
864
842
  printInitNextSteps(args.root);
865
843
  }
866
844
 
867
-
868
845
  function readManifest(root, manifestPath) {
869
846
  if (!manifestPath) return undefined;
870
847
  const fullPath = path.isAbsolute(manifestPath)
@@ -876,667 +853,6 @@ function readManifest(root, manifestPath) {
876
853
  return readJson(fullPath);
877
854
  }
878
855
 
879
- const SOURCE_FILE_NAME = /\.[cm]?[tj]sx?$/;
880
-
881
- /** Unit/e2e test files are not architecture surface — agents and Nest put them next
882
- * to production code (*.spec.ts). Counting them as ungoverned forces false
883
- * CONFIG_UNCLASSIFIED_FILES under --strict-config on every starter. */
884
- const TEST_FILE_NAME =
885
- /\.(spec|test)\.(tsx?|jsx?|mts|cts)$/i;
886
-
887
- function isGovernableSourceFile(name) {
888
- return SOURCE_FILE_NAME.test(name) && !name.endsWith('.d.ts') && !TEST_FILE_NAME.test(name);
889
- }
890
-
891
- function isSkippedSourceDir(name) {
892
- return (
893
- name === 'node_modules' ||
894
- name === 'dist' ||
895
- name === 'coverage' ||
896
- name === '__tests__' ||
897
- name === '__mocks__' ||
898
- name === 'e2e' ||
899
- // Top-level style Nest/Jest folders (not "testing" helpers inside src)
900
- name === 'test' ||
901
- name === 'tests'
902
- );
903
- }
904
-
905
- function walk(dir, files = []) {
906
- const stat = fs.statSync(dir, { throwIfNoEntry: false });
907
- if (!stat) return files;
908
- // An `include` entry may be a single file (e.g. a root-level "middleware.ts"),
909
- // not just a directory — govern it directly instead of trying to scandir it
910
- // (which threw ENOTDIR). The extension filter still applies.
911
- if (stat.isFile()) {
912
- if (isGovernableSourceFile(path.basename(dir))) files.push(dir);
913
- return files;
914
- }
915
- if (!stat.isDirectory()) return files;
916
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
917
- const full = path.join(dir, entry.name);
918
- if (entry.isDirectory()) {
919
- if (isSkippedSourceDir(entry.name)) continue;
920
- walk(full, files);
921
- } else if (isGovernableSourceFile(entry.name)) {
922
- files.push(full);
923
- }
924
- }
925
- return files;
926
- }
927
-
928
- /** Walk include roots then drop codegen / config.exclude (universal scan filter). */
929
- function collectGovernedFiles(root, config) {
930
- const raw = (config.include ?? []).flatMap((entry) => walk(path.join(root, entry)));
931
- return raw.filter((abs) => {
932
- const rel = normalize(path.relative(root, abs));
933
- return !isScanExcludedRelative(rel, config);
934
- });
935
- }
936
-
937
- function normalize(value) {
938
- return value.split(path.sep).join('/');
939
- }
940
-
941
- function intentLayersFromManifest(manifest) {
942
- const layers = manifest?.architecture?.layers;
943
- if (!Array.isArray(layers)) return undefined;
944
- return layers
945
- .filter((layer) => Array.isArray(layer.prefixes) && layer.prefixes.length > 0)
946
- .map((layer) => ({ name: layer.name, prefixes: layer.prefixes }));
947
- }
948
-
949
- function layerForIntent(intent, layers, manifestIntentLayers) {
950
- // Use only layers that declare intent prefixes; fall back to the built-in defaults when
951
- // none do (mirrors the write-gate). resolveIntentLayer applies the library's exact
952
- // longest-prefix + trailing-dot semantics so CI and the MCP gate classify identically.
953
- const configured =
954
- manifestIntentLayers ??
955
- layers
956
- .filter((layer) => (layer.intentPrefixes ?? []).length > 0)
957
- .map((layer) => ({ name: layer.name, prefixes: layer.intentPrefixes }));
958
- const source =
959
- configured.length > 0
960
- ? configured
961
- : DEFAULT_INTENT_PREFIXES.map((entry) => ({ name: entry.layer, prefixes: entry.prefixes }));
962
- return resolveIntentLayer(intent, source);
963
- }
964
-
965
- function isBlocked(rules, from, to) {
966
- return rules.find((rule) => !rule.allowed && rule.from === from && rule.to === to);
967
- }
968
-
969
- function configWarning(ruleId, message, extra = {}) {
970
- return { ruleId, message, ...extra };
971
- }
972
-
973
- function collectConfigWarnings(root, config, files, rules, manifest) {
974
- const warnings = [];
975
- const layers = Array.isArray(config.layers) ? config.layers : [];
976
- const manifestLayers = Array.isArray(manifest?.architecture?.layers)
977
- ? manifest.architecture.layers
978
- : [];
979
- const knownLayers = new Set([
980
- ...layers.map((layer) => layer.name).filter(Boolean),
981
- ...manifestLayers.map((layer) => layer.name).filter(Boolean),
982
- ]);
983
-
984
- if (layers.length === 0) {
985
- warnings.push(
986
- configWarning(
987
- 'CONFIG_NO_LAYERS',
988
- 'No file layers are configured; ark-check cannot classify files for import-boundary enforcement.'
989
- )
990
- );
991
- }
992
-
993
- const seenLayers = new Set();
994
- const duplicateLayers = new Set();
995
- for (const layer of layers) {
996
- if (!layer.name) {
997
- warnings.push(
998
- configWarning('CONFIG_LAYER_WITHOUT_NAME', 'A configured layer is missing a name.')
999
- );
1000
- continue;
1001
- }
1002
- if (seenLayers.has(layer.name)) duplicateLayers.add(layer.name);
1003
- seenLayers.add(layer.name);
1004
-
1005
- if (
1006
- layer.forbiddenGlobals !== undefined &&
1007
- (!Array.isArray(layer.forbiddenGlobals) ||
1008
- layer.forbiddenGlobals.some((entry) => typeof entry !== 'string'))
1009
- ) {
1010
- warnings.push(
1011
- configWarning(
1012
- 'CONFIG_INVALID_FORBIDDEN_GLOBALS',
1013
- `Layer "${layer.name}" has an invalid forbiddenGlobals value; expected an array of strings (e.g. ["fetch", "Date.now"]). The entry is ignored.`,
1014
- { layer: layer.name }
1015
- )
1016
- );
1017
- }
1018
-
1019
- const patterns = Array.isArray(layer.patterns) ? layer.patterns : [];
1020
- if (patterns.length === 0) {
1021
- warnings.push(
1022
- configWarning(
1023
- 'CONFIG_LAYER_WITHOUT_PATTERNS',
1024
- `Layer "${layer.name}" has no file patterns and will never classify files.`,
1025
- { layer: layer.name }
1026
- )
1027
- );
1028
- continue;
1029
- }
1030
-
1031
- for (const pattern of patterns) {
1032
- let re;
1033
- try {
1034
- re = globToRegExp(pattern);
1035
- } catch (err) {
1036
- warnings.push(
1037
- configWarning(
1038
- 'CONFIG_INVALID_LAYER_PATTERN',
1039
- `Layer "${layer.name}" has an invalid pattern "${pattern}": ${
1040
- err instanceof Error ? err.message : String(err)
1041
- }`,
1042
- { layer: layer.name, pattern }
1043
- )
1044
- );
1045
- continue;
1046
- }
1047
-
1048
- const matched = files.some((file) => {
1049
- const rel = normalize(path.relative(root, file));
1050
- return re.test(rel);
1051
- });
1052
- if (!matched && !layer.optional) {
1053
- // Advisory only under --strict-config: monorepo/Next presets ship many optional-looking
1054
- // globs (e.g. src/layouts/**, app/**) that never match when include is ["frontend"].
1055
- // Failing the release gate on dead preset globs caused false CI red while architecture
1056
- // edges were clean (deer-flow host validation). Real safety is import violations +
1057
- // CONFIG_UNCLASSIFIED_FILES / invalid patterns.
1058
- warnings.push(
1059
- configWarning(
1060
- 'CONFIG_LAYER_PATTERN_NO_MATCHES',
1061
- `Layer "${layer.name}" pattern "${pattern}" matched no included files.`,
1062
- { layer: layer.name, pattern, failsStrict: false }
1063
- )
1064
- );
1065
- }
1066
- }
1067
- }
1068
-
1069
- for (const name of duplicateLayers) {
1070
- warnings.push(
1071
- configWarning(
1072
- 'CONFIG_DUPLICATE_LAYER',
1073
- `Layer "${name}" is configured more than once.`,
1074
- { layer: name }
1075
- )
1076
- );
1077
- }
1078
-
1079
- if (knownLayers.size > 0) {
1080
- for (const rule of rules ?? []) {
1081
- if (rule.from && !knownLayers.has(rule.from)) {
1082
- warnings.push(
1083
- configWarning(
1084
- 'CONFIG_RULE_UNKNOWN_FROM_LAYER',
1085
- `Rule references unknown source layer "${rule.from}".`,
1086
- { fromLayer: rule.from, toLayer: rule.to }
1087
- )
1088
- );
1089
- }
1090
- if (rule.to && !knownLayers.has(rule.to)) {
1091
- warnings.push(
1092
- configWarning(
1093
- 'CONFIG_RULE_UNKNOWN_TO_LAYER',
1094
- `Rule references unknown target layer "${rule.to}".`,
1095
- { fromLayer: rule.from, toLayer: rule.to }
1096
- )
1097
- );
1098
- }
1099
- }
1100
- }
1101
-
1102
- // Ambiguous overlap: a file matched by two different layers at the SAME top specificity.
1103
- // layerForFile breaks the tie by declaration order, but the config is genuinely undecided
1104
- // (unlike a facade split, where the surface pattern is strictly more specific and wins
1105
- // cleanly). Surface the layer pairs so the author disambiguates instead of relying on order.
1106
- const ambiguousPairs = new Set();
1107
- if (layers.length > 1) {
1108
- for (const file of files) {
1109
- const rel = normalize(path.relative(root, file));
1110
- let topScore = -1;
1111
- let topLayers = [];
1112
- for (const layer of layers) {
1113
- for (const pattern of layer.patterns ?? []) {
1114
- if (!globToRegExp(pattern).test(rel)) continue;
1115
- const score = patternSpecificity(pattern);
1116
- if (score > topScore) {
1117
- topScore = score;
1118
- topLayers = [layer.name];
1119
- } else if (score === topScore && !topLayers.includes(layer.name)) {
1120
- topLayers.push(layer.name);
1121
- }
1122
- }
1123
- }
1124
- if (topLayers.length > 1) {
1125
- ambiguousPairs.add([...topLayers].sort().join(' + '));
1126
- }
1127
- }
1128
- }
1129
- if (ambiguousPairs.size > 0) {
1130
- warnings.push(
1131
- configWarning(
1132
- 'CONFIG_AMBIGUOUS_LAYERS',
1133
- `Some files match multiple layers at equal specificity; classification falls back to declaration order. Disambiguate the overlapping patterns: ${[...ambiguousPairs].join(', ')}.`,
1134
- { pairs: [...ambiguousPairs] }
1135
- )
1136
- );
1137
- }
1138
-
1139
- const unclassified = files.filter((file) => !layerForFile(root, file, layers));
1140
- if (unclassified.length > 0) {
1141
- warnings.push(
1142
- configWarning(
1143
- 'CONFIG_UNCLASSIFIED_FILES',
1144
- `${unclassified.length} included source file(s) are not matched by any configured layer; ark-check will not enforce import rules for those source files.`,
1145
- {
1146
- count: unclassified.length,
1147
- samples: unclassified.slice(0, 5).map((file) => normalize(path.relative(root, file))),
1148
- }
1149
- )
1150
- );
1151
- }
1152
-
1153
- return warnings;
1154
- }
1155
-
1156
- function createModuleResolutionHost(ts) {
1157
- const sys = ts?.sys;
1158
- const fileExists = (f) => {
1159
- if (sys?.fileExists) return sys.fileExists(f);
1160
- return fs.existsSync(f);
1161
- };
1162
- const readFile = (f) => {
1163
- if (sys?.readFile) return sys.readFile(f);
1164
- try {
1165
- return fs.readFileSync(f, 'utf8');
1166
- } catch {
1167
- return undefined;
1168
- }
1169
- };
1170
- const directoryExists = (d) => {
1171
- if (sys?.directoryExists) return sys.directoryExists(d);
1172
- try {
1173
- return fs.statSync(d).isDirectory();
1174
- } catch {
1175
- return false;
1176
- }
1177
- };
1178
- return {
1179
- fileExists,
1180
- readFile,
1181
- directoryExists,
1182
- getCurrentDirectory: () =>
1183
- sys?.getCurrentDirectory ? sys.getCurrentDirectory() : process.cwd(),
1184
- getDirectories: (d) => {
1185
- if (sys?.getDirectories) return sys.getDirectories(d);
1186
- try {
1187
- return fs
1188
- .readdirSync(d, { withFileTypes: true })
1189
- .filter((e) => e.isDirectory())
1190
- .map((e) => e.name);
1191
- } catch {
1192
- return [];
1193
- }
1194
- },
1195
- realpath: sys?.realpath ? (p) => sys.realpath(p) : undefined,
1196
- useCaseSensitiveFileNames: sys?.useCaseSensitiveFileNames ?? true,
1197
- };
1198
- }
1199
-
1200
- function parseTsconfig(ts, configPath) {
1201
- const host = createModuleResolutionHost(ts);
1202
- const read = ts.readConfigFile(configPath, host.readFile);
1203
- if (read.error) return {};
1204
- // parseJsonConfigFileContent wants a ParseConfigHost-like object; our resolution host
1205
- // is enough for option extraction.
1206
- const parsed = ts.parseJsonConfigFileContent(
1207
- read.config,
1208
- {
1209
- useCaseSensitiveFileNames: host.useCaseSensitiveFileNames,
1210
- readDirectory: ts.sys?.readDirectory
1211
- ? (...args) => ts.sys.readDirectory(...args)
1212
- : () => [],
1213
- fileExists: host.fileExists,
1214
- readFile: host.readFile,
1215
- },
1216
- path.dirname(configPath)
1217
- );
1218
- return parsed.options;
1219
- }
1220
-
1221
- /**
1222
- * Compiler options for a given source file. With --tsconfig every file uses that one
1223
- * config; otherwise each file uses the NEAREST tsconfig.json above it (like tsc does),
1224
- * so monorepo packages with per-package path aliases resolve correctly under one --root.
1225
- */
1226
- function createCompilerOptionsLookup(ts, root, tsconfigArg) {
1227
- if (tsconfigArg) {
1228
- const configPath = path.isAbsolute(tsconfigArg) ? tsconfigArg : path.join(root, tsconfigArg);
1229
- const options = fs.existsSync(configPath) ? parseTsconfig(ts, configPath) : {};
1230
- return () => options;
1231
- }
1232
- const byDir = new Map();
1233
- const byConfig = new Map();
1234
- return (file) => {
1235
- const dir = path.dirname(file);
1236
- if (byDir.has(dir)) return byDir.get(dir);
1237
- const configPath = ts.findConfigFile(dir, ts.sys.fileExists, 'tsconfig.json');
1238
- let options = {};
1239
- if (configPath) {
1240
- if (!byConfig.has(configPath)) byConfig.set(configPath, parseTsconfig(ts, configPath));
1241
- options = byConfig.get(configPath);
1242
- }
1243
- byDir.set(dir, options);
1244
- return options;
1245
- };
1246
- }
1247
-
1248
- /**
1249
- * Per-file scan cache. A cache entry stores the parsed file's content-derived results:
1250
- * content violations (forbidden globals, publish checks, intent references) and the list
1251
- * of module-edge specifiers. Edges are NEVER cached as violations — they are re-resolved
1252
- * against the live filesystem every run, because resolution depends on files and tsconfigs
1253
- * outside the cached file. The whole cache is keyed by the config+manifest contents, so
1254
- * any rule change invalidates everything.
1255
- */
1256
- function scanCachePath(root) {
1257
- return path.join(root, 'node_modules', '.cache', 'ark-check.json');
1258
- }
1259
-
1260
- function scanCacheKey(root, args) {
1261
- const read = (p) => {
1262
- try {
1263
- return fs.readFileSync(p, 'utf8');
1264
- } catch {
1265
- return '';
1266
- }
1267
- };
1268
- const configPath = path.isAbsolute(args.config) ? args.config : path.join(root, args.config);
1269
- const manifestPath = args.manifest
1270
- ? path.isAbsolute(args.manifest)
1271
- ? args.manifest
1272
- : path.join(root, args.manifest)
1273
- : undefined;
1274
- // Bump this schema tag whenever the cached scan shape changes, so a warm cache from an
1275
- // older Ark can't feed stale entries to new logic. v2: typeOnly on edges. v3: per-file
1276
- // exportsOnlyTypes (target-module type-only export detection for plan classifier).
1277
- return crypto
1278
- .createHash('sha1')
1279
- .update(`ark-check-cache-v3\0${read(configPath)}\0${manifestPath ? read(manifestPath) : ''}`)
1280
- .digest('hex');
1281
- }
1282
-
1283
- function loadScanCache(root, key) {
1284
- try {
1285
- const data = JSON.parse(fs.readFileSync(scanCachePath(root), 'utf8'));
1286
- return data.key === key && data.files && typeof data.files === 'object' ? data.files : undefined;
1287
- } catch {
1288
- return undefined;
1289
- }
1290
- }
1291
-
1292
- function saveScanCache(root, key, files) {
1293
- try {
1294
- const target = scanCachePath(root);
1295
- fs.mkdirSync(path.dirname(target), { recursive: true });
1296
- fs.writeFileSync(target, JSON.stringify({ key, files }));
1297
- } catch {
1298
- // cache is best-effort: read-only filesystems just re-parse every run
1299
- }
1300
- }
1301
-
1302
- /**
1303
- * Fallback resolver for extensionless relative imports whose on-disk target uses an
1304
- * extension `ts.resolveModuleName` won't resolve without a matching tsconfig
1305
- * (notably `.mts`/`.cts`). Mirrors the classic candidate list.
1306
- */
1307
- function isFile(candidate) {
1308
- try {
1309
- return fs.statSync(candidate).isFile();
1310
- } catch {
1311
- return false;
1312
- }
1313
- }
1314
-
1315
- function resolveRelativeFallback(fromFile, specifier) {
1316
- const base = path.resolve(path.dirname(fromFile), specifier);
1317
- const candidates = [
1318
- base, // only used when the specifier already carries an extension (isFile filters dirs)
1319
- `${base}.ts`,
1320
- `${base}.tsx`,
1321
- `${base}.mts`,
1322
- `${base}.cts`,
1323
- `${base}.js`,
1324
- `${base}.jsx`,
1325
- `${base}.mjs`,
1326
- `${base}.cjs`,
1327
- path.join(base, 'index.ts'),
1328
- path.join(base, 'index.tsx'),
1329
- path.join(base, 'index.mts'),
1330
- path.join(base, 'index.cts'),
1331
- ];
1332
- // isFile (not existsSync) so a directory named like the specifier never shadows the
1333
- // real module file — e.g. `./foo` must not resolve to a `foo/` directory before `foo.mts`.
1334
- return candidates.find(isFile);
1335
- }
1336
-
1337
- /**
1338
- * Resolve any import specifier (relative, tsconfig path-alias, or package) to a source
1339
- * file using TypeScript's module resolver, returning the resolved file (or undefined for
1340
- * unresolved / declaration-only targets).
1341
- *
1342
- * ark-check governs one project rooted at --root. A resolved target is skipped when its
1343
- * path RELATIVE TO ROOT either escapes the root (leading `..`) or contains a `node_modules`
1344
- * segment. Using the root-relative path (not an absolute substring) means a project that
1345
- * itself lives under a node_modules segment is still governed, while a broad catch-all
1346
- * pattern (`**`) can't false-flag vendored deps or files outside the project. Monorepos can
1347
- * run under a single --root (per-package tsconfigs are honored via the nearest-tsconfig
1348
- * lookup); edges that resolve outside the root are still skipped.
1349
- */
1350
- function resolveImport(ts, specifier, containingFile, options, host, root) {
1351
- const res = ts.resolveModuleName(specifier, containingFile, options, host);
1352
- let file = res.resolvedModule?.resolvedFileName;
1353
- if (!file && specifier.startsWith('.')) {
1354
- file = resolveRelativeFallback(containingFile, specifier);
1355
- }
1356
- if (!file) return undefined;
1357
- if (file.endsWith('.d.ts')) return undefined;
1358
- const abs = path.resolve(file);
1359
- const segments = path.relative(root, abs).split(path.sep);
1360
- if (segments[0] === '..' || segments.includes('node_modules')) return undefined;
1361
- return abs;
1362
- }
1363
-
1364
- function lineOf(sourceFile, pos) {
1365
- return sourceFile.getLineAndCharacterOfPosition(pos).line + 1;
1366
- }
1367
-
1368
- function textOfModuleSpecifier(node) {
1369
- return node.moduleSpecifier && typeof node.moduleSpecifier.text === 'string'
1370
- ? node.moduleSpecifier.text
1371
- : undefined;
1372
- }
1373
-
1374
- // True when an import/export edge carries ONLY types (`import type …`, or a named import
1375
- // where every binding is `type`-qualified). Type-only edges are erased at compile time —
1376
- // they create no runtime coupling, only a design/type-placement dependency — so callers can
1377
- // rank them below real value imports in a burn-down. A side-effect import (`import "x"`) or
1378
- // any default/namespace/value binding is NOT type-only.
1379
- function isTypeOnlyModuleReference(ts, node) {
1380
- if (ts.isImportDeclaration(node)) {
1381
- const clause = node.importClause;
1382
- if (!clause) return false; // side-effect import — runtime edge
1383
- if (clause.isTypeOnly) return true; // `import type …`
1384
- const named = clause.namedBindings;
1385
- if (named && ts.isNamedImports(named) && named.elements.length > 0) {
1386
- return named.elements.every((element) => element.isTypeOnly);
1387
- }
1388
- return false; // default or namespace binding of a value
1389
- }
1390
- if (ts.isExportDeclaration(node)) {
1391
- if (node.isTypeOnly) return true;
1392
- const clause = node.exportClause;
1393
- if (clause && ts.isNamedExports(clause) && clause.elements.length > 0) {
1394
- return clause.elements.every((element) => element.isTypeOnly);
1395
- }
1396
- return false;
1397
- }
1398
- return false;
1399
- }
1400
-
1401
- /**
1402
- * True when a module is a pure type-surface file: only type/interface exports and
1403
- * type-only imports. Conservative false (→ judgment) when:
1404
- * - any top-level runtime statement (value decls, expression stmts, side-effect imports)
1405
- * - ambiguous `export { X }` without type keyword, export *, default/export=
1406
- * Used so static value-syntax `import { T }` of a pure-type module can be mechanical-safe
1407
- * (convert to `import type`). Never trust this for require()/import() edges.
1408
- */
1409
- function sourceFileExportsOnlyTypes(ts, sourceFile) {
1410
- let sawTypeExport = false;
1411
- const hasExportModifier = (node) =>
1412
- Array.isArray(node.modifiers) &&
1413
- node.modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
1414
-
1415
- for (const stmt of sourceFile.statements) {
1416
- // Type-only imports OK; value or side-effect imports mean runtime load of deps.
1417
- if (ts.isImportDeclaration(stmt)) {
1418
- if (!isTypeOnlyModuleReference(ts, stmt)) return false;
1419
- continue;
1420
- }
1421
- if (typeof ts.isImportEqualsDeclaration === 'function' && ts.isImportEqualsDeclaration(stmt)) {
1422
- return false;
1423
- }
1424
- if (ts.isExportDeclaration(stmt)) {
1425
- if (stmt.isTypeOnly) {
1426
- sawTypeExport = true;
1427
- continue;
1428
- }
1429
- // export * from '…' can re-export values — not provably type-only.
1430
- if (!stmt.exportClause) return false;
1431
- if (ts.isNamespaceExport(stmt.exportClause)) return false;
1432
- if (ts.isNamedExports(stmt.exportClause)) {
1433
- if (stmt.exportClause.elements.length === 0) return false;
1434
- for (const el of stmt.exportClause.elements) {
1435
- if (!el.isTypeOnly) return false; // bare `export { X }` — ambiguous without checker
1436
- }
1437
- sawTypeExport = true;
1438
- continue;
1439
- }
1440
- return false;
1441
- }
1442
- if (ts.isExportAssignment(stmt)) return false; // export = / export default expr
1443
- if (ts.isTypeAliasDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)) {
1444
- if (hasExportModifier(stmt)) sawTypeExport = true;
1445
- continue;
1446
- }
1447
- // Any other top-level statement (const/fn/class/enum, console.log, if, …) is runtime.
1448
- return false;
1449
- }
1450
- return sawTypeExport;
1451
- }
1452
-
1453
- function propertyName(ts, node) {
1454
- if (!node) return undefined;
1455
- if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) return node.text;
1456
- return undefined;
1457
- }
1458
-
1459
- function objectProperty(ts, node, name) {
1460
- if (!node || !ts.isObjectLiteralExpression(node)) return undefined;
1461
- return node.properties.find((property) => {
1462
- if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) {
1463
- return false;
1464
- }
1465
- return propertyName(ts, property.name) === name;
1466
- });
1467
- }
1468
-
1469
- function objectHasProperty(ts, node, name) {
1470
- return objectProperty(ts, node, name) !== undefined;
1471
- }
1472
-
1473
- function objectPropertyValue(ts, node, name) {
1474
- const property = objectProperty(ts, node, name);
1475
- return property && ts.isPropertyAssignment(property)
1476
- ? property.initializer
1477
- : undefined;
1478
- }
1479
-
1480
- function objectHasMetadataSource(ts, node) {
1481
- const metadata = objectPropertyValue(ts, node, 'metadata');
1482
- return objectHasProperty(ts, metadata, 'source');
1483
- }
1484
-
1485
- function stringLiteralText(ts, node) {
1486
- return node && ts.isStringLiteralLike(node) ? node.text : undefined;
1487
- }
1488
-
1489
- function isPublishCall(ts, node) {
1490
- if (!ts.isCallExpression(node)) return false;
1491
- const expression = node.expression;
1492
- if (ts.isPropertyAccessExpression(expression)) {
1493
- return expression.name.text === 'publish';
1494
- }
1495
- return ts.isIdentifier(expression) && expression.text === 'publish';
1496
- }
1497
-
1498
- function looksLikeIntentCreatorExpression(ts, node) {
1499
- if (!node) return false;
1500
- if (ts.isIdentifier(node)) {
1501
- return /^[A-Z]/.test(node.text);
1502
- }
1503
- if (ts.isPropertyAccessExpression(node)) {
1504
- return looksLikeIntentCreatorExpression(ts, node.name);
1505
- }
1506
- return false;
1507
- }
1508
-
1509
- function isArkPublishCandidate(ts, node) {
1510
- if (!ts.isCallExpression(node)) return false;
1511
- const firstArg = node.arguments[0];
1512
- const rawIntent = stringLiteralText(ts, firstArg);
1513
- return (
1514
- (rawIntent !== undefined && looksLikeIntent(rawIntent)) ||
1515
- objectHasProperty(ts, firstArg, 'intent') ||
1516
- looksLikeIntentCreatorExpression(ts, firstArg)
1517
- );
1518
- }
1519
-
1520
- function publishSourceLiteral(ts, node) {
1521
- if (!ts.isCallExpression(node)) return undefined;
1522
- const [firstArg, secondArg, thirdArg] = node.arguments;
1523
- const rawMetadata = objectPropertyValue(ts, firstArg, 'metadata');
1524
- return (
1525
- stringLiteralText(ts, objectPropertyValue(ts, rawMetadata, 'source')) ??
1526
- stringLiteralText(ts, objectPropertyValue(ts, secondArg, 'source')) ??
1527
- stringLiteralText(ts, objectPropertyValue(ts, thirdArg, 'source'))
1528
- );
1529
- }
1530
-
1531
- function publishHasSource(ts, node) {
1532
- if (!ts.isCallExpression(node)) return false;
1533
- const [firstArg, secondArg, thirdArg] = node.arguments;
1534
- return (
1535
- objectHasMetadataSource(ts, firstArg) ||
1536
- objectHasProperty(ts, secondArg, 'source') ||
1537
- objectHasProperty(ts, thirdArg, 'source')
1538
- );
1539
- }
1540
856
  const useColor = process.stderr.isTTY && !process.env.NO_COLOR;
1541
857
  const color = {
1542
858
  red: (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
@@ -1546,85 +862,6 @@ const color = {
1546
862
  bold: (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
1547
863
  };
1548
864
 
1549
- function detectCycles(graph) {
1550
- let index = 0;
1551
- const indices = new Map();
1552
- const low = new Map();
1553
- const onStack = new Set();
1554
- const stack = [];
1555
- const components = [];
1556
-
1557
- // ponytail: recursive Tarjan; make it iterative only if a real repo blows the stack.
1558
- const strongconnect = (v) => {
1559
- indices.set(v, index);
1560
- low.set(v, index);
1561
- index += 1;
1562
- stack.push(v);
1563
- onStack.add(v);
1564
- for (const w of [...(graph.get(v) ?? [])].sort()) {
1565
- if (!graph.has(w)) continue;
1566
- if (!indices.has(w)) {
1567
- strongconnect(w);
1568
- low.set(v, Math.min(low.get(v), low.get(w)));
1569
- } else if (onStack.has(w)) {
1570
- low.set(v, Math.min(low.get(v), indices.get(w)));
1571
- }
1572
- }
1573
- if (low.get(v) === indices.get(v)) {
1574
- const comp = [];
1575
- let w;
1576
- do {
1577
- w = stack.pop();
1578
- onStack.delete(w);
1579
- comp.push(w);
1580
- } while (w !== v);
1581
- if (comp.length > 1) components.push(comp.sort());
1582
- }
1583
- };
1584
-
1585
- for (const v of [...graph.keys()].sort()) {
1586
- if (!indices.has(v)) strongconnect(v);
1587
- }
1588
-
1589
- return components
1590
- .sort((a, b) => a[0].localeCompare(b[0]))
1591
- .map((members) => ({
1592
- ruleId: 'CIRCULAR_DEPENDENCY',
1593
- file: members[0],
1594
- line: 1,
1595
- target: members.join(' → '),
1596
- message: `Circular dependency among ${members.length} files: ${members.join(' → ')} → ${members[0]}.`,
1597
- // Graph is value/runtime edges only (type-only imports omitted).
1598
- cycleKind: 'value',
1599
- }));
1600
- }
1601
-
1602
-
1603
- function moduleSpecifierFromCall(ts, node) {
1604
- if (!ts.isCallExpression(node)) return undefined;
1605
-
1606
- if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
1607
- const first = node.arguments[0];
1608
- const value = stringLiteralText(ts, first);
1609
- return value ? { value, kind: 'dynamic-import' } : undefined;
1610
- }
1611
-
1612
- if (ts.isIdentifier(node.expression) && node.expression.text === 'require') {
1613
- const first = node.arguments[0];
1614
- const value = stringLiteralText(ts, first);
1615
- return value ? { value, kind: 'require' } : undefined;
1616
- }
1617
-
1618
- return undefined;
1619
- }
1620
-
1621
- // --coverage: a standalone visibility report (never changes the exit code). Answers
1622
- // "which files does each layer actually govern, and what is slipping through?" — the
1623
- // data the /ark-coverage skill otherwise has to hand-roll with find/readdir walks.
1624
- // Pure coverage computation (glob-only, no TypeScript): the object both `--coverage` and
1625
- // `--doctor` render. `governed` is the headline honesty number — the share of in-scope code
1626
- // Ark actually enforces rules on; `suggestions` proposes a layer for each ungoverned dir.
1627
-
1628
865
  async function main() {
1629
866
  const args = parseArgs(process.argv);
1630
867
  if (args.version) {
@@ -1791,226 +1028,15 @@ async function main() {
1791
1028
  );
1792
1029
  }
1793
1030
 
1794
- const manifestIntentLayers = intentLayersFromManifest(manifest);
1795
- const compilerOptionsFor = createCompilerOptionsLookup(ts, root, args.tsconfig);
1796
- const moduleHost = createModuleResolutionHost(ts);
1797
-
1798
- const violations = [];
1799
- const warnings = collectConfigWarnings(root, config, files, rules, manifest);
1800
- const cacheKey = args.noCache ? undefined : scanCacheKey(root, args);
1801
- const cachedFiles = cacheKey ? loadScanCache(root, cacheKey) : undefined;
1802
- const nextCacheFiles = {};
1803
-
1804
- // Parses one file and returns its cacheable scan result: violations derived purely from
1805
- // the file's content (+config/manifest, hashed into the cache key) and the module-edge
1806
- // specifiers found, which the driver loop below resolves fresh on every run.
1807
- function scanSourceFile(file, sourceLayer) {
1808
- const source = fs.readFileSync(file, 'utf8');
1809
- const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
1810
- const violations = [];
1811
- const edges = [];
1812
-
1813
- const layerConfig = config.layers.find((layer) => layer.name === sourceLayer);
1814
- const forbiddenGlobals = Array.isArray(layerConfig?.forbiddenGlobals)
1815
- ? layerConfig.forbiddenGlobals.filter((entry) => typeof entry === 'string')
1816
- : [];
1817
- for (const use of collectForbiddenGlobalUses(ts, sourceFile, forbiddenGlobals)) {
1818
- violations.push({
1819
- ruleId: 'FORBIDDEN_GLOBAL',
1820
- file: normalize(path.relative(root, file)),
1821
- line: lineOf(sourceFile, use.node.getStart(sourceFile)),
1822
- fromLayer: sourceLayer,
1823
- target: use.name,
1824
- message: `${sourceLayer} must not use the ambient global "${use.name}".`,
1825
- });
1826
- }
1827
-
1828
- const checkModuleEdge = (specifier, node, kind, typeOnly = false) => {
1829
- edges.push({ specifier, line: lineOf(sourceFile, node.getStart(sourceFile)), kind, typeOnly });
1830
- };
1831
-
1832
- const visit = (node) => {
1833
- if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
1834
- const specifier = textOfModuleSpecifier(node);
1835
- if (specifier) {
1836
- checkModuleEdge(
1837
- specifier,
1838
- node,
1839
- ts.isImportDeclaration(node) ? 'import' : 'export',
1840
- isTypeOnlyModuleReference(ts, node)
1841
- );
1842
- }
1843
- }
1844
-
1845
- if (ts.isCallExpression(node)) {
1846
- const moduleCall = moduleSpecifierFromCall(ts, node);
1847
- if (moduleCall) {
1848
- checkModuleEdge(moduleCall.value, node, moduleCall.kind);
1849
- }
1850
-
1851
- if (isPublishCall(ts, node)) {
1852
- const firstArg = node.arguments[0];
1853
- const rawIntent = stringLiteralText(ts, firstArg);
1854
- if (
1855
- (rawIntent && looksLikeIntent(rawIntent)) ||
1856
- objectHasProperty(ts, firstArg, 'intent')
1857
- ) {
1858
- violations.push({
1859
- ruleId: 'RAW_EVENT_PUBLISH',
1860
- file: normalize(path.relative(root, file)),
1861
- line: lineOf(sourceFile, node.getStart(sourceFile)),
1862
- message:
1863
- 'Publish through a registered intent creator; raw event objects or intent strings bypass Ark contracts and tooling.',
1864
- });
1865
- }
1866
-
1867
- if (isArkPublishCandidate(ts, node) && !publishHasSource(ts, node)) {
1868
- violations.push({
1869
- ruleId: 'PUBLISH_MISSING_SOURCE',
1870
- file: normalize(path.relative(root, file)),
1871
- line: lineOf(sourceFile, node.getStart(sourceFile)),
1872
- fromLayer: sourceLayer,
1873
- message: 'Strict Ark publish calls must include metadata.source.',
1874
- });
1875
- }
1876
-
1877
- const sourceIntent = publishSourceLiteral(ts, node);
1878
- if (sourceIntent && looksLikeIntent(sourceIntent)) {
1879
- const sourceIntentLayer = layerForIntent(
1880
- sourceIntent,
1881
- config.layers,
1882
- manifestIntentLayers
1883
- );
1884
- if (sourceIntentLayer && sourceIntentLayer !== sourceLayer) {
1885
- violations.push({
1886
- ruleId: 'PUBLISH_SOURCE_LAYER_MISMATCH',
1887
- file: normalize(path.relative(root, file)),
1888
- line: lineOf(sourceFile, node.getStart(sourceFile)),
1889
- fromLayer: sourceLayer,
1890
- toLayer: sourceIntentLayer,
1891
- target: sourceIntent,
1892
- message:
1893
- `Publish source "${sourceIntent}" resolves to ${sourceIntentLayer}, but the publishing file is classified as ${sourceLayer}.`,
1894
- });
1895
- }
1896
- }
1897
- }
1898
- }
1899
-
1900
- if (ts.isStringLiteralLike(node) && looksLikeIntent(node.text)) {
1901
- const targetLayer = layerForIntent(node.text, config.layers, manifestIntentLayers);
1902
- const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
1903
- if (rule) {
1904
- violations.push({
1905
- ruleId: 'LAYER_INTENT_REFERENCE_VIOLATION',
1906
- file: normalize(path.relative(root, file)),
1907
- line: lineOf(sourceFile, node.getStart(sourceFile)),
1908
- fromLayer: sourceLayer,
1909
- toLayer: targetLayer,
1910
- target: node.text,
1911
- message:
1912
- rule.message ??
1913
- `${sourceLayer} must not reference ${targetLayer} intent ${node.text}.`,
1914
- });
1915
- }
1916
- }
1917
-
1918
- ts.forEachChild(node, visit);
1919
- };
1920
- visit(sourceFile);
1921
- return {
1922
- contentViolations: violations,
1923
- edges,
1924
- exportsOnlyTypes: sourceFileExportsOnlyTypes(ts, sourceFile),
1925
- };
1926
- }
1927
-
1928
- // Pass 1: scan every governed file into nextCacheFiles (needs complete map before
1929
- // targetTypeOnlyExports can be resolved for import edges).
1930
- const importGraph = new Map();
1931
- const scanned = []; // { file, sourceLayer, relFile, entry }
1932
- for (const file of files) {
1933
- const sourceLayer = layerForFile(root, file, config.layers);
1934
- if (!sourceLayer) continue;
1935
- const relFile = normalize(path.relative(root, file));
1936
- if (!importGraph.has(relFile)) importGraph.set(relFile, new Set());
1937
- const stat = fs.statSync(file);
1938
- const fileKey = `${stat.mtimeMs}:${stat.size}`;
1939
- const cached = cachedFiles?.[relFile];
1940
- const entry =
1941
- cached && cached.fileKey === fileKey
1942
- ? cached
1943
- : { fileKey, ...scanSourceFile(file, sourceLayer) };
1944
- nextCacheFiles[relFile] = entry;
1945
- scanned.push({ file, sourceLayer, relFile, entry });
1946
- }
1947
-
1948
- // Pass 2: content violations + layer edges (with target type-export surface).
1949
- for (const { file, sourceLayer, relFile, entry } of scanned) {
1950
- violations.push(...entry.contentViolations);
1951
- for (const edge of entry.edges) {
1952
- const target = resolveImport(ts, edge.specifier, file, compilerOptionsFor(file), moduleHost, root);
1953
- const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
1954
- if (target && targetLayer) {
1955
- const relTarget = normalize(path.relative(root, target));
1956
- // Cycle graph is runtime coupling only. Type-only imports are erased by TS and
1957
- // must not form CIRCULAR_DEPENDENCY (e.g. codegen `import type` back-edges).
1958
- if (relTarget !== relFile && !edge.typeOnly) {
1959
- importGraph.get(relFile).add(relTarget);
1960
- }
1961
- }
1962
- const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
1963
- if (rule) {
1964
- const relTarget = normalize(path.relative(root, target));
1965
- // After pass 1 every in-scope target is in nextCacheFiles. Missing → not type-only.
1966
- // targetTypeOnlyExports only for static import/export declarations — never require()
1967
- // or dynamic import(), which always load the module at runtime (side effects matter).
1968
- const targetCached = nextCacheFiles[relTarget];
1969
- const staticEdge = edge.kind === 'import' || edge.kind === 'export';
1970
- const targetTypeOnlyExports =
1971
- staticEdge && Boolean(targetCached?.exportsOnlyTypes) && !edge.typeOnly;
1972
- // Importer is itself a pure type-surface file (no runtime body) — enables
1973
- // pure-type-file-relocate classification when the edge is type-only.
1974
- const sourcePureTypeModule = Boolean(entry.exportsOnlyTypes);
1975
- violations.push({
1976
- ruleId: 'LAYER_IMPORT_VIOLATION',
1977
- file: relFile,
1978
- line: edge.line,
1979
- fromLayer: sourceLayer,
1980
- toLayer: targetLayer,
1981
- target: relTarget,
1982
- ...(edge.typeOnly ? { typeOnly: true } : {}),
1983
- ...(targetTypeOnlyExports ? { targetTypeOnlyExports: true } : {}),
1984
- ...(sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
1985
- ...(edge.kind ? { edgeKind: edge.kind } : {}),
1986
- message: rule.message ?? `${sourceLayer} must not ${edge.kind} ${targetLayer}.`,
1987
- });
1988
- }
1989
- }
1990
- }
1991
-
1992
- if (cacheKey) saveScanCache(root, cacheKey, nextCacheFiles);
1993
-
1994
- // cyclePolicy: strict (default) | soft (advisory only, never fails --strict-config) | off
1995
- const cyclePolicy = String(config.cyclePolicy || 'strict').toLowerCase();
1996
- if (cyclePolicy !== 'off') {
1997
- const cycles = detectCycles(importGraph);
1998
- if (cyclePolicy === 'soft' || cyclePolicy === 'framework-soft') {
1999
- for (const c of cycles) {
2000
- // failsStrict: false — soft cycles must NOT trip --strict-config / check:architecture.
2001
- // Only CONFIG_* (and similar) warnings fail under --strict-config.
2002
- warnings.push({
2003
- ruleId: 'CIRCULAR_DEPENDENCY',
2004
- message: `${c.message} (soft cycle policy — advisory only; set cyclePolicy: "strict" to fail the check)`,
2005
- file: c.file,
2006
- target: c.target,
2007
- failsStrict: false,
2008
- });
2009
- }
2010
- } else {
2011
- violations.push(...cycles);
2012
- }
2013
- }
1031
+ const { violations, warnings } = runArchitectureScan({
1032
+ root,
1033
+ config,
1034
+ manifest,
1035
+ rules,
1036
+ files,
1037
+ ts,
1038
+ args,
1039
+ });
2014
1040
 
2015
1041
  if (args.doctor) {
2016
1042
  runDoctor(root, config, files, rules, violations, args.json, {