arkgate 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +98 -70
  3. package/bin/ark-check.mjs +240 -1001
  4. package/bin/ark-layer-match.mjs +153 -147
  5. package/bin/ark-mcp.mjs +102 -5
  6. package/bin/ark-shared.mjs +304 -165
  7. package/bin/ark.mjs +44 -34
  8. package/bin/lib/agent-gates.mjs +448 -15
  9. package/bin/lib/architecture-scan.mjs +279 -0
  10. package/bin/lib/ast-scan.mjs +199 -0
  11. package/bin/lib/baseline-key.mjs +23 -0
  12. package/bin/lib/config-warnings.mjs +228 -0
  13. package/bin/lib/doctor-plan.mjs +11 -4
  14. package/bin/lib/graph-cycles.mjs +56 -0
  15. package/bin/lib/presets.mjs +75 -4
  16. package/bin/lib/remediation.mjs +150 -0
  17. package/bin/lib/scan-files.mjs +69 -0
  18. package/bin/lib/ts-resolve.mjs +215 -0
  19. package/bin/lib/violations.mjs +3 -9
  20. package/dist/eslint/index.cjs +21 -3
  21. package/dist/eslint/index.cjs.map +1 -1
  22. package/dist/eslint/index.d.cts +5 -3
  23. package/dist/eslint/index.d.ts +5 -3
  24. package/dist/eslint/index.js +21 -3
  25. package/dist/eslint/index.js.map +1 -1
  26. package/dist/index.cjs +1 -1
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +3 -3
  29. package/dist/index.d.ts +3 -3
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/nestjs/index.cjs +1 -1
  33. package/dist/nestjs/index.cjs.map +1 -1
  34. package/dist/nestjs/index.d.cts +1 -1
  35. package/dist/nestjs/index.d.ts +1 -1
  36. package/dist/nestjs/index.js +1 -1
  37. package/dist/nestjs/index.js.map +1 -1
  38. package/dist/runtime/index.cjs +3080 -0
  39. package/dist/runtime/index.cjs.map +1 -0
  40. package/dist/runtime/index.d.cts +2 -0
  41. package/dist/runtime/index.d.ts +2 -0
  42. package/dist/runtime/index.js +2998 -0
  43. package/dist/runtime/index.js.map +1 -0
  44. package/dist/{types-DpdVN7Lm.d.cts → types-CP3KkwZt.d.cts} +1 -1
  45. package/dist/{types-DpdVN7Lm.d.ts → types-CP3KkwZt.d.ts} +1 -1
  46. package/docs/agent-guide.md +67 -1
  47. package/docs/migrate-from-ark-runtime-kernel.md +4 -2
  48. package/docs/package-surface.md +72 -0
  49. package/docs/production-hardening.md +3 -0
  50. package/package.json +11 -1
  51. package/server.json +2 -2
  52. package/templates/skills/ark-adopt.md +43 -87
  53. package/templates/skills/ark-autopilot.md +39 -77
  54. package/templates/skills/ark-contract.md +43 -84
  55. package/templates/skills/ark-coverage.md +62 -83
  56. package/templates/skills/ark-fix.md +45 -90
  57. package/templates/skills/ark-loop.md +44 -66
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,43 +20,27 @@ import {
23
20
  listPolicyPackIds,
24
21
  loadPolicyPackMeta,
25
22
  writeAdoptionPlan,
26
- classifyRemediation,
27
- detectPackageManager,
28
- execCommandParts,
29
- execRunner,
23
+ detectWorkspaces,
24
+ detectTsPackageRoots,
25
+ resolveIncludeRoots,
30
26
  formatArchitectureRecommendationHuman,
31
- globToRegExp,
32
27
  installDevHint,
33
- presentLockfiles,
34
28
  layerForFile,
35
- looksLikeIntent,
36
- patternSpecificity,
37
- resolveIntentLayer,
38
- resolveOperatingMode,
39
- shouldShowNewHereNudge,
40
- usableTypescript,
41
- typescriptUsabilityHint,
42
29
  } from './ark-shared.mjs';
43
30
 
44
31
  import {
45
32
  runInstallAgentGates,
46
- runMigrateCommands,
47
33
  loadTypeScript,
48
- collectAdoptionGaps,
49
34
  detectSkillGaps,
50
35
  detectCodexHomeGap,
51
36
  missingGates,
52
37
  staleRunnerGateFiles,
53
38
  brokenMcpGateFiles,
54
39
  readJson,
55
- readPackageJson,
56
40
  hasCheckArchitectureScript,
57
- hasArkWorkflow,
58
41
  checkArchitectureScriptSnippet,
59
42
  arkCheckCommand,
60
43
  arkPackageVersion,
61
- agentInstructions,
62
- packageManager,
63
44
  REQUIRED_GATE_FILES,
64
45
  codexPromptsDir,
65
46
  } from './lib/agent-gates.mjs';
@@ -78,33 +59,35 @@ import {
78
59
  runCoverage,
79
60
  runPlan,
80
61
  runDoctor,
81
- buildRemediationPlan,
82
62
  } from './lib/doctor-plan.mjs';
83
63
  import {
84
64
  baselineKey,
85
65
  readBaseline,
86
66
  summarizeViolations,
87
- violationEdge,
88
67
  writeBaseline,
89
68
  printViolation,
90
69
  printViolationBreakdown,
91
70
  CONCENTRATION_MIN_VIOLATIONS,
92
71
  } from './lib/violations.mjs';
93
72
  import {
94
- buildUnclassifiedSuggestions,
95
73
  suggestLayerForDir,
96
- suggestLayerForPath,
97
74
  detectBestFitModel,
98
75
  dirSegmentsFromGlob,
99
76
  } from './lib/suggestions.mjs';
100
77
  import {
101
78
  ARCHITECTURE_PRESETS,
102
- CANONICAL_LAYER_NAMES,
103
- denyUpward,
104
- presetWithOverlays,
105
- FRAMEWORK_INTERNAL_EXCLUDE,
106
79
  } from './lib/presets.mjs';
107
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
+
108
91
 
109
92
  function parseArgs(argv) {
110
93
  const args = {
@@ -168,6 +151,9 @@ function parseArgs(argv) {
168
151
  else if (arg === '--write-plan') args.writePlan = true;
169
152
  else if (arg === '--list-policy-packs') args.listPolicyPacks = true;
170
153
  else if (arg === '--apply-policy-pack') args.applyPolicyPack = argv[++i];
154
+ else if (arg === '--suggest-include') args.suggestInclude = true;
155
+ else if (arg === '--adopt-contract') args.adoptContract = true;
156
+ else if (arg === '--write') args.write = true;
171
157
  else if (arg === '--watch') args.watch = true;
172
158
  else if (arg === '--beginner') args.beginner = true;
173
159
  else if (arg === '--codex-home') args.codexHome = true;
@@ -213,8 +199,10 @@ function usage() {
213
199
  ' ark-check --coverage [--json] per-layer file counts + full unclassified list (report only, exit 0)',
214
200
  ' ark-check --plan [--json] classified remediation plan (mechanical-safe / judgment / deferred) + goal; report only',
215
201
  ' ark-check --recommend [--json] [--write-plan] application-shape plan; --write-plan emits ark-adoption-plan.json',
216
- ' ark-check --list-policy-packs enthusiast preset configs (hexagonal, layered, feature-sliced, monorepo)',
202
+ ' ark-check --list-policy-packs enthusiast preset configs (hexagonal, layered, feature-sliced, monorepo, ui-surface)',
217
203
  ' ark-check --apply-policy-pack <id> [--force] write ark.config.json from templates/policy-packs/ (uses preset factory)',
204
+ ' ark-check --suggest-include [--json] propose include roots (TS packages / workspaces)',
205
+ ' ark-check --adopt-contract [--write] expand include + UI patterns from ungoverned dirs (contract adopt)',
218
206
  ' ark-check --watch re-run the check when governed files change (debounced)',
219
207
  ' ark-check --report [file.html] [--beginner] [--reset-origin] [--no-archive]',
220
208
  ' HTML report + snapshots under .ark/reports/ (origin once, latest each run, history JSON)',
@@ -249,6 +237,7 @@ function usage() {
249
237
  'Config shape:',
250
238
  '{',
251
239
  ' "include": ["src"],',
240
+ ' // optional: "exclude": ["**/vendor/**"], "excludeGenerated": false (default skips *.gen.ts / *.generated.ts)',
252
241
  ' "layers": [',
253
242
  ' { "name": "DomainModel", "patterns": ["src/domain/**"], "intentPrefixes": ["Domain."],',
254
243
  ' "forbiddenGlobals": ["fetch", "process", "Date.now", "Math.random"] }',
@@ -289,7 +278,6 @@ function usage() {
289
278
  ].join('\n');
290
279
  }
291
280
 
292
-
293
281
  function readConfig(root, configPath) {
294
282
  const fullPath = path.isAbsolute(configPath)
295
283
  ? configPath
@@ -306,6 +294,9 @@ function readConfig(root, configPath) {
306
294
  include: raw.include ?? ['src'],
307
295
  layers: raw.layers ?? [],
308
296
  rules: raw.rules ?? DEFAULT_RULES,
297
+ ...(raw.exclude ? { exclude: raw.exclude } : {}),
298
+ ...(raw.excludeGenerated !== undefined ? { excludeGenerated: raw.excludeGenerated } : {}),
299
+ ...(raw.cyclePolicy ? { cyclePolicy: raw.cyclePolicy } : {}),
309
300
  };
310
301
  }
311
302
 
@@ -362,39 +353,8 @@ function uncoveredDirectories(root, srcDir, layers) {
362
353
  });
363
354
  }
364
355
 
365
- // Reads workspace globs from package.json (npm/yarn/bun `workspaces`, array or
366
- // `{ packages: [] }`) and pnpm-workspace.yaml, returning the distinct base directories
367
- // (the glob prefix before the first `*`), e.g. "packages/*" -> "packages". Empty when
368
- // the project declares no workspaces — the signal that says "this is a monorepo".
369
- function detectWorkspaces(root) {
370
- const dirs = new Set();
371
- const addGlob = (glob) => {
372
- if (typeof glob !== 'string') return;
373
- const beforeStar = glob.split('*')[0].replace(/\/+$/, '');
374
- if (beforeStar && beforeStar !== '.') dirs.add(normalize(beforeStar));
375
- };
376
- const pkg = readPackageJson(root);
377
- const ws = Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages;
378
- if (Array.isArray(ws)) ws.forEach(addGlob);
379
- const pnpmFile = path.join(root, 'pnpm-workspace.yaml');
380
- if (fs.existsSync(pnpmFile)) {
381
- // Minimal read (no YAML dep): collect list items under the top-level `packages:` key
382
- // ONLY. pnpm files also carry other list-valued keys (onlyBuiltDependencies, catalog,
383
- // …) whose items are NOT workspace globs — a key-agnostic scan would pull those in.
384
- let inPackages = false;
385
- for (const line of fs.readFileSync(pnpmFile, 'utf8').split('\n')) {
386
- const keyMatch = line.match(/^([A-Za-z0-9_-]+):/); // top-level key (no indentation)
387
- if (keyMatch) {
388
- inPackages = keyMatch[1] === 'packages';
389
- continue;
390
- }
391
- if (!inPackages) continue;
392
- const item = line.match(/^\s+-\s*['"]?([^'"#]+?)['"]?\s*$/); // indented list item
393
- if (item) addGlob(item[1].trim());
394
- }
395
- }
396
- return [...dirs];
397
- }
356
+ // detectWorkspaces: shared implementation in ark-shared.mjs (npm/pnpm/rush/lerna +
357
+ // conventional multi-package roots).
398
358
 
399
359
  // Deny every "upward" edge for an ordered layer list (index 0 = outermost/top,
400
360
  // which may import everything below it). Inner/lower layers must not import outer
@@ -455,7 +415,10 @@ function buildConfigFromPolicyPack(packId, root) {
455
415
  `Policy pack "${packId}" references unknown preset "${pack.preset}".`
456
416
  );
457
417
  }
458
- const workspaces = pack.preset === 'monorepo' ? detectWorkspaces(root) : [];
418
+ const workspaces =
419
+ pack.preset === 'monorepo' || pack.preset === 'ui-surface'
420
+ ? resolveIncludeRoots(root)
421
+ : [];
459
422
  const config = factory(workspaces, root);
460
423
  if (pack.layerDescriptions) {
461
424
  for (const layer of config.layers) {
@@ -553,7 +516,7 @@ const THIN_COVERAGE_PERCENT = 50;
553
516
  function maybeWarnBrownfield(root, config) {
554
517
  let files;
555
518
  try {
556
- files = (config.include ?? []).flatMap((entry) => walk(path.join(root, entry)));
519
+ files = collectGovernedFiles(root, config);
557
520
  } catch {
558
521
  return false;
559
522
  }
@@ -577,6 +540,144 @@ function maybeWarnBrownfield(root, config) {
577
540
  return true;
578
541
  }
579
542
 
543
+ /** Propose include roots (workspaces + nested TS packages) — contract-adopt primitive. */
544
+ function runSuggestInclude(args) {
545
+ const root = args.root;
546
+ const workspaces = detectWorkspaces(root);
547
+ const tsPackages = detectTsPackageRoots(root);
548
+ const include = resolveIncludeRoots(root);
549
+ const payload = {
550
+ ok: true,
551
+ workspaces,
552
+ tsPackages,
553
+ suggestedInclude: include.length > 0 ? include : tsPackages.length > 0 ? tsPackages : ['src'],
554
+ note:
555
+ include.length === 0 && tsPackages.length === 0
556
+ ? 'No TS packages or workspaces found — default suggestion is src/ (create it or pass include by hand).'
557
+ : 'Use these paths as ark.config.json "include". Prefer --adopt-contract --write to expand patterns too.',
558
+ };
559
+ if (args.json) {
560
+ console.log(JSON.stringify(payload, null, 2));
561
+ return;
562
+ }
563
+ console.log(color.bold('Suggested include roots'));
564
+ console.log(` workspaces: ${workspaces.join(', ') || '(none)'}`);
565
+ console.log(` tsPackages: ${tsPackages.join(', ') || '(none)'}`);
566
+ console.log(` suggestedInclude: ${payload.suggestedInclude.join(', ')}`);
567
+ console.log(color.dim(payload.note));
568
+ }
569
+
570
+ /**
571
+ * Contract-adopt: expand include + presentation patterns from ungoverned proposals.
572
+ * Read-only unless --write. Does not weaken rules or baseline violations.
573
+ */
574
+ function runAdoptContract(args) {
575
+ const root = args.root;
576
+ const configPath = path.isAbsolute(args.config)
577
+ ? args.config
578
+ : path.join(root, args.config);
579
+ let config;
580
+ try {
581
+ config = fs.existsSync(configPath)
582
+ ? readConfig(root, args.config)
583
+ : {
584
+ include: ['src'],
585
+ layers: ARCHITECTURE_PRESETS['ui-surface']([], root).layers,
586
+ rules: ARCHITECTURE_PRESETS['ui-surface']([], root).rules,
587
+ };
588
+ } catch (error) {
589
+ console.error(error instanceof Error ? error.message : String(error));
590
+ process.exitCode = 2;
591
+ return;
592
+ }
593
+ const suggestedInclude = resolveIncludeRoots(root);
594
+ const tsPackages = detectTsPackageRoots(root);
595
+ const nextInclude = [
596
+ ...new Set([
597
+ ...(config.include || []),
598
+ ...(suggestedInclude.length > 0 ? suggestedInclude : tsPackages),
599
+ ]),
600
+ ].filter(Boolean);
601
+ const files = collectGovernedFiles(root, { ...config, include: nextInclude.length ? nextInclude : config.include });
602
+ const cov = computeCoverage(root, { ...config, include: nextInclude.length ? nextInclude : config.include }, files, config.rules || []);
603
+ const uiPatterns = [
604
+ '**/components/**',
605
+ '**/hooks/**',
606
+ '**/lib/**',
607
+ '**/routes/**',
608
+ '**/app/**',
609
+ '**/pages/**',
610
+ ];
611
+ const layers = (config.layers || []).map((layer) => {
612
+ if (layer.name !== 'PresentationAdapters') return layer;
613
+ const patterns = [...new Set([...(layer.patterns || []), ...uiPatterns])];
614
+ return { ...layer, patterns };
615
+ });
616
+ // If no PresentationAdapters layer, leave layers as-is (don't invent full profile).
617
+ const proposal = {
618
+ ok: true,
619
+ before: {
620
+ include: config.include || [],
621
+ governedPercent: null,
622
+ totalFiles: null,
623
+ },
624
+ after: {
625
+ include: nextInclude.length > 0 ? nextInclude : config.include,
626
+ presentationPatterns: uiPatterns,
627
+ totalFiles: cov.totalFiles,
628
+ governedPercent: cov.governed.percent,
629
+ unclassified: cov.unclassified.count,
630
+ },
631
+ wrote: false,
632
+ };
633
+ // Compute before coverage for honesty.
634
+ try {
635
+ const beforeFiles = collectGovernedFiles(root, config);
636
+ const beforeCov = computeCoverage(root, config, beforeFiles, config.rules || []);
637
+ proposal.before.totalFiles = beforeCov.totalFiles;
638
+ proposal.before.governedPercent = beforeCov.governed.percent;
639
+ } catch {
640
+ /* ignore */
641
+ }
642
+
643
+ if (args.write) {
644
+ const next = {
645
+ ...config,
646
+ include: proposal.after.include,
647
+ layers,
648
+ };
649
+ fs.writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`);
650
+ proposal.wrote = true;
651
+ }
652
+
653
+ if (args.json) {
654
+ console.log(JSON.stringify(proposal, null, 2));
655
+ return;
656
+ }
657
+ console.log(color.bold('Contract adopt (coverage first)'));
658
+ console.log(
659
+ ` before: include=[${(proposal.before.include || []).join(', ')}] governed=${proposal.before.governedPercent ?? '?'}% files=${proposal.before.totalFiles ?? '?'}`
660
+ );
661
+ console.log(
662
+ ` after: include=[${(proposal.after.include || []).join(', ')}] governed=${proposal.after.governedPercent}% files=${proposal.after.totalFiles} unclassified=${proposal.after.unclassified}`
663
+ );
664
+ console.log(` presentation patterns += ${uiPatterns.join(', ')}`);
665
+ if (proposal.wrote) {
666
+ console.log(color.green(` wrote ${path.relative(root, configPath) || args.config}`));
667
+ console.log(color.dim(` Next: ${arkCommand(root, 'ark-check', '--coverage')} then --plan`));
668
+ } else {
669
+ console.log(color.dim(' Dry-run only. Re-run with --write to apply (does not weaken rules).'));
670
+ }
671
+ if ((proposal.after.totalFiles ?? 0) === 0) {
672
+ console.log(
673
+ color.yellow(
674
+ ' Empty scope remains — no TS packages found. Point include at your package roots manually.'
675
+ )
676
+ );
677
+ process.exitCode = 1;
678
+ }
679
+ }
680
+
580
681
  function runInit(args) {
581
682
  const configPath = path.isAbsolute(args.config)
582
683
  ? args.config
@@ -597,7 +698,12 @@ function runInit(args) {
597
698
  process.exitCode = 2;
598
699
  return;
599
700
  }
600
- const finalConfig = factory(detectWorkspaces(args.root), args.root);
701
+ const finalConfig = factory(
702
+ args.preset === 'monorepo' || args.preset === 'ui-surface'
703
+ ? resolveIncludeRoots(args.root)
704
+ : detectWorkspaces(args.root),
705
+ args.root
706
+ );
601
707
  fs.writeFileSync(configPath, `${JSON.stringify(finalConfig, null, 2)}\n`);
602
708
  console.log(`Wrote ${configPath} (${args.preset} preset)`);
603
709
  if (finalConfig.frameworkOverlay) {
@@ -625,8 +731,14 @@ function runInit(args) {
625
731
  // When no conventional src/ layout is found, a `workspaces` declaration means this is a
626
732
  // monorepo — the src/** 11-layer starter would match nothing there, so use the
627
733
  // cross-package monorepo profile anchored at the real workspace roots instead.
628
- const workspaces = greenfield ? detectWorkspaces(args.root) : [];
629
- const mode = !greenfield ? 'detected' : workspaces.length > 0 ? 'monorepo' : 'greenfield';
734
+ const includeRoots = greenfield ? resolveIncludeRoots(args.root) : [];
735
+ const tsPackages = greenfield ? detectTsPackageRoots(args.root) : [];
736
+ // Prefer monorepo/ui when nested TS packages exist without a conventional src layout.
737
+ const mode = !greenfield
738
+ ? 'detected'
739
+ : includeRoots.length > 0 || tsPackages.length > 0
740
+ ? 'monorepo'
741
+ : 'greenfield';
630
742
  // Greenfield: anchor the starter profile at src/ (the convention a fresh project will
631
743
  // scaffold under) even when src/ doesn't exist yet — the layers are optional, so the
632
744
  // check passes today and governance switches on the moment src/domain/ etc. appear.
@@ -635,7 +747,10 @@ function runInit(args) {
635
747
  mode === 'detected'
636
748
  ? applyFrameworkLayoutOverlays(config, args.root)
637
749
  : mode === 'monorepo'
638
- ? ARCHITECTURE_PRESETS.monorepo(workspaces, args.root)
750
+ ? ARCHITECTURE_PRESETS.monorepo(
751
+ includeRoots.length > 0 ? includeRoots : tsPackages,
752
+ args.root
753
+ )
639
754
  : createElevenLayerConfig({
640
755
  rootDir: srcDir === '.' ? 'src' : srcDir,
641
756
  root: args.root,
@@ -646,10 +761,11 @@ function runInit(args) {
646
761
  console.log(`Wrote ${configPath}`);
647
762
  console.log('');
648
763
  if (mode === 'monorepo') {
649
- console.log(`Monorepo detected (workspaces: ${workspaces.join(', ')}). Generated a cross-package`);
650
- console.log('profile matching domain/application/presentation/persistence directories in any');
651
- console.log('package. Every layer is optional, so the strict check passes now and each switches');
652
- console.log('on as matching directories gain files. Adjust patterns to your naming if they differ:');
764
+ const roots = finalConfig.include?.join(', ') || '(none)';
765
+ console.log(`Multi-package / TS package surface detected (include: ${roots}). Generated a`);
766
+ console.log('cross-package profile matching domain/application/presentation/persistence dirs');
767
+ console.log('in any package. Every layer is optional, so the strict check passes now and each');
768
+ console.log('switches on as matching directories gain files. Adjust patterns to your naming:');
653
769
  for (const layer of finalConfig.layers) {
654
770
  console.log(` ${layer.name}: ${layer.patterns.join(', ')}`);
655
771
  }
@@ -726,7 +842,6 @@ function runInit(args) {
726
842
  printInitNextSteps(args.root);
727
843
  }
728
844
 
729
-
730
845
  function readManifest(root, manifestPath) {
731
846
  if (!manifestPath) return undefined;
732
847
  const fullPath = path.isAbsolute(manifestPath)
@@ -738,653 +853,6 @@ function readManifest(root, manifestPath) {
738
853
  return readJson(fullPath);
739
854
  }
740
855
 
741
- const SOURCE_FILE_NAME = /\.[cm]?[tj]sx?$/;
742
-
743
- /** Unit/e2e test files are not architecture surface — agents and Nest put them next
744
- * to production code (*.spec.ts). Counting them as ungoverned forces false
745
- * CONFIG_UNCLASSIFIED_FILES under --strict-config on every starter. */
746
- const TEST_FILE_NAME =
747
- /\.(spec|test)\.(tsx?|jsx?|mts|cts)$/i;
748
-
749
- function isGovernableSourceFile(name) {
750
- return SOURCE_FILE_NAME.test(name) && !name.endsWith('.d.ts') && !TEST_FILE_NAME.test(name);
751
- }
752
-
753
- function isSkippedSourceDir(name) {
754
- return (
755
- name === 'node_modules' ||
756
- name === 'dist' ||
757
- name === 'coverage' ||
758
- name === '__tests__' ||
759
- name === '__mocks__' ||
760
- name === 'e2e' ||
761
- // Top-level style Nest/Jest folders (not "testing" helpers inside src)
762
- name === 'test' ||
763
- name === 'tests'
764
- );
765
- }
766
-
767
- function walk(dir, files = []) {
768
- const stat = fs.statSync(dir, { throwIfNoEntry: false });
769
- if (!stat) return files;
770
- // An `include` entry may be a single file (e.g. a root-level "middleware.ts"),
771
- // not just a directory — govern it directly instead of trying to scandir it
772
- // (which threw ENOTDIR). The extension filter still applies.
773
- if (stat.isFile()) {
774
- if (isGovernableSourceFile(path.basename(dir))) files.push(dir);
775
- return files;
776
- }
777
- if (!stat.isDirectory()) return files;
778
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
779
- const full = path.join(dir, entry.name);
780
- if (entry.isDirectory()) {
781
- if (isSkippedSourceDir(entry.name)) continue;
782
- walk(full, files);
783
- } else if (isGovernableSourceFile(entry.name)) {
784
- files.push(full);
785
- }
786
- }
787
- return files;
788
- }
789
-
790
- function normalize(value) {
791
- return value.split(path.sep).join('/');
792
- }
793
-
794
- function intentLayersFromManifest(manifest) {
795
- const layers = manifest?.architecture?.layers;
796
- if (!Array.isArray(layers)) return undefined;
797
- return layers
798
- .filter((layer) => Array.isArray(layer.prefixes) && layer.prefixes.length > 0)
799
- .map((layer) => ({ name: layer.name, prefixes: layer.prefixes }));
800
- }
801
-
802
- function layerForIntent(intent, layers, manifestIntentLayers) {
803
- // Use only layers that declare intent prefixes; fall back to the built-in defaults when
804
- // none do (mirrors the write-gate). resolveIntentLayer applies the library's exact
805
- // longest-prefix + trailing-dot semantics so CI and the MCP gate classify identically.
806
- const configured =
807
- manifestIntentLayers ??
808
- layers
809
- .filter((layer) => (layer.intentPrefixes ?? []).length > 0)
810
- .map((layer) => ({ name: layer.name, prefixes: layer.intentPrefixes }));
811
- const source =
812
- configured.length > 0
813
- ? configured
814
- : DEFAULT_INTENT_PREFIXES.map((entry) => ({ name: entry.layer, prefixes: entry.prefixes }));
815
- return resolveIntentLayer(intent, source);
816
- }
817
-
818
- function isBlocked(rules, from, to) {
819
- return rules.find((rule) => !rule.allowed && rule.from === from && rule.to === to);
820
- }
821
-
822
- function configWarning(ruleId, message, extra = {}) {
823
- return { ruleId, message, ...extra };
824
- }
825
-
826
- function collectConfigWarnings(root, config, files, rules, manifest) {
827
- const warnings = [];
828
- const layers = Array.isArray(config.layers) ? config.layers : [];
829
- const manifestLayers = Array.isArray(manifest?.architecture?.layers)
830
- ? manifest.architecture.layers
831
- : [];
832
- const knownLayers = new Set([
833
- ...layers.map((layer) => layer.name).filter(Boolean),
834
- ...manifestLayers.map((layer) => layer.name).filter(Boolean),
835
- ]);
836
-
837
- if (layers.length === 0) {
838
- warnings.push(
839
- configWarning(
840
- 'CONFIG_NO_LAYERS',
841
- 'No file layers are configured; ark-check cannot classify files for import-boundary enforcement.'
842
- )
843
- );
844
- }
845
-
846
- const seenLayers = new Set();
847
- const duplicateLayers = new Set();
848
- for (const layer of layers) {
849
- if (!layer.name) {
850
- warnings.push(
851
- configWarning('CONFIG_LAYER_WITHOUT_NAME', 'A configured layer is missing a name.')
852
- );
853
- continue;
854
- }
855
- if (seenLayers.has(layer.name)) duplicateLayers.add(layer.name);
856
- seenLayers.add(layer.name);
857
-
858
- if (
859
- layer.forbiddenGlobals !== undefined &&
860
- (!Array.isArray(layer.forbiddenGlobals) ||
861
- layer.forbiddenGlobals.some((entry) => typeof entry !== 'string'))
862
- ) {
863
- warnings.push(
864
- configWarning(
865
- 'CONFIG_INVALID_FORBIDDEN_GLOBALS',
866
- `Layer "${layer.name}" has an invalid forbiddenGlobals value; expected an array of strings (e.g. ["fetch", "Date.now"]). The entry is ignored.`,
867
- { layer: layer.name }
868
- )
869
- );
870
- }
871
-
872
- const patterns = Array.isArray(layer.patterns) ? layer.patterns : [];
873
- if (patterns.length === 0) {
874
- warnings.push(
875
- configWarning(
876
- 'CONFIG_LAYER_WITHOUT_PATTERNS',
877
- `Layer "${layer.name}" has no file patterns and will never classify files.`,
878
- { layer: layer.name }
879
- )
880
- );
881
- continue;
882
- }
883
-
884
- for (const pattern of patterns) {
885
- let re;
886
- try {
887
- re = globToRegExp(pattern);
888
- } catch (err) {
889
- warnings.push(
890
- configWarning(
891
- 'CONFIG_INVALID_LAYER_PATTERN',
892
- `Layer "${layer.name}" has an invalid pattern "${pattern}": ${
893
- err instanceof Error ? err.message : String(err)
894
- }`,
895
- { layer: layer.name, pattern }
896
- )
897
- );
898
- continue;
899
- }
900
-
901
- const matched = files.some((file) => {
902
- const rel = normalize(path.relative(root, file));
903
- return re.test(rel);
904
- });
905
- if (!matched && !layer.optional) {
906
- warnings.push(
907
- configWarning(
908
- 'CONFIG_LAYER_PATTERN_NO_MATCHES',
909
- `Layer "${layer.name}" pattern "${pattern}" matched no included files.`,
910
- { layer: layer.name, pattern }
911
- )
912
- );
913
- }
914
- }
915
- }
916
-
917
- for (const name of duplicateLayers) {
918
- warnings.push(
919
- configWarning(
920
- 'CONFIG_DUPLICATE_LAYER',
921
- `Layer "${name}" is configured more than once.`,
922
- { layer: name }
923
- )
924
- );
925
- }
926
-
927
- if (knownLayers.size > 0) {
928
- for (const rule of rules ?? []) {
929
- if (rule.from && !knownLayers.has(rule.from)) {
930
- warnings.push(
931
- configWarning(
932
- 'CONFIG_RULE_UNKNOWN_FROM_LAYER',
933
- `Rule references unknown source layer "${rule.from}".`,
934
- { fromLayer: rule.from, toLayer: rule.to }
935
- )
936
- );
937
- }
938
- if (rule.to && !knownLayers.has(rule.to)) {
939
- warnings.push(
940
- configWarning(
941
- 'CONFIG_RULE_UNKNOWN_TO_LAYER',
942
- `Rule references unknown target layer "${rule.to}".`,
943
- { fromLayer: rule.from, toLayer: rule.to }
944
- )
945
- );
946
- }
947
- }
948
- }
949
-
950
- // Ambiguous overlap: a file matched by two different layers at the SAME top specificity.
951
- // layerForFile breaks the tie by declaration order, but the config is genuinely undecided
952
- // (unlike a facade split, where the surface pattern is strictly more specific and wins
953
- // cleanly). Surface the layer pairs so the author disambiguates instead of relying on order.
954
- const ambiguousPairs = new Set();
955
- if (layers.length > 1) {
956
- for (const file of files) {
957
- const rel = normalize(path.relative(root, file));
958
- let topScore = -1;
959
- let topLayers = [];
960
- for (const layer of layers) {
961
- for (const pattern of layer.patterns ?? []) {
962
- if (!globToRegExp(pattern).test(rel)) continue;
963
- const score = patternSpecificity(pattern);
964
- if (score > topScore) {
965
- topScore = score;
966
- topLayers = [layer.name];
967
- } else if (score === topScore && !topLayers.includes(layer.name)) {
968
- topLayers.push(layer.name);
969
- }
970
- }
971
- }
972
- if (topLayers.length > 1) {
973
- ambiguousPairs.add([...topLayers].sort().join(' + '));
974
- }
975
- }
976
- }
977
- if (ambiguousPairs.size > 0) {
978
- warnings.push(
979
- configWarning(
980
- 'CONFIG_AMBIGUOUS_LAYERS',
981
- `Some files match multiple layers at equal specificity; classification falls back to declaration order. Disambiguate the overlapping patterns: ${[...ambiguousPairs].join(', ')}.`,
982
- { pairs: [...ambiguousPairs] }
983
- )
984
- );
985
- }
986
-
987
- const unclassified = files.filter((file) => !layerForFile(root, file, layers));
988
- if (unclassified.length > 0) {
989
- warnings.push(
990
- configWarning(
991
- 'CONFIG_UNCLASSIFIED_FILES',
992
- `${unclassified.length} included source file(s) are not matched by any configured layer; ark-check will not enforce import rules for those source files.`,
993
- {
994
- count: unclassified.length,
995
- samples: unclassified.slice(0, 5).map((file) => normalize(path.relative(root, file))),
996
- }
997
- )
998
- );
999
- }
1000
-
1001
- return warnings;
1002
- }
1003
-
1004
- function createModuleResolutionHost(ts) {
1005
- const sys = ts?.sys;
1006
- const fileExists = (f) => {
1007
- if (sys?.fileExists) return sys.fileExists(f);
1008
- return fs.existsSync(f);
1009
- };
1010
- const readFile = (f) => {
1011
- if (sys?.readFile) return sys.readFile(f);
1012
- try {
1013
- return fs.readFileSync(f, 'utf8');
1014
- } catch {
1015
- return undefined;
1016
- }
1017
- };
1018
- const directoryExists = (d) => {
1019
- if (sys?.directoryExists) return sys.directoryExists(d);
1020
- try {
1021
- return fs.statSync(d).isDirectory();
1022
- } catch {
1023
- return false;
1024
- }
1025
- };
1026
- return {
1027
- fileExists,
1028
- readFile,
1029
- directoryExists,
1030
- getCurrentDirectory: () =>
1031
- sys?.getCurrentDirectory ? sys.getCurrentDirectory() : process.cwd(),
1032
- getDirectories: (d) => {
1033
- if (sys?.getDirectories) return sys.getDirectories(d);
1034
- try {
1035
- return fs
1036
- .readdirSync(d, { withFileTypes: true })
1037
- .filter((e) => e.isDirectory())
1038
- .map((e) => e.name);
1039
- } catch {
1040
- return [];
1041
- }
1042
- },
1043
- realpath: sys?.realpath ? (p) => sys.realpath(p) : undefined,
1044
- useCaseSensitiveFileNames: sys?.useCaseSensitiveFileNames ?? true,
1045
- };
1046
- }
1047
-
1048
- function parseTsconfig(ts, configPath) {
1049
- const host = createModuleResolutionHost(ts);
1050
- const read = ts.readConfigFile(configPath, host.readFile);
1051
- if (read.error) return {};
1052
- // parseJsonConfigFileContent wants a ParseConfigHost-like object; our resolution host
1053
- // is enough for option extraction.
1054
- const parsed = ts.parseJsonConfigFileContent(
1055
- read.config,
1056
- {
1057
- useCaseSensitiveFileNames: host.useCaseSensitiveFileNames,
1058
- readDirectory: ts.sys?.readDirectory
1059
- ? (...args) => ts.sys.readDirectory(...args)
1060
- : () => [],
1061
- fileExists: host.fileExists,
1062
- readFile: host.readFile,
1063
- },
1064
- path.dirname(configPath)
1065
- );
1066
- return parsed.options;
1067
- }
1068
-
1069
- /**
1070
- * Compiler options for a given source file. With --tsconfig every file uses that one
1071
- * config; otherwise each file uses the NEAREST tsconfig.json above it (like tsc does),
1072
- * so monorepo packages with per-package path aliases resolve correctly under one --root.
1073
- */
1074
- function createCompilerOptionsLookup(ts, root, tsconfigArg) {
1075
- if (tsconfigArg) {
1076
- const configPath = path.isAbsolute(tsconfigArg) ? tsconfigArg : path.join(root, tsconfigArg);
1077
- const options = fs.existsSync(configPath) ? parseTsconfig(ts, configPath) : {};
1078
- return () => options;
1079
- }
1080
- const byDir = new Map();
1081
- const byConfig = new Map();
1082
- return (file) => {
1083
- const dir = path.dirname(file);
1084
- if (byDir.has(dir)) return byDir.get(dir);
1085
- const configPath = ts.findConfigFile(dir, ts.sys.fileExists, 'tsconfig.json');
1086
- let options = {};
1087
- if (configPath) {
1088
- if (!byConfig.has(configPath)) byConfig.set(configPath, parseTsconfig(ts, configPath));
1089
- options = byConfig.get(configPath);
1090
- }
1091
- byDir.set(dir, options);
1092
- return options;
1093
- };
1094
- }
1095
-
1096
- /**
1097
- * Per-file scan cache. A cache entry stores the parsed file's content-derived results:
1098
- * content violations (forbidden globals, publish checks, intent references) and the list
1099
- * of module-edge specifiers. Edges are NEVER cached as violations — they are re-resolved
1100
- * against the live filesystem every run, because resolution depends on files and tsconfigs
1101
- * outside the cached file. The whole cache is keyed by the config+manifest contents, so
1102
- * any rule change invalidates everything.
1103
- */
1104
- function scanCachePath(root) {
1105
- return path.join(root, 'node_modules', '.cache', 'ark-check.json');
1106
- }
1107
-
1108
- function scanCacheKey(root, args) {
1109
- const read = (p) => {
1110
- try {
1111
- return fs.readFileSync(p, 'utf8');
1112
- } catch {
1113
- return '';
1114
- }
1115
- };
1116
- const configPath = path.isAbsolute(args.config) ? args.config : path.join(root, args.config);
1117
- const manifestPath = args.manifest
1118
- ? path.isAbsolute(args.manifest)
1119
- ? args.manifest
1120
- : path.join(root, args.manifest)
1121
- : undefined;
1122
- // Bump this schema tag whenever the cached scan shape changes, so a warm cache from an
1123
- // older Ark can't feed stale entries to new logic. v2: typeOnly on edges. v3: per-file
1124
- // exportsOnlyTypes (target-module type-only export detection for plan classifier).
1125
- return crypto
1126
- .createHash('sha1')
1127
- .update(`ark-check-cache-v3\0${read(configPath)}\0${manifestPath ? read(manifestPath) : ''}`)
1128
- .digest('hex');
1129
- }
1130
-
1131
- function loadScanCache(root, key) {
1132
- try {
1133
- const data = JSON.parse(fs.readFileSync(scanCachePath(root), 'utf8'));
1134
- return data.key === key && data.files && typeof data.files === 'object' ? data.files : undefined;
1135
- } catch {
1136
- return undefined;
1137
- }
1138
- }
1139
-
1140
- function saveScanCache(root, key, files) {
1141
- try {
1142
- const target = scanCachePath(root);
1143
- fs.mkdirSync(path.dirname(target), { recursive: true });
1144
- fs.writeFileSync(target, JSON.stringify({ key, files }));
1145
- } catch {
1146
- // cache is best-effort: read-only filesystems just re-parse every run
1147
- }
1148
- }
1149
-
1150
- /**
1151
- * Fallback resolver for extensionless relative imports whose on-disk target uses an
1152
- * extension `ts.resolveModuleName` won't resolve without a matching tsconfig
1153
- * (notably `.mts`/`.cts`). Mirrors the classic candidate list.
1154
- */
1155
- function isFile(candidate) {
1156
- try {
1157
- return fs.statSync(candidate).isFile();
1158
- } catch {
1159
- return false;
1160
- }
1161
- }
1162
-
1163
- function resolveRelativeFallback(fromFile, specifier) {
1164
- const base = path.resolve(path.dirname(fromFile), specifier);
1165
- const candidates = [
1166
- base, // only used when the specifier already carries an extension (isFile filters dirs)
1167
- `${base}.ts`,
1168
- `${base}.tsx`,
1169
- `${base}.mts`,
1170
- `${base}.cts`,
1171
- `${base}.js`,
1172
- `${base}.jsx`,
1173
- `${base}.mjs`,
1174
- `${base}.cjs`,
1175
- path.join(base, 'index.ts'),
1176
- path.join(base, 'index.tsx'),
1177
- path.join(base, 'index.mts'),
1178
- path.join(base, 'index.cts'),
1179
- ];
1180
- // isFile (not existsSync) so a directory named like the specifier never shadows the
1181
- // real module file — e.g. `./foo` must not resolve to a `foo/` directory before `foo.mts`.
1182
- return candidates.find(isFile);
1183
- }
1184
-
1185
- /**
1186
- * Resolve any import specifier (relative, tsconfig path-alias, or package) to a source
1187
- * file using TypeScript's module resolver, returning the resolved file (or undefined for
1188
- * unresolved / declaration-only targets).
1189
- *
1190
- * ark-check governs one project rooted at --root. A resolved target is skipped when its
1191
- * path RELATIVE TO ROOT either escapes the root (leading `..`) or contains a `node_modules`
1192
- * segment. Using the root-relative path (not an absolute substring) means a project that
1193
- * itself lives under a node_modules segment is still governed, while a broad catch-all
1194
- * pattern (`**`) can't false-flag vendored deps or files outside the project. Monorepos can
1195
- * run under a single --root (per-package tsconfigs are honored via the nearest-tsconfig
1196
- * lookup); edges that resolve outside the root are still skipped.
1197
- */
1198
- function resolveImport(ts, specifier, containingFile, options, host, root) {
1199
- const res = ts.resolveModuleName(specifier, containingFile, options, host);
1200
- let file = res.resolvedModule?.resolvedFileName;
1201
- if (!file && specifier.startsWith('.')) {
1202
- file = resolveRelativeFallback(containingFile, specifier);
1203
- }
1204
- if (!file) return undefined;
1205
- if (file.endsWith('.d.ts')) return undefined;
1206
- const abs = path.resolve(file);
1207
- const segments = path.relative(root, abs).split(path.sep);
1208
- if (segments[0] === '..' || segments.includes('node_modules')) return undefined;
1209
- return abs;
1210
- }
1211
-
1212
- function lineOf(sourceFile, pos) {
1213
- return sourceFile.getLineAndCharacterOfPosition(pos).line + 1;
1214
- }
1215
-
1216
- function textOfModuleSpecifier(node) {
1217
- return node.moduleSpecifier && typeof node.moduleSpecifier.text === 'string'
1218
- ? node.moduleSpecifier.text
1219
- : undefined;
1220
- }
1221
-
1222
- // True when an import/export edge carries ONLY types (`import type …`, or a named import
1223
- // where every binding is `type`-qualified). Type-only edges are erased at compile time —
1224
- // they create no runtime coupling, only a design/type-placement dependency — so callers can
1225
- // rank them below real value imports in a burn-down. A side-effect import (`import "x"`) or
1226
- // any default/namespace/value binding is NOT type-only.
1227
- function isTypeOnlyModuleReference(ts, node) {
1228
- if (ts.isImportDeclaration(node)) {
1229
- const clause = node.importClause;
1230
- if (!clause) return false; // side-effect import — runtime edge
1231
- if (clause.isTypeOnly) return true; // `import type …`
1232
- const named = clause.namedBindings;
1233
- if (named && ts.isNamedImports(named) && named.elements.length > 0) {
1234
- return named.elements.every((element) => element.isTypeOnly);
1235
- }
1236
- return false; // default or namespace binding of a value
1237
- }
1238
- if (ts.isExportDeclaration(node)) {
1239
- if (node.isTypeOnly) return true;
1240
- const clause = node.exportClause;
1241
- if (clause && ts.isNamedExports(clause) && clause.elements.length > 0) {
1242
- return clause.elements.every((element) => element.isTypeOnly);
1243
- }
1244
- return false;
1245
- }
1246
- return false;
1247
- }
1248
-
1249
- /**
1250
- * True when a module is a pure type-surface file: only type/interface exports and
1251
- * type-only imports. Conservative false (→ judgment) when:
1252
- * - any top-level runtime statement (value decls, expression stmts, side-effect imports)
1253
- * - ambiguous `export { X }` without type keyword, export *, default/export=
1254
- * Used so static value-syntax `import { T }` of a pure-type module can be mechanical-safe
1255
- * (convert to `import type`). Never trust this for require()/import() edges.
1256
- */
1257
- function sourceFileExportsOnlyTypes(ts, sourceFile) {
1258
- let sawTypeExport = false;
1259
- const hasExportModifier = (node) =>
1260
- Array.isArray(node.modifiers) &&
1261
- node.modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
1262
-
1263
- for (const stmt of sourceFile.statements) {
1264
- // Type-only imports OK; value or side-effect imports mean runtime load of deps.
1265
- if (ts.isImportDeclaration(stmt)) {
1266
- if (!isTypeOnlyModuleReference(ts, stmt)) return false;
1267
- continue;
1268
- }
1269
- if (typeof ts.isImportEqualsDeclaration === 'function' && ts.isImportEqualsDeclaration(stmt)) {
1270
- return false;
1271
- }
1272
- if (ts.isExportDeclaration(stmt)) {
1273
- if (stmt.isTypeOnly) {
1274
- sawTypeExport = true;
1275
- continue;
1276
- }
1277
- // export * from '…' can re-export values — not provably type-only.
1278
- if (!stmt.exportClause) return false;
1279
- if (ts.isNamespaceExport(stmt.exportClause)) return false;
1280
- if (ts.isNamedExports(stmt.exportClause)) {
1281
- if (stmt.exportClause.elements.length === 0) return false;
1282
- for (const el of stmt.exportClause.elements) {
1283
- if (!el.isTypeOnly) return false; // bare `export { X }` — ambiguous without checker
1284
- }
1285
- sawTypeExport = true;
1286
- continue;
1287
- }
1288
- return false;
1289
- }
1290
- if (ts.isExportAssignment(stmt)) return false; // export = / export default expr
1291
- if (ts.isTypeAliasDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)) {
1292
- if (hasExportModifier(stmt)) sawTypeExport = true;
1293
- continue;
1294
- }
1295
- // Any other top-level statement (const/fn/class/enum, console.log, if, …) is runtime.
1296
- return false;
1297
- }
1298
- return sawTypeExport;
1299
- }
1300
-
1301
- function propertyName(ts, node) {
1302
- if (!node) return undefined;
1303
- if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) return node.text;
1304
- return undefined;
1305
- }
1306
-
1307
- function objectProperty(ts, node, name) {
1308
- if (!node || !ts.isObjectLiteralExpression(node)) return undefined;
1309
- return node.properties.find((property) => {
1310
- if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) {
1311
- return false;
1312
- }
1313
- return propertyName(ts, property.name) === name;
1314
- });
1315
- }
1316
-
1317
- function objectHasProperty(ts, node, name) {
1318
- return objectProperty(ts, node, name) !== undefined;
1319
- }
1320
-
1321
- function objectPropertyValue(ts, node, name) {
1322
- const property = objectProperty(ts, node, name);
1323
- return property && ts.isPropertyAssignment(property)
1324
- ? property.initializer
1325
- : undefined;
1326
- }
1327
-
1328
- function objectHasMetadataSource(ts, node) {
1329
- const metadata = objectPropertyValue(ts, node, 'metadata');
1330
- return objectHasProperty(ts, metadata, 'source');
1331
- }
1332
-
1333
- function stringLiteralText(ts, node) {
1334
- return node && ts.isStringLiteralLike(node) ? node.text : undefined;
1335
- }
1336
-
1337
- function isPublishCall(ts, node) {
1338
- if (!ts.isCallExpression(node)) return false;
1339
- const expression = node.expression;
1340
- if (ts.isPropertyAccessExpression(expression)) {
1341
- return expression.name.text === 'publish';
1342
- }
1343
- return ts.isIdentifier(expression) && expression.text === 'publish';
1344
- }
1345
-
1346
- function looksLikeIntentCreatorExpression(ts, node) {
1347
- if (!node) return false;
1348
- if (ts.isIdentifier(node)) {
1349
- return /^[A-Z]/.test(node.text);
1350
- }
1351
- if (ts.isPropertyAccessExpression(node)) {
1352
- return looksLikeIntentCreatorExpression(ts, node.name);
1353
- }
1354
- return false;
1355
- }
1356
-
1357
- function isArkPublishCandidate(ts, node) {
1358
- if (!ts.isCallExpression(node)) return false;
1359
- const firstArg = node.arguments[0];
1360
- const rawIntent = stringLiteralText(ts, firstArg);
1361
- return (
1362
- (rawIntent !== undefined && looksLikeIntent(rawIntent)) ||
1363
- objectHasProperty(ts, firstArg, 'intent') ||
1364
- looksLikeIntentCreatorExpression(ts, firstArg)
1365
- );
1366
- }
1367
-
1368
- function publishSourceLiteral(ts, node) {
1369
- if (!ts.isCallExpression(node)) return undefined;
1370
- const [firstArg, secondArg, thirdArg] = node.arguments;
1371
- const rawMetadata = objectPropertyValue(ts, firstArg, 'metadata');
1372
- return (
1373
- stringLiteralText(ts, objectPropertyValue(ts, rawMetadata, 'source')) ??
1374
- stringLiteralText(ts, objectPropertyValue(ts, secondArg, 'source')) ??
1375
- stringLiteralText(ts, objectPropertyValue(ts, thirdArg, 'source'))
1376
- );
1377
- }
1378
-
1379
- function publishHasSource(ts, node) {
1380
- if (!ts.isCallExpression(node)) return false;
1381
- const [firstArg, secondArg, thirdArg] = node.arguments;
1382
- return (
1383
- objectHasMetadataSource(ts, firstArg) ||
1384
- objectHasProperty(ts, secondArg, 'source') ||
1385
- objectHasProperty(ts, thirdArg, 'source')
1386
- );
1387
- }
1388
856
  const useColor = process.stderr.isTTY && !process.env.NO_COLOR;
1389
857
  const color = {
1390
858
  red: (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
@@ -1394,83 +862,6 @@ const color = {
1394
862
  bold: (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
1395
863
  };
1396
864
 
1397
- function detectCycles(graph) {
1398
- let index = 0;
1399
- const indices = new Map();
1400
- const low = new Map();
1401
- const onStack = new Set();
1402
- const stack = [];
1403
- const components = [];
1404
-
1405
- // ponytail: recursive Tarjan; make it iterative only if a real repo blows the stack.
1406
- const strongconnect = (v) => {
1407
- indices.set(v, index);
1408
- low.set(v, index);
1409
- index += 1;
1410
- stack.push(v);
1411
- onStack.add(v);
1412
- for (const w of [...(graph.get(v) ?? [])].sort()) {
1413
- if (!graph.has(w)) continue;
1414
- if (!indices.has(w)) {
1415
- strongconnect(w);
1416
- low.set(v, Math.min(low.get(v), low.get(w)));
1417
- } else if (onStack.has(w)) {
1418
- low.set(v, Math.min(low.get(v), indices.get(w)));
1419
- }
1420
- }
1421
- if (low.get(v) === indices.get(v)) {
1422
- const comp = [];
1423
- let w;
1424
- do {
1425
- w = stack.pop();
1426
- onStack.delete(w);
1427
- comp.push(w);
1428
- } while (w !== v);
1429
- if (comp.length > 1) components.push(comp.sort());
1430
- }
1431
- };
1432
-
1433
- for (const v of [...graph.keys()].sort()) {
1434
- if (!indices.has(v)) strongconnect(v);
1435
- }
1436
-
1437
- return components
1438
- .sort((a, b) => a[0].localeCompare(b[0]))
1439
- .map((members) => ({
1440
- ruleId: 'CIRCULAR_DEPENDENCY',
1441
- file: members[0],
1442
- line: 1,
1443
- target: members.join(' → '),
1444
- message: `Circular dependency among ${members.length} files: ${members.join(' → ')} → ${members[0]}.`,
1445
- }));
1446
- }
1447
-
1448
-
1449
- function moduleSpecifierFromCall(ts, node) {
1450
- if (!ts.isCallExpression(node)) return undefined;
1451
-
1452
- if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
1453
- const first = node.arguments[0];
1454
- const value = stringLiteralText(ts, first);
1455
- return value ? { value, kind: 'dynamic-import' } : undefined;
1456
- }
1457
-
1458
- if (ts.isIdentifier(node.expression) && node.expression.text === 'require') {
1459
- const first = node.arguments[0];
1460
- const value = stringLiteralText(ts, first);
1461
- return value ? { value, kind: 'require' } : undefined;
1462
- }
1463
-
1464
- return undefined;
1465
- }
1466
-
1467
- // --coverage: a standalone visibility report (never changes the exit code). Answers
1468
- // "which files does each layer actually govern, and what is slipping through?" — the
1469
- // data the /ark-coverage skill otherwise has to hand-roll with find/readdir walks.
1470
- // Pure coverage computation (glob-only, no TypeScript): the object both `--coverage` and
1471
- // `--doctor` render. `governed` is the headline honesty number — the share of in-scope code
1472
- // Ark actually enforces rules on; `suggestions` proposes a layer for each ungoverned dir.
1473
-
1474
865
  async function main() {
1475
866
  const args = parseArgs(process.argv);
1476
867
  if (args.version) {
@@ -1509,6 +900,16 @@ async function main() {
1509
900
  return;
1510
901
  }
1511
902
 
903
+ if (args.suggestInclude) {
904
+ runSuggestInclude(args);
905
+ return;
906
+ }
907
+
908
+ if (args.adoptContract) {
909
+ runAdoptContract(args);
910
+ return;
911
+ }
912
+
1512
913
  if (args.recommend) {
1513
914
  try {
1514
915
  const recommendation = buildArchitectureRecommendation(args.root);
@@ -1582,7 +983,7 @@ async function main() {
1582
983
  const config = readConfig(root, args.config);
1583
984
  const manifest = readManifest(root, args.manifest);
1584
985
  const rules = manifest?.architecture?.rules ?? config.rules;
1585
- const files = config.include.flatMap((entry) => walk(path.join(root, entry)));
986
+ const files = collectGovernedFiles(root, config);
1586
987
 
1587
988
  // --coverage is a pure glob/report view (no TypeScript resolver), so serve it BEFORE the
1588
989
  // TS import: the report must work — and exit 0 — even when typescript isn't installed.
@@ -1627,203 +1028,15 @@ async function main() {
1627
1028
  );
1628
1029
  }
1629
1030
 
1630
- const manifestIntentLayers = intentLayersFromManifest(manifest);
1631
- const compilerOptionsFor = createCompilerOptionsLookup(ts, root, args.tsconfig);
1632
- const moduleHost = createModuleResolutionHost(ts);
1633
-
1634
- const violations = [];
1635
- const warnings = collectConfigWarnings(root, config, files, rules, manifest);
1636
- const cacheKey = args.noCache ? undefined : scanCacheKey(root, args);
1637
- const cachedFiles = cacheKey ? loadScanCache(root, cacheKey) : undefined;
1638
- const nextCacheFiles = {};
1639
-
1640
- // Parses one file and returns its cacheable scan result: violations derived purely from
1641
- // the file's content (+config/manifest, hashed into the cache key) and the module-edge
1642
- // specifiers found, which the driver loop below resolves fresh on every run.
1643
- function scanSourceFile(file, sourceLayer) {
1644
- const source = fs.readFileSync(file, 'utf8');
1645
- const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
1646
- const violations = [];
1647
- const edges = [];
1648
-
1649
- const layerConfig = config.layers.find((layer) => layer.name === sourceLayer);
1650
- const forbiddenGlobals = Array.isArray(layerConfig?.forbiddenGlobals)
1651
- ? layerConfig.forbiddenGlobals.filter((entry) => typeof entry === 'string')
1652
- : [];
1653
- for (const use of collectForbiddenGlobalUses(ts, sourceFile, forbiddenGlobals)) {
1654
- violations.push({
1655
- ruleId: 'FORBIDDEN_GLOBAL',
1656
- file: normalize(path.relative(root, file)),
1657
- line: lineOf(sourceFile, use.node.getStart(sourceFile)),
1658
- fromLayer: sourceLayer,
1659
- target: use.name,
1660
- message: `${sourceLayer} must not use the ambient global "${use.name}".`,
1661
- });
1662
- }
1663
-
1664
- const checkModuleEdge = (specifier, node, kind, typeOnly = false) => {
1665
- edges.push({ specifier, line: lineOf(sourceFile, node.getStart(sourceFile)), kind, typeOnly });
1666
- };
1667
-
1668
- const visit = (node) => {
1669
- if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
1670
- const specifier = textOfModuleSpecifier(node);
1671
- if (specifier) {
1672
- checkModuleEdge(
1673
- specifier,
1674
- node,
1675
- ts.isImportDeclaration(node) ? 'import' : 'export',
1676
- isTypeOnlyModuleReference(ts, node)
1677
- );
1678
- }
1679
- }
1680
-
1681
- if (ts.isCallExpression(node)) {
1682
- const moduleCall = moduleSpecifierFromCall(ts, node);
1683
- if (moduleCall) {
1684
- checkModuleEdge(moduleCall.value, node, moduleCall.kind);
1685
- }
1686
-
1687
- if (isPublishCall(ts, node)) {
1688
- const firstArg = node.arguments[0];
1689
- const rawIntent = stringLiteralText(ts, firstArg);
1690
- if (
1691
- (rawIntent && looksLikeIntent(rawIntent)) ||
1692
- objectHasProperty(ts, firstArg, 'intent')
1693
- ) {
1694
- violations.push({
1695
- ruleId: 'RAW_EVENT_PUBLISH',
1696
- file: normalize(path.relative(root, file)),
1697
- line: lineOf(sourceFile, node.getStart(sourceFile)),
1698
- message:
1699
- 'Publish through a registered intent creator; raw event objects or intent strings bypass Ark contracts and tooling.',
1700
- });
1701
- }
1702
-
1703
- if (isArkPublishCandidate(ts, node) && !publishHasSource(ts, node)) {
1704
- violations.push({
1705
- ruleId: 'PUBLISH_MISSING_SOURCE',
1706
- file: normalize(path.relative(root, file)),
1707
- line: lineOf(sourceFile, node.getStart(sourceFile)),
1708
- fromLayer: sourceLayer,
1709
- message: 'Strict Ark publish calls must include metadata.source.',
1710
- });
1711
- }
1712
-
1713
- const sourceIntent = publishSourceLiteral(ts, node);
1714
- if (sourceIntent && looksLikeIntent(sourceIntent)) {
1715
- const sourceIntentLayer = layerForIntent(
1716
- sourceIntent,
1717
- config.layers,
1718
- manifestIntentLayers
1719
- );
1720
- if (sourceIntentLayer && sourceIntentLayer !== sourceLayer) {
1721
- violations.push({
1722
- ruleId: 'PUBLISH_SOURCE_LAYER_MISMATCH',
1723
- file: normalize(path.relative(root, file)),
1724
- line: lineOf(sourceFile, node.getStart(sourceFile)),
1725
- fromLayer: sourceLayer,
1726
- toLayer: sourceIntentLayer,
1727
- target: sourceIntent,
1728
- message:
1729
- `Publish source "${sourceIntent}" resolves to ${sourceIntentLayer}, but the publishing file is classified as ${sourceLayer}.`,
1730
- });
1731
- }
1732
- }
1733
- }
1734
- }
1735
-
1736
- if (ts.isStringLiteralLike(node) && looksLikeIntent(node.text)) {
1737
- const targetLayer = layerForIntent(node.text, config.layers, manifestIntentLayers);
1738
- const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
1739
- if (rule) {
1740
- violations.push({
1741
- ruleId: 'LAYER_INTENT_REFERENCE_VIOLATION',
1742
- file: normalize(path.relative(root, file)),
1743
- line: lineOf(sourceFile, node.getStart(sourceFile)),
1744
- fromLayer: sourceLayer,
1745
- toLayer: targetLayer,
1746
- target: node.text,
1747
- message:
1748
- rule.message ??
1749
- `${sourceLayer} must not reference ${targetLayer} intent ${node.text}.`,
1750
- });
1751
- }
1752
- }
1753
-
1754
- ts.forEachChild(node, visit);
1755
- };
1756
- visit(sourceFile);
1757
- return {
1758
- contentViolations: violations,
1759
- edges,
1760
- exportsOnlyTypes: sourceFileExportsOnlyTypes(ts, sourceFile),
1761
- };
1762
- }
1763
-
1764
- // Pass 1: scan every governed file into nextCacheFiles (needs complete map before
1765
- // targetTypeOnlyExports can be resolved for import edges).
1766
- const importGraph = new Map();
1767
- const scanned = []; // { file, sourceLayer, relFile, entry }
1768
- for (const file of files) {
1769
- const sourceLayer = layerForFile(root, file, config.layers);
1770
- if (!sourceLayer) continue;
1771
- const relFile = normalize(path.relative(root, file));
1772
- if (!importGraph.has(relFile)) importGraph.set(relFile, new Set());
1773
- const stat = fs.statSync(file);
1774
- const fileKey = `${stat.mtimeMs}:${stat.size}`;
1775
- const cached = cachedFiles?.[relFile];
1776
- const entry =
1777
- cached && cached.fileKey === fileKey
1778
- ? cached
1779
- : { fileKey, ...scanSourceFile(file, sourceLayer) };
1780
- nextCacheFiles[relFile] = entry;
1781
- scanned.push({ file, sourceLayer, relFile, entry });
1782
- }
1783
-
1784
- // Pass 2: content violations + layer edges (with target type-export surface).
1785
- for (const { file, sourceLayer, relFile, entry } of scanned) {
1786
- violations.push(...entry.contentViolations);
1787
- for (const edge of entry.edges) {
1788
- const target = resolveImport(ts, edge.specifier, file, compilerOptionsFor(file), moduleHost, root);
1789
- const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
1790
- if (target && targetLayer) {
1791
- const relTarget = normalize(path.relative(root, target));
1792
- if (relTarget !== relFile) importGraph.get(relFile).add(relTarget);
1793
- }
1794
- const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
1795
- if (rule) {
1796
- const relTarget = normalize(path.relative(root, target));
1797
- // After pass 1 every in-scope target is in nextCacheFiles. Missing → not type-only.
1798
- // targetTypeOnlyExports only for static import/export declarations — never require()
1799
- // or dynamic import(), which always load the module at runtime (side effects matter).
1800
- const targetCached = nextCacheFiles[relTarget];
1801
- const staticEdge = edge.kind === 'import' || edge.kind === 'export';
1802
- const targetTypeOnlyExports =
1803
- staticEdge && Boolean(targetCached?.exportsOnlyTypes) && !edge.typeOnly;
1804
- // Importer is itself a pure type-surface file (no runtime body) — enables
1805
- // pure-type-file-relocate classification when the edge is type-only.
1806
- const sourcePureTypeModule = Boolean(entry.exportsOnlyTypes);
1807
- violations.push({
1808
- ruleId: 'LAYER_IMPORT_VIOLATION',
1809
- file: relFile,
1810
- line: edge.line,
1811
- fromLayer: sourceLayer,
1812
- toLayer: targetLayer,
1813
- target: relTarget,
1814
- ...(edge.typeOnly ? { typeOnly: true } : {}),
1815
- ...(targetTypeOnlyExports ? { targetTypeOnlyExports: true } : {}),
1816
- ...(sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
1817
- ...(edge.kind ? { edgeKind: edge.kind } : {}),
1818
- message: rule.message ?? `${sourceLayer} must not ${edge.kind} ${targetLayer}.`,
1819
- });
1820
- }
1821
- }
1822
- }
1823
-
1824
- if (cacheKey) saveScanCache(root, cacheKey, nextCacheFiles);
1825
-
1826
- violations.push(...detectCycles(importGraph));
1031
+ const { violations, warnings } = runArchitectureScan({
1032
+ root,
1033
+ config,
1034
+ manifest,
1035
+ rules,
1036
+ files,
1037
+ ts,
1038
+ args,
1039
+ });
1827
1040
 
1828
1041
  if (args.doctor) {
1829
1042
  runDoctor(root, config, files, rules, violations, args.json, {
@@ -1848,6 +1061,24 @@ async function main() {
1848
1061
  process.exitCode = 2;
1849
1062
  return;
1850
1063
  }
1064
+ const baselineName = args.baseline || '.ark-baseline.json';
1065
+ const fullBaselinePath = path.isAbsolute(baselineName)
1066
+ ? baselineName
1067
+ : path.join(root, baselineName);
1068
+ // Zero debt: do not leave an empty baseline file (unclear policy — "is ratchet on?").
1069
+ // Delete any existing empty/orphan baseline so doctor/CI stay honest.
1070
+ if (violations.length === 0) {
1071
+ if (fs.existsSync(fullBaselinePath)) {
1072
+ fs.unlinkSync(fullBaselinePath);
1073
+ console.log(
1074
+ `No violations to freeze — removed empty baseline ${fullBaselinePath} (zero debt; no ratchet file needed).`
1075
+ );
1076
+ } else {
1077
+ console.log('No violations to freeze — baseline not written (zero debt).');
1078
+ }
1079
+ console.log('Gate with: ark-check --root . --config ark.config.json --strict-config');
1080
+ return;
1081
+ }
1851
1082
  const { fullPath, count } = writeBaseline(root, args.baseline, violations);
1852
1083
  console.log(`Wrote ${fullPath} with ${count} frozen violation key(s).`);
1853
1084
  console.log('Commit it and gate CI with: ark-check --baseline (only NEW violations fail).');
@@ -1877,7 +1108,10 @@ async function main() {
1877
1108
  }
1878
1109
  }
1879
1110
 
1880
- const ok = activeViolations.length === 0 && (!args.strictConfig || warnings.length === 0);
1111
+ // Soft/advisory warnings (failsStrict === false) never fail --strict-config.
1112
+ const strictWarnings = warnings.filter((w) => w.failsStrict !== false);
1113
+ const ok =
1114
+ activeViolations.length === 0 && (!args.strictConfig || strictWarnings.length === 0);
1881
1115
 
1882
1116
  if (args.plan) {
1883
1117
  const cov = computeCoverage(root, config, files, rules);
@@ -2023,11 +1257,16 @@ async function main() {
2023
1257
  );
2024
1258
  }
2025
1259
  if (activeViolations.length === 0) {
1260
+ const advisoryOnly = warnings.length > 0 && strictWarnings.length === 0;
2026
1261
  if (warnings.length === 0) {
2027
1262
  console.log(`${color.green('✔')} Ark check passed.${baselineNote}`);
2028
- } else if (args.strictConfig) {
1263
+ } else if (args.strictConfig && strictWarnings.length > 0) {
2029
1264
  console.error(
2030
- `${color.red('✖')} Ark check failed with ${warnings.length} config warning(s).${baselineNote}`
1265
+ `${color.red('✖')} Ark check failed with ${strictWarnings.length} config warning(s).${baselineNote}`
1266
+ );
1267
+ } else if (advisoryOnly) {
1268
+ console.log(
1269
+ `${color.green('✔')} Ark check passed with ${warnings.length} advisory warning(s).${baselineNote}`
2031
1270
  );
2032
1271
  } else {
2033
1272
  console.log(