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
@@ -224,6 +224,10 @@ export function applyFrameworkLayoutOverlays(config, root) {
224
224
  'src/services/**',
225
225
  'src/use-cases/**',
226
226
  'src/lib/**',
227
+ // Common Next "app core" bags (API clients, hooks, auth, stores) — not UI routes.
228
+ // Without these, monorepos like */src/core/** stay ungoverned and produce false greens.
229
+ 'src/core/**',
230
+ '**/core/**',
227
231
  'src/actions/**',
228
232
  'src/**/actions.ts',
229
233
  'src/**/actions.tsx',
@@ -244,6 +248,20 @@ export function applyFrameworkLayoutOverlays(config, root) {
244
248
  'src/lib/prisma/**',
245
249
  'src/server/db/**',
246
250
  ]);
251
+ // Demo assets, generated public output, and tool configs are not architecture surface.
252
+ const nextExcludes = [
253
+ '**/public/**',
254
+ '**/*.config.js',
255
+ '**/*.config.ts',
256
+ '**/*.config.mjs',
257
+ '**/playwright*.ts',
258
+ '**/eslint.config.*',
259
+ '**/postcss.config.*',
260
+ '**/prettier.config.*',
261
+ '**/rstest.config.*',
262
+ '**/scripts/**',
263
+ ];
264
+ next.exclude = [...new Set([...(next.exclude ?? []), ...nextExcludes])];
247
265
  next.frameworkOverlay = next.frameworkOverlay
248
266
  ? `${next.frameworkOverlay}+next`
249
267
  : 'next';
@@ -362,13 +380,16 @@ export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
362
380
  return uses;
363
381
  }
364
382
 
365
- // Layer glob matching — single source of truth in ark-layer-match.mjs (also used by ESLint).
383
+ // Layer glob matching — generated from canonical src/domain/layerMatch.ts (see generate:layer-match).
366
384
  export {
367
385
  globToRegExp,
368
386
  patternSpecificity,
369
387
  layerForFile,
370
388
  layerForRelativePath,
371
389
  isEdgeDenied,
390
+ DEFAULT_GENERATED_FILE_GLOBS,
391
+ scanExcludePatterns,
392
+ isScanExcludedRelative,
372
393
  } from './ark-layer-match.mjs';
373
394
 
374
395
  function normalizePrefix(prefix) {
@@ -410,102 +431,15 @@ export function looksLikeIntent(value) {
410
431
  }
411
432
 
412
433
  /**
413
- * Co-pilot Phase F — the work classifier. Every architecture violation is remediated in one of
414
- * three ways, and this is the TRUST BOUNDARY that decides what an agent may auto-apply:
415
- *
416
- * - 'mechanical-safe' : behavior-preserving AND gate-verifiable → an agent may auto-apply it.
417
- * - 'judgment' : real coupling or a design choice → Ark PROPOSES it, a human decides.
418
- * - 'deferred' : not enough signal to place it → a human should look first.
419
- *
420
- * Deliberately biased toward 'judgment': a false 'mechanical-safe' that auto-lands a bad edit
421
- * is the failure mode that sinks trust. Only statically-provable type-surface fixes earn 'auto':
422
- * (1) whole source file is pure type-surface + type-only edge → relocate the file
423
- * (2) import/export already marked type-only → move/re-export the type
424
- * (3) static value-syntax import of a pure type-only *target* module → import type
425
- * Pure function of one violation object so CLI, MCP, and apply-loop classify identically.
426
- * Returns { class, confidence, rationale, remediationKind? }.
434
+ * Co-pilot Phase F — work classifier + fix-class enrich (R4).
435
+ * Canonical TypeScript: src/domain/remediation.ts
436
+ * Generated CLI load path: bin/lib/remediation.mjs (`npm run generate:cli-pure`).
427
437
  */
428
- export const REMEDIATION_CLASSES = ['mechanical-safe', 'judgment', 'deferred'];
429
-
430
- export function classifyRemediation(violation) {
431
- const ruleId = violation?.ruleId;
432
- if (ruleId === 'LAYER_IMPORT_VIOLATION') {
433
- // Pure type-only *source file* with a type-only edge: relocating the whole file is
434
- // behavior-preserving (no runtime body). Distinct from a single import-type move.
435
- if (violation.typeOnly && violation.sourcePureTypeModule) {
436
- return {
437
- class: 'mechanical-safe',
438
- confidence: 0.88,
439
- remediationKind: 'pure-type-file-relocate',
440
- rationale:
441
- 'Whole source file is type-only surface (no runtime statements) with a type-only cross-layer edge: relocate the file to the owning layer (or extract the type there). Behavior-preserving; gate verifies.',
442
- };
443
- }
444
- if (violation.typeOnly) {
445
- return {
446
- class: 'mechanical-safe',
447
- confidence: 0.9,
448
- remediationKind: 'type-only-import-move',
449
- rationale:
450
- 'Type-only import (erased at runtime): move the type to the layer that owns it and re-export for back-compat. Behavior-preserving, and the gate verifies it.',
451
- };
452
- }
453
- // Target module is a pure type-surface file AND the edge is a static import/export
454
- // (flag only set on those edges). Value-syntax `import { T }` → convert to import type.
455
- // require()/import() never get this flag (runtime load). Mixed modules stay judgment.
456
- if (violation.targetTypeOnlyExports) {
457
- const kind = violation.edgeKind;
458
- if (kind === 'require' || kind === 'dynamic-import') {
459
- return {
460
- class: 'judgment',
461
- confidence: 0.75,
462
- rationale:
463
- 'Runtime module load (require/import()) of a type-only module still executes the target file — not auto-safe; rewrite to a static import type if appropriate.',
464
- };
465
- }
466
- return {
467
- class: 'mechanical-safe',
468
- confidence: 0.85,
469
- remediationKind: 'import-type-from-pure-type-module',
470
- rationale:
471
- 'Static import targets a pure type-only module: convert to `import type` (erased at runtime) and place the type in a shared/owning layer. No runtime coupling; gate verifies.',
472
- };
473
- }
474
- return {
475
- class: 'judgment',
476
- confidence: 0.7,
477
- rationale:
478
- 'Value import — real runtime coupling. Relocating it (e.g. a route reaching the DB → a repository) is a refactor whose organization is a human choice.',
479
- };
480
- }
481
- if (ruleId === 'FORBIDDEN_GLOBAL') {
482
- return {
483
- class: 'judgment',
484
- confidence: 0.8,
485
- rationale:
486
- 'Ambient global in a pure layer: inject the capability through a port (Clock, Config, Http). Introducing the port is a design decision.',
487
- };
488
- }
489
- if (ruleId === 'CIRCULAR_DEPENDENCY') {
490
- return {
491
- class: 'judgment',
492
- confidence: 0.7,
493
- rationale: 'Dependency cycle: breaking it means deciding which side owns the shared abstraction.',
494
- };
495
- }
496
- if (typeof ruleId === 'string' && ruleId.length > 0) {
497
- return {
498
- class: 'judgment',
499
- confidence: 0.6,
500
- rationale: 'Needs a human decision on how to satisfy the contract without weakening the gate.',
501
- };
502
- }
503
- return {
504
- class: 'deferred',
505
- confidence: 0.3,
506
- rationale: 'Unrecognized violation shape — a human should look before anything is changed.',
507
- };
508
- }
438
+ export {
439
+ REMEDIATION_CLASSES,
440
+ classifyRemediation,
441
+ enrichViolationWithFixClass,
442
+ } from './lib/remediation.mjs';
509
443
 
510
444
  /**
511
445
  * Normalize a required/imported TypeScript module for ark-check's host.
@@ -719,7 +653,15 @@ function readPackageJson(root) {
719
653
  }
720
654
  }
721
655
 
722
- /** Workspace roots from package.json workspaces and pnpm-workspace.yaml (no YAML dependency). */
656
+ /**
657
+ * Workspace / multi-package roots for monorepo include.
658
+ * Sources (universal — no project-specific names):
659
+ * 1. package.json workspaces + pnpm-workspace.yaml
660
+ * 2. rush.json projectFolder top segments (JSONC-tolerant)
661
+ * 3. lerna.json packages globs
662
+ * 4. If still empty: conventional multi-package top-level dirs that exist and
663
+ * contain at least one package.json (packages, apps, plugins, services, …)
664
+ */
723
665
  export function detectWorkspaces(root) {
724
666
  const dirs = new Set();
725
667
  const addGlob = (glob) => {
@@ -727,9 +669,16 @@ export function detectWorkspaces(root) {
727
669
  const beforeStar = glob.split('*')[0].replace(/\/+$/, '');
728
670
  if (beforeStar && beforeStar !== '.') dirs.add(normalizeRel(beforeStar));
729
671
  };
672
+ const addTop = (rel) => {
673
+ if (typeof rel !== 'string') return;
674
+ const top = rel.split(/[/\\]/).filter(Boolean)[0];
675
+ if (top && top !== '.') dirs.add(normalizeRel(top));
676
+ };
677
+
730
678
  const pkg = readPackageJson(root);
731
679
  const ws = Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages;
732
680
  if (Array.isArray(ws)) ws.forEach(addGlob);
681
+
733
682
  const pnpmFile = path.join(root, 'pnpm-workspace.yaml');
734
683
  if (fs.existsSync(pnpmFile)) {
735
684
  let inPackages = false;
@@ -744,9 +693,183 @@ export function detectWorkspaces(root) {
744
693
  if (item) addGlob(item[1].trim());
745
694
  }
746
695
  }
696
+
697
+ // Rush monorepos often have no root package.json workspaces — only rush.json.
698
+ const rushFile = path.join(root, 'rush.json');
699
+ if (fs.existsSync(rushFile)) {
700
+ try {
701
+ let text = fs.readFileSync(rushFile, 'utf8');
702
+ text = text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
703
+ const rush = JSON.parse(text);
704
+ for (const project of rush.projects || []) {
705
+ if (typeof project?.projectFolder === 'string') addTop(project.projectFolder);
706
+ }
707
+ } catch {
708
+ /* ignore malformed rush.json */
709
+ }
710
+ }
711
+
712
+ // Lerna packages globs.
713
+ const lernaFile = path.join(root, 'lerna.json');
714
+ if (fs.existsSync(lernaFile)) {
715
+ try {
716
+ const lerna = JSON.parse(fs.readFileSync(lernaFile, 'utf8'));
717
+ if (Array.isArray(lerna.packages)) lerna.packages.forEach(addGlob);
718
+ } catch {
719
+ /* ignore */
720
+ }
721
+ }
722
+
723
+ // Conventional multi-package roots when no explicit workspace manifest listed roots.
724
+ if (dirs.size === 0) {
725
+ const conventional = [
726
+ 'packages',
727
+ 'apps',
728
+ 'plugins',
729
+ 'services',
730
+ 'server',
731
+ 'servers',
732
+ 'server-plugins',
733
+ 'libs',
734
+ 'lib',
735
+ 'modules',
736
+ 'foundations',
737
+ 'pods',
738
+ 'models',
739
+ 'clients',
740
+ 'sdks',
741
+ 'tools',
742
+ 'tooling',
743
+ 'desktop',
744
+ 'common',
745
+ ];
746
+ for (const name of conventional) {
747
+ const abs = path.join(root, name);
748
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) continue;
749
+ if (dirContainsPackageJson(abs, 2)) dirs.add(name);
750
+ }
751
+ }
752
+
747
753
  return [...dirs];
748
754
  }
749
755
 
756
+ /** True if dir (or a child within maxDepth) has a package.json — multi-package root signal. */
757
+ function dirContainsPackageJson(dir, maxDepth) {
758
+ try {
759
+ if (fs.existsSync(path.join(dir, 'package.json'))) return true;
760
+ if (maxDepth <= 0) return false;
761
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
762
+ if (!entry.isDirectory()) continue;
763
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
764
+ if (dirContainsPackageJson(path.join(dir, entry.name), maxDepth - 1)) return true;
765
+ }
766
+ } catch {
767
+ return false;
768
+ }
769
+ return false;
770
+ }
771
+
772
+ const SKIP_DIR_NAMES = new Set([
773
+ 'node_modules',
774
+ 'dist',
775
+ 'build',
776
+ 'coverage',
777
+ '.git',
778
+ '.next',
779
+ '.turbo',
780
+ '.cache',
781
+ 'vendor',
782
+ '__pycache__',
783
+ ]);
784
+
785
+ function dirHasTsSources(dir, maxDepth) {
786
+ try {
787
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
788
+ if (entry.isFile() && /\.(tsx?|jsx?|mts|cts)$/i.test(entry.name)) return true;
789
+ if (!entry.isDirectory()) continue;
790
+ if (SKIP_DIR_NAMES.has(entry.name) || entry.name.startsWith('.')) continue;
791
+ if (maxDepth > 0 && dirHasTsSources(path.join(dir, entry.name), maxDepth - 1)) return true;
792
+ }
793
+ } catch {
794
+ return false;
795
+ }
796
+ return false;
797
+ }
798
+
799
+ /**
800
+ * Discover package roots that contain TypeScript/JS sources (polyglot-safe).
801
+ * Returns relative paths (e.g. remotion-composer, packages/ui). Caps depth/count
802
+ * to avoid scanning huge trees. Universal — no project-specific names.
803
+ */
804
+ export function detectTsPackageRoots(root, options = {}) {
805
+ const maxDepth = options.maxDepth ?? 3;
806
+ const maxRoots = options.maxRoots ?? 40;
807
+ const found = [];
808
+
809
+ const visit = (abs, rel, depth) => {
810
+ if (found.length >= maxRoots || depth > maxDepth) return;
811
+ let entries;
812
+ try {
813
+ entries = fs.readdirSync(abs, { withFileTypes: true });
814
+ } catch {
815
+ return;
816
+ }
817
+ const hasPkg = entries.some((e) => e.isFile() && e.name === 'package.json');
818
+ if (hasPkg && rel && dirHasTsSources(abs, 3)) {
819
+ found.push(rel.split(path.sep).join('/'));
820
+ // Do not descend into nested packages under an already-selected package root
821
+ // unless maxDepth allows and we want monorepo packages/* children — still scan children
822
+ // for nested packages (packages/foo).
823
+ }
824
+ if (depth >= maxDepth) return;
825
+ for (const entry of entries) {
826
+ if (!entry.isDirectory()) continue;
827
+ if (SKIP_DIR_NAMES.has(entry.name) || entry.name.startsWith('.')) continue;
828
+ // Skip agent skill asset trees (not app code).
829
+ if (entry.name === 'skills' || entry.name === 'templates' || entry.name === 'fixtures') continue;
830
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
831
+ visit(path.join(abs, entry.name), childRel, depth + 1);
832
+ }
833
+ };
834
+
835
+ visit(root, '', 0);
836
+ // Also treat root itself if it has package.json + TS
837
+ if (fs.existsSync(path.join(root, 'package.json')) && dirHasTsSources(root, 2)) {
838
+ if (!found.includes('.')) {
839
+ // Prefer explicit '.' only when no nested packages found
840
+ if (found.length === 0) found.push('.');
841
+ }
842
+ }
843
+ return [...new Set(found)].sort();
844
+ }
845
+
846
+ /**
847
+ * Merge workspace globs with TS package roots. If workspaces alone would miss
848
+ * all TS (or are empty), fill from detectTsPackageRoots.
849
+ */
850
+ export function resolveIncludeRoots(root) {
851
+ const workspaces = detectWorkspaces(root);
852
+ const tsRoots = detectTsPackageRoots(root);
853
+ if (workspaces.length === 0) {
854
+ if (tsRoots.length > 0) return tsRoots.filter((r) => r !== '.');
855
+ return [];
856
+ }
857
+ // Workspaces present: keep them, add TS package roots not covered by a workspace prefix
858
+ const merged = new Set(workspaces);
859
+ for (const tr of tsRoots) {
860
+ if (tr === '.') continue;
861
+ const covered = workspaces.some(
862
+ (w) => tr === w || tr.startsWith(`${w}/`) || w.startsWith(`${tr}/`)
863
+ );
864
+ if (!covered) merged.add(tr.split('/')[0]); // top segment if nested
865
+ // Prefer full package path when it's a direct child
866
+ if (!tr.includes('/') || workspaces.includes(tr.split('/')[0])) {
867
+ if (fs.existsSync(path.join(root, tr, 'package.json'))) merged.add(tr);
868
+ }
869
+ }
870
+ return [...merged];
871
+ }
872
+
750
873
  function isSourceFile(name) {
751
874
  return /\.(tsx?|jsx?|mjsx?|cjsx?|mts|cts)$/i.test(name);
752
875
  }
@@ -822,6 +945,47 @@ function countTsxFiles(files) {
822
945
  return files.filter((f) => /\.(tsx|jsx)$/i.test(f)).length;
823
946
  }
824
947
 
948
+ /**
949
+ * Merge root + nested package.json dependencies (frontend/, packages/*, apps/*, …).
950
+ * Hosts that only put `arkgate` at the monorepo root and Next under frontend/ must
951
+ * still detect nextFramework so layout overlays (src/core/**) apply on day one.
952
+ */
953
+ export function collectAggregatedDeps(root) {
954
+ const deps = {};
955
+ const mergePkg = (pkg) => {
956
+ if (!pkg || typeof pkg !== 'object') return;
957
+ for (const key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
958
+ const block = pkg[key];
959
+ if (block && typeof block === 'object') Object.assign(deps, block);
960
+ }
961
+ };
962
+ mergePkg(readPackageJson(root));
963
+ let entries = [];
964
+ try {
965
+ entries = fs.readdirSync(root, { withFileTypes: true });
966
+ } catch {
967
+ return deps;
968
+ }
969
+ for (const entry of entries) {
970
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
971
+ const childRoot = path.join(root, entry.name);
972
+ mergePkg(readPackageJson(childRoot));
973
+ // One level under conventional multi-package roots
974
+ if (['packages', 'apps', 'services', 'plugins', 'packages-internal'].includes(entry.name)) {
975
+ try {
976
+ for (const sub of fs.readdirSync(childRoot, { withFileTypes: true })) {
977
+ if (sub.isDirectory() && !sub.name.startsWith('.')) {
978
+ mergePkg(readPackageJson(path.join(childRoot, sub.name)));
979
+ }
980
+ }
981
+ } catch {
982
+ /* ignore */
983
+ }
984
+ }
985
+ }
986
+ return deps;
987
+ }
988
+
825
989
  /**
826
990
  * Collect deterministic repo shape signals for architecture archetype scoring.
827
991
  * Vendor packages may appear in toolHints only — never as the primary label.
@@ -830,7 +994,20 @@ export function collectRepoShapeSignals(root) {
830
994
  const pkg = readPackageJson(root);
831
995
  const workspaceDirs = detectWorkspaces(root);
832
996
  const workspaces = workspaceDirs.length > 0;
833
- const srcDirs = ['src', 'lib', 'api', 'packages', 'apps'].filter((d) =>
997
+ // Include frontend/web/client common Next monorepo app folders (deer-flow-style).
998
+ const candidateScanDirs = [
999
+ 'src',
1000
+ 'lib',
1001
+ 'api',
1002
+ 'packages',
1003
+ 'apps',
1004
+ 'frontend',
1005
+ 'web',
1006
+ 'client',
1007
+ 'app',
1008
+ ...workspaceDirs,
1009
+ ];
1010
+ const srcDirs = [...new Set(candidateScanDirs)].filter((d) =>
834
1011
  fs.existsSync(path.join(root, d))
835
1012
  );
836
1013
  const scanRoots = srcDirs.length > 0 ? srcDirs.map((d) => path.join(root, d)) : [root];
@@ -838,13 +1015,14 @@ export function collectRepoShapeSignals(root) {
838
1015
  const sourceFileCount = sourceFiles.length;
839
1016
  const tinyTree = sourceFileCount < 3;
840
1017
 
841
- const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
1018
+ // Nested package.json deps (not root-only) critical for monorepo Next under frontend/
1019
+ const deps = collectAggregatedDeps(root);
842
1020
  const hasUiFramework = Object.keys(deps).some((name) =>
843
1021
  /^(react|react-dom|vue|svelte|preact|solid-js)$/i.test(name.split('/')[0])
844
1022
  );
845
1023
  const srcUiFiles = sourceFiles.filter((file) => {
846
1024
  const rel = path.relative(root, file).split(path.sep).join('/');
847
- return rel.startsWith('src/') && /\.(tsx|jsx)$/i.test(file);
1025
+ return (rel.includes('/src/') || rel.startsWith('src/')) && /\.(tsx|jsx)$/i.test(file);
848
1026
  });
849
1027
 
850
1028
  const topNames = new Set(srcDirs.flatMap((d) => listTopLevelDirNames(root, d)));
@@ -860,7 +1038,8 @@ export function collectRepoShapeSignals(root) {
860
1038
  const rel = path.relative(root, file).split(path.sep).join('/');
861
1039
  return (
862
1040
  /(^|\/)next\.config\./.test(rel) ||
863
- /(^|\/)app\/.*\/page\.(t|j)sx?$/.test(rel) ||
1041
+ // app/page.tsx OR app/dashboard/page.tsx (middle segment optional)
1042
+ /(^|\/)app\/(?:.*\/)?page\.(t|j)sx?$/.test(rel) ||
864
1043
  /(^|\/)pages\/.+\.(t|j)sx?$/.test(rel)
865
1044
  );
866
1045
  });
@@ -1127,7 +1306,15 @@ export function scoreArchetypes(signals, playbook) {
1127
1306
  const rawConfidence = top.score / top.maxPositive;
1128
1307
  const margin =
1129
1308
  second && second.score > 0 ? (top.score - second.score) / Math.max(top.score, 1) : 0.25;
1130
- const confidence = Math.min(1, Math.max(0.1, rawConfidence * 0.7 + margin * 0.3));
1309
+ let confidence = Math.min(1, Math.max(0.1, rawConfidence * 0.7 + margin * 0.3));
1310
+ // Thin / zero TS surface: never present a high-confidence archetype as the sole answer.
1311
+ const thinTs =
1312
+ !signals.sourceFileCount ||
1313
+ signals.sourceFileCount < 8 ||
1314
+ signals.tinyTree;
1315
+ if (thinTs) {
1316
+ confidence = Math.min(confidence, 0.28);
1317
+ }
1131
1318
 
1132
1319
  return {
1133
1320
  ranked: scored,
@@ -1135,6 +1322,13 @@ export function scoreArchetypes(signals, playbook) {
1135
1322
  label: top.label,
1136
1323
  preset: top.preset,
1137
1324
  confidence: Math.round(confidence * 1000) / 1000,
1325
+ ...(thinTs
1326
+ ? {
1327
+ thinTsSurface: true,
1328
+ caution:
1329
+ 'TypeScript/JS surface is thin or missing — treat the archetype as a weak hint. Prefer ark-check --suggest-include / --adopt-contract on the real package roots before scaffolding.',
1330
+ }
1331
+ : {}),
1138
1332
  phases: top.phases,
1139
1333
  analogy: top.analogy,
1140
1334
  antiPatterns: top.antiPatterns,
@@ -1169,6 +1363,7 @@ export function buildArchitectureRecommendation(root, options = {}) {
1169
1363
  label: result.label,
1170
1364
  preset: result.preset,
1171
1365
  confidence: result.confidence,
1366
+ ...(result.thinTsSurface ? { thinTsSurface: true, caution: result.caution } : {}),
1172
1367
  phases: result.phases,
1173
1368
  adoptInOrder,
1174
1369
  analogy: result.analogy,
@@ -1265,75 +1460,19 @@ export function shouldShowNewHereNudge(root, configPath, governedPercent, config
1265
1460
  return false;
1266
1461
  }
1267
1462
 
1268
- /**
1269
- * Deterministic fix-class labels for JSON output (English, shared with future skills).
1270
- */
1271
- export function enrichViolationWithFixClass(violation) {
1272
- const enriched = { ...violation };
1273
- switch (violation.ruleId) {
1274
- case 'LAYER_IMPORT_VIOLATION':
1275
- if (violation.typeOnly || violation.targetTypeOnlyExports) {
1276
- enriched.fixClass = 'file-move';
1277
- enriched.effort = 'small';
1278
- enriched.enthusiastHint = violation.targetTypeOnlyExports
1279
- ? 'The imported module only exports types — use `import type` and place the type in a layer both sides may share.'
1280
- : 'This is a type-only import — move the type to a layer both sides may share, or relocate the file to match its role.';
1281
- } else {
1282
- enriched.fixClass = 'port-inversion';
1283
- enriched.effort = 'medium';
1284
- enriched.enthusiastHint = `${violation.fromLayer ?? 'This layer'} must not import ${violation.toLayer ?? 'that layer'} directly. Define an interface (port) where you need the capability and inject the implementation from the outer layer.`;
1285
- }
1286
- break;
1287
- case 'FORBIDDEN_GLOBAL':
1288
- enriched.fixClass = 'inject-port';
1289
- enriched.effort = 'small';
1290
- enriched.enthusiastHint = `Do not call "${violation.target ?? 'that global'}" here. Pass the capability in through a small interface (for example a Clock, HttpPort, or Config provider).`;
1291
- break;
1292
- case 'RAW_EVENT_PUBLISH':
1293
- enriched.fixClass = 'registered-intent';
1294
- enriched.effort = 'small';
1295
- enriched.enthusiastHint =
1296
- 'Register the event intent first, then publish through the creator returned by the registry — not a raw string or object.';
1297
- break;
1298
- case 'PUBLISH_MISSING_SOURCE':
1299
- enriched.fixClass = 'add-source-metadata';
1300
- enriched.effort = 'small';
1301
- enriched.enthusiastHint =
1302
- 'Add metadata.source to the publish call so Ark knows which layer is publishing the event.';
1303
- break;
1304
- case 'PUBLISH_SOURCE_LAYER_MISMATCH':
1305
- enriched.fixClass = 'fix-source-layer';
1306
- enriched.effort = 'small';
1307
- enriched.enthusiastHint =
1308
- 'Use a source intent that belongs to the same layer as this file, or move the publish call to the layer that owns the source.';
1309
- break;
1310
- case 'LAYER_INTENT_REFERENCE_VIOLATION':
1311
- enriched.fixClass = 'intent-relocation';
1312
- enriched.effort = 'small';
1313
- enriched.enthusiastHint =
1314
- 'Reference that intent from a layer allowed to know about it — usually an adapter or application layer, not the domain core.';
1315
- break;
1316
- case 'CIRCULAR_DEPENDENCY':
1317
- enriched.fixClass = 'break-cycle';
1318
- enriched.effort = 'medium';
1319
- enriched.enthusiastHint =
1320
- 'Two modules import each other in a loop. Extract shared code, invert one dependency behind a port, or merge them if they are really one unit.';
1321
- break;
1322
- default:
1323
- enriched.fixClass = 'review-contract';
1324
- enriched.effort = 'small';
1325
- enriched.enthusiastHint =
1326
- 'Read the violation message and the layer rules in ark.config.json, then adjust imports or move code to the correct layer.';
1327
- }
1328
- return enriched;
1329
- }
1330
-
1331
1463
  export function formatArchitectureRecommendationHuman(recommendation) {
1332
1464
  const lines = [];
1333
1465
  lines.push('Ark architecture recommendation (application shape, not vendor stack)');
1334
1466
  lines.push('');
1335
1467
  lines.push(`Archetype: ${recommendation.archetype} — ${recommendation.label}`);
1336
1468
  lines.push(`Preset: ${recommendation.preset} (confidence ${recommendation.confidence})`);
1469
+ if (recommendation.thinTsSurface) {
1470
+ lines.push('');
1471
+ lines.push(
1472
+ `⚠ Thin TypeScript surface (${recommendation.signals?.sourceFileCount ?? 0} files) — confidence is capped. Do not treat this as a firm shape.`
1473
+ );
1474
+ if (recommendation.caution) lines.push(recommendation.caution);
1475
+ }
1337
1476
  if (recommendation.runnerUp?.id) {
1338
1477
  lines.push(
1339
1478
  `Runner-up: ${recommendation.runnerUp.id}${recommendation.runnerUp.label ? ` (${recommendation.runnerUp.label})` : ''}`