arkgate 2.6.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ark-check.mjs CHANGED
@@ -25,11 +25,15 @@ import {
25
25
  writeAdoptionPlan,
26
26
  classifyRemediation,
27
27
  detectPackageManager,
28
+ detectWorkspaces,
29
+ detectTsPackageRoots,
30
+ resolveIncludeRoots,
28
31
  execCommandParts,
29
32
  execRunner,
30
33
  formatArchitectureRecommendationHuman,
31
34
  globToRegExp,
32
35
  installDevHint,
36
+ isScanExcludedRelative,
33
37
  presentLockfiles,
34
38
  layerForFile,
35
39
  looksLikeIntent,
@@ -168,6 +172,9 @@ function parseArgs(argv) {
168
172
  else if (arg === '--write-plan') args.writePlan = true;
169
173
  else if (arg === '--list-policy-packs') args.listPolicyPacks = true;
170
174
  else if (arg === '--apply-policy-pack') args.applyPolicyPack = argv[++i];
175
+ else if (arg === '--suggest-include') args.suggestInclude = true;
176
+ else if (arg === '--adopt-contract') args.adoptContract = true;
177
+ else if (arg === '--write') args.write = true;
171
178
  else if (arg === '--watch') args.watch = true;
172
179
  else if (arg === '--beginner') args.beginner = true;
173
180
  else if (arg === '--codex-home') args.codexHome = true;
@@ -213,8 +220,10 @@ function usage() {
213
220
  ' ark-check --coverage [--json] per-layer file counts + full unclassified list (report only, exit 0)',
214
221
  ' ark-check --plan [--json] classified remediation plan (mechanical-safe / judgment / deferred) + goal; report only',
215
222
  ' 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)',
223
+ ' ark-check --list-policy-packs enthusiast preset configs (hexagonal, layered, feature-sliced, monorepo, ui-surface)',
217
224
  ' ark-check --apply-policy-pack <id> [--force] write ark.config.json from templates/policy-packs/ (uses preset factory)',
225
+ ' ark-check --suggest-include [--json] propose include roots (TS packages / workspaces)',
226
+ ' ark-check --adopt-contract [--write] expand include + UI patterns from ungoverned dirs (contract adopt)',
218
227
  ' ark-check --watch re-run the check when governed files change (debounced)',
219
228
  ' ark-check --report [file.html] [--beginner] [--reset-origin] [--no-archive]',
220
229
  ' HTML report + snapshots under .ark/reports/ (origin once, latest each run, history JSON)',
@@ -249,6 +258,7 @@ function usage() {
249
258
  'Config shape:',
250
259
  '{',
251
260
  ' "include": ["src"],',
261
+ ' // optional: "exclude": ["**/vendor/**"], "excludeGenerated": false (default skips *.gen.ts / *.generated.ts)',
252
262
  ' "layers": [',
253
263
  ' { "name": "DomainModel", "patterns": ["src/domain/**"], "intentPrefixes": ["Domain."],',
254
264
  ' "forbiddenGlobals": ["fetch", "process", "Date.now", "Math.random"] }',
@@ -306,6 +316,9 @@ function readConfig(root, configPath) {
306
316
  include: raw.include ?? ['src'],
307
317
  layers: raw.layers ?? [],
308
318
  rules: raw.rules ?? DEFAULT_RULES,
319
+ ...(raw.exclude ? { exclude: raw.exclude } : {}),
320
+ ...(raw.excludeGenerated !== undefined ? { excludeGenerated: raw.excludeGenerated } : {}),
321
+ ...(raw.cyclePolicy ? { cyclePolicy: raw.cyclePolicy } : {}),
309
322
  };
310
323
  }
311
324
 
@@ -362,39 +375,8 @@ function uncoveredDirectories(root, srcDir, layers) {
362
375
  });
363
376
  }
364
377
 
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
- }
378
+ // detectWorkspaces: shared implementation in ark-shared.mjs (npm/pnpm/rush/lerna +
379
+ // conventional multi-package roots).
398
380
 
399
381
  // Deny every "upward" edge for an ordered layer list (index 0 = outermost/top,
400
382
  // which may import everything below it). Inner/lower layers must not import outer
@@ -455,7 +437,10 @@ function buildConfigFromPolicyPack(packId, root) {
455
437
  `Policy pack "${packId}" references unknown preset "${pack.preset}".`
456
438
  );
457
439
  }
458
- const workspaces = pack.preset === 'monorepo' ? detectWorkspaces(root) : [];
440
+ const workspaces =
441
+ pack.preset === 'monorepo' || pack.preset === 'ui-surface'
442
+ ? resolveIncludeRoots(root)
443
+ : [];
459
444
  const config = factory(workspaces, root);
460
445
  if (pack.layerDescriptions) {
461
446
  for (const layer of config.layers) {
@@ -553,7 +538,7 @@ const THIN_COVERAGE_PERCENT = 50;
553
538
  function maybeWarnBrownfield(root, config) {
554
539
  let files;
555
540
  try {
556
- files = (config.include ?? []).flatMap((entry) => walk(path.join(root, entry)));
541
+ files = collectGovernedFiles(root, config);
557
542
  } catch {
558
543
  return false;
559
544
  }
@@ -577,6 +562,144 @@ function maybeWarnBrownfield(root, config) {
577
562
  return true;
578
563
  }
579
564
 
565
+ /** Propose include roots (workspaces + nested TS packages) — contract-adopt primitive. */
566
+ function runSuggestInclude(args) {
567
+ const root = args.root;
568
+ const workspaces = detectWorkspaces(root);
569
+ const tsPackages = detectTsPackageRoots(root);
570
+ const include = resolveIncludeRoots(root);
571
+ const payload = {
572
+ ok: true,
573
+ workspaces,
574
+ tsPackages,
575
+ suggestedInclude: include.length > 0 ? include : tsPackages.length > 0 ? tsPackages : ['src'],
576
+ note:
577
+ include.length === 0 && tsPackages.length === 0
578
+ ? 'No TS packages or workspaces found — default suggestion is src/ (create it or pass include by hand).'
579
+ : 'Use these paths as ark.config.json "include". Prefer --adopt-contract --write to expand patterns too.',
580
+ };
581
+ if (args.json) {
582
+ console.log(JSON.stringify(payload, null, 2));
583
+ return;
584
+ }
585
+ console.log(color.bold('Suggested include roots'));
586
+ console.log(` workspaces: ${workspaces.join(', ') || '(none)'}`);
587
+ console.log(` tsPackages: ${tsPackages.join(', ') || '(none)'}`);
588
+ console.log(` suggestedInclude: ${payload.suggestedInclude.join(', ')}`);
589
+ console.log(color.dim(payload.note));
590
+ }
591
+
592
+ /**
593
+ * Contract-adopt: expand include + presentation patterns from ungoverned proposals.
594
+ * Read-only unless --write. Does not weaken rules or baseline violations.
595
+ */
596
+ function runAdoptContract(args) {
597
+ const root = args.root;
598
+ const configPath = path.isAbsolute(args.config)
599
+ ? args.config
600
+ : path.join(root, args.config);
601
+ let config;
602
+ try {
603
+ config = fs.existsSync(configPath)
604
+ ? readConfig(root, args.config)
605
+ : {
606
+ include: ['src'],
607
+ layers: ARCHITECTURE_PRESETS['ui-surface']([], root).layers,
608
+ rules: ARCHITECTURE_PRESETS['ui-surface']([], root).rules,
609
+ };
610
+ } catch (error) {
611
+ console.error(error instanceof Error ? error.message : String(error));
612
+ process.exitCode = 2;
613
+ return;
614
+ }
615
+ const suggestedInclude = resolveIncludeRoots(root);
616
+ const tsPackages = detectTsPackageRoots(root);
617
+ const nextInclude = [
618
+ ...new Set([
619
+ ...(config.include || []),
620
+ ...(suggestedInclude.length > 0 ? suggestedInclude : tsPackages),
621
+ ]),
622
+ ].filter(Boolean);
623
+ const files = collectGovernedFiles(root, { ...config, include: nextInclude.length ? nextInclude : config.include });
624
+ const cov = computeCoverage(root, { ...config, include: nextInclude.length ? nextInclude : config.include }, files, config.rules || []);
625
+ const uiPatterns = [
626
+ '**/components/**',
627
+ '**/hooks/**',
628
+ '**/lib/**',
629
+ '**/routes/**',
630
+ '**/app/**',
631
+ '**/pages/**',
632
+ ];
633
+ const layers = (config.layers || []).map((layer) => {
634
+ if (layer.name !== 'PresentationAdapters') return layer;
635
+ const patterns = [...new Set([...(layer.patterns || []), ...uiPatterns])];
636
+ return { ...layer, patterns };
637
+ });
638
+ // If no PresentationAdapters layer, leave layers as-is (don't invent full profile).
639
+ const proposal = {
640
+ ok: true,
641
+ before: {
642
+ include: config.include || [],
643
+ governedPercent: null,
644
+ totalFiles: null,
645
+ },
646
+ after: {
647
+ include: nextInclude.length > 0 ? nextInclude : config.include,
648
+ presentationPatterns: uiPatterns,
649
+ totalFiles: cov.totalFiles,
650
+ governedPercent: cov.governed.percent,
651
+ unclassified: cov.unclassified.count,
652
+ },
653
+ wrote: false,
654
+ };
655
+ // Compute before coverage for honesty.
656
+ try {
657
+ const beforeFiles = collectGovernedFiles(root, config);
658
+ const beforeCov = computeCoverage(root, config, beforeFiles, config.rules || []);
659
+ proposal.before.totalFiles = beforeCov.totalFiles;
660
+ proposal.before.governedPercent = beforeCov.governed.percent;
661
+ } catch {
662
+ /* ignore */
663
+ }
664
+
665
+ if (args.write) {
666
+ const next = {
667
+ ...config,
668
+ include: proposal.after.include,
669
+ layers,
670
+ };
671
+ fs.writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`);
672
+ proposal.wrote = true;
673
+ }
674
+
675
+ if (args.json) {
676
+ console.log(JSON.stringify(proposal, null, 2));
677
+ return;
678
+ }
679
+ console.log(color.bold('Contract adopt (coverage first)'));
680
+ console.log(
681
+ ` before: include=[${(proposal.before.include || []).join(', ')}] governed=${proposal.before.governedPercent ?? '?'}% files=${proposal.before.totalFiles ?? '?'}`
682
+ );
683
+ console.log(
684
+ ` after: include=[${(proposal.after.include || []).join(', ')}] governed=${proposal.after.governedPercent}% files=${proposal.after.totalFiles} unclassified=${proposal.after.unclassified}`
685
+ );
686
+ console.log(` presentation patterns += ${uiPatterns.join(', ')}`);
687
+ if (proposal.wrote) {
688
+ console.log(color.green(` wrote ${path.relative(root, configPath) || args.config}`));
689
+ console.log(color.dim(` Next: ${arkCommand(root, 'ark-check', '--coverage')} then --plan`));
690
+ } else {
691
+ console.log(color.dim(' Dry-run only. Re-run with --write to apply (does not weaken rules).'));
692
+ }
693
+ if ((proposal.after.totalFiles ?? 0) === 0) {
694
+ console.log(
695
+ color.yellow(
696
+ ' Empty scope remains — no TS packages found. Point include at your package roots manually.'
697
+ )
698
+ );
699
+ process.exitCode = 1;
700
+ }
701
+ }
702
+
580
703
  function runInit(args) {
581
704
  const configPath = path.isAbsolute(args.config)
582
705
  ? args.config
@@ -597,7 +720,12 @@ function runInit(args) {
597
720
  process.exitCode = 2;
598
721
  return;
599
722
  }
600
- const finalConfig = factory(detectWorkspaces(args.root), args.root);
723
+ const finalConfig = factory(
724
+ args.preset === 'monorepo' || args.preset === 'ui-surface'
725
+ ? resolveIncludeRoots(args.root)
726
+ : detectWorkspaces(args.root),
727
+ args.root
728
+ );
601
729
  fs.writeFileSync(configPath, `${JSON.stringify(finalConfig, null, 2)}\n`);
602
730
  console.log(`Wrote ${configPath} (${args.preset} preset)`);
603
731
  if (finalConfig.frameworkOverlay) {
@@ -625,8 +753,14 @@ function runInit(args) {
625
753
  // When no conventional src/ layout is found, a `workspaces` declaration means this is a
626
754
  // monorepo — the src/** 11-layer starter would match nothing there, so use the
627
755
  // 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';
756
+ const includeRoots = greenfield ? resolveIncludeRoots(args.root) : [];
757
+ const tsPackages = greenfield ? detectTsPackageRoots(args.root) : [];
758
+ // Prefer monorepo/ui when nested TS packages exist without a conventional src layout.
759
+ const mode = !greenfield
760
+ ? 'detected'
761
+ : includeRoots.length > 0 || tsPackages.length > 0
762
+ ? 'monorepo'
763
+ : 'greenfield';
630
764
  // Greenfield: anchor the starter profile at src/ (the convention a fresh project will
631
765
  // scaffold under) even when src/ doesn't exist yet — the layers are optional, so the
632
766
  // check passes today and governance switches on the moment src/domain/ etc. appear.
@@ -635,7 +769,10 @@ function runInit(args) {
635
769
  mode === 'detected'
636
770
  ? applyFrameworkLayoutOverlays(config, args.root)
637
771
  : mode === 'monorepo'
638
- ? ARCHITECTURE_PRESETS.monorepo(workspaces, args.root)
772
+ ? ARCHITECTURE_PRESETS.monorepo(
773
+ includeRoots.length > 0 ? includeRoots : tsPackages,
774
+ args.root
775
+ )
639
776
  : createElevenLayerConfig({
640
777
  rootDir: srcDir === '.' ? 'src' : srcDir,
641
778
  root: args.root,
@@ -646,10 +783,11 @@ function runInit(args) {
646
783
  console.log(`Wrote ${configPath}`);
647
784
  console.log('');
648
785
  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:');
786
+ const roots = finalConfig.include?.join(', ') || '(none)';
787
+ console.log(`Multi-package / TS package surface detected (include: ${roots}). Generated a`);
788
+ console.log('cross-package profile matching domain/application/presentation/persistence dirs');
789
+ console.log('in any package. Every layer is optional, so the strict check passes now and each');
790
+ console.log('switches on as matching directories gain files. Adjust patterns to your naming:');
653
791
  for (const layer of finalConfig.layers) {
654
792
  console.log(` ${layer.name}: ${layer.patterns.join(', ')}`);
655
793
  }
@@ -787,6 +925,15 @@ function walk(dir, files = []) {
787
925
  return files;
788
926
  }
789
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
+
790
937
  function normalize(value) {
791
938
  return value.split(path.sep).join('/');
792
939
  }
@@ -903,11 +1050,16 @@ function collectConfigWarnings(root, config, files, rules, manifest) {
903
1050
  return re.test(rel);
904
1051
  });
905
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.
906
1058
  warnings.push(
907
1059
  configWarning(
908
1060
  'CONFIG_LAYER_PATTERN_NO_MATCHES',
909
1061
  `Layer "${layer.name}" pattern "${pattern}" matched no included files.`,
910
- { layer: layer.name, pattern }
1062
+ { layer: layer.name, pattern, failsStrict: false }
911
1063
  )
912
1064
  );
913
1065
  }
@@ -1442,6 +1594,8 @@ function detectCycles(graph) {
1442
1594
  line: 1,
1443
1595
  target: members.join(' → '),
1444
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',
1445
1599
  }));
1446
1600
  }
1447
1601
 
@@ -1509,6 +1663,16 @@ async function main() {
1509
1663
  return;
1510
1664
  }
1511
1665
 
1666
+ if (args.suggestInclude) {
1667
+ runSuggestInclude(args);
1668
+ return;
1669
+ }
1670
+
1671
+ if (args.adoptContract) {
1672
+ runAdoptContract(args);
1673
+ return;
1674
+ }
1675
+
1512
1676
  if (args.recommend) {
1513
1677
  try {
1514
1678
  const recommendation = buildArchitectureRecommendation(args.root);
@@ -1582,7 +1746,7 @@ async function main() {
1582
1746
  const config = readConfig(root, args.config);
1583
1747
  const manifest = readManifest(root, args.manifest);
1584
1748
  const rules = manifest?.architecture?.rules ?? config.rules;
1585
- const files = config.include.flatMap((entry) => walk(path.join(root, entry)));
1749
+ const files = collectGovernedFiles(root, config);
1586
1750
 
1587
1751
  // --coverage is a pure glob/report view (no TypeScript resolver), so serve it BEFORE the
1588
1752
  // TS import: the report must work — and exit 0 — even when typescript isn't installed.
@@ -1789,7 +1953,11 @@ async function main() {
1789
1953
  const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
1790
1954
  if (target && targetLayer) {
1791
1955
  const relTarget = normalize(path.relative(root, target));
1792
- if (relTarget !== relFile) importGraph.get(relFile).add(relTarget);
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
+ }
1793
1961
  }
1794
1962
  const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
1795
1963
  if (rule) {
@@ -1823,7 +1991,26 @@ async function main() {
1823
1991
 
1824
1992
  if (cacheKey) saveScanCache(root, cacheKey, nextCacheFiles);
1825
1993
 
1826
- violations.push(...detectCycles(importGraph));
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
+ }
1827
2014
 
1828
2015
  if (args.doctor) {
1829
2016
  runDoctor(root, config, files, rules, violations, args.json, {
@@ -1848,6 +2035,24 @@ async function main() {
1848
2035
  process.exitCode = 2;
1849
2036
  return;
1850
2037
  }
2038
+ const baselineName = args.baseline || '.ark-baseline.json';
2039
+ const fullBaselinePath = path.isAbsolute(baselineName)
2040
+ ? baselineName
2041
+ : path.join(root, baselineName);
2042
+ // Zero debt: do not leave an empty baseline file (unclear policy — "is ratchet on?").
2043
+ // Delete any existing empty/orphan baseline so doctor/CI stay honest.
2044
+ if (violations.length === 0) {
2045
+ if (fs.existsSync(fullBaselinePath)) {
2046
+ fs.unlinkSync(fullBaselinePath);
2047
+ console.log(
2048
+ `No violations to freeze — removed empty baseline ${fullBaselinePath} (zero debt; no ratchet file needed).`
2049
+ );
2050
+ } else {
2051
+ console.log('No violations to freeze — baseline not written (zero debt).');
2052
+ }
2053
+ console.log('Gate with: ark-check --root . --config ark.config.json --strict-config');
2054
+ return;
2055
+ }
1851
2056
  const { fullPath, count } = writeBaseline(root, args.baseline, violations);
1852
2057
  console.log(`Wrote ${fullPath} with ${count} frozen violation key(s).`);
1853
2058
  console.log('Commit it and gate CI with: ark-check --baseline (only NEW violations fail).');
@@ -1877,7 +2082,10 @@ async function main() {
1877
2082
  }
1878
2083
  }
1879
2084
 
1880
- const ok = activeViolations.length === 0 && (!args.strictConfig || warnings.length === 0);
2085
+ // Soft/advisory warnings (failsStrict === false) never fail --strict-config.
2086
+ const strictWarnings = warnings.filter((w) => w.failsStrict !== false);
2087
+ const ok =
2088
+ activeViolations.length === 0 && (!args.strictConfig || strictWarnings.length === 0);
1881
2089
 
1882
2090
  if (args.plan) {
1883
2091
  const cov = computeCoverage(root, config, files, rules);
@@ -2023,11 +2231,16 @@ async function main() {
2023
2231
  );
2024
2232
  }
2025
2233
  if (activeViolations.length === 0) {
2234
+ const advisoryOnly = warnings.length > 0 && strictWarnings.length === 0;
2026
2235
  if (warnings.length === 0) {
2027
2236
  console.log(`${color.green('✔')} Ark check passed.${baselineNote}`);
2028
- } else if (args.strictConfig) {
2237
+ } else if (args.strictConfig && strictWarnings.length > 0) {
2029
2238
  console.error(
2030
- `${color.red('✖')} Ark check failed with ${warnings.length} config warning(s).${baselineNote}`
2239
+ `${color.red('✖')} Ark check failed with ${strictWarnings.length} config warning(s).${baselineNote}`
2240
+ );
2241
+ } else if (advisoryOnly) {
2242
+ console.log(
2243
+ `${color.green('✔')} Ark check passed with ${warnings.length} advisory warning(s).${baselineNote}`
2031
2244
  );
2032
2245
  } else {
2033
2246
  console.log(
@@ -166,3 +166,32 @@ export function isEdgeDenied(rules, from, to) {
166
166
  const hit = (rules ?? []).find((r) => r.from === from && r.to === to);
167
167
  return hit?.allowed === false;
168
168
  }
169
+
170
+ /**
171
+ * Codegen / generated source globs skipped by the default scan.
172
+ * Universal (TanStack Router routeTree.gen, many `*.generated.ts` tools, etc.).
173
+ * Opt out with `excludeGenerated: false` in ark.config.json; add more via top-level `exclude`.
174
+ */
175
+ export const DEFAULT_GENERATED_FILE_GLOBS = [
176
+ '**/*.gen.ts',
177
+ '**/*.gen.tsx',
178
+ '**/*.generated.ts',
179
+ '**/*.generated.tsx',
180
+ ];
181
+
182
+ /**
183
+ * Globs that remove files from ark-check scan (cycles, layers, coverage).
184
+ * @param {{ exclude?: string[], excludeGenerated?: boolean } | null | undefined} config
185
+ */
186
+ export function scanExcludePatterns(config) {
187
+ const custom = Array.isArray(config?.exclude) ? config.exclude.filter((p) => typeof p === 'string') : [];
188
+ const generated =
189
+ config?.excludeGenerated === false ? [] : DEFAULT_GENERATED_FILE_GLOBS;
190
+ return [...generated, ...custom];
191
+ }
192
+
193
+ /** Relative path (posix) matches any scan-exclude glob. */
194
+ export function isScanExcludedRelative(relPath, config) {
195
+ const rel = String(relPath).split(path.sep).join('/');
196
+ return scanExcludePatterns(config).some((pattern) => globToRegExp(pattern).test(rel));
197
+ }
package/bin/ark-mcp.mjs CHANGED
@@ -41,6 +41,9 @@ import {
41
41
  arkCommand,
42
42
  layerForFile,
43
43
  shouldShowNewHereNudge,
44
+ detectWorkspaces,
45
+ detectTsPackageRoots,
46
+ resolveIncludeRoots,
44
47
  } from './ark-shared.mjs';
45
48
 
46
49
  const arkCheckBin = fileURLToPath(new URL('./ark-check.mjs', import.meta.url));
@@ -576,9 +579,9 @@ async function main() {
576
579
  {
577
580
  name: 'ark_place',
578
581
  description:
579
- 'Given a target file path, return which layer it belongs to, which layers it may and ' +
580
- 'must NOT import, and its forbidden globals so generated code lands in a governed ' +
581
- 'location with the right dependencies. Call this BEFORE writing a new file.',
582
+ 'Place a file in the architecture: pass filePath (preferred) and/or description. ' +
583
+ 'Returns layer, mayImport / mustNotImport, forbiddenGlobals. Call BEFORE writing a new file. ' +
584
+ 'If only description is given, returns a conventional path proposal under a governed layer.',
582
585
  inputSchema: {
583
586
  type: 'object',
584
587
  properties: {
@@ -586,8 +589,12 @@ async function main() {
586
589
  type: 'string',
587
590
  description: 'Path (relative to project root or absolute) of the file to place.',
588
591
  },
592
+ description: {
593
+ type: 'string',
594
+ description:
595
+ 'What you are building (e.g. "Remotion caption overlay"). Used when filePath is omitted to propose a path.',
596
+ },
589
597
  },
590
- required: ['filePath'],
591
598
  },
592
599
  },
593
600
  {
@@ -599,6 +606,14 @@ async function main() {
599
606
  'Call BEFORE generating project structure on greenfield or early-adoption repos.',
600
607
  inputSchema: { type: 'object', properties: {} },
601
608
  },
609
+ {
610
+ name: 'ark_suggest_include',
611
+ description:
612
+ 'Propose ark.config.json include roots from workspaces and nested TypeScript packages ' +
613
+ '(polyglot-safe). Same idea as ark-check --suggest-include. Use when coverage is empty ' +
614
+ 'or the contract misses package roots.',
615
+ inputSchema: { type: 'object', properties: {} },
616
+ },
602
617
  ];
603
618
 
604
619
  const RESOURCES = [
@@ -741,8 +756,52 @@ async function main() {
741
756
  // `allowed:false` denies) — which layers it may and must not import.
742
757
  function runPlace(params) {
743
758
  const filePath = params?.arguments?.filePath;
759
+ const description = params?.arguments?.description;
760
+ if ((typeof filePath !== 'string' || !filePath) && typeof description === 'string' && description.trim()) {
761
+ // Description-only: propose a governed path under PresentationAdapters (UI default).
762
+ const slug = description
763
+ .trim()
764
+ .toLowerCase()
765
+ .replace(/[^a-z0-9]+/g, '-')
766
+ .replace(/^-|-$/g, '')
767
+ .slice(0, 48) || 'component';
768
+ const proposedPath = `src/components/${slug}.tsx`;
769
+ const layerName = inferLayer(proposedPath, config, args.root) || 'PresentationAdapters';
770
+ return {
771
+ content: [
772
+ {
773
+ type: 'text',
774
+ text: JSON.stringify(
775
+ {
776
+ filePath: proposedPath,
777
+ proposed: true,
778
+ description: description.trim(),
779
+ layer: layerName,
780
+ governed: Boolean(inferLayer(proposedPath, config, args.root)),
781
+ note:
782
+ 'filePath was omitted — proposed a conventional path from description. ' +
783
+ 'Pass filePath explicitly for authoritative placement. Then validate_code the snippet.',
784
+ },
785
+ null,
786
+ 2
787
+ ),
788
+ },
789
+ ],
790
+ isError: false,
791
+ };
792
+ }
744
793
  if (typeof filePath !== 'string' || !filePath) {
745
- return { content: [{ type: 'text', text: 'Missing required "filePath" argument.' }], isError: true };
794
+ return {
795
+ content: [
796
+ {
797
+ type: 'text',
798
+ text:
799
+ 'ark_place needs filePath and/or description. ' +
800
+ 'Example: { "filePath": "src/components/Foo.tsx" } or { "description": "caption overlay UI component" }.',
801
+ },
802
+ ],
803
+ isError: true,
804
+ };
746
805
  }
747
806
  const layerName = inferLayer(filePath, config, args.root);
748
807
  if (!layerName) {
@@ -814,12 +873,50 @@ async function main() {
814
873
  };
815
874
  }
816
875
 
876
+ function runSuggestIncludeTool() {
877
+ try {
878
+ const workspaces = detectWorkspaces(args.root);
879
+ const tsPackages = detectTsPackageRoots(args.root);
880
+ const suggestedInclude = resolveIncludeRoots(args.root);
881
+ return {
882
+ content: [
883
+ {
884
+ type: 'text',
885
+ text: JSON.stringify(
886
+ {
887
+ ok: true,
888
+ workspaces,
889
+ tsPackages,
890
+ suggestedInclude:
891
+ suggestedInclude.length > 0
892
+ ? suggestedInclude
893
+ : tsPackages.length > 0
894
+ ? tsPackages
895
+ : ['src'],
896
+ next: 'npx ark-check --adopt-contract --write',
897
+ },
898
+ null,
899
+ 2
900
+ ),
901
+ },
902
+ ],
903
+ isError: false,
904
+ };
905
+ } catch (error) {
906
+ return {
907
+ content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
908
+ isError: true,
909
+ };
910
+ }
911
+ }
912
+
817
913
  const TOOL_HANDLERS = {
818
914
  validate_code: runValidate,
819
915
  ark_check: runCheckTool,
820
916
  ark_coverage: runCoverageTool,
821
917
  ark_place: runPlace,
822
918
  ark_recommend: runRecommendTool,
919
+ ark_suggest_include: runSuggestIncludeTool,
823
920
  };
824
921
 
825
922
  const send = (msg) => process.stdout.write(`${JSON.stringify(msg)}\n`);