arkgate 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ark-check.mjs CHANGED
@@ -41,6 +41,71 @@ import {
41
41
  typescriptUsabilityHint,
42
42
  } from './ark-shared.mjs';
43
43
 
44
+ import {
45
+ runInstallAgentGates,
46
+ runMigrateCommands,
47
+ loadTypeScript,
48
+ collectAdoptionGaps,
49
+ detectSkillGaps,
50
+ detectCodexHomeGap,
51
+ missingGates,
52
+ staleRunnerGateFiles,
53
+ brokenMcpGateFiles,
54
+ readJson,
55
+ readPackageJson,
56
+ hasCheckArchitectureScript,
57
+ hasArkWorkflow,
58
+ checkArchitectureScriptSnippet,
59
+ arkCheckCommand,
60
+ arkPackageVersion,
61
+ agentInstructions,
62
+ packageManager,
63
+ REQUIRED_GATE_FILES,
64
+ codexPromptsDir,
65
+ } from './lib/agent-gates.mjs';
66
+ import {
67
+ detectEnforcement,
68
+ renderHtmlReport,
69
+ renderBeginnerHtmlReport,
70
+ archiveReportSnapshots,
71
+ buildReportSnapshot,
72
+ computeReportFitness,
73
+ reportsDir,
74
+ readJsonSafe,
75
+ } from './lib/html-report.mjs';
76
+ import {
77
+ computeCoverage,
78
+ runCoverage,
79
+ runPlan,
80
+ runDoctor,
81
+ buildRemediationPlan,
82
+ } from './lib/doctor-plan.mjs';
83
+ import {
84
+ baselineKey,
85
+ readBaseline,
86
+ summarizeViolations,
87
+ violationEdge,
88
+ writeBaseline,
89
+ printViolation,
90
+ printViolationBreakdown,
91
+ CONCENTRATION_MIN_VIOLATIONS,
92
+ } from './lib/violations.mjs';
93
+ import {
94
+ buildUnclassifiedSuggestions,
95
+ suggestLayerForDir,
96
+ suggestLayerForPath,
97
+ detectBestFitModel,
98
+ dirSegmentsFromGlob,
99
+ } from './lib/suggestions.mjs';
100
+ import {
101
+ ARCHITECTURE_PRESETS,
102
+ CANONICAL_LAYER_NAMES,
103
+ denyUpward,
104
+ presetWithOverlays,
105
+ FRAMEWORK_INTERNAL_EXCLUDE,
106
+ } from './lib/presets.mjs';
107
+
108
+
44
109
  function parseArgs(argv) {
45
110
  const args = {
46
111
  root: process.cwd(),
@@ -69,6 +134,8 @@ function parseArgs(argv) {
69
134
  applyPolicyPack: undefined,
70
135
  watch: false,
71
136
  beginner: false,
137
+ version: false,
138
+ help: false,
72
139
  };
73
140
  for (let i = 2; i < argv.length; i += 1) {
74
141
  const arg = argv[i];
@@ -124,13 +191,25 @@ function parseArgs(argv) {
124
191
  else if (arg === '--print-config') args.printConfig = argv[++i];
125
192
  else if (arg === '--tsconfig') args.tsconfig = argv[++i];
126
193
  else if (arg === '--help' || arg === '-h') args.help = true;
194
+ else if (arg === '--version' || arg === '-V') args.version = true;
127
195
  }
128
196
  return args;
129
197
  }
130
198
 
199
+ /** Path shown to humans: project-relative when inside root, absolute otherwise (no `../../..`). */
200
+ function displayPathFromRoot(root, absPath) {
201
+ const rel = path.relative(root, absPath);
202
+ if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
203
+ return absPath;
204
+ }
205
+ return rel.split(path.sep).join('/');
206
+ }
207
+
131
208
  function usage() {
132
209
  return [
133
- 'Usage: ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict-config] [--require-gates] [--json] [--baseline [file]] [--report [file.html]] [--no-cache]',
210
+ 'Usage: arkgate-check | ark-check (identical bins; product name ArkGate)',
211
+ ' ark-check --version',
212
+ ' ark-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict-config] [--require-gates] [--json] [--baseline [file]] [--report [file.html]] [--no-cache]',
134
213
  ' ark-check --coverage [--json] per-layer file counts + full unclassified list (report only, exit 0)',
135
214
  ' ark-check --plan [--json] classified remediation plan (mechanical-safe / judgment / deferred) + goal; report only',
136
215
  ' ark-check --recommend [--json] [--write-plan] application-shape plan; --write-plan emits ark-adoption-plan.json',
@@ -210,57 +289,6 @@ function usage() {
210
289
  ].join('\n');
211
290
  }
212
291
 
213
- function readJson(file) {
214
- return JSON.parse(fs.readFileSync(file, 'utf8'));
215
- }
216
-
217
- function readPackageJson(root) {
218
- const file = path.join(root, 'package.json');
219
- if (!fs.existsSync(file)) return null;
220
- return readJson(file);
221
- }
222
-
223
- function hasCheckArchitectureScript(root) {
224
- const pkg = readPackageJson(root);
225
- return Boolean(pkg?.scripts?.['check:architecture']);
226
- }
227
-
228
- const REQUIRED_GATE_FILES = [
229
- 'AGENTS.md',
230
- '.mcp.json',
231
- ];
232
- const REQUIRED_GATE_WORKFLOW = '.github/workflows/*.yml running ark-check';
233
-
234
- function hasArkWorkflow(root) {
235
- const workflowsDir = path.join(root, '.github', 'workflows');
236
- if (!fs.existsSync(workflowsDir)) return false;
237
- return fs
238
- .readdirSync(workflowsDir)
239
- .filter((file) => /\.ya?ml$/i.test(file))
240
- .some((file) => {
241
- try {
242
- const content = fs.readFileSync(path.join(workflowsDir, file), 'utf8');
243
- return /\bark-check\b/.test(content) || /\bcheck:architecture\b/.test(content);
244
- } catch {
245
- return false;
246
- }
247
- });
248
- }
249
-
250
- function missingGates(root) {
251
- const missing = REQUIRED_GATE_FILES.filter(
252
- (relativePath) => !fs.existsSync(path.join(root, relativePath))
253
- );
254
- if (!hasArkWorkflow(root)) missing.push(REQUIRED_GATE_WORKFLOW);
255
- return missing;
256
- }
257
-
258
- function checkArchitectureScriptSnippet(root) {
259
- // The package manager's runner resolves the installed binary; `node bin/ark-check.mjs`
260
- // only works inside Ark's own repo. Package-manager aware so a pnpm/yarn repo isn't
261
- // handed an `npx` alias that violates its "never npx" policy.
262
- return `"check:architecture": "${arkCheckCommand(root)}"`;
263
- }
264
292
 
265
293
  function readConfig(root, configPath) {
266
294
  const fullPath = path.isAbsolute(configPath)
@@ -371,343 +399,7 @@ function detectWorkspaces(root) {
371
399
  // Deny every "upward" edge for an ordered layer list (index 0 = outermost/top,
372
400
  // which may import everything below it). Inner/lower layers must not import outer
373
401
  // ones — the shared shape behind linear layered and feature-sliced layouts.
374
- function denyUpward(names) {
375
- const rules = [];
376
- for (let i = 0; i < names.length; i += 1) {
377
- for (let j = i + 1; j < names.length; j += 1) {
378
- rules.push({ from: names[j], to: names[i], allowed: false });
379
- }
380
- }
381
- return rules;
382
- }
383
-
384
- // Named starter configs. Globs use `**` so they fit both flat (src/domain/**) and
385
- // modular (src/modules/x/domain/**) layouts. Every layer is optional, so the strict
386
- // check passes on a greenfield repo and each layer switches on as its dir gains files.
387
- //
388
- // Framework internals live under conventional names like `kernel/` and are NOT application
389
- // architecture — a broad `src/**/domain/**` would otherwise swallow `src/kernel/domain`
390
- // (DI/runtime wiring) and fire domain-purity rules on it, the false-positive class that
391
- // motivated `exclude`. Carve those out of every wildcard preset layer by default; a config
392
- // author who really does keep app code under kernel/ can drop the exclude.
393
- const FRAMEWORK_INTERNAL_EXCLUDE = ['**/kernel/**'];
394
- function presetWithOverlays(baseConfig, root) {
395
- if (!root) return baseConfig;
396
- return applyFrameworkLayoutOverlays(baseConfig, root);
397
- }
398
-
399
- const ARCHITECTURE_PRESETS = {
400
- // Second arg `root` is optional — when provided (init/start on a real repo), framework
401
- // filename conventions (Nest/Next/express) are overlaid so starters get real governed%.
402
- hexagonal: (_workspaces, root) =>
403
- presetWithOverlays(
404
- {
405
- include: ['src'],
406
- layers: [
407
- {
408
- name: 'DomainModel',
409
- description: 'Pure business rules and entities. No I/O, no framework, no ambient globals.',
410
- patterns: ['src/**/domain/**'],
411
- exclude: FRAMEWORK_INTERNAL_EXCLUDE,
412
- forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
413
- optional: true,
414
- },
415
- {
416
- name: 'ApplicationOrchestration',
417
- description: 'Use cases that coordinate the domain through ports. No I/O of its own.',
418
- patterns: ['src/**/application/**'],
419
- exclude: FRAMEWORK_INTERNAL_EXCLUDE,
420
- optional: true,
421
- },
422
- {
423
- name: 'PresentationAdapters',
424
- description: 'Entrypoints — HTTP routes, controllers, UI. Drives use cases.',
425
- patterns: [
426
- 'src/**/presentation/**',
427
- 'src/**/controllers/**',
428
- 'src/**/interface-adapters/**',
429
- 'src/**/http/**',
430
- ],
431
- exclude: FRAMEWORK_INTERNAL_EXCLUDE,
432
- optional: true,
433
- },
434
- {
435
- name: 'PersistenceAdapters',
436
- description: 'Implements ports with real infrastructure: DB, external APIs, filesystem.',
437
- patterns: [
438
- 'src/**/infrastructure/**',
439
- 'src/**/adapters/**',
440
- 'src/**/persistence/**',
441
- 'src/**/repositories/**',
442
- ],
443
- exclude: FRAMEWORK_INTERNAL_EXCLUDE,
444
- optional: true,
445
- },
446
- ],
447
- rules: [
448
- { from: 'DomainModel', to: 'ApplicationOrchestration', allowed: false },
449
- { from: 'DomainModel', to: 'PersistenceAdapters', allowed: false },
450
- { from: 'DomainModel', to: 'PresentationAdapters', allowed: false },
451
- { from: 'ApplicationOrchestration', to: 'PersistenceAdapters', allowed: false },
452
- { from: 'ApplicationOrchestration', to: 'PresentationAdapters', allowed: false },
453
- { from: 'PresentationAdapters', to: 'PersistenceAdapters', allowed: false },
454
- { from: 'PresentationAdapters', to: 'DomainModel', allowed: false },
455
- { from: 'PersistenceAdapters', to: 'ApplicationOrchestration', allowed: false },
456
- { from: 'PersistenceAdapters', to: 'PresentationAdapters', allowed: false },
457
- ],
458
- },
459
- root
460
- ),
461
- layered: (_workspaces, root) =>
462
- presetWithOverlays(
463
- {
464
- include: ['src'],
465
- layers: [
466
- {
467
- name: 'PresentationAdapters',
468
- description: 'UI and API entrypoints.',
469
- patterns: [
470
- 'src/**/presentation/**',
471
- 'src/**/controllers/**',
472
- 'src/**/ui/**',
473
- 'src/**/http/**',
474
- ],
475
- exclude: FRAMEWORK_INTERNAL_EXCLUDE,
476
- optional: true,
477
- },
478
- {
479
- name: 'ApplicationOrchestration',
480
- description: 'Business services and use-case coordination.',
481
- patterns: ['src/**/application/**', 'src/**/services/**'],
482
- exclude: FRAMEWORK_INTERNAL_EXCLUDE,
483
- optional: true,
484
- },
485
- {
486
- name: 'DomainModel',
487
- description: 'Pure business rules and entities. No I/O, no framework, no ambient globals.',
488
- patterns: ['src/**/domain/**'],
489
- exclude: FRAMEWORK_INTERNAL_EXCLUDE,
490
- forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
491
- optional: true,
492
- },
493
- {
494
- name: 'PersistenceAdapters',
495
- description: 'Data access and infrastructure.',
496
- patterns: [
497
- 'src/**/persistence/**',
498
- 'src/**/data/**',
499
- 'src/**/repositories/**',
500
- 'src/**/infrastructure/**',
501
- ],
502
- exclude: FRAMEWORK_INTERNAL_EXCLUDE,
503
- optional: true,
504
- },
505
- ],
506
- rules: denyUpward([
507
- 'PresentationAdapters',
508
- 'ApplicationOrchestration',
509
- 'DomainModel',
510
- 'PersistenceAdapters',
511
- ]),
512
- },
513
- root
514
- ),
515
- 'feature-sliced': (_workspaces, root) => {
516
- const order = ['App', 'Pages', 'Widgets', 'Features', 'Entities', 'Shared'];
517
- const purpose = {
518
- App: 'App-wide setup, providers, and routing.',
519
- Pages: 'Route-level compositions.',
520
- Widgets: 'Self-contained UI blocks composed from features and entities.',
521
- Features: 'User-facing feature units.',
522
- Entities: 'Business entities with their UI and logic.',
523
- Shared: 'Reusable primitives with no business knowledge.',
524
- };
525
- return presetWithOverlays(
526
- {
527
- include: ['src'],
528
- layers: order.map((name) => ({
529
- name,
530
- description: purpose[name],
531
- patterns: [`src/${name.toLowerCase()}/**`],
532
- optional: true,
533
- })),
534
- rules: denyUpward(order),
535
- },
536
- root
537
- );
538
- },
539
- // Cross-package profile for workspace monorepos. Patterns match by directory NAME
540
- // anywhere in the tree (`**/domain/**` hits packages/x/domain AND apps/y/src/domain),
541
- // so one profile governs every package. include defaults to the detected workspace
542
- // roots (falls back to packages+apps). Naming varies by repo — adjust and re-check.
543
- monorepo: (includeDirs, root) =>
544
- presetWithOverlays(
545
- {
546
- include: includeDirs && includeDirs.length > 0 ? includeDirs : ['packages', 'apps'],
547
- layers: [
548
- {
549
- name: 'DomainModel',
550
- description:
551
- 'Pure business rules and entities, in any package. No I/O, no framework, no ambient globals.',
552
- patterns: ['**/domain/**', '**/entities/**'],
553
- forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
554
- optional: true,
555
- },
556
- {
557
- name: 'ApplicationOrchestration',
558
- description: 'Use cases and services that coordinate the domain through ports.',
559
- patterns: ['**/application/**', '**/use-cases/**', '**/services/**'],
560
- optional: true,
561
- },
562
- {
563
- name: 'PresentationAdapters',
564
- description: 'Entrypoints — HTTP routes, controllers, UI, framework app/pages dirs.',
565
- patterns: [
566
- '**/app/**',
567
- '**/pages/**',
568
- '**/components/**',
569
- '**/controllers/**',
570
- '**/http/**',
571
- '**/routes/**',
572
- ],
573
- optional: true,
574
- },
575
- {
576
- name: 'PersistenceAdapters',
577
- description: 'Implements ports with real infrastructure: DB, external APIs, filesystem.',
578
- patterns: [
579
- '**/infrastructure/**',
580
- '**/adapters/**',
581
- '**/persistence/**',
582
- '**/repositories/**',
583
- ],
584
- optional: true,
585
- },
586
- ],
587
- rules: [
588
- { from: 'DomainModel', to: 'ApplicationOrchestration', allowed: false },
589
- { from: 'DomainModel', to: 'PresentationAdapters', allowed: false },
590
- { from: 'DomainModel', to: 'PersistenceAdapters', allowed: false },
591
- { from: 'ApplicationOrchestration', to: 'PresentationAdapters', allowed: false },
592
- { from: 'PresentationAdapters', to: 'PersistenceAdapters', allowed: false },
593
- { from: 'PersistenceAdapters', to: 'ApplicationOrchestration', allowed: false },
594
- ],
595
- },
596
- root
597
- ),
598
- };
599
-
600
- // ── Layer suggestion engine ──────────────────────────────────────────────────
601
- // Everything here is HARVESTED from Ark's own canonical sources — the 11-layer defaults
602
- // (DEFAULT_LAYER_DIRECTORIES) and the named presets — so a suggestion can never drift from
603
- // what the gate actually enforces. No ad-hoc directory heuristics: a directory Ark doesn't
604
- // already know about is reported as "unrecognized — you classify", never guessed. This is
605
- // what lets `init`/`--coverage` PROPOSE where ungoverned code belongs instead of silently
606
- // leaving the majority of a repo ungoverned behind a false-green check.
607
- const CANONICAL_LAYER_NAMES = new Set(DEFAULT_INTENT_PREFIXES.map((entry) => entry.layer));
608
-
609
- function dirSegmentsFromGlob(pattern) {
610
- return String(pattern)
611
- .split('/')
612
- .filter((segment) => segment && !segment.includes('*'));
613
- }
614
-
615
- let _layerByDir;
616
- // Map<dirBasename, string[] layers>. A basename mapping to >1 layer (e.g. `app` — Application
617
- // orchestration in the 11-layer defaults, but Presentation in the monorepo/Next preset) is
618
- // genuinely ambiguous; every candidate is surfaced rather than silently picked.
619
- function layerByDir() {
620
- if (_layerByDir) return _layerByDir;
621
- const map = new Map();
622
- const add = (segment, layer) => {
623
- if (!segment) return;
624
- const existing = map.get(segment) ?? [];
625
- if (!existing.includes(layer)) existing.push(layer);
626
- map.set(segment, existing);
627
- };
628
- for (const [layer, dirs] of Object.entries(DEFAULT_LAYER_DIRECTORIES)) {
629
- for (const dir of dirs) add(dirSegmentsFromGlob(dir).pop(), layer);
630
- }
631
- // The canonical-named presets reuse the 11 layer names, so their directory synonyms
632
- // (services→Application, components/pages→Presentation, data/infrastructure→Persistence…)
633
- // map cleanly onto the same taxonomy. feature-sliced uses a different vocabulary
634
- // (Widgets/Entities/…) that doesn't reduce to the 11, so it's covered by model-fit, not here.
635
- for (const preset of ['hexagonal', 'layered', 'monorepo']) {
636
- for (const layer of ARCHITECTURE_PRESETS[preset]([]).layers) {
637
- if (!CANONICAL_LAYER_NAMES.has(layer.name)) continue;
638
- for (const pattern of layer.patterns ?? []) {
639
- add(dirSegmentsFromGlob(pattern).pop(), layer.name);
640
- }
641
- }
642
- }
643
- _layerByDir = map;
644
- return map;
645
- }
646
-
647
- // Suggest a canonical layer for a directory by its basename. null when Ark doesn't recognize
648
- // it (the honest "you classify this" case), else { layer, alternatives }.
649
- function suggestLayerForDir(name) {
650
- const layers = layerByDir().get(name);
651
- if (!layers || layers.length === 0) return null;
652
- return { layer: layers[0], alternatives: layers.slice(1) };
653
- }
654
-
655
- // Suggest a layer for a directory PATH by finding the deepest segment Ark recognizes, so
656
- // `src/lib/repositories` proposes PersistenceAdapters even though `lib` itself is unknown.
657
- function suggestLayerForPath(relDir) {
658
- const segments = relDir.split('/').filter(Boolean);
659
- for (let i = segments.length - 1; i >= 0; i -= 1) {
660
- const hit = suggestLayerForDir(segments[i]);
661
- if (hit) return { ...hit, matchedDir: segments[i] };
662
- }
663
- return null;
664
- }
665
-
666
- // Which starter model does this set of directory basenames most resemble? Scored purely by
667
- // how many of the repo's directories each preset's patterns recognize — a hint toward
668
- // `ark init --preset <name>`. null when nothing lines up.
669
- function detectBestFitModel(dirBasenames) {
670
- const present = new Set(dirBasenames);
671
- const scored = ['hexagonal', 'layered', 'feature-sliced', 'monorepo'].map((name) => {
672
- const segments = new Set();
673
- for (const layer of ARCHITECTURE_PRESETS[name]([]).layers) {
674
- for (const pattern of layer.patterns ?? []) {
675
- const seg = dirSegmentsFromGlob(pattern).pop();
676
- if (seg) segments.add(seg);
677
- }
678
- }
679
- let hits = 0;
680
- for (const dir of present) if (segments.has(dir)) hits += 1;
681
- return { name, hits };
682
- });
683
- scored.sort((a, b) => b.hits - a.hits);
684
- return scored[0].hits > 0 ? scored[0] : null;
685
- }
686
-
687
- // Group ungoverned files by their parent directory and attach a proposed layer (or the
688
- // honest "unrecognized"). The single source the coverage report and init both format.
689
- function buildUnclassifiedSuggestions(unclassifiedRelFiles) {
690
- const byDir = new Map();
691
- for (const rel of unclassifiedRelFiles) {
692
- const dir = rel.split('/').slice(0, -1).join('/') || '.';
693
- byDir.set(dir, (byDir.get(dir) ?? 0) + 1);
694
- }
695
- return [...byDir.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([dir, files]) => {
696
- const hit = suggestLayerForPath(dir);
697
- return hit
698
- ? {
699
- dir,
700
- files,
701
- layer: hit.layer,
702
- ...(hit.alternatives.length > 0 ? { alternatives: hit.alternatives } : {}),
703
- }
704
- : { dir, files, unrecognized: true };
705
- });
706
- }
707
402
 
708
- // For `init`: propose a layer for every ungoverned top-level directory, descending one level
709
- // into unrecognized ones so `lib/repositories`, `lib/db` etc. still get a concrete proposal
710
- // instead of a blanket "lib is ungoverned".
711
403
  function proposeForUncovered(root, srcDir, layers) {
712
404
  const proposals = [];
713
405
  for (const top of uncoveredDirectories(root, srcDir, layers)) {
@@ -1034,1600 +726,134 @@ function runInit(args) {
1034
726
  printInitNextSteps(args.root);
1035
727
  }
1036
728
 
1037
- function ensureDirForFile(file) {
1038
- fs.mkdirSync(path.dirname(file), { recursive: true });
1039
- }
1040
729
 
1041
- function writeTemplate(root, relativePath, content, force) {
1042
- const fullPath = path.join(root, relativePath);
1043
- if (fs.existsSync(fullPath) && !force) {
1044
- return { relativePath, status: 'skipped' };
1045
- }
1046
- try {
1047
- ensureDirForFile(fullPath);
1048
- fs.writeFileSync(fullPath, content);
1049
- return { relativePath, status: 'written' };
1050
- } catch {
1051
- return { relativePath, status: 'failed' };
730
+ function readManifest(root, manifestPath) {
731
+ if (!manifestPath) return undefined;
732
+ const fullPath = path.isAbsolute(manifestPath)
733
+ ? manifestPath
734
+ : path.join(root, manifestPath);
735
+ if (!fs.existsSync(fullPath)) {
736
+ throw new Error(`Manifest not found: ${fullPath}`);
1052
737
  }
738
+ return readJson(fullPath);
1053
739
  }
1054
740
 
1055
- /**
1056
- * Load a TypeScript module with a working JS API host (`sys` + AST + resolve).
1057
- * Prefer the project's install when API-compatible (TS 5/6 + any TS 7 that still
1058
- * exposes the classic JS host). TypeScript 7.0.x main entry is version-only
1059
- * (`{ version, versionMajorMinor }`); programmatic APIs live under
1060
- * `typescript/unstable/*` and are not yet the gate's host — we fall through to
1061
- * ArkGate's own `typescript` dependency (JS-API 5.x) or a bare import.
1062
- * Returns `{ ts, source, version, fallbackReason? }` or null.
1063
- */
1064
- async function loadTypeScript(root) {
1065
- const { createRequire } = await import('node:module');
1066
- const loaders = [];
1067
- try {
1068
- const req = createRequire(path.join(root, 'package.json'));
1069
- loaders.push({
1070
- label: 'project',
1071
- load: () => req('typescript'),
1072
- resolvePath: () => {
1073
- try {
1074
- return req.resolve('typescript');
1075
- } catch {
1076
- return null;
1077
- }
1078
- },
1079
- });
1080
- } catch {
1081
- /* project has no package.json resolvable tree */
1082
- }
1083
- // Nested under arkgate (production dependency) — must work when project has only TS7.
1084
- try {
1085
- const req = createRequire(__arkCheckCli);
1086
- loaders.push({
1087
- label: 'arkgate',
1088
- load: () => req('typescript'),
1089
- resolvePath: () => {
1090
- try {
1091
- return req.resolve('typescript');
1092
- } catch {
1093
- return null;
1094
- }
1095
- },
1096
- });
1097
- } catch {
1098
- /* ark install tree unavailable */
1099
- }
1100
- loaders.push({
1101
- label: 'import',
1102
- load: async () => {
1103
- const m = await import('typescript');
1104
- return m;
1105
- },
1106
- resolvePath: () => null,
1107
- });
741
+ const SOURCE_FILE_NAME = /\.[cm]?[tj]sx?$/;
1108
742
 
1109
- let projectRejected = null;
1110
- const triedPaths = new Set();
1111
- for (const { label, load, resolvePath } of loaders) {
1112
- try {
1113
- const resolved = typeof resolvePath === 'function' ? resolvePath() : null;
1114
- if (resolved && triedPaths.has(resolved)) {
1115
- // Same physical package already rejected (e.g. project === hoisted arkgate path).
1116
- continue;
1117
- }
1118
- if (resolved) triedPaths.add(resolved);
1119
-
1120
- const mod = await load();
1121
- const ts = usableTypescript(mod);
1122
- if (ts) {
1123
- const version =
1124
- typeof ts.version === 'string'
1125
- ? ts.version
1126
- : typeof mod?.version === 'string'
1127
- ? mod.version
1128
- : undefined;
1129
- return {
1130
- ts,
1131
- source: label,
1132
- version,
1133
- ...(projectRejected ? { fallbackReason: projectRejected } : {}),
1134
- };
1135
- }
1136
- if (label === 'project' && mod) {
1137
- projectRejected = `project typescript is not API-compatible (${typescriptUsabilityHint(mod)}); using ArkGate's JS-API TypeScript fallback (TypeScript 7.0 main export is version-only). See docs/typescript-support.md.`;
1138
- }
1139
- } catch {
1140
- /* try next loader */
1141
- }
1142
- }
1143
- return null;
1144
- }
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;
1145
748
 
1146
- function packageManager(root) {
1147
- // If the project already froze violations in a baseline, the generated CI must
1148
- // keep the ratchet — otherwise regenerating the workflow (especially with
1149
- // --force) silently drops --baseline and CI starts failing on frozen violations.
1150
- const baselineFlag = fs.existsSync(path.join(root, '.ark-baseline.json'))
1151
- ? ' --baseline .ark-baseline.json'
1152
- : '';
1153
- const checkArgs = `--root . --config ark.config.json --strict-config${baselineFlag} --require-gates`;
1154
- // Same detection as every emitted command (execRunner): honors the packageManager field and
1155
- // won't let a stray pnpm-lock.yaml hijack an npm project (package-lock.json wins the tie).
1156
- const pm = detectPackageManager(root);
1157
- if (pm === 'pnpm') {
1158
- return {
1159
- cache: 'pnpm',
1160
- setup: ['corepack enable'],
1161
- install: 'pnpm install --frozen-lockfile',
1162
- // Same runner as execRunner(): skip pnpm's verify-deps gate (ERR_PNPM_IGNORED_BUILDS).
1163
- run: `pnpm --config.verify-deps-before-run=false exec ark-check ${checkArgs}`,
1164
- };
1165
- }
1166
- if (pm === 'yarn') {
1167
- return {
1168
- cache: 'yarn',
1169
- setup: ['corepack enable'],
1170
- install: 'yarn install --frozen-lockfile',
1171
- run: `yarn ark-check ${checkArgs}`,
1172
- };
1173
- }
1174
- return {
1175
- cache: 'npm',
1176
- setup: [],
1177
- install: fs.existsSync(path.join(root, 'package-lock.json')) ? 'npm ci' : 'npm install',
1178
- run: `npx ark-check ${checkArgs}`,
1179
- };
749
+ function isGovernableSourceFile(name) {
750
+ return SOURCE_FILE_NAME.test(name) && !name.endsWith('.d.ts') && !TEST_FILE_NAME.test(name);
1180
751
  }
1181
752
 
1182
- // The args every emitted `ark-check` command carries. The runner prefix (npx / pnpm exec /
1183
- // yarn) is added per project by arkCheckCommand so a pnpm-only repo never gets an `npx`
1184
- // instruction see execRunner() in ark-shared.mjs.
1185
- const CHECK_ARGS = '--root . --config ark.config.json --strict-config';
1186
- function arkCheckCommand(root) {
1187
- return arkCommand(root, 'ark-check', CHECK_ARGS);
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
+ );
1188
765
  }
1189
766
 
1190
- // Canonical agent contract. AGENTS.md and the Cursor rule both derive from this single
1191
- // source so the steps can never drift out of sync between the two files. `steps(checkCommand)`
1192
- // is a builder because the check command's runner prefix varies with the package manager.
1193
- const AGENT_CONTRACT = {
1194
- manifestResource: 'ark://manifest',
1195
- steps: (checkCommand) => [
1196
- `Read the Ark contract from \`ark://manifest\` when the MCP server is available.`,
1197
- `Keep source files inside the layer boundaries declared in \`ark.config.json\`.`,
1198
- `Do not bypass Ark publishers, event contracts, or source metadata for runtime mutations.`,
1199
- `After edits, run \`${checkCommand}\`.`,
1200
- `If Ark reports violations, fix the architecture instead of weakening the gate.`,
1201
- ],
1202
- // Cursor-only guidance: the write-time validate_code tool is available in
1203
- // Cursor's runtime but has no equivalent in a plain AGENTS.md read.
1204
- cursorValidateStep: `Validate the full post-edit file content with the \`validate_code\` tool before writing whenever your runtime supports it.`,
1205
- };
1206
-
1207
- function layerPlacementTable() {
1208
- const rows = DEFAULT_INTENT_PREFIXES.map((entry) => {
1209
- const dirs = (DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? [])
1210
- .map((directory) => `\`${directory}/\``)
1211
- .join(', ');
1212
- return `| ${entry.layer} | ${dirs} | ${entry.prefixes.map((p) => `\`${p}\``).join(', ')} |`;
1213
- }).join('\n');
1214
- return `| Layer | Conventional directories (under the source root) | Intent prefixes |
1215
- |-------|---------------------------------------------------|-----------------|
1216
- ${rows}`;
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;
1217
788
  }
1218
789
 
1219
- function agentInstructions(root) {
1220
- const steps = AGENT_CONTRACT.steps(arkCheckCommand(root))
1221
- .map((step, index) => `${index + 1}. ${step}`)
1222
- .join('\n');
1223
- return `# Ark Enforcement
1224
-
1225
- Before editing TypeScript or JavaScript source files:
1226
-
1227
- ${steps}
1228
-
1229
- ## Where new code belongs
1230
-
1231
- \`ark.config.json\` is authoritative for this project. When creating a NEW kind of code
1232
- that no existing layer covers (a saga, a background job, a read model, ...), use the
1233
- default 11-layer placement below and add the layer to \`ark.config.json\` — do not invent
1234
- an ungoverned location:
1235
-
1236
- ${layerPlacementTable()}
1237
-
1238
- The project is only considered Ark-enforced when the write gate, CI gate, and runtime path all pass.
1239
- `;
790
+ function normalize(value) {
791
+ return value.split(path.sep).join('/');
1240
792
  }
1241
793
 
1242
- function mcpJson(root) {
1243
- return `${JSON.stringify({
1244
- mcpServers: {
1245
- ark: {
1246
- type: 'stdio',
1247
- // Prefer arkgate-mcp; ark-mcp alias still works for one major.
1248
- ...execCommandParts(root, PREFERRED_MCP_BIN, ['--root', '.', '--config', 'ark.config.json']),
1249
- },
1250
- },
1251
- }, null, 2)}\n`;
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 }));
1252
800
  }
1253
801
 
1254
- // Sample for docs/ — `ark-check --install-agent-gates --tools codex` auto-merges the real
1255
- // block (with absolute paths) into ~/.codex/config.toml. This copy is a reference only, so
1256
- // it flags the two gotchas of hand-editing the global config: absolute paths (config.toml is
1257
- // loaded without the project as cwd) and the required restart.
1258
- function codexTomlSnippet(root) {
1259
- const { command, args } = execCommandParts(root, PREFERRED_MCP_BIN, [
1260
- '--root',
1261
- '/absolute/path/to/project',
1262
- '--config',
1263
- '/absolute/path/to/project/ark.config.json',
1264
- ]);
1265
- const argsToml = args.map((value) => `"${value}"`).join(', ');
1266
- return `# Add to ~/.codex/config.toml (or $CODEX_HOME/config.toml), then RESTART Codex —
1267
- # it does not hot-load MCP servers. Use ABSOLUTE paths: config.toml is global, so
1268
- # "." would resolve against Codex's launch dir, not this project. Prefer:
1269
- # ark-check --install-agent-gates --tools codex (auto-merges the absolute paths)
1270
- [mcp_servers.ark]
1271
- command = "${command}"
1272
- args = [${argsToml}]
1273
- `;
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);
1274
816
  }
1275
817
 
1276
- /**
1277
- * Compact always-on rule for instruction-tier hosts (Windsurf, Cline, GitHub Copilot,
1278
- * Kiro, ...): agents that read a project rule file but have no MCP tools or hooks.
1279
- * Derived from the same AGENT_CONTRACT as AGENTS.md and the Cursor rule so the steps
1280
- * can never drift; points at AGENTS.md for the full placement table.
1281
- */
1282
- function instructionRule(root) {
1283
- const steps = AGENT_CONTRACT.steps(arkCheckCommand(root))
1284
- .map((step, index) => `${index + 1}. ${step}`)
1285
- .join('\n');
1286
- return `# Ark architecture contract
1287
-
1288
- This project's architecture is governed by Ark (\`ark.config.json\` is authoritative).
1289
- Before writing or editing TypeScript or JavaScript source files:
1290
-
1291
- ${steps}
1292
-
1293
- See \`AGENTS.md\` for the full contract and the layer placement table.
1294
- `;
818
+ function isBlocked(rules, from, to) {
819
+ return rules.find((rule) => !rule.allowed && rule.from === from && rule.to === to);
1295
820
  }
1296
821
 
1297
- function cursorRule(root) {
1298
- return `---
1299
- description: Ark architecture contract
1300
- alwaysApply: true
1301
- ---
1302
-
1303
- Before writing or editing TypeScript or JavaScript source files, read the
1304
- \`${AGENT_CONTRACT.manifestResource}\` resource from the \`ark\` MCP server when available.
1305
-
1306
- ${AGENT_CONTRACT.cursorValidateStep} After edits, run:
1307
-
1308
- \`\`\`bash
1309
- ${arkCheckCommand(root)}
1310
- \`\`\`
1311
-
1312
- If Ark reports violations, fix the architecture instead of bypassing the gate.
1313
- `;
822
+ function configWarning(ruleId, message, extra = {}) {
823
+ return { ruleId, message, ...extra };
1314
824
  }
1315
825
 
1316
- // Default CI Node when the project declares nothing. A current LTS, NOT the
1317
- // oldest supported: the npm-ci-lockfile-mismatch failure only happens when CI's
1318
- // npm is OLDER than the npm that wrote the lockfile, so defaulting high is safer.
1319
- const DEFAULT_CI_NODE_VERSION = '22';
1320
-
1321
- // Decide the Node the generated CI should use, preferring the project's own
1322
- // declaration so CI's npm matches the dev's (a mismatch makes `npm ci` fail with
1323
- // "missing from lock file" — a red gate unrelated to architecture). In order:
1324
- // 1. .nvmrc / .node-version → setup-node's node-version-file (exact, best)
1325
- // 2. package.json engines.node → its concrete major
1326
- // 3. a current-LTS default
1327
- function detectCiNode(root) {
1328
- for (const file of ['.nvmrc', '.node-version']) {
1329
- if (fs.existsSync(path.join(root, file))) return { kind: 'file', value: file };
1330
- }
1331
- const enginesNode = readPackageJson(root)?.engines?.node;
1332
- if (typeof enginesNode === 'string') {
1333
- const major = enginesNode.match(/\d+/)?.[0];
1334
- if (major) return { kind: 'version', value: major };
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
+ );
1335
844
  }
1336
- return { kind: 'default', value: DEFAULT_CI_NODE_VERSION };
1337
- }
1338
845
 
1339
- function githubWorkflow(pm, ciNode) {
1340
- // pnpm/yarn setup (corepack enable) MUST run before actions/setup-node so the package
1341
- // manager is on PATH when setup-node's `cache: pnpm|yarn` tries to resolve the store —
1342
- // otherwise the cache step fails on a fresh runner ("Unable to locate executable file: pnpm").
1343
- const setupSteps = pm.setup.map((command) => ` - run: ${command}`).join('\n');
1344
- // node-version-file keeps CI locked to the dev's exact toolchain; an explicit
1345
- // version comes from engines.node; the default carries a hint for the mismatch
1346
- // symptom since we can't know which npm wrote the lockfile.
1347
- const nodeSetup =
1348
- ciNode.kind === 'file'
1349
- ? ` node-version-file: ${ciNode.value}`
1350
- : ciNode.kind === 'version'
1351
- ? ` node-version: '${ciNode.value}'`
1352
- : ` # If the install step fails with "missing from lock file" / lockfile out
1353
- # of sync, your local package manager is newer than this Node's — add a
1354
- # .nvmrc with your Node version so CI matches the dev environment.
1355
- node-version: '${ciNode.value}'`;
1356
- return `name: Ark architecture gate
1357
-
1358
- on:
1359
- pull_request:
1360
- push:
1361
- branches: [main, master]
1362
-
1363
- jobs:
1364
- ark-check:
1365
- runs-on: ubuntu-latest
1366
- steps:
1367
- - name: Checkout
1368
- uses: actions/checkout@v4
1369
- ${setupSteps ? `${setupSteps}\n` : ''} - name: Setup Node
1370
- uses: actions/setup-node@v4
1371
- with:
1372
- ${nodeSetup}
1373
- cache: ${pm.cache}
1374
- - name: Install dependencies
1375
- run: ${pm.install}
1376
- - name: Ark architecture check
1377
- run: ${pm.run}
1378
- `;
1379
- }
1380
-
1381
- function claudeSettings(root) {
1382
- const runner = execRunner(root);
1383
- return `${JSON.stringify({
1384
- hooks: {
1385
- // Inject the contract at session start so the agent knows the architecture from
1386
- // the first token. Project-scoped by design; --session-context is also a silent
1387
- // no-op when no ark.config.json exists, so it can never leak into other projects.
1388
- SessionStart: [
1389
- {
1390
- hooks: [
1391
- {
1392
- type: 'command',
1393
- command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
1394
- },
1395
- ],
1396
- },
1397
- ],
1398
- PreToolUse: [
1399
- {
1400
- matcher: 'Write|Edit|MultiEdit',
1401
- hooks: [
1402
- {
1403
- type: 'command',
1404
- command: `${runner} ${PREFERRED_MCP_BIN} --hook --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
1405
- },
1406
- ],
1407
- },
1408
- ],
1409
- },
1410
- }, null, 2)}\n`;
1411
- }
1412
-
1413
- // Grok Build project config: MCP registration (commit-friendly relative paths — unlike
1414
- // Codex's global config.toml, Grok loads .grok/config.toml from the project).
1415
- function grokProjectConfig(root) {
1416
- const { command, args } = execCommandParts(root, PREFERRED_MCP_BIN, [
1417
- '--root',
1418
- '.',
1419
- '--config',
1420
- 'ark.config.json',
1421
- ]);
1422
- const argsToml = args.map((value) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`).join(', ');
1423
- return `# Generated by ark-check --install-agent-gates (Grok Build project scope).
1424
- # Restart Grok (or /mcps → refresh) after changes. Also loads repo-root .mcp.json.
1425
- [mcp_servers.ark]
1426
- command = "${command.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
1427
- args = [${argsToml}]
1428
- `;
1429
- }
1430
-
1431
- // Grok Build hooks: same arkgate-mcp contracts as Claude. Grok sets CLAUDE_PROJECT_DIR as
1432
- // an alias for GROK_WORKSPACE_ROOT. Matcher keeps Claude names (Write|Edit|MultiEdit)
1433
- // and Grok natives (write|search_replace) — Grok aliases both directions.
1434
- function grokHooks(root) {
1435
- const runner = execRunner(root);
1436
- return `${JSON.stringify({
1437
- hooks: {
1438
- SessionStart: [
1439
- {
1440
- hooks: [
1441
- {
1442
- type: 'command',
1443
- timeout: 30,
1444
- command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
1445
- },
1446
- ],
1447
- },
1448
- ],
1449
- PreToolUse: [
1450
- {
1451
- matcher: 'Write|Edit|MultiEdit|write|search_replace',
1452
- hooks: [
1453
- {
1454
- type: 'command',
1455
- timeout: 30,
1456
- command: `${runner} ${PREFERRED_MCP_BIN} --hook --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
1457
- },
1458
- ],
1459
- },
1460
- ],
1461
- },
1462
- }, null, 2)}\n`;
1463
- }
1464
-
1465
- function resolveTools(args) {
1466
- if (args.tools && args.tools.length > 0) {
1467
- return { tools: new Set(args.tools), source: 'explicit' };
1468
- }
1469
- const root = args.root;
1470
- const detected = new Set();
1471
- if (fs.existsSync(path.join(root, '.claude'))) detected.add('claude');
1472
- if (fs.existsSync(path.join(root, '.cursor'))) detected.add('cursor');
1473
- if (fs.existsSync(path.join(root, '.codex'))) detected.add('codex');
1474
- if (fs.existsSync(path.join(root, '.grok'))) detected.add('grok');
1475
- if (fs.existsSync(path.join(root, '.windsurf'))) detected.add('windsurf');
1476
- // .clinerules can also be a single FILE (older Cline convention); only a directory
1477
- // can receive .clinerules/ark.md, so a file must not trigger detection.
1478
- if (fs.statSync(path.join(root, '.clinerules'), { throwIfNoEntry: false })?.isDirectory()) {
1479
- detected.add('cline');
1480
- }
1481
- if (fs.existsSync(path.join(root, '.kiro'))) detected.add('kiro');
1482
- if (fs.existsSync(path.join(root, '.roo'))) detected.add('roo');
1483
- if (fs.existsSync(path.join(root, '.continue'))) detected.add('continue');
1484
- if (fs.existsSync(path.join(root, '.gemini'))) detected.add('gemini');
1485
- // copilot has no reliable directory signal (.github exists in most repos),
1486
- // so it is explicit-only via --tools.
1487
- // No signal at all: fall back to writing the primary tools' templates so a fresh
1488
- // project still gets a complete, reviewable starter set.
1489
- if (detected.size === 0) {
1490
- return { tools: new Set(['claude', 'cursor', 'codex']), source: 'default' };
1491
- }
1492
- return { tools: detected, source: 'detected' };
1493
- }
1494
-
1495
- const KNOWN_TOOLS = [
1496
- 'claude',
1497
- 'cursor',
1498
- 'codex',
1499
- 'grok',
1500
- 'windsurf',
1501
- 'cline',
1502
- 'copilot',
1503
- 'kiro',
1504
- 'roo',
1505
- 'continue',
1506
- 'gemini',
1507
- ];
1508
-
1509
- // One canonical markdown per skill (templates/skills/*.md, shipped in the npm
1510
- // package); installed into each tool's slash-command location. The YAML
1511
- // frontmatter (name/description) is understood or harmlessly ignored by every
1512
- // host. Kiro has no command mechanism — its steering rule file is the only gate.
1513
- const SKILL_TOOL_TARGETS = {
1514
- claude: (name) => `.claude/skills/${name}/SKILL.md`,
1515
- cursor: (name) => `.cursor/commands/${name}.md`,
1516
- codex: (name) => `.codex/prompts/${name}.md`,
1517
- // Grok Build: project skills at .grok/skills/<name>/SKILL.md (slash-invocable).
1518
- grok: (name) => `.grok/skills/${name}/SKILL.md`,
1519
- windsurf: (name) => `.windsurf/workflows/${name}.md`,
1520
- cline: (name) => `.clinerules/workflows/${name}.md`,
1521
- copilot: (name) => `.github/prompts/${name}.prompt.md`,
1522
- };
1523
-
1524
- // The version of the arkgate package these bins ship with. Used to
1525
- // stamp installed skills so a normal ark-check can tell "outdated skill from an
1526
- // older Ark" apart from "user-customized skill" — the stamp moves with the
1527
- // package, editing the body doesn't.
1528
- function arkPackageVersion() {
1529
- try {
1530
- const pkg = readJson(
1531
- path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
1532
- );
1533
- return typeof pkg.version === 'string' ? pkg.version : null;
1534
- } catch {
1535
- return null;
1536
- }
1537
- }
1538
-
1539
- // Insert `arkVersion: <v>` into a skill's YAML frontmatter (before its closing
1540
- // `---`). No frontmatter → returned unchanged. Idempotent for a given version.
1541
- function stampSkill(content, version) {
1542
- if (!version) return content;
1543
- const lines = content.split('\n');
1544
- if (lines[0] !== '---') return content;
1545
- const closeIdx = lines.indexOf('---', 1);
1546
- if (closeIdx === -1) return content;
1547
- const existing = lines.findIndex(
1548
- (line, i) => i > 0 && i < closeIdx && /^arkVersion:/.test(line)
1549
- );
1550
- if (existing !== -1) {
1551
- lines[existing] = `arkVersion: ${version}`;
1552
- } else {
1553
- lines.splice(closeIdx, 0, `arkVersion: ${version}`);
1554
- }
1555
- return lines.join('\n');
1556
- }
1557
-
1558
- // Read the `arkVersion:` stamp from an installed skill file. Returns null when
1559
- // the file is absent or has no stamp (installed by a pre-stamp Ark, or hand-authored).
1560
- function installedSkillVersion(filePath) {
1561
- let content;
1562
- try {
1563
- content = fs.readFileSync(filePath, 'utf8');
1564
- } catch {
1565
- return null;
1566
- }
1567
- const match = content.match(/^arkVersion:\s*(.+)$/m);
1568
- return match ? match[1].trim() : null;
1569
- }
1570
-
1571
- // Numeric-tuple compare of dotted versions; true when `a` is strictly older than
1572
- // `b`. Non-numeric/absent segments compare as 0, so "1.7" < "1.7.5".
1573
- function isVersionOlder(a, b) {
1574
- const parse = (v) => String(v).split('.').map((n) => Number.parseInt(n, 10) || 0);
1575
- const av = parse(a);
1576
- const bv = parse(b);
1577
- const len = Math.max(av.length, bv.length);
1578
- for (let i = 0; i < len; i += 1) {
1579
- const x = av[i] ?? 0;
1580
- const y = bv[i] ?? 0;
1581
- if (x !== y) return x < y;
1582
- }
1583
- return false;
1584
- }
1585
-
1586
- function skillTemplates() {
1587
- const dir = path.join(
1588
- path.dirname(fileURLToPath(import.meta.url)),
1589
- '..',
1590
- 'templates',
1591
- 'skills'
1592
- );
1593
- // A missing/mispackaged templates dir would otherwise install zero skills with
1594
- // exit 0 — warn so a packaging regression (e.g. "templates" dropped from the
1595
- // package.json files array) is visible instead of a silent no-op.
1596
- let entries;
1597
- try {
1598
- entries = fs.readdirSync(dir, { withFileTypes: true });
1599
- } catch {
1600
- console.error(
1601
- `Warning: skill templates directory not found (${dir}); no /ark-* skills installed.`
1602
- );
1603
- return [];
1604
- }
1605
- return entries
1606
- .filter((entry) => entry.isFile() && /^[a-z0-9-]+\.md$/.test(entry.name))
1607
- .map((entry) => entry.name)
1608
- .sort()
1609
- .map((name) => [path.basename(name, '.md'), fs.readFileSync(path.join(dir, name), 'utf8')]);
1610
- }
1611
-
1612
- // Skill names only, silent on a missing templates dir — for the freshness
1613
- // advisory below, which must not print packaging warnings on every check run.
1614
- function skillTemplateNames() {
1615
- const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates', 'skills');
1616
- let entries;
1617
- try {
1618
- entries = fs.readdirSync(dir, { withFileTypes: true });
1619
- } catch {
1620
- return [];
1621
- }
1622
- return entries
1623
- .filter((entry) => entry.isFile() && /^[a-z0-9-]+\.md$/.test(entry.name))
1624
- .map((entry) => path.basename(entry.name, '.md'));
1625
- }
1626
-
1627
- // A normal ark-check run is the reliable discovery point for new /ark-* skills.
1628
- // Ark ships no install lifecycle script (a postinstall banner would be blocked by
1629
- // modern package managers' script-approval policy anyway, so careful users never
1630
- // saw it — and it broke hardened installs). When a project has adopted Ark agent
1631
- // gates (AGENTS.md present) but a detected tool is missing
1632
- // skills this version ships, surface it here so agents and CI actually notice.
1633
- // Advisory only — never affects the exit code. Copilot has no reliable directory
1634
- // signal, so it is not auto-detected (explicit --tools only), matching resolveTools.
1635
- // Where Codex loads slash-command prompts from. Codex reads $CODEX_HOME/prompts
1636
- // (defaulting to ~/.codex/prompts), NOT the repo — so home copies of the /ark-*
1637
- // skills drift out of date when a repo refresh only touches in-repo tool dirs.
1638
- function codexPromptsDir() {
1639
- const base = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
1640
- return path.join(base, 'prompts');
1641
- }
1642
-
1643
- // Where Codex reads its MCP server registrations. Unlike Claude (.claude/settings.json)
1644
- // and Cursor (.cursor/mcp.json), Codex loads MCP servers only from $CODEX_HOME/config.toml
1645
- // (~/.codex/config.toml) — never from .mcp.json — so wiring Codex means editing the user's
1646
- // home config, not a repo file.
1647
- function codexConfigPath() {
1648
- const base = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
1649
- return path.join(base, 'config.toml');
1650
- }
1651
-
1652
- // Merge the [mcp_servers.ark] table into Codex's config.toml so `ark://manifest` and the
1653
- // AI write gate are live from the first edit — the piece that was previously only shipped as
1654
- // a copy-me sample in docs/ark-codex-config.toml. Idempotent: an existing ark table is left
1655
- // untouched unless `force` replaces it; other content in the file is preserved. Returns a
1656
- // status for the install summary. The table match runs from the [mcp_servers.ark] header to
1657
- // the line before the next top-level table header (a line starting with `[`) or EOF.
1658
- //
1659
- // Unlike .mcp.json / .cursor/mcp.json (loaded relative to the project), config.toml is a
1660
- // GLOBAL file — Codex launches it without the project as cwd — so `--root .` would resolve
1661
- // against the wrong directory. The paths must be absolute, and TOML string values need the
1662
- // backslashes/quotes escaped (matters on Windows and for repo paths containing quotes).
1663
- function wireCodexMcp(root, force) {
1664
- const file = codexConfigPath();
1665
- const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1666
- const absRoot = path.resolve(root);
1667
- const absConfig = path.join(absRoot, 'ark.config.json');
1668
- // Preferred product bin; absolute --root so Codex (cwd ≠ project) resolves correctly.
1669
- const preferredBin = 'arkgate-mcp';
1670
- const { command, args } = execCommandParts(root, preferredBin, [
1671
- '--root',
1672
- esc(absRoot),
1673
- '--config',
1674
- esc(absConfig),
1675
- ]);
1676
- const argsToml = args.map((value) => `"${value}"`).join(', ');
1677
- const block = `[mcp_servers.ark]
1678
- command = "${command}"
1679
- args = [${argsToml}]`;
1680
- let existing = '';
1681
- try {
1682
- if (fs.existsSync(file)) existing = fs.readFileSync(file, 'utf8');
1683
- } catch (error) {
1684
- return { status: 'failed', file, message: error.message };
1685
- }
1686
- const tableRe = /(^|\n)\[mcp_servers\.ark\][^\n]*\n(?:(?!\[)[^\n]*\n?)*/;
1687
- const hasTable = tableRe.test(existing);
1688
- // Fail-closed: rewrite temp/upgrade roots and dual/wrong bins even without --force.
1689
- const mustRewrite = hasTable && codexArkBlockNeedsRewrite(existing, absRoot);
1690
- if (hasTable && !force && !mustRewrite) {
1691
- return { status: 'skipped', file };
1692
- }
1693
- let next;
1694
- if (hasTable) {
1695
- next = existing.replace(tableRe, (match) => `${match.startsWith('\n') ? '\n' : ''}${block}\n`);
1696
- } else {
1697
- const sep = existing.length === 0 ? '' : existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
1698
- next = `${existing}${sep}${block}\n`;
1699
- }
1700
- try {
1701
- fs.mkdirSync(path.dirname(file), { recursive: true });
1702
- fs.writeFileSync(file, next);
1703
- } catch (error) {
1704
- return { status: 'failed', file, message: error.message };
1705
- }
1706
- return {
1707
- status: hasTable ? 'updated' : 'written',
1708
- file,
1709
- ...(mustRewrite && !force ? { reason: 'temp-or-stale-root' } : {}),
1710
- };
1711
- }
1712
-
1713
- // Detects stale/missing /ark-* skills in the Codex home prompts dir. Only nags
1714
- // when at least one ark-* prompt already lives there (evidence Codex was set up
1715
- // for this user) — never introduces Codex to someone who doesn't use it. Same
1716
- // guards as detectSkillGaps (adopted repo, not the Ark source tree).
1717
- function detectCodexHomeGap(root) {
1718
- if (!fs.existsSync(path.join(root, 'AGENTS.md'))) return null;
1719
- if (fs.existsSync(path.join(root, 'templates', 'skills'))) return null;
1720
- const skillNames = skillTemplateNames();
1721
- if (skillNames.length === 0) return null;
1722
- const dir = codexPromptsDir();
1723
- if (!fs.existsSync(dir)) return null;
1724
- const present = skillNames.filter((name) => fs.existsSync(path.join(dir, `${name}.md`)));
1725
- if (present.length === 0) return null; // Codex home never set up for Ark — don't nag.
1726
- const version = arkPackageVersion();
1727
- const missing = skillNames.length - present.length;
1728
- let stale = 0;
1729
- if (version) {
1730
- for (const name of present) {
1731
- const installed = installedSkillVersion(path.join(dir, `${name}.md`));
1732
- if (installed === null || isVersionOlder(installed, version)) stale += 1;
1733
- }
1734
- }
1735
- return missing > 0 || stale > 0 ? { missing, stale } : null;
1736
- }
1737
-
1738
- function detectSkillGaps(root) {
1739
- if (!fs.existsSync(path.join(root, 'AGENTS.md'))) return [];
1740
- // The Ark source tree keeps the skill templates at templates/skills/ — it's the
1741
- // producer, not a consumer, so it must not nag itself to "install" its own skills.
1742
- if (fs.existsSync(path.join(root, 'templates', 'skills'))) return [];
1743
- const skillNames = skillTemplateNames();
1744
- if (skillNames.length === 0) return [];
1745
- const detected = [];
1746
- if (fs.existsSync(path.join(root, '.claude'))) detected.push('claude');
1747
- if (fs.existsSync(path.join(root, '.cursor'))) detected.push('cursor');
1748
- if (fs.existsSync(path.join(root, '.codex'))) detected.push('codex');
1749
- if (fs.existsSync(path.join(root, '.grok'))) detected.push('grok');
1750
- if (fs.existsSync(path.join(root, '.windsurf'))) detected.push('windsurf');
1751
- if (fs.statSync(path.join(root, '.clinerules'), { throwIfNoEntry: false })?.isDirectory()) {
1752
- detected.push('cline');
1753
- }
1754
- const version = arkPackageVersion();
1755
- const gaps = [];
1756
- for (const tool of detected) {
1757
- const target = SKILL_TOOL_TARGETS[tool];
1758
- if (!target) continue;
1759
- let missing = 0;
1760
- let stale = 0;
1761
- for (const name of skillNames) {
1762
- const file = path.join(root, target(name));
1763
- if (!fs.existsSync(file)) {
1764
- missing += 1;
1765
- } else if (version) {
1766
- // An installed skill with no stamp predates stamping (older Ark), or one
1767
- // stamped behind the current version is left over from an older install.
1768
- // Either way the shipped skill has moved on — offer a --force refresh.
1769
- const installed = installedSkillVersion(file);
1770
- if (installed === null || isVersionOlder(installed, version)) stale += 1;
1771
- }
1772
- }
1773
- if (missing > 0 || stale > 0) gaps.push({ tool, missing, stale });
1774
- }
1775
- return gaps;
1776
- }
1777
-
1778
- // Files carrying an emitted Ark command whose runner (npx / pnpm exec / yarn) should match
1779
- // the project's package manager. .mcp.json / .cursor/mcp.json hold it structurally
1780
- // (command/args); the rest hold it as text ("npx ark-check …", incl. .claude/settings.json
1781
- // hook strings and the package.json check:architecture script).
1782
- const COMMAND_GATE_TEXT_FILES = [
1783
- '.claude/settings.json', 'AGENTS.md', '.cursor/rules/ark.mdc', '.windsurf/rules/ark.md',
1784
- '.clinerules/ark.md', '.github/copilot-instructions.md', '.kiro/steering/ark.md',
1785
- '.roo/rules/ark.md', '.continue/rules/ark.md', 'GEMINI.md', 'package.json',
1786
- '.grok/hooks/ark-write-gate.json', '.grok/config.toml',
1787
- ];
1788
- const COMMAND_GATE_JSON_FILES = ['.mcp.json', '.cursor/mcp.json'];
1789
- // Primary CLI names (product) + one-major aliases. migrate-commands must strip ALL of these
1790
- // before re-emitting a single preferred bin — otherwise a partial rename leaves
1791
- // args: ["ark-mcp", "arkgate-mcp", ...] which breaks stdio MCP hosts.
1792
- const ARK_MCP_BINS = new Set(['arkgate-mcp', 'ark-mcp']);
1793
- const ARK_CHECK_BINS = new Set(['arkgate-check', 'ark-check']);
1794
- const ARK_CLI_BINS = new Set(['arkgate', 'ark']);
1795
- const PREFERRED_MCP_BIN = 'arkgate-mcp';
1796
- const PREFERRED_CHECK_BIN = 'arkgate-check';
1797
- const PREFERRED_CLI_BIN = 'arkgate';
1798
- // Runner argv noise that is not a bin argument (pnpm exec form).
1799
- const MCP_RUNNER_ARGV = new Set(['exec', '--config.verify-deps-before-run=false']);
1800
- // The runner token immediately before an ark command in a text command string.
1801
- // Matches npm/yarn runners and both pnpm forms (legacy `pnpm exec` + verify-deps-safe form).
1802
- // Longer bin names first so `arkgate-check` is not partially matched as `ark`.
1803
- const RUNNER_BEFORE_ARK =
1804
- /\b(?:npx|pnpm --config\.verify-deps-before-run=false exec|pnpm exec|yarn)(?= (?:arkgate-check|arkgate-mcp|arkgate|ark-check|ark-mcp|ark)\b)/g;
1805
-
1806
- /** Keep only MCP server flags from existing args (drop runner tokens + any ark* bin names). */
1807
- function stripMcpServerArgs(args) {
1808
- if (!Array.isArray(args) || args.length === 0) {
1809
- return ['--root', '.', '--config', 'ark.config.json'];
1810
- }
1811
- const kept = args.filter(
1812
- (entry) =>
1813
- typeof entry === 'string' &&
1814
- !MCP_RUNNER_ARGV.has(entry) &&
1815
- !ARK_MCP_BINS.has(entry) &&
1816
- !ARK_CHECK_BINS.has(entry) &&
1817
- !ARK_CLI_BINS.has(entry)
1818
- );
1819
- return kept.length > 0 ? kept : ['--root', '.', '--config', 'ark.config.json'];
1820
- }
1821
-
1822
- /** True when mcpServers.ark.args list more than one Ark MCP bin (broken dual rename). */
1823
- function mcpArgsHaveDuplicateBins(args) {
1824
- if (!Array.isArray(args)) return false;
1825
- const hits = args.filter((entry) => ARK_MCP_BINS.has(entry));
1826
- return hits.length > 1 || (hits.length === 1 && args.indexOf(hits[0]) !== args.lastIndexOf(hits[0]));
1827
- }
1828
-
1829
- function brokenMcpGateFiles(root) {
1830
- const bad = [];
1831
- for (const rel of COMMAND_GATE_JSON_FILES) {
1832
- let json;
1833
- try {
1834
- json = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
1835
- } catch {
1836
- continue;
1837
- }
1838
- const ark = json?.mcpServers?.ark;
1839
- if (ark && mcpArgsHaveDuplicateBins(ark.args)) bad.push(rel);
1840
- }
1841
- return bad;
1842
- }
1843
-
1844
- /** Core layers whose optionality matters once they match files (presets share these names). */
1845
- const CORE_LAYER_NAMES = new Set([
1846
- 'DomainModel',
1847
- 'ApplicationOrchestration',
1848
- 'PresentationAdapters',
1849
- 'PersistenceAdapters',
1850
- ]);
1851
-
1852
- /** Temp / upgrade sandbox roots must never remain as Codex MCP --root. */
1853
- function isTempOrUpgradeRoot(p) {
1854
- if (!p || typeof p !== 'string') return false;
1855
- const n = p.replace(/\\/g, '/');
1856
- return (
1857
- /\/var\/folders\//i.test(n) ||
1858
- /\/tmp\//i.test(n) ||
1859
- /\/Temp\//i.test(n) ||
1860
- /ark-upgrade/i.test(n) ||
1861
- /\/T\/(?:ark-|grok-)/i.test(n) ||
1862
- /[\\/]AppData[\\/]Local[\\/]Temp[\\/]/i.test(n)
1863
- );
1864
- }
1865
-
1866
- /** Extract --root value from Codex [mcp_servers.ark] args array text. */
1867
- function extractCodexArkRootFromToml(tomlText) {
1868
- if (!tomlText || typeof tomlText !== 'string') return null;
1869
- const start = tomlText.search(/(^|\n)\[mcp_servers\.ark\]/);
1870
- if (start < 0) return null;
1871
- const rest = tomlText.slice(start);
1872
- const endMatch = rest.slice(1).search(/\n\[/);
1873
- const block = endMatch >= 0 ? rest.slice(0, endMatch + 1) : rest;
1874
- // args = ["arkgate-mcp", "--root", "/abs/path", ...]
1875
- const rootIdx = block.search(/"--root"\s*,\s*"/);
1876
- if (rootIdx < 0) {
1877
- // alternate: --root as adjacent string after any bin
1878
- const m = block.match(/"--root"\s*,\s*"([^"]+)"/);
1879
- return m ? m[1] : null;
1880
- }
1881
- const m = block.slice(rootIdx).match(/"--root"\s*,\s*"([^"]+)"/);
1882
- return m ? m[1] : null;
1883
- }
1884
-
1885
- function codexArkBlockHasPreferredBin(tomlText) {
1886
- if (!tomlText) return false;
1887
- const start = tomlText.search(/(^|\n)\[mcp_servers\.ark\]/);
1888
- if (start < 0) return false;
1889
- const rest = tomlText.slice(start);
1890
- const endMatch = rest.slice(1).search(/\n\[/);
1891
- const block = endMatch >= 0 ? rest.slice(0, endMatch + 1) : rest;
1892
- const bins = [...block.matchAll(/"(arkgate-mcp|ark-mcp)"/g)].map((m) => m[1]);
1893
- if (bins.length > 1) return false;
1894
- return bins.length === 1 && bins[0] === PREFERRED_MCP_BIN;
1895
- }
1896
-
1897
- function codexArkBlockNeedsRewrite(tomlText, absRoot) {
1898
- if (!tomlText || !tomlText.includes('[mcp_servers.ark]')) return true;
1899
- const rootArg = extractCodexArkRootFromToml(tomlText);
1900
- if (!rootArg || isTempOrUpgradeRoot(rootArg)) return true;
1901
- try {
1902
- if (path.resolve(rootArg) !== path.resolve(absRoot)) return true;
1903
- } catch {
1904
- return true;
1905
- }
1906
- if (!codexArkBlockHasPreferredBin(tomlText)) return true;
1907
- return false;
1908
- }
1909
-
1910
- /**
1911
- * Adoption completeness (separate from 0–100 fitness). Pure-ish: filesystem + config.
1912
- * @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null }}
1913
- */
1914
- function collectAdoptionGaps(root, config, coverage) {
1915
- const gaps = [];
1916
- const adopted = fs.existsSync(path.join(root, 'AGENTS.md'));
1917
- const isProducer = fs.existsSync(path.join(root, 'templates', 'skills'));
1918
-
1919
- // --- Repo MCP dual-bin ---
1920
- const dualMcp = brokenMcpGateFiles(root);
1921
- const mcp = {
1922
- dualBinFiles: dualMcp,
1923
- ok: dualMcp.length === 0,
1924
- };
1925
- if (dualMcp.length > 0) {
1926
- gaps.push({
1927
- id: 'mcp-dual-bin',
1928
- severity: 'warn',
1929
- message: `Broken MCP argv in ${dualMcp.join(', ')}: more than one of ark-mcp/arkgate-mcp`,
1930
- fix: arkCommand(root, 'ark-check', '--install-agent-gates --migrate-commands'),
1931
- });
1932
- }
1933
-
1934
- // --- Host completeness (only when project already adopted gates) ---
1935
- const hosts = [];
1936
- if (adopted && !isProducer) {
1937
- const skillNames = skillTemplateNames();
1938
- const hostChecks = [
1939
- {
1940
- host: 'grok',
1941
- dir: '.grok',
1942
- skill: (n) => path.join(root, '.grok', 'skills', n, 'SKILL.md'),
1943
- extras: [
1944
- ['.grok/hooks/ark-write-gate.json', 'write-gate hook'],
1945
- ['.grok/config.toml', 'project MCP config'],
1946
- ],
1947
- toolsFlag: 'grok',
1948
- },
1949
- {
1950
- host: 'claude',
1951
- dir: '.claude',
1952
- skill: (n) => path.join(root, '.claude', 'skills', n, 'SKILL.md'),
1953
- extras: [['.claude/settings.json', 'settings/hooks']],
1954
- toolsFlag: 'claude',
1955
- },
1956
- {
1957
- host: 'cursor',
1958
- dir: '.cursor',
1959
- skill: (n) => path.join(root, '.cursor', 'commands', `${n}.md`),
1960
- extras: [['.cursor/mcp.json', 'MCP config']],
1961
- toolsFlag: 'cursor',
1962
- },
1963
- ];
1964
- for (const h of hostChecks) {
1965
- if (!fs.existsSync(path.join(root, h.dir))) continue;
1966
- const missingSkills = skillNames.filter((n) => !fs.existsSync(h.skill(n)));
1967
- const missingExtras = h.extras.filter(([rel]) => !fs.existsSync(path.join(root, rel)));
1968
- const complete = missingSkills.length === 0 && missingExtras.length === 0;
1969
- hosts.push({
1970
- host: h.host,
1971
- present: true,
1972
- complete,
1973
- missingSkills: missingSkills.length,
1974
- missingExtras: missingExtras.map(([, label]) => label),
1975
- });
1976
- if (!complete) {
1977
- gaps.push({
1978
- id: `host-${h.host}-incomplete`,
1979
- severity: 'warn',
1980
- message: `${h.host} dir present but incomplete (${missingSkills.length} skill(s) missing${
1981
- missingExtras.length ? `; missing ${missingExtras.map(([, l]) => l).join(', ')}` : ''
1982
- })`,
1983
- fix: arkCommand(
1984
- root,
1985
- 'ark-check',
1986
- `--install-agent-gates --tools ${h.toolsFlag} --force`
1987
- ),
1988
- });
1989
- }
1990
- }
1991
- }
1992
-
1993
- // --- Codex home MCP (temp path / wrong root / dual bin) ---
1994
- let codexHome = null;
1995
- if (adopted && !isProducer) {
1996
- const codexFile = codexConfigPath();
1997
- let toml = '';
1998
- try {
1999
- if (fs.existsSync(codexFile)) toml = fs.readFileSync(codexFile, 'utf8');
2000
- } catch {
2001
- toml = '';
2002
- }
2003
- if (toml.includes('[mcp_servers.ark]')) {
2004
- const rootArg = extractCodexArkRootFromToml(toml);
2005
- const absRoot = path.resolve(root);
2006
- const temp = isTempOrUpgradeRoot(rootArg);
2007
- let wrongRoot = false;
2008
- try {
2009
- wrongRoot = rootArg ? path.resolve(rootArg) !== absRoot : true;
2010
- } catch {
2011
- wrongRoot = true;
2012
- }
2013
- const preferredBin = codexArkBlockHasPreferredBin(toml);
2014
- const needsRewrite = codexArkBlockNeedsRewrite(toml, absRoot);
2015
- codexHome = {
2016
- file: codexFile,
2017
- root: rootArg,
2018
- tempPath: temp,
2019
- wrongRoot,
2020
- preferredBin,
2021
- needsRewrite,
2022
- };
2023
- if (needsRewrite) {
2024
- gaps.push({
2025
- id: 'codex-home-mcp',
2026
- severity: temp || wrongRoot ? 'warn' : 'info',
2027
- message: temp
2028
- ? `Codex home MCP --root points at a temp/upgrade path (${rootArg})`
2029
- : wrongRoot
2030
- ? `Codex home MCP --root is not this project (${rootArg || 'missing'} ≠ ${absRoot})`
2031
- : `Codex home MCP should use a single ${PREFERRED_MCP_BIN} bin with absolute project paths`,
2032
- fix: arkCommand(
2033
- root,
2034
- 'ark-check',
2035
- '--install-agent-gates --codex-home --force'
2036
- ),
2037
- });
2038
- }
2039
- }
2040
- }
2041
-
2042
- // --- Core layers optional but populated ---
2043
- const coreOptional = [];
2044
- const layerRows = coverage?.layers ?? [];
2045
- const countByName = new Map(layerRows.map((r) => [r.name, r.files]));
2046
- for (const layer of config?.layers ?? []) {
2047
- if (!CORE_LAYER_NAMES.has(layer.name)) continue;
2048
- if (layer.optional !== true) continue;
2049
- const files = countByName.get(layer.name) ?? 0;
2050
- if (files > 0) {
2051
- coreOptional.push({ layer: layer.name, files });
2052
- gaps.push({
2053
- id: `core-optional-${layer.name}`,
2054
- severity: 'info',
2055
- message: `Core layer ${layer.name} has ${files} file(s) but is still optional: true — contract is weaker than the tree`,
2056
- fix: `Edit ark.config.json: remove optional on ${layer.name} (or set false), then ${arkCommand(root, 'ark-check', '--strict-config')}`,
2057
- });
2058
- }
2059
- }
2060
-
2061
- // --- Origin report ---
2062
- const originJson = path.join(root, '.ark', 'reports', 'origin.json');
2063
- const originReport = {
2064
- present: fs.existsSync(originJson),
2065
- path: '.ark/reports/origin.json',
2066
- };
2067
- if (adopted && !originReport.present && (coverage?.governed?.percent ?? 0) >= 50) {
2068
- gaps.push({
2069
- id: 'origin-report-missing',
2070
- severity: 'info',
2071
- message: 'No origin architecture snapshot under .ark/reports/ yet',
2072
- fix: arkCommand(root, 'ark-check', '--report ark-report.html'),
2073
- });
2074
- }
2075
-
2076
- // --- Baseline policy ---
2077
- const baselinePath = path.join(root, '.ark-baseline.json');
2078
- const baselineExists = fs.existsSync(baselinePath);
2079
- let frozenKeys = 0;
2080
- if (baselineExists) {
2081
- try {
2082
- const raw = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
2083
- frozenKeys = Array.isArray(raw.violations) ? raw.violations.length : 0;
2084
- } catch {
2085
- frozenKeys = 0;
2086
- }
2087
- }
2088
- let primaryPathUsesBaseline = false;
2089
- try {
2090
- const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
2091
- const scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
2092
- primaryPathUsesBaseline = Object.values(scripts).some(
2093
- (s) => typeof s === 'string' && s.includes('--baseline')
2094
- );
2095
- } catch {
2096
- /* no package.json */
2097
- }
2098
- if (!primaryPathUsesBaseline) {
2099
- try {
2100
- const wfDir = path.join(root, '.github', 'workflows');
2101
- if (fs.existsSync(wfDir)) {
2102
- for (const f of fs.readdirSync(wfDir)) {
2103
- if (!/\.ya?ml$/i.test(f)) continue;
2104
- const text = fs.readFileSync(path.join(wfDir, f), 'utf8');
2105
- if (text.includes('--baseline') && (text.includes('ark-check') || text.includes('arkgate-check'))) {
2106
- primaryPathUsesBaseline = true;
2107
- break;
2108
- }
2109
- }
2110
- }
2111
- } catch {
2112
- /* ignore */
2113
- }
2114
- }
2115
- const baseline = {
2116
- exists: baselineExists,
2117
- frozenKeys,
2118
- primaryPathUsesBaseline,
2119
- signal: baselineExists
2120
- ? frozenKeys === 0
2121
- ? 'keep-empty'
2122
- : 'active-ratchet'
2123
- : 'absent',
2124
- };
2125
- if (adopted && baselineExists && frozenKeys === 0 && !primaryPathUsesBaseline) {
2126
- gaps.push({
2127
- id: 'baseline-unused',
2128
- severity: 'info',
2129
- message:
2130
- 'Empty .ark-baseline.json exists but primary scripts/CI do not pass --baseline (policy unclear)',
2131
- fix: 'Either add --baseline .ark-baseline.json to check:architecture / CI, or remove the unused baseline file',
2132
- });
2133
- }
2134
-
2135
- // --- Educational layer balance (not a violation) ---
2136
- let layerBalance = null;
2137
- const total = layerRows.reduce((s, r) => s + (r.files || 0), 0);
2138
- if (total >= 20) {
2139
- const presentation = layerRows.find((r) => r.name === 'PresentationAdapters');
2140
- const domain = layerRows.find((r) => r.name === 'DomainModel');
2141
- if (presentation && domain) {
2142
- const pShare = presentation.files / total;
2143
- const dShare = domain.files / total;
2144
- if (pShare >= 0.5 && dShare < 0.1) {
2145
- layerBalance = {
2146
- kind: 'presentation-heavy-thin-domain',
2147
- presentationFiles: presentation.files,
2148
- domainFiles: domain.files,
2149
- totalFiles: total,
2150
- educational:
2151
- 'Presentation holds most of the tree while DomainModel is thin — common for UI apps; consider extracting domain types/use-cases as the product grows. Educational only (not a gate failure).',
2152
- };
2153
- }
2154
- }
2155
- }
2156
-
2157
- return {
2158
- gaps,
2159
- hosts,
2160
- mcp,
2161
- codexHome,
2162
- coreOptional,
2163
- originReport,
2164
- baseline,
2165
- layerBalance,
2166
- };
2167
- }
2168
-
2169
- // Gate files whose Ark command runner doesn't match this project's package manager — the
2170
- // advisory (and --migrate-commands) target. Returns [] for npm/unknown projects (npx is right)
2171
- // so the check is silent unless there's a real mismatch.
2172
- function staleRunnerGateFiles(root) {
2173
- const want = execRunner(root);
2174
- if (want === 'npx') return [];
2175
- const stale = [];
2176
- for (const rel of COMMAND_GATE_TEXT_FILES) {
2177
- let text;
2178
- try {
2179
- text = fs.readFileSync(path.join(root, rel), 'utf8');
2180
- } catch {
2181
- continue;
2182
- }
2183
- RUNNER_BEFORE_ARK.lastIndex = 0;
2184
- let match;
2185
- while ((match = RUNNER_BEFORE_ARK.exec(text))) {
2186
- if (match[0] !== want) {
2187
- stale.push(rel);
2188
- break;
2189
- }
2190
- }
2191
- }
2192
- for (const rel of COMMAND_GATE_JSON_FILES) {
2193
- let json;
2194
- try {
2195
- json = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
2196
- } catch {
2197
- continue;
2198
- }
2199
- const ark = json?.mcpServers?.ark;
2200
- if (ark && ark.command && ark.command !== want.split(' ')[0]) stale.push(rel);
2201
- }
2202
- return stale;
2203
- }
2204
-
2205
- // When more than one lockfile is present the project is ambiguous. detectPackageManager()
2206
- // resolves it (package-lock.json wins so a stray pnpm-lock.yaml can't hijack an npm project),
2207
- // but the user should know it happened and how to make it explicit — otherwise a leftover
2208
- // lockfile silently steers which runner every emitted command uses.
2209
- function warnLockfileConflict(root) {
2210
- const locks = presentLockfiles(root);
2211
- if (locks.length <= 1) return;
2212
- const chosen = detectPackageManager(root);
2213
- const files = { pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock', npm: 'package-lock.json' };
2214
- console.log('');
2215
- console.log(
2216
- `Note: multiple lockfiles present (${locks.map((pm) => files[pm]).join(', ')}). Treating this`
2217
- );
2218
- console.log(
2219
- `as a ${chosen} project — Ark commands use "${execRunner(root)}". If that's wrong, set`
2220
- );
2221
- console.log(
2222
- '"packageManager" in package.json (e.g. "pnpm@9") to declare it, or remove the stray lockfile.'
2223
- );
2224
- }
2225
-
2226
- // --migrate-commands: rewrite ONLY the Ark command runner in existing gate files to the
2227
- // project's package manager (no --force clobber). Closes the upgrade gap where a repo that
2228
- // adopted before the package-manager-aware templates keeps a stale `npx`.
2229
- // Also normalizes MCP JSON to a single preferred bin (arkgate-mcp), stripping any dual
2230
- // ark-mcp + arkgate-mcp residue left by partial renames during package identity cutover.
2231
- function runMigrateCommands(root) {
2232
- const runner = execRunner(root);
2233
- const changed = [];
2234
- for (const rel of COMMAND_GATE_TEXT_FILES) {
2235
- const full = path.join(root, rel);
2236
- let text;
2237
- try {
2238
- text = fs.readFileSync(full, 'utf8');
2239
- } catch {
2240
- continue;
2241
- }
2242
- let next = text.replace(RUNNER_BEFORE_ARK, runner);
2243
- // Prefer primary product bins in command strings (aliases still work if left alone).
2244
- next = next
2245
- .replace(/\bark-mcp\b/g, PREFERRED_MCP_BIN)
2246
- .replace(/\bark-check\b/g, PREFERRED_CHECK_BIN);
2247
- // Do not blanket-replace bare `ark` — it appears in prose ("Ark check", product name).
2248
- if (next !== text) {
2249
- fs.writeFileSync(full, next);
2250
- changed.push(rel);
2251
- }
2252
- }
2253
- for (const rel of COMMAND_GATE_JSON_FILES) {
2254
- const full = path.join(root, rel);
2255
- let json;
2256
- try {
2257
- json = JSON.parse(fs.readFileSync(full, 'utf8'));
2258
- } catch {
2259
- continue;
2260
- }
2261
- const ark = json?.mcpServers?.ark;
2262
- if (!ark) continue;
2263
- const binArgs = stripMcpServerArgs(ark.args);
2264
- const parts = execCommandParts(root, PREFERRED_MCP_BIN, binArgs);
2265
- if (ark.command !== parts.command || JSON.stringify(ark.args) !== JSON.stringify(parts.args)) {
2266
- json.mcpServers.ark = { ...ark, ...parts };
2267
- fs.writeFileSync(full, `${JSON.stringify(json, null, 2)}\n`);
2268
- changed.push(rel);
2269
- }
2270
- }
2271
- const pm = runner === 'pnpm exec' || runner.startsWith('pnpm ') ? 'pnpm' : runner;
2272
- console.log(`Migrated ArkGate command runners to "${pm}" and normalized MCP bins in gate files.`);
2273
- if (changed.length === 0) {
2274
- console.log(' Nothing to change — runners and MCP bins already look correct.');
2275
- } else {
2276
- for (const rel of changed) console.log(` updated ${rel}`);
2277
- console.log(
2278
- ` (runner + single MCP bin \`${PREFERRED_MCP_BIN}\`; customized non-command content is untouched.)`
2279
- );
2280
- }
2281
- warnLockfileConflict(root);
2282
- }
2283
-
2284
- function runInstallAgentGates(args) {
2285
- const root = args.root;
2286
- if (args.migrateCommands) {
2287
- runMigrateCommands(root);
2288
- return;
2289
- }
2290
- if (args.tools) {
2291
- const unknown = args.tools.filter((tool) => !KNOWN_TOOLS.includes(tool));
2292
- if (args.tools.length === 0 || unknown.length > 0) {
2293
- console.error(
2294
- `--tools expects a comma-separated subset of: ${KNOWN_TOOLS.join(', ')}` +
2295
- (unknown.length > 0 ? ` (unknown: ${unknown.join(', ')})` : '')
2296
- );
2297
- process.exitCode = 2;
2298
- return;
2299
- }
2300
- }
2301
- const pm = packageManager(root);
2302
- const hasCheckScript = hasCheckArchitectureScript(root);
2303
- const { tools, source } = resolveTools(args);
2304
- const toolSource =
2305
- source === 'explicit'
2306
- ? 'from --tools'
2307
- : source === 'detected'
2308
- ? 'auto-detected from config dirs'
2309
- : 'default set — no agent config dirs found';
2310
- console.log(`Agent gates for: ${[...tools].sort().join(', ')} (${toolSource})`);
2311
- const templates = [];
2312
- // --skills-only refreshes just the canonical /ark-* skills, which are safe to
2313
- // overwrite (they track the package). The gate/instruction files (AGENTS.md,
2314
- // settings.json, CI workflow, rules) are the ones users customize, so a plain
2315
- // `--force` clobbers them — this is the safe way to pick up new skill versions.
2316
- if (!args.skillsOnly) {
2317
- // Base gates: tool-agnostic contract + CI backstop, always written.
2318
- templates.push(['AGENTS.md', agentInstructions(root)]);
2319
- templates.push(['.mcp.json', mcpJson(root)]);
2320
- templates.push([
2321
- '.github/workflows/ark-check.yml',
2322
- githubWorkflow(pm, detectCiNode(root)),
2323
- ]);
2324
- if (tools.has('cursor')) {
2325
- templates.push(['.cursor/mcp.json', mcpJson(root)]);
2326
- templates.push(['.cursor/rules/ark.mdc', cursorRule(root)]);
2327
- }
2328
- if (tools.has('claude')) {
2329
- templates.push(['.claude/settings.json', claudeSettings(root)]);
2330
- }
2331
- if (tools.has('codex')) {
2332
- templates.push(['docs/ark-codex-config.toml', codexTomlSnippet(root)]);
2333
- }
2334
- if (tools.has('grok')) {
2335
- templates.push(['.grok/config.toml', grokProjectConfig(root)]);
2336
- templates.push(['.grok/hooks/ark-write-gate.json', grokHooks(root)]);
2337
- }
2338
- // Instruction-tier hosts: one shared rule text, host-specific path.
2339
- if (tools.has('windsurf')) {
2340
- templates.push(['.windsurf/rules/ark.md', instructionRule(root)]);
2341
- }
2342
- if (tools.has('cline')) {
2343
- templates.push(['.clinerules/ark.md', instructionRule(root)]);
2344
- }
2345
- if (tools.has('copilot')) {
2346
- templates.push(['.github/copilot-instructions.md', instructionRule(root)]);
2347
- }
2348
- if (tools.has('kiro')) {
2349
- templates.push(['.kiro/steering/ark.md', instructionRule(root)]);
2350
- }
2351
- if (tools.has('roo')) {
2352
- templates.push(['.roo/rules/ark.md', instructionRule(root)]);
2353
- }
2354
- if (tools.has('continue')) {
2355
- templates.push(['.continue/rules/ark.md', instructionRule(root)]);
2356
- }
2357
- // Gemini CLI reads GEMINI.md as its primary project context (it also reads
2358
- // AGENTS.md, but GEMINI.md wins when both are present), so the rule lives there.
2359
- if (tools.has('gemini')) {
2360
- templates.push(['GEMINI.md', instructionRule(root)]);
2361
- }
2362
- }
2363
- // /ark-* skills for every detected tool that supports project-level commands.
2364
- // Stamp each with the shipping version so a later ark-check can flag skills
2365
- // left behind by an older Ark (see detectSkillGaps) without nagging about
2366
- // user edits to the body.
2367
- const version = arkPackageVersion();
2368
- const skills = skillTemplates().map(([name, content]) => [name, stampSkill(content, version)]);
2369
- const skillPaths = new Set();
2370
- for (const tool of tools) {
2371
- const target = SKILL_TOOL_TARGETS[tool];
2372
- if (!target) continue;
2373
- for (const [name, content] of skills) {
2374
- const relativePath = target(name);
2375
- skillPaths.add(relativePath);
2376
- templates.push([relativePath, content]);
2377
- }
2378
- }
2379
-
2380
- const results = templates.map(([relativePath, content]) =>
2381
- writeTemplate(root, relativePath, content, args.force)
2382
- );
2383
-
2384
- console.log('Ark agent gate templates:');
2385
- let staleSkipped = 0;
2386
- for (const result of results) {
2387
- const marker =
2388
- result.status === 'written' ? 'wrote' : result.status === 'failed' ? 'FAILED' : 'skipped';
2389
- // A skipped skill reads as "you're fine" — but it may be a version behind.
2390
- // Say which, so the user isn't left guessing (and knows the safe refresh cmd).
2391
- let note = '';
2392
- if (result.status === 'skipped' && skillPaths.has(result.relativePath) && version) {
2393
- const installed = installedSkillVersion(path.join(root, result.relativePath));
2394
- if (installed === null || isVersionOlder(installed, version)) {
2395
- staleSkipped += 1;
2396
- note = ` (stale: ${installed ?? 'no stamp'} < ${version})`;
2397
- } else {
2398
- note = ' (up to date)';
2399
- }
2400
- }
2401
- console.log(` ${marker.padEnd(7)} ${result.relativePath}${note}`);
2402
- }
2403
- if (staleSkipped > 0 && !args.skillsOnly) {
2404
- console.log('');
2405
- console.log(
2406
- ` ${staleSkipped} skill(s) are outdated but were left untouched. Refresh them with:`
2407
- );
2408
- console.log(` ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --force')}`);
2409
- }
2410
-
2411
- // --codex-home writes the canonical skills straight to $CODEX_HOME/prompts.
2412
- // Codex reads prompts from there (not the repo), so this is the only way to
2413
- // refresh them for a repo that isn't itself configured for Codex. It writes to
2414
- // the user's home dir, hence explicit opt-in rather than part of a normal run.
2415
- const homeResults = [];
2416
- if (args.codexHome) {
2417
- const dir = codexPromptsDir();
2418
- console.log('');
2419
- console.log(`Codex home skills (${dir}):`);
2420
- try {
2421
- fs.mkdirSync(dir, { recursive: true });
2422
- } catch (error) {
2423
- console.error(` FAILED to create ${dir} (${error.message})`);
2424
- homeResults.push({ status: 'failed' });
2425
- }
2426
- if (homeResults.length === 0) {
2427
- for (const [name, content] of skills) {
2428
- const file = path.join(dir, `${name}.md`);
2429
- if (fs.existsSync(file) && !args.force) {
2430
- const installed = installedSkillVersion(file);
2431
- const behind = installed === null || (version && isVersionOlder(installed, version));
2432
- const note = behind
2433
- ? ` (stale: ${installed ?? 'no stamp'} < ${version}; use --force)`
2434
- : ' (up to date)';
2435
- console.log(` ${'skipped'.padEnd(7)} ${name}.md${note}`);
2436
- homeResults.push({ status: 'skipped' });
2437
- continue;
2438
- }
2439
- try {
2440
- fs.writeFileSync(file, content);
2441
- console.log(` ${'wrote'.padEnd(7)} ${name}.md`);
2442
- homeResults.push({ status: 'written' });
2443
- } catch (error) {
2444
- console.log(` ${'FAILED'.padEnd(7)} ${name}.md (${error.message})`);
2445
- homeResults.push({ status: 'failed' });
2446
- }
2447
- }
2448
- }
2449
- }
2450
-
2451
- // Auto-wire the ark MCP server into Codex's home config.toml. Claude and Cursor get
2452
- // machine-readable registrations (.claude/settings.json, .cursor/mcp.json) written as repo
2453
- // templates above; Codex reads MCP servers only from ~/.codex/config.toml, so it needs a
2454
- // home-dir merge instead. Fires whenever Codex is in play so `ark://manifest` is live
2455
- // without a manual copy step.
2456
- let codexMcp = null;
2457
- if (tools.has('codex') || args.codexHome) {
2458
- codexMcp = wireCodexMcp(root, args.force);
2459
- console.log('');
2460
- console.log(`Codex MCP registration (${codexMcp.file}):`);
2461
- if (codexMcp.status === 'skipped') {
2462
- console.log(` ${'skipped'.padEnd(7)} [mcp_servers.ark] already present (use --force to overwrite)`);
2463
- } else if (codexMcp.status === 'failed') {
2464
- console.log(` ${'FAILED'.padEnd(7)} [mcp_servers.ark] (${codexMcp.message})`);
2465
- } else {
2466
- const verb = codexMcp.status === 'updated' ? 'updated' : 'wrote';
2467
- console.log(` ${verb.padEnd(7)} [mcp_servers.ark] with absolute paths`);
2468
- console.log(' RESTART Codex — it does not hot-load MCP servers.');
2469
- console.log(' Then expect: resource ark://manifest + tools validate_code, ark_check, ark_coverage, ark_place.');
2470
- }
2471
- }
2472
-
2473
- const failed = [...results, ...homeResults, ...(codexMcp ? [codexMcp] : [])].filter((result) => result.status === 'failed');
2474
- if (failed.length > 0) {
2475
- console.error(`\nFailed to write ${failed.length} template(s).`);
2476
- process.exitCode = 1;
2477
- return;
2478
- }
2479
- console.log('');
2480
- console.log('Next steps:');
2481
- console.log(' 1. Review the generated files and commit the ones that match your tools.');
2482
- console.log(` 2. Run: ${arkCheckCommand(root)}`);
2483
- if (!hasCheckScript) {
2484
- console.log(' 3. Add the package.json alias if you want `run check:architecture`:');
2485
- console.log(` ${checkArchitectureScriptSnippet(root)}`);
2486
- }
2487
- if ((tools.has('codex') || args.codexHome)) {
2488
- console.log('');
2489
- if (codexMcp && codexMcp.status !== 'failed') {
2490
- console.log(` Codex: ark MCP registered in ${codexMcp.file} — restart Codex so \`ark://manifest\` loads.`);
2491
- }
2492
- if (args.codexHome) {
2493
- console.log(` Codex: refreshed the /ark-* skills in ${codexPromptsDir()} — Codex loads them from there.`);
2494
- } else if (skills.length > 0) {
2495
- console.log(' Codex loads slash-command prompts from $CODEX_HOME/prompts (~/.codex/prompts),');
2496
- console.log(' not the repo. Install the /ark-* skills there with:');
2497
- console.log(` ${arkCommand(root, 'ark-check', '--install-agent-gates --codex-home')}`);
2498
- console.log(' (writes to your home dir; agents driving this setup should offer to run it).');
2499
- }
2500
- }
2501
- warnLockfileConflict(root);
2502
- }
2503
-
2504
- function readManifest(root, manifestPath) {
2505
- if (!manifestPath) return undefined;
2506
- const fullPath = path.isAbsolute(manifestPath)
2507
- ? manifestPath
2508
- : path.join(root, manifestPath);
2509
- if (!fs.existsSync(fullPath)) {
2510
- throw new Error(`Manifest not found: ${fullPath}`);
2511
- }
2512
- return readJson(fullPath);
2513
- }
2514
-
2515
- const SOURCE_FILE_NAME = /\.[cm]?[tj]sx?$/;
2516
-
2517
- /** Unit/e2e test files are not architecture surface — agents and Nest put them next
2518
- * to production code (*.spec.ts). Counting them as ungoverned forces false
2519
- * CONFIG_UNCLASSIFIED_FILES under --strict-config on every starter. */
2520
- const TEST_FILE_NAME =
2521
- /\.(spec|test)\.(tsx?|jsx?|mts|cts)$/i;
2522
-
2523
- function isGovernableSourceFile(name) {
2524
- return SOURCE_FILE_NAME.test(name) && !name.endsWith('.d.ts') && !TEST_FILE_NAME.test(name);
2525
- }
2526
-
2527
- function isSkippedSourceDir(name) {
2528
- return (
2529
- name === 'node_modules' ||
2530
- name === 'dist' ||
2531
- name === 'coverage' ||
2532
- name === '__tests__' ||
2533
- name === '__mocks__' ||
2534
- name === 'e2e' ||
2535
- // Top-level style Nest/Jest folders (not "testing" helpers inside src)
2536
- name === 'test' ||
2537
- name === 'tests'
2538
- );
2539
- }
2540
-
2541
- function walk(dir, files = []) {
2542
- const stat = fs.statSync(dir, { throwIfNoEntry: false });
2543
- if (!stat) return files;
2544
- // An `include` entry may be a single file (e.g. a root-level "middleware.ts"),
2545
- // not just a directory — govern it directly instead of trying to scandir it
2546
- // (which threw ENOTDIR). The extension filter still applies.
2547
- if (stat.isFile()) {
2548
- if (isGovernableSourceFile(path.basename(dir))) files.push(dir);
2549
- return files;
2550
- }
2551
- if (!stat.isDirectory()) return files;
2552
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
2553
- const full = path.join(dir, entry.name);
2554
- if (entry.isDirectory()) {
2555
- if (isSkippedSourceDir(entry.name)) continue;
2556
- walk(full, files);
2557
- } else if (isGovernableSourceFile(entry.name)) {
2558
- files.push(full);
2559
- }
2560
- }
2561
- return files;
2562
- }
2563
-
2564
- function normalize(value) {
2565
- return value.split(path.sep).join('/');
2566
- }
2567
-
2568
- function intentLayersFromManifest(manifest) {
2569
- const layers = manifest?.architecture?.layers;
2570
- if (!Array.isArray(layers)) return undefined;
2571
- return layers
2572
- .filter((layer) => Array.isArray(layer.prefixes) && layer.prefixes.length > 0)
2573
- .map((layer) => ({ name: layer.name, prefixes: layer.prefixes }));
2574
- }
2575
-
2576
- function layerForIntent(intent, layers, manifestIntentLayers) {
2577
- // Use only layers that declare intent prefixes; fall back to the built-in defaults when
2578
- // none do (mirrors the write-gate). resolveIntentLayer applies the library's exact
2579
- // longest-prefix + trailing-dot semantics so CI and the MCP gate classify identically.
2580
- const configured =
2581
- manifestIntentLayers ??
2582
- layers
2583
- .filter((layer) => (layer.intentPrefixes ?? []).length > 0)
2584
- .map((layer) => ({ name: layer.name, prefixes: layer.intentPrefixes }));
2585
- const source =
2586
- configured.length > 0
2587
- ? configured
2588
- : DEFAULT_INTENT_PREFIXES.map((entry) => ({ name: entry.layer, prefixes: entry.prefixes }));
2589
- return resolveIntentLayer(intent, source);
2590
- }
2591
-
2592
- function isBlocked(rules, from, to) {
2593
- return rules.find((rule) => !rule.allowed && rule.from === from && rule.to === to);
2594
- }
2595
-
2596
- function configWarning(ruleId, message, extra = {}) {
2597
- return { ruleId, message, ...extra };
2598
- }
2599
-
2600
- function collectConfigWarnings(root, config, files, rules, manifest) {
2601
- const warnings = [];
2602
- const layers = Array.isArray(config.layers) ? config.layers : [];
2603
- const manifestLayers = Array.isArray(manifest?.architecture?.layers)
2604
- ? manifest.architecture.layers
2605
- : [];
2606
- const knownLayers = new Set([
2607
- ...layers.map((layer) => layer.name).filter(Boolean),
2608
- ...manifestLayers.map((layer) => layer.name).filter(Boolean),
2609
- ]);
2610
-
2611
- if (layers.length === 0) {
2612
- warnings.push(
2613
- configWarning(
2614
- 'CONFIG_NO_LAYERS',
2615
- 'No file layers are configured; ark-check cannot classify files for import-boundary enforcement.'
2616
- )
2617
- );
2618
- }
2619
-
2620
- const seenLayers = new Set();
2621
- const duplicateLayers = new Set();
2622
- for (const layer of layers) {
2623
- if (!layer.name) {
2624
- warnings.push(
2625
- configWarning('CONFIG_LAYER_WITHOUT_NAME', 'A configured layer is missing a name.')
2626
- );
2627
- continue;
2628
- }
2629
- if (seenLayers.has(layer.name)) duplicateLayers.add(layer.name);
2630
- seenLayers.add(layer.name);
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);
2631
857
 
2632
858
  if (
2633
859
  layer.forbiddenGlobals !== undefined &&
@@ -3159,177 +1385,15 @@ function publishHasSource(ts, node) {
3159
1385
  objectHasProperty(ts, thirdArg, 'source')
3160
1386
  );
3161
1387
  }
3162
-
3163
- // Baseline keys exclude the line number so unrelated edits that shift lines
3164
- // don't resurrect frozen violations; the trade-off is that N identical violations in one
3165
- // file collapse to one key.
3166
- function baselineKey(violation) {
3167
- return [
3168
- violation.ruleId,
3169
- violation.file,
3170
- violation.fromLayer ?? '',
3171
- violation.toLayer ?? '',
3172
- violation.target ?? '',
3173
- ].join('|');
3174
- }
3175
-
3176
- function readBaseline(root, baselinePath) {
3177
- const fullPath = path.isAbsolute(baselinePath) ? baselinePath : path.join(root, baselinePath);
3178
- if (!fs.existsSync(fullPath)) return { keys: new Set(), fullPath, exists: false };
3179
- const raw = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
3180
- return { keys: new Set(raw.violations ?? []), fullPath, exists: true };
3181
- }
3182
-
3183
- function writeBaseline(root, baselinePath, violations) {
3184
- const fullPath = path.isAbsolute(baselinePath) ? baselinePath : path.join(root, baselinePath);
3185
- const keys = [...new Set(violations.map(baselineKey))].sort();
3186
- fs.writeFileSync(
3187
- fullPath,
3188
- `${JSON.stringify({ version: 1, note: 'Frozen ark-check violations. Only NEW violations fail --baseline runs. Regenerate with: ark-check --update-baseline', violations: keys }, null, 2)}\n`
3189
- );
3190
- return { fullPath, count: keys.length };
3191
- }
3192
-
3193
1388
  const useColor = process.stderr.isTTY && !process.env.NO_COLOR;
3194
1389
  const color = {
3195
1390
  red: (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
3196
1391
  yellow: (s) => (useColor ? `\x1b[33m${s}\x1b[0m` : s),
3197
- green: (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
3198
- dim: (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
3199
- bold: (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
3200
- };
3201
-
3202
- const FIX_HINTS = {
3203
- LAYER_IMPORT_VIOLATION:
3204
- 'Depend on a port/interface owned by an inner layer instead, or move this code to a layer allowed to make this import.',
3205
- LAYER_INTENT_REFERENCE_VIOLATION:
3206
- 'Reference intents through a layer that owns them (e.g. subscribe from an adapter, not from the domain).',
3207
- RAW_EVENT_PUBLISH:
3208
- 'Define the intent with ark.registry.define(...) and publish through the returned creator.',
3209
- PUBLISH_MISSING_SOURCE:
3210
- 'Add metadata.source (the publishing intent name) to the publish call.',
3211
- PUBLISH_SOURCE_LAYER_MISMATCH:
3212
- 'Use a source intent that belongs to the same layer as the publishing file, or move the file.',
3213
- FORBIDDEN_GLOBAL:
3214
- 'Inject the capability through a port (e.g. a Clock, IdGenerator, or HttpPort) instead of reaching for the ambient global.',
3215
- CIRCULAR_DEPENDENCY:
3216
- 'Break the cycle: extract the shared code into a module both sides import, invert one edge behind a port/interface, or merge the files if they are really one unit.',
3217
- };
3218
-
3219
- function printViolation(violation) {
3220
- const location = `${violation.file}:${violation.line}`;
3221
- console.error(`${color.red('✖')} ${color.bold(violation.ruleId)} ${location}`);
3222
- if (violation.fromLayer && violation.toLayer) {
3223
- const target = violation.target ? ` ${color.dim(`(${violation.target})`)}` : '';
3224
- console.error(` ${violation.fromLayer} → ${violation.toLayer}${target}`);
3225
- }
3226
- console.error(` ${violation.message}`);
3227
- const hint = FIX_HINTS[violation.ruleId];
3228
- if (hint) console.error(` ${color.dim(`fix: ${hint}`)}`);
3229
- console.error('');
3230
- }
3231
-
3232
- // ── Violation diagnosis ──────────────────────────────────────────────────────
3233
- // Groups violations by their layer EDGE (and target subtree) so a wall of N violations reads
3234
- // as "M distinct problems, ranked by size" — the burn-down order. The killer signal: when
3235
- // one edge dominates, the CONTRACT is usually wrong, not the code (e.g. every API route
3236
- // importing the kernel through a sanctioned entrypoint). Freezing that as "debt" buries a
3237
- // config fix behind a baseline, so --update-baseline refuses a lopsided freeze (see guard).
3238
- const CONCENTRATION_MIN_VIOLATIONS = 10;
3239
- const CONCENTRATION_SHARE = 0.9;
3240
-
3241
- function violationEdge(violation) {
3242
- if (violation.ruleId === 'CIRCULAR_DEPENDENCY') return 'circular dependency';
3243
- if (violation.ruleId === 'FORBIDDEN_GLOBAL') return `${violation.fromLayer ?? '?'} → ambient global`;
3244
- if (violation.fromLayer && violation.toLayer) return `${violation.fromLayer} → ${violation.toLayer}`;
3245
- return violation.ruleId;
3246
- }
3247
-
3248
- // The directory the offending import lands in — the signal for "where does this edge go?".
3249
- // For a LAYER_IMPORT_VIOLATION the target is a resolved file path; cluster by its dir prefix
3250
- // so `kernel/internal/x` and `kernel/internal/y` collapse to one "into kernel/internal/".
3251
- function violationTargetSubtree(violation) {
3252
- if (!violation.target || typeof violation.target !== 'string' || !violation.target.includes('/')) {
3253
- return undefined;
3254
- }
3255
- const segments = violation.target.split('/');
3256
- return segments.slice(0, Math.min(3, segments.length - 1)).join('/');
3257
- }
3258
-
3259
- function summarizeViolations(violations) {
3260
- const byEdge = new Map();
3261
- let typeOnly = 0;
3262
- for (const violation of violations) {
3263
- if (violation.typeOnly) typeOnly += 1;
3264
- const key = violationEdge(violation);
3265
- const entry = byEdge.get(key) ?? { edge: key, count: 0, typeOnly: 0, targets: new Map() };
3266
- entry.count += 1;
3267
- if (violation.typeOnly) entry.typeOnly += 1;
3268
- const subtree = violationTargetSubtree(violation);
3269
- if (subtree) entry.targets.set(subtree, (entry.targets.get(subtree) ?? 0) + 1);
3270
- byEdge.set(key, entry);
3271
- }
3272
- const edges = [...byEdge.values()]
3273
- .map((entry) => ({
3274
- edge: entry.edge,
3275
- count: entry.count,
3276
- typeOnly: entry.typeOnly,
3277
- topTargets: [...entry.targets.entries()]
3278
- .sort((a, b) => b[1] - a[1])
3279
- .slice(0, 4)
3280
- .map(([dir, count]) => ({ dir, count })),
3281
- }))
3282
- .sort((a, b) => b.count - a.count);
3283
- const total = violations.length;
3284
- const dominant = edges[0];
3285
- const dominantShare = total > 0 && dominant ? dominant.count / total : 0;
3286
- return {
3287
- total,
3288
- // Value edges are real runtime coupling; type-only edges (erased at compile time) are
3289
- // just type placement — fix the value ones first, the type-only ones move with the type.
3290
- valueCount: total - typeOnly,
3291
- typeOnlyCount: typeOnly,
3292
- edges,
3293
- dominant: dominant ? dominant.edge : undefined,
3294
- dominantShare,
3295
- concentrated: total >= CONCENTRATION_MIN_VIOLATIONS && dominantShare >= CONCENTRATION_SHARE,
3296
- };
3297
- }
3298
-
3299
- function printViolationBreakdown(summary, { toStderr = false } = {}) {
3300
- const out = toStderr ? (line) => console.error(line) : (line) => console.log(line);
3301
- out('');
3302
- out(`Violation breakdown — ${summary.total} across ${summary.edges.length} edge(s), largest first:`);
3303
- if (summary.typeOnlyCount > 0) {
3304
- out(
3305
- ` ${summary.valueCount} value (runtime coupling — fix first) · ${summary.typeOnlyCount} type-only (type placement — moves with the type)`
3306
- );
3307
- }
3308
- for (const edge of summary.edges) {
3309
- const pct = Math.round((edge.count / summary.total) * 100);
3310
- const typeNote = edge.typeOnly > 0 ? `, ${edge.typeOnly} type-only` : '';
3311
- out(` ${String(edge.count).padStart(5)} ${edge.edge} (${pct}%${typeNote})`);
3312
- for (const target of edge.topTargets) {
3313
- out(` ↳ ${target.count}× into ${target.dir}/`);
3314
- }
3315
- }
3316
- if (summary.concentrated) {
3317
- out('');
3318
- out(`⚠ ${Math.round(summary.dominantShare * 100)}% of violations are a SINGLE edge: ${summary.dominant}.`);
3319
- out(' That usually means the CONTRACT is wrong, not the code — e.g. app-land reaching a');
3320
- out(' framework/kernel through a sanctioned entrypoint. Before treating it as debt:');
3321
- out(' • If the edge is intended, allow it — or split the target layer into a public');
3322
- out(' surface app-land may import + internals it may not (see the target dirs above');
3323
- out(' to find the surface). Do it via /ark-contract.');
3324
- out(' • Only the minority hitting real internals is genuine debt for /ark-fix.');
3325
- out(` Fixing the contract clears ~${summary.edges[0].count} of ${summary.total} at once.`);
3326
- }
3327
- }
1392
+ green: (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
1393
+ dim: (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
1394
+ bold: (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
1395
+ };
3328
1396
 
3329
- // Finds strongly-connected components in the resolved import graph. Any component
3330
- // with more than one file is a set of files that transitively import each other —
3331
- // a circular dependency. One violation per component keeps the output minimal and
3332
- // the baseline key stable (anchored at the alphabetically-first member).
3333
1397
  function detectCycles(graph) {
3334
1398
  let index = 0;
3335
1399
  const indices = new Map();
@@ -3381,1292 +1445,6 @@ function detectCycles(graph) {
3381
1445
  }));
3382
1446
  }
3383
1447
 
3384
- function detectEnforcement(root) {
3385
- const has = (rel) => fs.existsSync(path.join(root, rel));
3386
- const fileIncludes = (rel, needle) => {
3387
- try {
3388
- return fs.readFileSync(path.join(root, rel), 'utf8').includes(needle);
3389
- } catch {
3390
- return false;
3391
- }
3392
- };
3393
- const workflowsMentionArk = () => {
3394
- const dir = path.join(root, '.github', 'workflows');
3395
- if (!fs.existsSync(dir)) return null;
3396
- const hit = fs
3397
- .readdirSync(dir)
3398
- .filter((f) => /\.ya?ml$/.test(f))
3399
- .find((f) => fileIncludes(path.join('.github', 'workflows', f), 'ark-check'));
3400
- return hit ? `.github/workflows/${hit}` : null;
3401
- };
3402
- const eslintFile = ['eslint.config.mjs', 'eslint.config.js', 'eslint.config.cjs', '.eslintrc.json', '.eslintrc.cjs'].find(
3403
- (f) => has(f) && (fileIncludes(f, 'arkgate') || fileIncludes(f, 'ark-runtime-kernel'))
3404
- );
3405
- const writeGateFile =
3406
- ((fileIncludes('.claude/settings.json', 'arkgate-mcp') ||
3407
- fileIncludes('.claude/settings.json', 'ark-mcp')) &&
3408
- '.claude/settings.json') ||
3409
- (has('.cursor/mcp.json') && '.cursor/mcp.json') ||
3410
- (fileIncludes('.grok/hooks/ark-write-gate.json', 'arkgate-mcp') &&
3411
- '.grok/hooks/ark-write-gate.json') ||
3412
- null;
3413
- return [
3414
- { name: 'Write gate', where: writeGateFile, what: 'blocks a bad edit as you type (PreToolUse hook / MCP)' },
3415
- { name: 'ESLint', where: eslintFile || null, what: 'flags violations in your editor' },
3416
- { name: 'CI check', where: workflowsMentionArk(), what: 'blocks the merge if the architecture breaks' },
3417
- { name: 'Baseline', where: has('.ark-baseline.json') ? '.ark-baseline.json' : null, what: 'old violations frozen; new ones fail' },
3418
- ].map((e) => ({ ...e, on: !!e.where }));
3419
- }
3420
-
3421
- function htmlEscape(value) {
3422
- return String(value)
3423
- .replace(/&/g, '&amp;')
3424
- .replace(/</g, '&lt;')
3425
- .replace(/>/g, '&gt;')
3426
- .replace(/"/g, '&quot;');
3427
- }
3428
-
3429
- /** Directory for origin / latest / history architecture report snapshots. */
3430
- const ARK_REPORTS_DIR = path.join('.ark', 'reports');
3431
- const ARK_REPORT_HISTORY_MAX = 20;
3432
-
3433
- function reportsDir(root) {
3434
- return path.join(root, ARK_REPORTS_DIR);
3435
- }
3436
-
3437
- /**
3438
- * Compact metrics snapshot — machine-readable so future reports can diff against origin.
3439
- * Intentionally small (not the full HTML). Layer file counts included for evolution.
3440
- */
3441
- function buildReportSnapshot({
3442
- root,
3443
- config,
3444
- coverage,
3445
- violations,
3446
- ok,
3447
- suppressed,
3448
- version,
3449
- fileCountByLayer,
3450
- enforcement,
3451
- score,
3452
- mode,
3453
- }) {
3454
- const layers = Array.isArray(config?.layers) ? config.layers : [];
3455
- const rules = Array.isArray(config?.rules) ? config.rules : [];
3456
- const counts = {};
3457
- if (fileCountByLayer instanceof Map) {
3458
- for (const [name, n] of fileCountByLayer) counts[name] = n;
3459
- }
3460
- const gatesOn = (enforcement || []).filter((e) => e.on).length;
3461
- return {
3462
- version: 1,
3463
- kind: 'ark-architecture-snapshot',
3464
- generatedAt: new Date().toISOString(),
3465
- arkVersion: version ?? null,
3466
- project: (() => {
3467
- try {
3468
- return readJson(path.join(root, 'package.json')).name || path.basename(root);
3469
- } catch {
3470
- return path.basename(root);
3471
- }
3472
- })(),
3473
- ok: Boolean(ok),
3474
- mode: mode ?? null,
3475
- score: score ?? null,
3476
- governedPercent: coverage?.governed?.percent ?? null,
3477
- classifiedFiles: coverage?.governed?.classifiedFiles ?? 0,
3478
- totalFiles: coverage?.governed?.totalFiles ?? 0,
3479
- unclassifiedFiles: coverage?.unclassified?.count ?? 0,
3480
- layerCount: layers.length,
3481
- denyRules: rules.filter((r) => r.allowed === false).length,
3482
- allowRules: rules.filter((r) => r.allowed === true).length,
3483
- activeViolations: Array.isArray(violations) ? violations.length : 0,
3484
- typeOnlyViolations: Array.isArray(violations)
3485
- ? violations.filter((v) => v.typeOnly).length
3486
- : 0,
3487
- valueViolations: Array.isArray(violations)
3488
- ? violations.filter((v) => !v.typeOnly).length
3489
- : 0,
3490
- suppressed: suppressed ?? 0,
3491
- gatesOn,
3492
- gatesTotal: (enforcement || []).length,
3493
- layerFiles: counts,
3494
- };
3495
- }
3496
-
3497
- function readJsonSafe(file) {
3498
- try {
3499
- return JSON.parse(fs.readFileSync(file, 'utf8'));
3500
- } catch {
3501
- return null;
3502
- }
3503
- }
3504
-
3505
- function deltaField(current, origin, key) {
3506
- const a = current?.[key];
3507
- const b = origin?.[key];
3508
- if (typeof a !== 'number' || typeof b !== 'number') return null;
3509
- return a - b;
3510
- }
3511
-
3512
- /**
3513
- * Persist origin (once), latest, optional history; return { origin, createdOrigin }.
3514
- */
3515
- /** Shared fitness numbers for HTML report + machine-readable snapshots. */
3516
- function computeReportFitness({ coverage, violations, ok, enforcement, config }) {
3517
- const layers = Array.isArray(config?.layers) ? config.layers : [];
3518
- const rules = Array.isArray(config?.rules) ? config.rules : [];
3519
- const deniedCount = rules.filter((r) => r.allowed === false).length;
3520
- const gatesOn = (enforcement || []).filter((e) => e.on).length;
3521
- const governedPercent = coverage?.governed?.percent ?? null;
3522
- const totalFiles = coverage?.governed?.totalFiles ?? 0;
3523
- const classifiedFiles = coverage?.governed?.classifiedFiles ?? 0;
3524
- const mode = resolveOperatingMode({
3525
- governedPercent: totalFiles === 0 ? 0 : governedPercent,
3526
- planMet:
3527
- ok &&
3528
- (violations?.length ?? 0) === 0 &&
3529
- totalFiles > 0 &&
3530
- (governedPercent == null || governedPercent >= 50),
3531
- mature: totalFiles >= 150,
3532
- totalFiles,
3533
- });
3534
- const modeLabel = { suggest: 'SUGGEST', adapt: 'ADAPT', enforce: 'ENFORCE' }[mode] || String(mode).toUpperCase();
3535
- const modeBlurb = {
3536
- suggest: 'Starter shape — expand layers as the codebase grows.',
3537
- adapt: 'Contract is live; raise governed coverage or match real folders.',
3538
- enforce: 'Contract governs the tree. Gates can honestly hold the line.',
3539
- }[mode];
3540
- const scoreCoverage = governedPercent == null ? 50 : governedPercent;
3541
- const scoreClean =
3542
- (violations?.length ?? 0) === 0
3543
- ? 100
3544
- : Math.max(0, 100 - Math.min(100, violations.length * 4));
3545
- const scoreGates = enforcement?.length
3546
- ? Math.round((gatesOn / enforcement.length) * 100)
3547
- : 40;
3548
- const scoreRules = layers.length
3549
- ? Math.min(
3550
- 100,
3551
- Math.round((deniedCount / Math.max(1, layers.length * (layers.length - 1))) * 120)
3552
- )
3553
- : 0;
3554
- const score = Math.round(
3555
- scoreCoverage * 0.4 + scoreClean * 0.3 + scoreGates * 0.2 + scoreRules * 0.1
3556
- );
3557
- const scoreTone = score >= 90 ? 'elite' : score >= 70 ? 'strong' : score >= 50 ? 'ok' : 'weak';
3558
- const scoreCaption =
3559
- score >= 90
3560
- ? 'World-class architecture fitness'
3561
- : score >= 70
3562
- ? 'Solid architecture discipline'
3563
- : score >= 50
3564
- ? 'Useful guardrails — room to grow'
3565
- : 'Early stage — keep adopting layers';
3566
- return {
3567
- governedPercent,
3568
- totalFiles,
3569
- classifiedFiles,
3570
- mode,
3571
- modeLabel,
3572
- modeBlurb,
3573
- score,
3574
- scoreCoverage,
3575
- scoreClean,
3576
- scoreGates,
3577
- scoreRules,
3578
- scoreTone,
3579
- scoreCaption,
3580
- gatesOn,
3581
- deniedCount,
3582
- };
3583
- }
3584
-
3585
- function formatDelta(n, opts = {}) {
3586
- if (n == null || Number.isNaN(n)) return '—';
3587
- if (n === 0) return '0';
3588
- const sign = n > 0 ? '+' : '';
3589
- const suffix = opts.suffix ?? '';
3590
- return `${sign}${n}${suffix}`;
3591
- }
3592
-
3593
- function archiveReportSnapshots(root, { html, snapshot, resetOrigin = false, noArchive = false }) {
3594
- const dir = reportsDir(root);
3595
- const historyDir = path.join(dir, 'history');
3596
- fs.mkdirSync(historyDir, { recursive: true });
3597
-
3598
- const originJson = path.join(dir, 'origin.json');
3599
- const originHtml = path.join(dir, 'origin.html');
3600
- const latestJson = path.join(dir, 'latest.json');
3601
- const latestHtml = path.join(dir, 'latest.html');
3602
-
3603
- let origin = readJsonSafe(originJson);
3604
- let createdOrigin = false;
3605
- if (!origin || resetOrigin) {
3606
- fs.writeFileSync(originJson, `${JSON.stringify(snapshot, null, 2)}\n`);
3607
- fs.writeFileSync(originHtml, html);
3608
- origin = snapshot;
3609
- createdOrigin = true;
3610
- }
3611
-
3612
- fs.writeFileSync(latestJson, `${JSON.stringify(snapshot, null, 2)}\n`);
3613
- fs.writeFileSync(latestHtml, html);
3614
-
3615
- if (!noArchive) {
3616
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
3617
- fs.writeFileSync(path.join(historyDir, `${stamp}.json`), `${JSON.stringify(snapshot, null, 2)}\n`);
3618
- // Cap history: keep newest ARK_REPORT_HISTORY_MAX JSON files.
3619
- try {
3620
- const files = fs
3621
- .readdirSync(historyDir)
3622
- .filter((f) => f.endsWith('.json'))
3623
- .map((f) => ({ f, t: fs.statSync(path.join(historyDir, f)).mtimeMs }))
3624
- .sort((a, b) => b.t - a.t);
3625
- for (const old of files.slice(ARK_REPORT_HISTORY_MAX)) {
3626
- fs.unlinkSync(path.join(historyDir, old.f));
3627
- }
3628
- } catch {
3629
- /* ignore prune errors */
3630
- }
3631
- }
3632
-
3633
- // Ensure .ark/ is gitignored when a .gitignore exists.
3634
- const gitignore = path.join(root, '.gitignore');
3635
- if (fs.existsSync(gitignore)) {
3636
- const text = fs.readFileSync(gitignore, 'utf8');
3637
- const hasArk =
3638
- text.split('\n').some((line) => {
3639
- const t = line.trim();
3640
- return t === '.ark/' || t === '.ark' || t === '/.ark/' || t === '**/.ark/';
3641
- });
3642
- if (!hasArk) {
3643
- const suffix = text.endsWith('\n') || text.length === 0 ? '' : '\n';
3644
- fs.writeFileSync(
3645
- gitignore,
3646
- `${text}${suffix}\n# Ark generated reports / local state\n.ark/\n`
3647
- );
3648
- }
3649
- }
3650
-
3651
- return { origin, createdOrigin, dir, originJson, latestHtml };
3652
- }
3653
-
3654
- // Simplified onboarding report: compact diagram, placement table, short violation list.
3655
- function renderBeginnerHtmlReport({ root, config, violations, ok, version, configPath, generatedAt }) {
3656
- const layers = Array.isArray(config.layers) ? config.layers : [];
3657
- const esc = htmlEscape;
3658
- const project = (() => {
3659
- try {
3660
- return readJson(path.join(root, 'package.json')).name || path.basename(root);
3661
- } catch {
3662
- return path.basename(root);
3663
- }
3664
- })();
3665
- const status = ok ? 'PASS' : 'FAIL';
3666
- const phase1 = layers.slice(0, 4);
3667
- const diagram = phase1
3668
- .map((layer, index) => `${index + 1}. ${layer.name}`)
3669
- .join(' → ') || 'Add layers in ark.config.json';
3670
-
3671
- const placementRows = layers
3672
- .map((layer) => {
3673
- const purpose = layer.description || 'See ark.config.json';
3674
- const folders = (layer.patterns || []).join(', ') || '—';
3675
- return `<tr><td><strong>${esc(layer.name)}</strong></td><td>${esc(purpose)}</td><td><code>${esc(folders)}</code></td></tr>`;
3676
- })
3677
- .join('\n');
3678
-
3679
- const violationRows = violations.length
3680
- ? violations
3681
- .slice(0, 12)
3682
- .map((v) => {
3683
- const enriched = enrichViolationWithFixClass(v);
3684
- return `<li><code>${esc(v.file)}:${v.line}</code> — ${esc(enriched.enthusiastHint ?? v.message)}</li>`;
3685
- })
3686
- .join('\n')
3687
- : '<li class="dim">No active violations — architecture matches the contract.</li>';
3688
-
3689
- const meta = [version ? `ark-check v${esc(version)}` : '', generatedAt ? esc(generatedAt) : '']
3690
- .filter(Boolean)
3691
- .join(' · ');
3692
-
3693
- return `<!doctype html>
3694
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
3695
- <title>Ark beginner guide — ${esc(project)}</title>
3696
- <style>
3697
- body { font-family: system-ui, sans-serif; margin: 2rem; line-height: 1.5; max-width: 720px; }
3698
- h1 { font-size: 1.4rem; }
3699
- .badge { padding: .2em .6em; border-radius: 999px; font-weight: 700; font-size: .85rem; }
3700
- .PASS { background: #dcfce7; color: #166534; }
3701
- .FAIL { background: #fee2e2; color: #991b1b; }
3702
- .diagram { background: #f4f4f5; padding: 1rem; border-radius: 8px; font-family: monospace; margin: 1rem 0; }
3703
- table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
3704
- th, td { text-align: left; padding: .5rem; border-bottom: 1px solid #e4e4e7; vertical-align: top; }
3705
- th { font-size: .75rem; text-transform: uppercase; color: #71717a; }
3706
- ul { padding-left: 1.2rem; }
3707
- .dim { color: #71717a; }
3708
- footer { margin-top: 2rem; font-size: .85rem; color: #71717a; }
3709
- </style></head>
3710
- <body>
3711
- <h1>${esc(project)} <span class="badge ${status}">${status}</span></h1>
3712
- <p class="dim">Beginner architecture guide · ${meta}</p>
3713
- <h2>How layers flow (inner → outer)</h2>
3714
- <div class="diagram">${esc(diagram)}</div>
3715
- <p>Business rules live in inner layers; UI and databases live in outer adapter layers. Inner code must not import outer code.</p>
3716
- <h2>Where code goes</h2>
3717
- <table>
3718
- <tr><th>Layer</th><th>Purpose</th><th>Typical folders</th></tr>
3719
- ${placementRows || '<tr><td colspan="3">No layers configured.</td></tr>'}
3720
- </table>
3721
- <h2>What to fix first</h2>
3722
- <ul>${violationRows}</ul>
3723
- <h2>Next steps</h2>
3724
- <p><code>${arkCheckCommand(root)}</code></p>
3725
- <p><code>${arkCommand(root, 'ark-check', '--recommend')}</code></p>
3726
- <footer>Generated by ark-check --report --beginner. Config: ${esc(configPath)}</footer>
3727
- </body></html>`;
3728
- }
3729
-
3730
- /**
3731
- * Showcase HTML architecture report — the visual product of `/ark-explain` + ark-check.
3732
- * Self-contained (no CDN), print-friendly, works offline. Designed to look great on a
3733
- * fully governed repo (100% coverage, clean gates) and still be useful when debt remains.
3734
- */
3735
- function renderHtmlReport({
3736
- root,
3737
- config,
3738
- exampleByLayer,
3739
- fileCountByLayer,
3740
- coverage,
3741
- violations,
3742
- ok,
3743
- suppressed,
3744
- version,
3745
- configPath,
3746
- generatedAt,
3747
- skillGaps = [],
3748
- originSnapshot = null,
3749
- currentSnapshot = null,
3750
- originJustCreated = false,
3751
- adoption = null,
3752
- }) {
3753
- const layers = Array.isArray(config.layers) ? config.layers : [];
3754
- const rules = Array.isArray(config.rules) ? config.rules : [];
3755
- const esc = htmlEscape;
3756
- const project = (() => {
3757
- try {
3758
- return readJson(path.join(root, 'package.json')).name || path.basename(root);
3759
- } catch {
3760
- return path.basename(root);
3761
- }
3762
- })();
3763
-
3764
- const findRule = (from, to) => rules.find((r) => r.from === from && r.to === to);
3765
- const deniedOut = (name) => rules.filter((r) => r.from === name && r.allowed === false).length;
3766
- // Innermost first: more outbound denies → deeper (pure core).
3767
- const ordered = [...layers].sort(
3768
- (a, b) => deniedOut(b.name) - deniedOut(a.name) || a.name.localeCompare(b.name)
3769
- );
3770
-
3771
- const deniedCount = rules.filter((r) => r.allowed === false).length;
3772
- const allowedCount = rules.filter((r) => r.allowed === true).length;
3773
- const guarded = layers.filter(
3774
- (l) => Array.isArray(l.forbiddenGlobals) && l.forbiddenGlobals.length
3775
- ).length;
3776
- const enforcement = detectEnforcement(root);
3777
- const gatesOn = enforcement.filter((e) => e.on).length;
3778
- const status = ok ? 'PASS' : 'FAIL';
3779
-
3780
- const fitness = computeReportFitness({
3781
- coverage,
3782
- violations,
3783
- ok,
3784
- enforcement,
3785
- config,
3786
- });
3787
- const {
3788
- governedPercent,
3789
- totalFiles,
3790
- classifiedFiles,
3791
- mode,
3792
- modeLabel,
3793
- modeBlurb,
3794
- score,
3795
- scoreCoverage,
3796
- scoreClean,
3797
- scoreGates,
3798
- scoreRules,
3799
- scoreTone,
3800
- scoreCaption,
3801
- } = fitness;
3802
-
3803
- const adoptionView = adoption || collectAdoptionGaps(root, config, coverage);
3804
-
3805
- // ── Senior diagnostics (coupling, purity, contract density) ──────────────
3806
- const layerNames = ordered.map((l) => l.name);
3807
- const pairCount = Math.max(1, layers.length * Math.max(0, layers.length - 1));
3808
- const denyRatio = Math.round((deniedCount / pairCount) * 1000) / 10;
3809
- const fanOut = new Map(layerNames.map((n) => [n, 0]));
3810
- const fanIn = new Map(layerNames.map((n) => [n, 0]));
3811
- for (const from of layerNames) {
3812
- for (const to of layerNames) {
3813
- if (from === to) continue;
3814
- const rule = findRule(from, to);
3815
- const denied = rule && rule.allowed === false;
3816
- if (!denied) {
3817
- fanOut.set(from, (fanOut.get(from) || 0) + 1);
3818
- fanIn.set(to, (fanIn.get(to) || 0) + 1);
3819
- }
3820
- }
3821
- }
3822
- const couplingRows = ordered
3823
- .map((layer) => {
3824
- const fo = fanOut.get(layer.name) || 0;
3825
- const fi = fanIn.get(layer.name) || 0;
3826
- const files = (fileCountByLayer instanceof Map ? fileCountByLayer.get(layer.name) : 0) || 0;
3827
- const density = files > 0 ? Math.round((fo / files) * 100) / 100 : fo;
3828
- return { name: layer.name, fo, fi, files, density, denyOut: deniedOut(layer.name) };
3829
- })
3830
- .sort((a, b) => b.fo - a.fo || b.fi - a.fi);
3831
-
3832
- const purityLayers = ordered.filter(
3833
- (l) => Array.isArray(l.forbiddenGlobals) && l.forbiddenGlobals.length
3834
- );
3835
- const infraLayers = ordered.filter((l) => l.mayImportInfrastructure);
3836
- const excludeLayers = ordered.filter((l) => Array.isArray(l.exclude) && l.exclude.length);
3837
- const intentMap = ordered
3838
- .filter((l) => Array.isArray(l.intentPrefixes) && l.intentPrefixes.length)
3839
- .map((l) => ({ name: l.name, prefixes: l.intentPrefixes }));
3840
-
3841
- const emptyLayers = coverage?.emptyLayers ?? [];
3842
- const layersWithoutRules = coverage?.layersWithoutRules ?? [];
3843
- const unclassifiedCount = coverage?.unclassified?.count ?? 0;
3844
- const includeRoots = Array.isArray(config.include) ? config.include : [];
3845
-
3846
- const typeOnlyN = violations.filter((v) => v.typeOnly).length;
3847
- const valueN = violations.length - typeOnlyN;
3848
- const byEdge = new Map();
3849
- for (const v of violations) {
3850
- if (!v.fromLayer || !v.toLayer) continue;
3851
- const key = `${v.fromLayer} → ${v.toLayer}`;
3852
- byEdge.set(key, (byEdge.get(key) || 0) + 1);
3853
- }
3854
- const topEdges = [...byEdge.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
3855
-
3856
- let packageManagerLabel = 'npm';
3857
- try {
3858
- packageManagerLabel = detectPackageManager(root);
3859
- } catch {
3860
- /* ignore */
3861
- }
3862
-
3863
- const baselinePath = path.join(root, '.ark-baseline.json');
3864
- let baselineKeys = 0;
3865
- if (fs.existsSync(baselinePath)) {
3866
- try {
3867
- const raw = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
3868
- baselineKeys = Array.isArray(raw?.violations)
3869
- ? raw.violations.length
3870
- : Array.isArray(raw)
3871
- ? raw.length
3872
- : typeof raw === 'object' && raw
3873
- ? Object.keys(raw).length
3874
- : 0;
3875
- } catch {
3876
- baselineKeys = suppressed || 0;
3877
- }
3878
- }
3879
-
3880
- // Pattern specificity hotspots: very broad globs (**/ or bare *) vs file-precise.
3881
- const broadPatterns = [];
3882
- const precisePatterns = [];
3883
- for (const layer of ordered) {
3884
- for (const pattern of layer.patterns || []) {
3885
- const p = String(pattern);
3886
- const scoreP = patternSpecificity(p);
3887
- if (p.includes('**') && p.split('/').filter(Boolean).length <= 2) {
3888
- broadPatterns.push({ layer: layer.name, pattern: p, score: scoreP });
3889
- }
3890
- if (!p.includes('*') || /\.[a-zA-Z0-9]+$/.test(p.replace(/\*$/, ''))) {
3891
- if (p.includes('.') && !p.endsWith('/**')) {
3892
- precisePatterns.push({ layer: layer.name, pattern: p, score: scoreP });
3893
- }
3894
- }
3895
- }
3896
- }
3897
- broadPatterns.sort((a, b) => a.score - b.score);
3898
- precisePatterns.sort((a, b) => b.score - a.score);
3899
-
3900
- const counts = fileCountByLayer instanceof Map ? fileCountByLayer : new Map();
3901
- const maxFiles = Math.max(1, ...ordered.map((l) => counts.get(l.name) || 0));
3902
-
3903
- // Concentric “onion” SVG — outer entrypoints, pure core in the center.
3904
- const palette = [
3905
- '#38bdf8',
3906
- '#818cf8',
3907
- '#a78bfa',
3908
- '#e879f9',
3909
- '#fb7185',
3910
- '#fb923c',
3911
- '#fbbf24',
3912
- '#a3e635',
3913
- '#34d399',
3914
- '#2dd4bf',
3915
- '#22d3ee',
3916
- '#60a5fa',
3917
- ];
3918
- // ordered is inner→outer; reverse for drawing outer rings first
3919
- const outerFirst = [...ordered].reverse();
3920
- const n = outerFirst.length || 1;
3921
- const cx = 200;
3922
- const cy = 200;
3923
- const rMax = 185;
3924
- const rMin = 28;
3925
- const rings = outerFirst
3926
- .map((layer, i) => {
3927
- const t0 = i / n;
3928
- const t1 = (i + 1) / n;
3929
- const rOuter = rMax - t0 * (rMax - rMin);
3930
- const rInner = rMax - t1 * (rMax - rMin);
3931
- const color = palette[i % palette.length];
3932
- const files = counts.get(layer.name) || 0;
3933
- // Donut sector as full ring (annulus) via two arcs
3934
- const ringPath = (() => {
3935
- if (rInner <= 0.5) {
3936
- return `<circle cx="${cx}" cy="${cy}" r="${rOuter}" fill="${color}" fill-opacity="0.22" stroke="${color}" stroke-width="1.2"/>`;
3937
- }
3938
- return `<circle cx="${cx}" cy="${cy}" r="${(rOuter + rInner) / 2}" fill="none" stroke="${color}" stroke-width="${Math.max(6, rOuter - rInner - 2)}" stroke-opacity="0.85"/>`;
3939
- })();
3940
- const labelR = (rOuter + rInner) / 2;
3941
- const labelY = cy - labelR + (i === n - 1 ? 0 : 0);
3942
- // Labels stacked on the right of the diagram for readability
3943
- return { layer, color, files, ringPath, labelR, i };
3944
- })
3945
- .map((item, idx, arr) => {
3946
- const legendY = 28 + idx * 22;
3947
- return `${item.ringPath}
3948
- <circle cx="430" cy="${legendY}" r="5" fill="${item.color}"/>
3949
- <text x="442" y="${legendY + 4}" class="svg-lbl">${esc(item.layer.name)} · ${item.files}</text>`;
3950
- })
3951
- .join('\n');
3952
- const coreLabel =
3953
- ordered.length > 0
3954
- ? `<text x="${cx}" y="${cy + 4}" text-anchor="middle" class="svg-core">${esc(ordered[0].name)}</text>`
3955
- : '';
3956
- const onionSvg = `<svg viewBox="0 0 560 400" class="onion" role="img" aria-label="Architecture layers from outer adapters to inner core">
3957
- <rect x="0" y="0" width="560" height="400" fill="transparent"/>
3958
- ${rings}
3959
- ${coreLabel}
3960
- <text x="${cx}" y="388" text-anchor="middle" class="svg-cap">outer adapters → pure core</text>
3961
- </svg>`;
3962
-
3963
- // Coverage bars
3964
- const barRows = ordered
3965
- .map((layer) => {
3966
- const files = counts.get(layer.name) || 0;
3967
- const pct = Math.round((files / maxFiles) * 100);
3968
- const example = exampleByLayer?.get?.(layer.name);
3969
- return `<div class="bar-row">
3970
- <div class="bar-name">${esc(layer.name)}</div>
3971
- <div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
3972
- <div class="bar-n">${files}</div>
3973
- <div class="bar-ex">${example ? `<code>${esc(example)}</code>` : '<span class="dim">—</span>'}</div>
3974
- </div>`;
3975
- })
3976
- .join('\n');
3977
-
3978
- const layerRows = ordered
3979
- .map((layer) => {
3980
- const tags = [
3981
- Array.isArray(layer.forbiddenGlobals) && layer.forbiddenGlobals.length
3982
- ? `<span class="tag warn">no ${layer.forbiddenGlobals.map(esc).join(', ')}</span>`
3983
- : '',
3984
- layer.mayImportInfrastructure ? '<span class="tag">may import infra</span>' : '',
3985
- Array.isArray(layer.intentPrefixes) && layer.intentPrefixes.length
3986
- ? `<span class="tag">${layer.intentPrefixes.map(esc).join(' ')}</span>`
3987
- : '',
3988
- layer.optional ? '<span class="tag dim-tag">optional</span>' : '',
3989
- ].join(' ');
3990
- const example = exampleByLayer?.get?.(layer.name);
3991
- const files = counts.get(layer.name) || 0;
3992
- return `<tr>
3993
- <td class="ln">${esc(layer.name)}<div class="tags">${tags}</div></td>
3994
- <td>${layer.description ? esc(layer.description) : '<span class="dim">—</span>'}</td>
3995
- <td class="num">${files}</td>
3996
- <td><code class="pat">${(layer.patterns || []).map(esc).join('<br>') || '—'}</code></td>
3997
- <td>${example ? `<code>${esc(example)}</code>` : '<span class="dim">no files yet</span>'}</td>
3998
- </tr>`;
3999
- })
4000
- .join('\n');
4001
-
4002
- const flowRows = ordered
4003
- .map((layer) => {
4004
- const targets = ordered
4005
- .filter((other) => other.name !== layer.name)
4006
- .filter((other) => {
4007
- const rule = findRule(layer.name, other.name);
4008
- return !(rule && rule.allowed === false);
4009
- })
4010
- .map((other) => `<span class="chip ok">${esc(other.name)}</span>`)
4011
- .join('');
4012
- return `<div class="flow"><span class="flow-name">${esc(layer.name)}</span>
4013
- <span class="flow-arrow">may import →</span>
4014
- <span class="flow-targets">${targets || '<span class="dim">nothing (pure core)</span>'}</span></div>`;
4015
- })
4016
- .join('\n');
4017
-
4018
- const matrixHead = ordered.map((l) => `<th class="rot"><span>${esc(l.name)}</span></th>`).join('');
4019
- const matrixBody = ordered
4020
- .map((from) => {
4021
- const cells = ordered
4022
- .map((to) => {
4023
- if (from.name === to.name) return '<td class="self">·</td>';
4024
- const rule = findRule(from.name, to.name);
4025
- if (!rule) return '<td class="implicit" title="no rule (implicitly allowed)">·</td>';
4026
- return rule.allowed
4027
- ? '<td class="allow" title="allowed">✓</td>'
4028
- : `<td class="deny" title="${esc(rule.message || 'denied')}">✕</td>`;
4029
- })
4030
- .join('');
4031
- return `<tr><th class="rowlbl">${esc(from.name)}</th>${cells}</tr>`;
4032
- })
4033
- .join('\n');
4034
-
4035
- const byRule = new Map();
4036
- for (const v of violations) {
4037
- if (!byRule.has(v.ruleId)) byRule.set(v.ruleId, []);
4038
- byRule.get(v.ruleId).push(v);
4039
- }
4040
- const violationBlocks = violations.length
4041
- ? [...byRule.entries()]
4042
- .map(([ruleId, items]) => {
4043
- const hint = FIX_HINTS[ruleId];
4044
- const rows = items
4045
- .map((v) => {
4046
- const edge =
4047
- v.fromLayer && v.toLayer ? `${esc(v.fromLayer)} → ${esc(v.toLayer)}` : '';
4048
- const enriched = enrichViolationWithFixClass(v);
4049
- return `<li>
4050
- <code>${esc(v.file)}:${v.line}</code>
4051
- ${edge ? `<span class="edge">${edge}${v.target ? ` <span class="dim">(${esc(v.target)})</span>` : ''}</span>` : ''}
4052
- <div class="msg">${esc(enriched.enthusiastHint || v.message)}</div>
4053
- </li>`;
4054
- })
4055
- .join('\n');
4056
- return `<div class="vgroup">
4057
- <div class="vghead"><span class="rule">${esc(ruleId)}</span> <span class="dim">${items.length}</span></div>
4058
- <ul class="vitems">${rows}</ul>
4059
- ${hint ? `<div class="fix">fix: ${esc(hint)}</div>` : ''}
4060
- </div>`;
4061
- })
4062
- .join('\n')
4063
- : `<div class="clean hero-clean">
4064
- <div class="clean-title">Architecture matches the contract</div>
4065
- <div class="clean-body">No active violations${suppressed ? ` · ${suppressed} frozen by baseline` : ''}. This is what “honest green” looks like when coverage is real.</div>
4066
- </div>`;
4067
-
4068
- const enforcementRows = enforcement
4069
- .map(
4070
- (e) =>
4071
- `<div class="gate ${e.on ? 'on' : 'off'}">
4072
- <span class="dot"></span>
4073
- <div><b>${esc(e.name)}</b><div class="gdesc">${esc(e.what)}</div>
4074
- ${e.where ? `<code>${esc(e.where)}</code>` : '<span class="dim">not configured</span>'}</div>
4075
- </div>`
4076
- )
4077
- .join('\n');
4078
-
4079
- const skillsNote =
4080
- skillGaps.length === 0
4081
- ? '<div class="pill good">Agent skills current for detected tools</div>'
4082
- : `<div class="pill warn">${skillGaps.length} skill gap(s) — run ark upgrade / --install-agent-gates</div>`;
4083
-
4084
- const meta = [
4085
- version ? `ark-check v${esc(version)}` : '',
4086
- generatedAt ? esc(generatedAt) : '',
4087
- configPath ? `config: ${esc(configPath)}` : '',
4088
- ]
4089
- .filter(Boolean)
4090
- .join(' · ');
4091
-
4092
- const govLabel =
4093
- governedPercent == null ? '—' : `${governedPercent}% (${classifiedFiles}/${totalFiles})`;
4094
-
4095
- return `<!doctype html>
4096
- <html lang="en"><head><meta charset="utf-8">
4097
- <meta name="viewport" content="width=device-width, initial-scale=1">
4098
- <title>Ark · ${esc(project)}</title>
4099
- <style>
4100
- :root {
4101
- --bg: #07090d; --panel: #10141b; --panel2: #161b24; --ink: #eef1f5; --dim: #8b93a0;
4102
- --line: #243041; --green: #34d399; --red: #f87171; --accent: #38bdf8; --gold: #fbbf24;
4103
- --violet: #a78bfa; --radius: 14px;
4104
- }
4105
- @media (prefers-color-scheme: light) {
4106
- :root {
4107
- --bg: #f4f6f9; --panel: #fff; --panel2: #f8fafc; --ink: #0f172a; --dim: #64748b;
4108
- --line: #e2e8f0; --green: #059669; --red: #dc2626; --accent: #0284c7; --gold: #d97706;
4109
- --violet: #7c3aed;
4110
- }
4111
- }
4112
- * { box-sizing: border-box; }
4113
- body {
4114
- margin: 0; padding: 0 0 4rem;
4115
- background:
4116
- radial-gradient(1200px 600px at 10% -10%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 60%),
4117
- radial-gradient(900px 500px at 100% 0%, color-mix(in srgb, var(--violet) 14%, transparent), transparent 55%),
4118
- var(--bg);
4119
- color: var(--ink);
4120
- font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
4121
- }
4122
- .wrap { max-width: 1080px; margin: 0 auto; padding: 2rem 1.25rem; }
4123
- .hero {
4124
- display: grid; grid-template-columns: 1.4fr 0.9fr; gap: 1.25rem; align-items: stretch;
4125
- margin-bottom: 1.5rem;
4126
- }
4127
- @media (max-width: 820px) { .hero { grid-template-columns: 1fr; } }
4128
- .card {
4129
- background: linear-gradient(180deg, color-mix(in srgb, var(--panel) 92%, #fff 4%), var(--panel));
4130
- border: 1px solid var(--line); border-radius: var(--radius);
4131
- padding: 1.15rem 1.25rem; box-shadow: 0 20px 50px rgba(0,0,0,.18);
4132
- }
4133
- h1 { font-size: 1.65rem; margin: 0 0 .35rem; letter-spacing: -0.02em; }
4134
- h2 { font-size: 1.05rem; margin: 0 0 .35rem; letter-spacing: -0.01em; }
4135
- h3 { font-size: .92rem; margin: 1rem 0 .4rem; color: var(--dim); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; }
4136
- .lede { color: var(--dim); margin: 0 0 1rem; max-width: 42rem; }
4137
- .meta { color: var(--dim); font-size: .8rem; margin: .75rem 0 0; }
4138
- .badge, .pill {
4139
- display: inline-flex; align-items: center; gap: .35rem;
4140
- padding: .2em .65em; border-radius: 999px; font-weight: 700; font-size: .78rem;
4141
- letter-spacing: .03em; border: 1px solid transparent;
4142
- }
4143
- .PASS { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); border-color: color-mix(in srgb, var(--green) 35%, transparent); }
4144
- .FAIL { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); border-color: color-mix(in srgb, var(--red) 35%, transparent); }
4145
- .mode { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, transparent); }
4146
- .pill.good { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); }
4147
- .pill.warn { background: color-mix(in srgb, var(--gold) 16%, transparent); color: var(--gold); }
4148
- .score-card { display: flex; flex-direction: column; justify-content: center; text-align: center; min-height: 100%; }
4149
- .score-ring {
4150
- --p: ${score};
4151
- width: 148px; height: 148px; margin: .25rem auto 0.85rem;
4152
- border-radius: 50%;
4153
- background:
4154
- radial-gradient(var(--panel) 58%, transparent 59%),
4155
- conic-gradient(var(--accent) calc(var(--p) * 1%), var(--line) 0);
4156
- display: grid; place-items: center;
4157
- }
4158
- .score-ring.elite { background:
4159
- radial-gradient(var(--panel) 58%, transparent 59%),
4160
- conic-gradient(var(--green) calc(var(--p) * 1%), var(--line) 0); }
4161
- .score-ring.strong { background:
4162
- radial-gradient(var(--panel) 58%, transparent 59%),
4163
- conic-gradient(var(--accent) calc(var(--p) * 1%), var(--line) 0); }
4164
- .score-ring.ok { background:
4165
- radial-gradient(var(--panel) 58%, transparent 59%),
4166
- conic-gradient(var(--gold) calc(var(--p) * 1%), var(--line) 0); }
4167
- .score-ring.weak { background:
4168
- radial-gradient(var(--panel) 58%, transparent 59%),
4169
- conic-gradient(var(--red) calc(var(--p) * 1%), var(--line) 0); }
4170
- .score-n { font-size: 2.1rem; font-weight: 800; letter-spacing: -0.03em; line-height: 1; }
4171
- .score-cap { color: var(--dim); font-size: .85rem; margin: 0; }
4172
- .kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: .65rem; margin: 1rem 0 0; }
4173
- @media (max-width: 720px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
4174
- .kpi { background: var(--panel2); border: 1px solid var(--line); border-radius: 12px; padding: .7rem .8rem; }
4175
- .kpi b { display: block; font-size: 1.25rem; letter-spacing: -0.02em; }
4176
- .kpi span { color: var(--dim); font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; }
4177
- .section { margin-top: 1.35rem; }
4178
- .grid-2 { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 1rem; }
4179
- @media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
4180
- .onion { width: 100%; height: auto; display: block; }
4181
- .svg-lbl { fill: var(--dim); font-size: 11px; font-family: ui-sans-serif, system-ui, sans-serif; }
4182
- .svg-core { fill: var(--ink); font-size: 11px; font-weight: 700; font-family: ui-sans-serif, system-ui, sans-serif; }
4183
- .svg-cap { fill: var(--dim); font-size: 11px; font-family: ui-sans-serif, system-ui, sans-serif; }
4184
- .bar-row { display: grid; grid-template-columns: 10.5rem 1fr 2.2rem minmax(0, 1fr); gap: .55rem; align-items: center; padding: .28rem 0; border-bottom: 1px solid var(--line); }
4185
- .bar-name { font-weight: 600; font-size: .86rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
4186
- .bar-track { height: 8px; background: var(--line); border-radius: 99px; overflow: hidden; }
4187
- .bar-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--violet)); border-radius: 99px; }
4188
- .bar-n { text-align: right; font-variant-numeric: tabular-nums; color: var(--dim); font-size: .85rem; }
4189
- .bar-ex { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4190
- .dim { color: var(--dim); }
4191
- code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .84em; }
4192
- code.pat { font-size: .78em; color: var(--dim); }
4193
- table { width: 100%; border-collapse: collapse; }
4194
- .layers td, .layers th { text-align: left; padding: .65rem .55rem; border-bottom: 1px solid var(--line); vertical-align: top; }
4195
- .layers th { color: var(--dim); font-weight: 600; font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; }
4196
- .ln { font-weight: 650; }
4197
- .num { font-variant-numeric: tabular-nums; font-weight: 650; }
4198
- .tags { margin-top: .3rem; display: flex; flex-wrap: wrap; gap: .25rem; }
4199
- .tag { display: inline-block; padding: .08em .45em; border: 1px solid var(--line); border-radius: 6px; font-size: .68rem; color: var(--dim); }
4200
- .tag.warn { border-color: color-mix(in srgb, var(--gold) 40%, var(--line)); color: var(--gold); }
4201
- .dim-tag { opacity: .75; }
4202
- .flow { display: flex; gap: .5rem; align-items: baseline; padding: .45rem 0; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
4203
- .flow-name { font-weight: 650; min-width: 12rem; }
4204
- .flow-arrow { color: var(--dim); font-size: .8rem; }
4205
- .flow-targets { display: flex; flex-wrap: wrap; gap: .3rem; }
4206
- .chip { display: inline-block; padding: .12em .5em; border: 1px solid var(--line); border-radius: 6px; font-size: .76rem; color: var(--dim); background: var(--panel2); }
4207
- .chip.ok { color: var(--ink); border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); }
4208
- details { margin-top: .85rem; }
4209
- summary { cursor: pointer; color: var(--accent); font-size: .9rem; }
4210
- /* Matrix must NOT inherit global table{width:100%} — that bloated the label column
4211
- and shoved every cell to the right. Keep it compact and left-aligned. */
4212
- .matrix-scroll {
4213
- overflow-x: auto; margin-top: .75rem; max-width: 100%;
4214
- text-align: left; -webkit-overflow-scrolling: touch;
4215
- }
4216
- .matrix {
4217
- width: max-content; max-width: none; border-collapse: collapse;
4218
- font-size: .8rem; margin: 0; table-layout: fixed;
4219
- }
4220
- .matrix th, .matrix td { border: 1px solid var(--line); }
4221
- .matrix td {
4222
- width: 2.05rem; min-width: 2.05rem; max-width: 2.05rem;
4223
- height: 2.05rem; text-align: center; font-weight: 700; padding: 0;
4224
- }
4225
- .matrix .rowlbl {
4226
- text-align: left; padding: 0 .75rem 0 .35rem; color: var(--dim);
4227
- font-weight: 600; white-space: nowrap; width: auto; min-width: 9.5rem;
4228
- max-width: none; position: sticky; left: 0; z-index: 1;
4229
- background: var(--panel); box-shadow: 4px 0 8px -4px rgba(0,0,0,.25);
4230
- }
4231
- .matrix thead th:first-child,
4232
- .matrix tr th.rowlbl { background: var(--panel); }
4233
- .matrix .corner {
4234
- position: sticky; left: 0; z-index: 2; background: var(--panel);
4235
- min-width: 9.5rem; box-shadow: 4px 0 8px -4px rgba(0,0,0,.25);
4236
- }
4237
- .matrix .rot {
4238
- height: 9.5rem; vertical-align: bottom; padding: .2rem .15rem;
4239
- width: 2.05rem; min-width: 2.05rem; max-width: 2.05rem;
4240
- }
4241
- .matrix .rot span {
4242
- writing-mode: vertical-rl; transform: rotate(180deg); color: var(--dim);
4243
- font-weight: 600; white-space: nowrap; display: inline-block; max-height: 9rem;
4244
- overflow: hidden; text-overflow: ellipsis;
4245
- }
4246
- .allow { color: var(--green); background: color-mix(in srgb, var(--green) 12%, transparent); }
4247
- .deny { color: var(--red); background: color-mix(in srgb, var(--red) 12%, transparent); }
4248
- .implicit { color: var(--dim); }
4249
- .self { color: var(--line); }
4250
- .legend { color: var(--dim); font-size: .8rem; margin: .55rem 0 0; }
4251
- .gates { display: grid; grid-template-columns: repeat(2, 1fr); gap: .65rem; }
4252
- @media (max-width: 700px) { .gates { grid-template-columns: 1fr; } }
4253
- .gate { display: flex; gap: .65rem; align-items: flex-start; padding: .75rem .8rem; border-radius: 12px; border: 1px solid var(--line); background: var(--panel2); }
4254
- .gate .dot { width: .65rem; height: .65rem; border-radius: 50%; margin-top: .35rem; background: var(--line); flex: 0 0 auto; }
4255
- .gate.on .dot { background: var(--green); box-shadow: 0 0 0 4px color-mix(in srgb, var(--green) 20%, transparent); }
4256
- .gate.off { opacity: .72; }
4257
- .gdesc { color: var(--dim); font-size: .85rem; margin: .1rem 0 .25rem; }
4258
- .vgroup { background: var(--panel2); border: 1px solid var(--line); border-left: 3px solid var(--red); border-radius: 10px; padding: .75rem .9rem; margin-bottom: .6rem; }
4259
- .vghead { display: flex; gap: .5rem; align-items: baseline; }
4260
- .rule { font-weight: 700; font-size: .8rem; color: var(--red); }
4261
- .vitems { list-style: none; padding: 0; margin: .4rem 0 0; }
4262
- .vitems li { padding: .35rem 0; border-top: 1px solid var(--line); }
4263
- .vitems li:first-child { border-top: none; }
4264
- .edge { color: var(--accent); font-weight: 650; margin-left: .35rem; }
4265
- .fix { margin-top: .4rem; color: var(--dim); font-size: .86rem; }
4266
- .clean, .hero-clean { background: var(--panel2); border: 1px solid var(--line); border-left: 3px solid var(--green); border-radius: 12px; padding: 1rem 1.1rem; }
4267
- .clean-title { font-weight: 750; color: var(--green); margin-bottom: .25rem; }
4268
- .clean-body { color: var(--dim); }
4269
- .cmds { display: grid; gap: .35rem; background: var(--panel2); border: 1px solid var(--line); border-radius: 12px; padding: .9rem 1rem; }
4270
- .cmds code { display: block; padding: .15rem 0; overflow-x: auto; }
4271
- footer { margin-top: 2.25rem; padding-top: 1rem; border-top: 1px solid var(--line); color: var(--dim); font-size: .8rem; }
4272
- .brand { display: inline-flex; align-items: center; gap: .4rem; color: var(--dim); font-size: .78rem; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; margin-bottom: .55rem; }
4273
- .brand i { width: .55rem; height: .55rem; border-radius: 2px; background: linear-gradient(135deg, var(--accent), var(--violet)); display: inline-block; }
4274
- .senior h3 { margin-top: 1.25rem; }
4275
- .senior-list { margin: .2rem 0 0; padding-left: 1.1rem; color: var(--ink); }
4276
- .senior-list li { margin: .2rem 0; }
4277
- .senior-list .edge { margin-left: 0; }
4278
- .delta.up { color: var(--green); font-weight: 700; }
4279
- .delta.down { color: var(--red); font-weight: 700; }
4280
- .delta.flat { color: var(--dim); }
4281
- .evolve { border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); }
4282
- @media print {
4283
- body { background: #fff; color: #111; padding: 0; }
4284
- .card, .kpi, .gate, .cmds, .clean, .vgroup { box-shadow: none; break-inside: avoid; }
4285
- details { open: true; }
4286
- }
4287
- </style></head>
4288
- <body><div class="wrap">
4289
- <div class="hero">
4290
- <div class="card">
4291
- <div class="brand"><i></i> Ark architecture report</div>
4292
- <h1>${esc(project)} <span class="badge ${status}">${status}</span> <span class="badge mode">${esc(modeLabel)}</span></h1>
4293
- <p class="lede">${esc(modeBlurb)} One machine-readable contract · write gate · CI · optional runtime.</p>
4294
- <div class="kpis">
4295
- <div class="kpi"><b>${esc(govLabel)}</b><span>Governed</span></div>
4296
- <div class="kpi"><b>${layers.length}</b><span>Layers</span></div>
4297
- <div class="kpi"><b>${gatesOn}/${enforcement.length}</b><span>Gates live</span></div>
4298
- <div class="kpi"><b>${violations.length}${suppressed ? ` · ${suppressed}Δ` : ''}</b><span>Violations${suppressed ? ' · frozen' : ''}</span></div>
4299
- </div>
4300
- <p class="meta">${meta}</p>
4301
- ${skillsNote}
4302
- </div>
4303
- <div class="card score-card">
4304
- <div class="score-ring ${scoreTone}"><div><div class="score-n">${score}</div><div class="dim" style="font-size:.72rem;letter-spacing:.08em;text-transform:uppercase">Ark score</div></div></div>
4305
- <p class="score-cap">${esc(scoreCaption)}</p>
4306
- <p class="meta" style="margin-top:.65rem">Coverage ${scoreCoverage} · Clean ${scoreClean} · Gates ${scoreGates} · Rules ${scoreRules}</p>
4307
- </div>
4308
- </div>
4309
-
4310
- <div class="section card" id="adoption">
4311
- <h2>Adoption</h2>
4312
- <p class="dim" style="margin:.15rem 0 .75rem;font-size:.88rem">
4313
- Co-pilot completeness — separate from the 0–100 fitness score above. Hosts, MCP health, origin snapshot, core optionality, baseline policy.
4314
- </p>
4315
- <div class="kpis" style="margin-bottom:.75rem">
4316
- <div class="kpi"><b>${adoptionView.gaps.length === 0 ? 'OK' : adoptionView.gaps.length}</b><span>${adoptionView.gaps.length === 0 ? 'No adoption gaps' : 'Adoption gap(s)'}</span></div>
4317
- <div class="kpi"><b>${adoptionView.originReport.present ? 'yes' : 'no'}</b><span>Origin report</span></div>
4318
- <div class="kpi"><b>${esc(adoptionView.baseline.signal)}</b><span>Baseline policy</span></div>
4319
- <div class="kpi"><b>${adoptionView.mcp.ok ? 'ok' : 'fix'}</b><span>Repo MCP argv</span></div>
4320
- </div>
4321
- ${
4322
- adoptionView.gaps.length
4323
- ? `<ul class="senior-list">${adoptionView.gaps
4324
- .map(
4325
- (g) =>
4326
- `<li><b>${esc(g.id)}</b> — ${esc(g.message)}${
4327
- g.fix ? `<br/><code>${esc(g.fix)}</code>` : ''
4328
- }</li>`
4329
- )
4330
- .join('')}</ul>`
4331
- : '<p class="clean-body">No adoption gaps detected for hosts, MCP, core optionality, or origin.</p>'
4332
- }
4333
- ${
4334
- adoptionView.coreOptional.length
4335
- ? `<p class="dim" style="margin-top:.65rem">Optional-but-populated cores: <code>${adoptionView.coreOptional
4336
- .map((c) => `${esc(c.layer)} (${c.files})`)
4337
- .join('</code>, <code>')}</code></p>`
4338
- : ''
4339
- }
4340
- ${
4341
- adoptionView.hosts.length
4342
- ? `<p class="dim" style="margin-top:.4rem">Hosts: ${adoptionView.hosts
4343
- .map((h) => `${esc(h.host)}${h.complete ? ' ✓' : ' incomplete'}`)
4344
- .join(' · ')}</p>`
4345
- : ''
4346
- }
4347
- </div>
4348
-
4349
- <div class="section grid-2">
4350
- <div class="card">
4351
- <h2>Architecture map</h2>
4352
- <p class="dim" style="margin:.15rem 0 0.75rem;font-size:.88rem">Outer rings = entrypoints & adapters. Center = purest core.</p>
4353
- ${onionSvg}
4354
- </div>
4355
- <div class="card">
4356
- <h2>Files per layer</h2>
4357
- <p class="dim" style="margin:.15rem 0 0.75rem;font-size:.88rem">${classifiedFiles} classified · ${totalFiles} in scope${coverage?.unclassified?.count ? ` · ${coverage.unclassified.count} unclassified` : ''}</p>
4358
- ${barRows || '<p class="dim">No layer file counts.</p>'}
4359
- </div>
4360
- </div>
4361
-
4362
- <div class="section card">
4363
- <h2>Layers</h2>
4364
- <p class="dim" style="margin:.15rem 0 .75rem;font-size:.88rem">Innermost (most restricted) → outermost (entrypoints). Forbidden globals protect pure cores.</p>
4365
- <table class="layers">
4366
- <tr><th>Layer</th><th>Purpose</th><th>Files</th><th>Patterns</th><th>Example</th></tr>
4367
- ${layerRows || '<tr><td colspan="5" class="dim">No layers configured.</td></tr>'}
4368
- </table>
4369
- </div>
4370
-
4371
- <div class="section card">
4372
- <h2>Dependency direction</h2>
4373
- <p class="dim" style="margin:.15rem 0 .75rem;font-size:.88rem">Inner layers stay ignorant of outer ones. Each row lists what it may import.</p>
4374
- ${flowRows || '<p class="dim">No layers configured.</p>'}
4375
- <details open>
4376
- <summary>Full matrix (precise ✓ / ✕ grid)</summary>
4377
- <div class="matrix-scroll"><table class="matrix">
4378
- <thead><tr><th class="corner"></th>${matrixHead}</tr></thead>
4379
- <tbody>${matrixBody}</tbody>
4380
- </table></div>
4381
- <p class="legend">Row imports column (left → top). ✓ allowed · ✕ denied · · = no explicit rule / self. Denied edges: ${deniedCount} · explicit allows: ${allowedCount} · purity-guarded layers: ${guarded}</p>
4382
- </details>
4383
- </div>
4384
-
4385
- <div class="section card">
4386
- <h2>Violations</h2>
4387
- ${violationBlocks}
4388
- </div>
4389
-
4390
- <div class="section card">
4391
- <h2>Enforcement points</h2>
4392
- <p class="dim" style="margin:.15rem 0 .85rem;font-size:.88rem">Write-time · merge-time · editor · ratchet. Same contract everywhere.</p>
4393
- <div class="gates">${enforcementRows}</div>
4394
- </div>
4395
-
4396
- ${(() => {
4397
- if (!currentSnapshot) return '';
4398
- // First report: originSnapshot is null at render time (written to disk just after).
4399
- if (originJustCreated || !originSnapshot) {
4400
- return `<div class="section card evolve">
4401
- <h2>Origin baseline captured</h2>
4402
- <p class="dim" style="margin:.2rem 0 0;font-size:.9rem">
4403
- This is the <b>first</b> architecture snapshot for this project
4404
- (<code>.ark/reports/origin.json</code> + <code>origin.html</code>).
4405
- Future reports will show deltas against this starting point so you can prove evolution.
4406
- </p>
4407
- </div>`;
4408
- }
4409
- const rows = [
4410
- ['Ark score', originSnapshot.score, currentSnapshot.score, ''],
4411
- ['Governed %', originSnapshot.governedPercent, currentSnapshot.governedPercent, 'pp'],
4412
- ['Files in scope', originSnapshot.totalFiles, currentSnapshot.totalFiles, ''],
4413
- ['Classified files', originSnapshot.classifiedFiles, currentSnapshot.classifiedFiles, ''],
4414
- ['Active violations', originSnapshot.activeViolations, currentSnapshot.activeViolations, ''],
4415
- ['Value violations', originSnapshot.valueViolations, currentSnapshot.valueViolations, ''],
4416
- ['Type-only violations', originSnapshot.typeOnlyViolations, currentSnapshot.typeOnlyViolations, ''],
4417
- ['Layers', originSnapshot.layerCount, currentSnapshot.layerCount, ''],
4418
- ['Deny rules', originSnapshot.denyRules, currentSnapshot.denyRules, ''],
4419
- ['Gates live', originSnapshot.gatesOn, currentSnapshot.gatesOn, ''],
4420
- ];
4421
- const originDate = (originSnapshot.generatedAt || '').slice(0, 10) || 'origin';
4422
- const nowDate = (currentSnapshot.generatedAt || '').slice(0, 10) || 'now';
4423
- const tr = rows
4424
- .map(([label, from, to, unit]) => {
4425
- const d =
4426
- typeof from === 'number' && typeof to === 'number' ? to - from : null;
4427
- const good =
4428
- label.includes('violation') || label.includes('Violation')
4429
- ? d != null && d <= 0
4430
- : label.includes('Governed') || label.includes('score') || label.includes('Classified') || label.includes('Gates')
4431
- ? d != null && d >= 0
4432
- : null;
4433
- const cls =
4434
- d == null || d === 0 ? 'flat' : good === true ? 'up' : good === false ? 'down' : 'flat';
4435
- const delta =
4436
- d == null
4437
- ? '—'
4438
- : unit === 'pp'
4439
- ? formatDelta(Math.round(d * 10) / 10, { suffix: ' pp' })
4440
- : formatDelta(d);
4441
- return `<tr>
4442
- <td>${esc(label)}</td>
4443
- <td class="num">${from ?? '—'}</td>
4444
- <td class="num">${to ?? '—'}</td>
4445
- <td class="num delta ${cls}">${esc(delta)}</td>
4446
- </tr>`;
4447
- })
4448
- .join('\n');
4449
- // Layer file deltas
4450
- const originLayers = originSnapshot.layerFiles || {};
4451
- const currentLayers = currentSnapshot.layerFiles || {};
4452
- const layerKeys = [...new Set([...Object.keys(originLayers), ...Object.keys(currentLayers)])].sort();
4453
- const layerTr = layerKeys
4454
- .map((name) => {
4455
- const from = originLayers[name] || 0;
4456
- const to = currentLayers[name] || 0;
4457
- const d = to - from;
4458
- const cls = d === 0 ? 'flat' : d > 0 ? 'up' : 'down';
4459
- return `<tr>
4460
- <td class="ln">${esc(name)}</td>
4461
- <td class="num">${from}</td>
4462
- <td class="num">${to}</td>
4463
- <td class="num delta ${cls}">${esc(formatDelta(d))}</td>
4464
- </tr>`;
4465
- })
4466
- .join('\n');
4467
- return `<div class="section card evolve">
4468
- <h2>Evolution vs origin</h2>
4469
- <p class="dim" style="margin:.15rem 0 .75rem;font-size:.88rem">
4470
- Origin snapshot <code>${esc(originDate)}</code> → this report <code>${esc(nowDate)}</code>
4471
- · frozen at <code>.ark/reports/origin.*</code> · reopen origin HTML anytime for the starting picture.
4472
- </p>
4473
- <table class="layers">
4474
- <tr><th>Metric</th><th>Origin</th><th>Now</th><th>Δ</th></tr>
4475
- ${tr}
4476
- </table>
4477
- <h3>Files per layer</h3>
4478
- <table class="layers">
4479
- <tr><th>Layer</th><th>Origin</th><th>Now</th><th>Δ</th></tr>
4480
- ${layerTr || '<tr><td colspan="4" class="dim">No layer file data in snapshots.</td></tr>'}
4481
- </table>
4482
- <p class="legend">Green Δ = improvement for that metric (↑ coverage/score/gates, ↓ violations). History JSON under <code>.ark/reports/history/</code> (last ${ARK_REPORT_HISTORY_MAX}).</p>
4483
- </div>`;
4484
- })()}
4485
-
4486
- <div class="section card senior">
4487
- <h2>Senior diagnostics</h2>
4488
- <p class="dim" style="margin:.15rem 0 .85rem;font-size:.88rem">
4489
- Coupling, purity surface, contract density, and config forensics — for tech leads reviewing the fitness of the gate itself.
4490
- </p>
4491
-
4492
- <h3>Contract density</h3>
4493
- <div class="kpis" style="margin-top:.35rem">
4494
- <div class="kpi"><b>${denyRatio}%</b><span>Edges denied</span></div>
4495
- <div class="kpi"><b>${deniedCount}</b><span>Deny rules</span></div>
4496
- <div class="kpi"><b>${allowedCount}</b><span>Explicit allows</span></div>
4497
- <div class="kpi"><b>${pairCount}</b><span>Directed pairs</span></div>
4498
- </div>
4499
- <p class="dim" style="margin:.55rem 0 0;font-size:.84rem">
4500
- Deny ratio = denied ÷ (layers × (layers−1)). High ratio = strict inward architecture.
4501
- Package manager detected: <code>${esc(packageManagerLabel)}</code>
4502
- · include roots: <code>${includeRoots.map(esc).join('</code>, <code>') || '—'}</code>
4503
- ${emptyLayers.length ? ` · empty layers: <code>${emptyLayers.map(esc).join(', ')}</code>` : ''}
4504
- ${layersWithoutRules.length ? ` · layers with no rule edge: <code>${layersWithoutRules.map(esc).join(', ')}</code>` : ''}
4505
- ${unclassifiedCount ? ` · unclassified files: <b>${unclassifiedCount}</b>` : ''}
4506
- </p>
4507
-
4508
- <h3>Layer coupling (allowed import graph)</h3>
4509
- <p class="dim" style="margin:.1rem 0 .55rem;font-size:.84rem">
4510
- Fan-out = layers this layer may import · Fan-in = layers that may import it · based on non-denied edges (implicit allow counts as open).
4511
- </p>
4512
- <table class="layers">
4513
- <tr><th>Layer</th><th>Files</th><th>Fan-out</th><th>Fan-in</th><th>Deny-out</th><th>FO/files</th></tr>
4514
- ${couplingRows
4515
- .map(
4516
- (r) => `<tr>
4517
- <td class="ln">${esc(r.name)}</td>
4518
- <td class="num">${r.files}</td>
4519
- <td class="num">${r.fo}</td>
4520
- <td class="num">${r.fi}</td>
4521
- <td class="num">${r.denyOut}</td>
4522
- <td class="num">${r.density}</td>
4523
- </tr>`
4524
- )
4525
- .join('\n')}
4526
- </table>
4527
- <p class="legend">High fan-out on a large presentation layer is normal. High fan-out on a “domain” layer is a smell — the core is leaking outward privileges.</p>
4528
-
4529
- <h3>Purity &amp; infrastructure surface</h3>
4530
- <div class="grid-2" style="margin-top:.5rem">
4531
- <div>
4532
- <div class="pill ${purityLayers.length ? 'good' : 'warn'}" style="margin-bottom:.55rem">
4533
- ${purityLayers.length} purity-guarded layer(s)
4534
- </div>
4535
- ${
4536
- purityLayers.length
4537
- ? `<ul class="senior-list">${purityLayers
4538
- .map(
4539
- (l) =>
4540
- `<li><b>${esc(l.name)}</b> forbids <code>${(l.forbiddenGlobals || []).map(esc).join('</code>, <code>')}</code></li>`
4541
- )
4542
- .join('')}</ul>`
4543
- : '<p class="dim">No <code>forbiddenGlobals</code> — ambient I/O can still leak into pure cores.</p>'
4544
- }
4545
- </div>
4546
- <div>
4547
- <div class="pill ${infraLayers.length ? 'good' : 'warn'}" style="margin-bottom:.55rem">
4548
- ${infraLayers.length} infra-capable layer(s)
4549
- </div>
4550
- ${
4551
- infraLayers.length
4552
- ? `<ul class="senior-list">${infraLayers
4553
- .map((l) => `<li><b>${esc(l.name)}</b> <span class="tag">mayImportInfrastructure</span></li>`)
4554
- .join('')}</ul>`
4555
- : '<p class="dim">No layer opts into infrastructure imports via <code>mayImportInfrastructure</code> (write-gate heuristic still applies to ungoverned targets).</p>'
4556
- }
4557
- ${
4558
- excludeLayers.length
4559
- ? `<p class="dim" style="margin-top:.65rem">Exclude globs (facade / kernel carve-outs):</p>
4560
- <ul class="senior-list">${excludeLayers
4561
- .map(
4562
- (l) =>
4563
- `<li><b>${esc(l.name)}</b> · <code>${(l.exclude || []).map(esc).join('</code>, <code>')}</code></li>`
4564
- )
4565
- .join('')}</ul>`
4566
- : ''
4567
- }
4568
- </div>
4569
- </div>
4570
-
4571
- <h3>Intent prefixes</h3>
4572
- ${
4573
- intentMap.length
4574
- ? `<table class="layers"><tr><th>Layer</th><th>Prefixes</th></tr>
4575
- ${intentMap
4576
- .map(
4577
- (row) =>
4578
- `<tr><td class="ln">${esc(row.name)}</td><td><code>${row.prefixes.map(esc).join('</code> <code>')}</code></td></tr>`
4579
- )
4580
- .join('\n')}</table>`
4581
- : '<p class="dim">No <code>intentPrefixes</code> on layers — runtime intent governance and string-intent checks have less to bind to.</p>'
4582
- }
4583
-
4584
- <h3>Layer balance (educational)</h3>
4585
- ${
4586
- adoptionView.layerBalance
4587
- ? `<p class="dim" style="margin:.1rem 0 .55rem;font-size:.88rem">${esc(adoptionView.layerBalance.educational)}</p>
4588
- <p class="meta">PresentationAdapters ${adoptionView.layerBalance.presentationFiles} · DomainModel ${adoptionView.layerBalance.domainFiles} · total ${adoptionView.layerBalance.totalFiles}</p>`
4589
- : '<p class="dim" style="margin:.1rem 0 .55rem;font-size:.88rem">No presentation-heavy / thin-domain imbalance flagged (educational only when Presentation ≥50% and Domain &lt;10% of files).</p>'
4590
- }
4591
-
4592
- <h3>Pattern forensics</h3>
4593
- <div class="grid-2" style="margin-top:.45rem">
4594
- <div>
4595
- <p class="dim" style="margin:0 0 .4rem;font-size:.84rem">Broadest globs (watch for over-governance / false layer hits)</p>
4596
- ${
4597
- broadPatterns.length
4598
- ? `<ul class="senior-list">${broadPatterns
4599
- .slice(0, 8)
4600
- .map(
4601
- (p) =>
4602
- `<li><b>${esc(p.layer)}</b> · <code>${esc(p.pattern)}</code> <span class="dim">spec ${p.score}</span></li>`
4603
- )
4604
- .join('')}</ul>`
4605
- : '<p class="dim">No ultra-broad patterns detected.</p>'
4606
- }
4607
- </div>
4608
- <div>
4609
- <p class="dim" style="margin:0 0 .4rem;font-size:.84rem">Most precise patterns (file-level overlays, facades)</p>
4610
- ${
4611
- precisePatterns.length
4612
- ? `<ul class="senior-list">${precisePatterns
4613
- .slice(0, 8)
4614
- .map(
4615
- (p) =>
4616
- `<li><b>${esc(p.layer)}</b> · <code>${esc(p.pattern)}</code> <span class="dim">spec ${p.score}</span></li>`
4617
- )
4618
- .join('')}</ul>`
4619
- : '<p class="dim">No file-level patterns — only directory globs.</p>'
4620
- }
4621
- </div>
4622
- </div>
4623
-
4624
- <h3>Debt &amp; violation taxonomy</h3>
4625
- <div class="kpis" style="margin-top:.35rem">
4626
- <div class="kpi"><b>${violations.length}</b><span>Active</span></div>
4627
- <div class="kpi"><b>${valueN}</b><span>Value edges</span></div>
4628
- <div class="kpi"><b>${typeOnlyN}</b><span>Type-only</span></div>
4629
- <div class="kpi"><b>${suppressed || baselineKeys}</b><span>Baseline keys</span></div>
4630
- </div>
4631
- ${
4632
- topEdges.length
4633
- ? `<p class="dim" style="margin:.55rem 0 .35rem;font-size:.84rem">Hottest active edges</p>
4634
- <ul class="senior-list">${topEdges
4635
- .map(([edge, n]) => `<li><span class="edge">${esc(edge)}</span> · <b>${n}</b></li>`)
4636
- .join('')}</ul>`
4637
- : '<p class="dim" style="margin-top:.55rem">No active edge concentration — either clean or all debt is baselined.</p>'
4638
- }
4639
-
4640
- <details style="margin-top:1rem">
4641
- <summary>Score model (transparent)</summary>
4642
- <p class="legend">
4643
- Ark score = 0.4×coverage + 0.3×clean + 0.2×gates + 0.1×rule-density.
4644
- Coverage=${scoreCoverage}, clean=${scoreClean}, gates=${scoreGates}, rules=${scoreRules} → <b>${score}</b>.
4645
- This is a fitness signal for humans, not a CI gate.
4646
- </p>
4647
- </details>
4648
- </div>
4649
-
4650
- <div class="section card">
4651
- <h2>Commands worth memorizing</h2>
4652
- <div class="cmds">
4653
- <code>${arkCheckCommand(root)}</code>
4654
- <code>${arkCommand(root, 'ark-check', '--coverage')}</code>
4655
- <code>${arkCommand(root, 'ark-check', '--plan')}</code>
4656
- <code>${arkCommand(root, 'ark-check', '--doctor')}</code>
4657
- <code>${arkCommand(root, 'ark-check', '--report ark-report.html')}</code>
4658
- <code>/ark-place "&lt;what you're building&gt;"</code>
4659
- <code>/ark-explain</code>
4660
- </div>
4661
- </div>
4662
-
4663
- <footer>
4664
- Generated by ${meta || 'ark-check'} · visual twin of <code>/ark-explain</code>.
4665
- Regenerate with <code>ark-check --report</code>; add the file to <code>.gitignore</code> rather than committing it.
4666
- </footer>
4667
- </div></body></html>
4668
- `;
4669
- }
4670
1448
 
4671
1449
  function moduleSpecifierFromCall(ts, node) {
4672
1450
  if (!ts.isCallExpression(node)) return undefined;
@@ -4692,463 +1470,13 @@ function moduleSpecifierFromCall(ts, node) {
4692
1470
  // Pure coverage computation (glob-only, no TypeScript): the object both `--coverage` and
4693
1471
  // `--doctor` render. `governed` is the headline honesty number — the share of in-scope code
4694
1472
  // Ark actually enforces rules on; `suggestions` proposes a layer for each ungoverned dir.
4695
- function computeCoverage(root, config, files, rules) {
4696
- const layers = config.layers ?? [];
4697
- const counts = new Map(layers.map((layer) => [layer.name, 0]));
4698
- const unclassified = [];
4699
- for (const file of files) {
4700
- const layer = layerForFile(root, file, layers);
4701
- if (layer && counts.has(layer)) counts.set(layer, counts.get(layer) + 1);
4702
- else unclassified.push(normalize(path.relative(root, file)));
4703
- }
4704
- unclassified.sort();
4705
- const layerRows = layers.map((layer) => ({
4706
- name: layer.name,
4707
- patterns: layer.patterns ?? [],
4708
- files: counts.get(layer.name) ?? 0,
4709
- }));
4710
- // A layer whose patterns match zero files is dead config — it enforces nothing, usually a
4711
- // wrong glob (the #1 monorepo mistake). A layer with no rule edge can import anything.
4712
- const emptyLayers = layerRows.filter((row) => row.files === 0).map((row) => row.name);
4713
- const layersWithoutRules = layerRows
4714
- .map((row) => row.name)
4715
- .filter((name) => !rules.some((rule) => rule.from === name || rule.to === name));
4716
- const classifiedFiles = files.length - unclassified.length;
4717
- // Empty scope is NOT "100% governed" — that was a false-green for monorepos/mis-includes
4718
- // (0/0 → ENFORCE). Zero files means the contract is not checking anything yet.
4719
- const fraction = files.length > 0 ? classifiedFiles / files.length : 0;
4720
- return {
4721
- include: config.include ?? [],
4722
- totalFiles: files.length,
4723
- emptyScope: files.length === 0,
4724
- governed: { classifiedFiles, totalFiles: files.length, percent: Math.round(fraction * 100) },
4725
- layers: layerRows,
4726
- unclassified: { count: unclassified.length, files: unclassified },
4727
- suggestions: buildUnclassifiedSuggestions(unclassified),
4728
- emptyLayers,
4729
- layersWithoutRules,
4730
- };
4731
- }
4732
-
4733
- function runCoverage(root, config, files, rules, asJson) {
4734
- const cov = computeCoverage(root, config, files, rules);
4735
- if (asJson) {
4736
- console.log(JSON.stringify({ ok: true, coverage: cov }, null, 2));
4737
- return;
4738
- }
4739
- const { governed, layers: layerRows, suggestions, layersWithoutRules } = cov;
4740
- const classifiedFiles = governed.classifiedFiles;
4741
- const unclassified = cov.unclassified.files;
4742
-
4743
- const nameWidth = Math.max(
4744
- 'Layer'.length,
4745
- '(unclassified)'.length,
4746
- ...layerRows.map((row) => row.name.length)
4747
- );
4748
- const pad = (value) => value.padEnd(nameWidth);
4749
- console.log(`Ark coverage (include: ${(config.include ?? []).join(', ') || '.'}):`);
4750
- console.log('');
4751
- console.log(` ${pad('Layer')} Files`);
4752
- for (const row of layerRows) {
4753
- const flag = row.files === 0 ? ' (pattern matches nothing)' : '';
4754
- console.log(` ${pad(row.name)} ${String(row.files).padStart(5)}${flag}`);
4755
- }
4756
- console.log(` ${pad('(unclassified)')} ${String(unclassified.length).padStart(5)}`);
4757
- console.log('');
4758
- console.log(
4759
- `${files.length} source file(s) in scope; ${unclassified.length} not matched by any layer.`
4760
- );
4761
- console.log(`Governed: ${governed.percent}% (${classifiedFiles}/${files.length} files).`);
4762
- if (files.length > 0 && governed.percent < 50) {
4763
- console.log('');
4764
- console.log(
4765
- `⚠ Ark governs a MINORITY of your code (${governed.percent}%). A green check here does NOT`
4766
- );
4767
- console.log(' mean the codebase is checked — the rest is ungoverned. Classify the directories');
4768
- console.log(' below to actually cover it.');
4769
- }
4770
- if (suggestions.length > 0) {
4771
- console.log('');
4772
- console.log('Ungoverned directories (proposed layer — from the 11-layer profile + presets):');
4773
- for (const s of suggestions) {
4774
- const count = `(${s.files})`.padStart(6);
4775
- if (s.unrecognized) {
4776
- console.log(` ${count} ${s.dir}/ — unrecognized, you classify`);
4777
- } else {
4778
- const alt = s.alternatives ? ` (or ${s.alternatives.join(' / ')})` : '';
4779
- console.log(` ${count} ${s.dir}/ → ${s.layer}${alt}`);
4780
- }
4781
- }
4782
- console.log('');
4783
- console.log('Apply these via /ark-contract (adds the layer patterns to ark.config.json).');
4784
- }
4785
- if (layersWithoutRules.length > 0) {
4786
- console.log('');
4787
- console.log(`Layers with no rule edge (can import anything): ${layersWithoutRules.join(', ')}`);
4788
- }
4789
- }
4790
-
4791
- // --doctor: one consolidated health view — coverage, violations, gates, skills, baseline,
4792
- // and command runners — each with the exact command to fix it. Folds the data the other
4793
- // modes already produce so a team sees "what state is my Ark adoption in?" at a glance.
4794
- // Co-pilot Phase F — turn active violations into a classified, ordered remediation PLAN with an
4795
- // embedded GOAL. This is the `plan` primitive the future apply-loop (Phase H, `loop`) consumes
4796
- // and the autopilot (Phase I) drives toward the `goal`. Read-only: it changes no files.
4797
- function buildRemediationPlan(root, activeViolations, governedPercent = null, totalFiles = null) {
4798
- // A plan with 0 violations but ~0% governed (or ZERO files in scope) is a FALSE green:
4799
- // nothing is actually being checked. Treat as "not done — classify / fix include first."
4800
- const governedLow = governedPercent != null && governedPercent < 50;
4801
- const emptyScope = totalFiles === 0;
4802
- const notHonestlyEnforced = governedLow || emptyScope;
4803
- const steps = activeViolations.map((v, index) => {
4804
- const verdict = classifyRemediation(v);
4805
- return {
4806
- id: `${v.ruleId}:${v.file}:${v.line ?? 0}:${index}`,
4807
- class: verdict.class,
4808
- confidence: verdict.confidence,
4809
- rationale: verdict.rationale,
4810
- ruleId: v.ruleId,
4811
- edge: violationEdge(v),
4812
- file: v.file,
4813
- ...(v.line ? { line: v.line } : {}),
4814
- ...(v.target ? { target: v.target } : {}),
4815
- ...(v.typeOnly ? { typeOnly: true } : {}),
4816
- ...(v.targetTypeOnlyExports ? { targetTypeOnlyExports: true } : {}),
4817
- ...(v.sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
4818
- ...(verdict.remediationKind ? { remediationKind: verdict.remediationKind } : {}),
4819
- };
4820
- });
4821
- // Order: auto-applicable first (quick, safe wins), then human decisions, then deferred.
4822
- const rank = { 'mechanical-safe': 0, judgment: 1, deferred: 2 };
4823
- steps.sort((a, b) => rank[a.class] - rank[b.class]);
4824
- const countOf = (cls) => steps.filter((s) => s.class === cls).length;
4825
- const counts = {
4826
- mechanicalSafe: countOf('mechanical-safe'),
4827
- judgment: countOf('judgment'),
4828
- deferred: countOf('deferred'),
4829
- };
4830
- return {
4831
- version: '1',
4832
- goal: {
4833
- statement:
4834
- activeViolations.length > 0
4835
- ? `Resolve ${activeViolations.length} architecture violation(s) without weakening the contract.`
4836
- : emptyScope
4837
- ? 'No source files matched the contract include paths — this "clean" result checks nothing. Fix include/layers (monorepo → apps/packages, or /ark-adopt) so Ark has real code to govern.'
4838
- : governedLow
4839
- ? `No violations — but Ark governs only ${governedPercent}% of your code, so this "clean" result checks almost nothing. Classify the rest (ark-check --coverage, then /ark-adopt) so it's actually enforced.`
4840
- : 'No active violations — the architecture already meets its contract.',
4841
- // The loop's termination signal (Phase H): nothing left to remediate AND the contract
4842
- // actually governs real code. Empty scope or low coverage is not "met".
4843
- met: activeViolations.length === 0 && !notHonestlyEnforced,
4844
- ...(governedPercent != null ? { governedPercent } : {}),
4845
- ...(totalFiles != null ? { totalFiles } : {}),
4846
- ...(emptyScope ? { emptyScope: true } : {}),
4847
- activeViolations: activeViolations.length,
4848
- autoApplicable: counts.mechanicalSafe,
4849
- needsDecision: counts.judgment,
4850
- deferred: counts.deferred,
4851
- },
4852
- counts,
4853
- steps,
4854
- };
4855
- }
4856
-
4857
- // `--plan`: print the classified remediation plan. Dual-focus output — a one-line headline
4858
- // anyone can read, then the per-step detail a developer acts on. Read-only.
4859
- function runPlan(root, activeViolations, asJson, governedPercent = null, totalFiles = null) {
4860
- const plan = buildRemediationPlan(root, activeViolations, governedPercent, totalFiles);
4861
- // Honesty: a zero-violation plan with almost nothing governed is NOT "ok".
4862
- const planOk = plan.goal.met === true;
4863
- if (asJson) {
4864
- console.log(JSON.stringify({ ok: planOk, plan }, null, 2));
4865
- return plan;
4866
- }
4867
- console.log(color.bold(`Ark plan — ${path.basename(path.resolve(root)) || '.'}`));
4868
- console.log('');
4869
- console.log(plan.goal.statement);
4870
- if (governedPercent != null) {
4871
- const pctLabel =
4872
- governedPercent < 50
4873
- ? color.yellow(`Governed: ${governedPercent}% of in-scope files`)
4874
- : color.dim(`Governed: ${governedPercent}% of in-scope files`);
4875
- console.log(pctLabel);
4876
- }
4877
- if (activeViolations.length === 0) return plan;
4878
- console.log('');
4879
- console.log(
4880
- ` ${color.green(`${plan.counts.mechanicalSafe} safe to auto-apply`)} · ` +
4881
- `${color.yellow(`${plan.counts.judgment} need your decision`)} · ` +
4882
- `${color.dim(`${plan.counts.deferred} deferred`)}`
4883
- );
4884
- console.log('');
4885
- const tag = {
4886
- 'mechanical-safe': color.green('auto '),
4887
- judgment: color.yellow('decide'),
4888
- deferred: color.dim('defer '),
4889
- };
4890
- for (const step of plan.steps) {
4891
- const where = `${step.file}${step.line ? `:${step.line}` : ''}`;
4892
- console.log(` [${tag[step.class]}] ${step.edge} ${color.dim(where)}`);
4893
- console.log(color.dim(` ${step.rationale}`));
4894
- }
4895
- console.log('');
4896
- console.log(
4897
- color.dim(
4898
- 'Plan only — no files changed. "auto" = an agent can safely apply it; "decide" = your call.'
4899
- )
4900
- );
4901
- return plan;
4902
- }
4903
-
4904
- function runDoctor(root, config, files, rules, violations, asJson, options = {}) {
4905
- const cov = computeCoverage(root, config, files, rules);
4906
- const summary = summarizeViolations(violations);
4907
- const configPath = options.configPath ?? path.join(root, 'ark.config.json');
4908
- const configMissing = options.configMissing ?? !fs.existsSync(configPath);
4909
- const showNewHere = shouldShowNewHereNudge(root, configPath, cov.governed.percent, configMissing);
4910
- let recommendation;
4911
- if (showNewHere) {
4912
- try {
4913
- recommendation = buildArchitectureRecommendation(root);
4914
- } catch {
4915
- recommendation = undefined;
4916
- }
4917
- }
4918
- const gatesMissing = missingGates(root);
4919
- const skillGaps = detectSkillGaps(root);
4920
- const staleRunners = staleRunnerGateFiles(root);
4921
- const adoption = collectAdoptionGaps(root, config, cov);
4922
- const baseline = readBaseline(root, '.ark-baseline.json');
4923
- const currentKeys = new Set(violations.map(baselineKey));
4924
- const suppressed = baseline.exists
4925
- ? violations.filter((v) => baseline.keys.has(baselineKey(v))).length
4926
- : 0;
4927
- const staleBaseline = baseline.exists
4928
- ? [...baseline.keys].filter((key) => !currentKeys.has(key)).length
4929
- : 0;
4930
- const activeCount = violations.length - suppressed;
4931
- const missingSkills = skillGaps.reduce((sum, gap) => sum + gap.missing, 0);
4932
- const staleSkills = skillGaps.reduce((sum, gap) => sum + gap.stale, 0);
4933
-
4934
- if (asJson) {
4935
- console.log(
4936
- JSON.stringify(
4937
- {
4938
- ok: true,
4939
- doctor: {
4940
- operatingMode: resolveOperatingMode({
4941
- governedPercent: cov.governed.percent,
4942
- planMet: activeCount === 0 && cov.governed.percent >= 50,
4943
- mature: cov.governed.totalFiles >= 150,
4944
- }),
4945
- governed: cov.governed,
4946
- emptyLayers: cov.emptyLayers,
4947
- layersWithoutRules: cov.layersWithoutRules,
4948
- ungovernedDirs: cov.suggestions.length,
4949
- violations: {
4950
- total: violations.length,
4951
- active: activeCount,
4952
- suppressed,
4953
- value: summary.valueCount,
4954
- typeOnly: summary.typeOnlyCount,
4955
- concentrated: summary.concentrated,
4956
- dominant: summary.dominant,
4957
- topEdges: summary.edges.slice(0, 5),
4958
- },
4959
- baseline: {
4960
- exists: baseline.exists,
4961
- frozen: baseline.exists ? baseline.keys.size : 0,
4962
- stale: staleBaseline,
4963
- policy: adoption.baseline,
4964
- },
4965
- gatesMissing,
4966
- skillGaps,
4967
- staleRunnerFiles: staleRunners,
4968
- adoption,
4969
- newHere: showNewHere
4970
- ? {
4971
- show: true,
4972
- archetype: recommendation?.archetype,
4973
- label: recommendation?.label,
4974
- preset: recommendation?.preset,
4975
- recommendCommand: arkCommand(root, 'ark-check', '--recommend'),
4976
- initCommand: recommendation?.archetype
4977
- ? arkCommand(root, 'ark', `init --archetype ${recommendation.archetype} --yes`)
4978
- : undefined,
4979
- }
4980
- : { show: false },
4981
- },
4982
- },
4983
- null,
4984
- 2
4985
- )
4986
- );
4987
- return;
4988
- }
4989
-
4990
- const ok = color.green('✓');
4991
- const warn = color.yellow('!');
4992
- const bad = color.red('✗');
4993
- const actions = [];
4994
- const line = (mark, text) => console.log(` ${mark} ${text}`);
4995
-
4996
- console.log(color.bold(`Ark doctor — ${path.basename(path.resolve(root)) || '.'}`));
4997
-
4998
- const emptyScope = cov.governed.totalFiles === 0;
4999
- const mode = resolveOperatingMode({
5000
- governedPercent: emptyScope ? 0 : cov.governed.percent,
5001
- planMet:
5002
- activeCount === 0 && !emptyScope && cov.governed.percent >= 50,
5003
- mature: cov.governed.totalFiles >= 150,
5004
- });
5005
- console.log('');
5006
- console.log(color.bold('Operating mode'));
5007
- const modeMark = mode === 'enforce' ? ok : mode === 'adapt' ? warn : warn;
5008
- const modeHelp = {
5009
- suggest: 'starter shape / thin tree — expand layers as you grow',
5010
- adapt: 'contract still needs to match real layout or raise coverage',
5011
- enforce: 'contract governs enough code; gates can honestly hold the line',
5012
- };
5013
- line(modeMark, `${mode.toUpperCase()} — ${modeHelp[mode]}`);
5014
- if (emptyScope) {
5015
- line(
5016
- bad,
5017
- 'Empty scope: include paths match 0 source files — a green check is meaningless until include/layers match the tree (monorepo → apps/packages, or /ark-adopt).'
5018
- );
5019
- }
5020
-
5021
- console.log('');
5022
- console.log(color.bold('Coverage'));
5023
- const govMark =
5024
- emptyScope || cov.governed.percent < 50
5025
- ? bad
5026
- : cov.governed.percent >= 80
5027
- ? ok
5028
- : warn;
5029
- line(govMark, `Governed: ${cov.governed.percent}% (${cov.governed.classifiedFiles}/${cov.governed.totalFiles} files)`);
5030
- if (cov.suggestions.length > 0) {
5031
- line(warn, `${cov.suggestions.length} ungoverned director(y/ies) — proposals: ${arkCommand(root, 'ark-check', '--coverage')}`);
5032
- actions.push('classify the ungoverned directories (/ark-contract)');
5033
- }
5034
- if (cov.emptyLayers.length > 0) line(warn, `Empty layers (pattern matches nothing): ${cov.emptyLayers.join(', ')}`);
5035
- if (cov.layersWithoutRules.length > 0) line(warn, `Layers with no rule edge: ${cov.layersWithoutRules.join(', ')}`);
5036
- if (cov.suggestions.length === 0 && cov.emptyLayers.length === 0) line(ok, 'Every layer classifies files; no empty layers');
5037
-
5038
- if (showNewHere) {
5039
- console.log('');
5040
- console.log(color.bold('New here?'));
5041
- if (recommendation) {
5042
- line(warn, `Suggested application shape: ${recommendation.archetype} — ${recommendation.label} (preset ${recommendation.preset})`);
5043
- } else {
5044
- line(warn, 'Low governed coverage or fresh config — pick an application shape before adding code.');
5045
- }
5046
- line(ok, `See the plan: ${arkCommand(root, 'ark-check', '--recommend')}`);
5047
- if (recommendation?.archetype) {
5048
- line(ok, `Quick setup: ${arkCommand(root, 'ark', `init --archetype ${recommendation.archetype} --yes`)}`);
5049
- }
5050
- actions.unshift('run ark-check --recommend or /ark-architect to choose your application shape');
5051
- }
5052
-
5053
- console.log('');
5054
- console.log(color.bold('Violations'));
5055
- if (violations.length === 0) {
5056
- line(ok, 'None — the code matches the contract');
5057
- } else {
5058
- const typeNote = summary.typeOnlyCount > 0 ? ` (${summary.valueCount} value · ${summary.typeOnlyCount} type-only)` : '';
5059
- const supNote = suppressed > 0 ? `, ${suppressed} frozen` : '';
5060
- line(
5061
- activeCount > 0 ? warn : ok,
5062
- `${violations.length} total${typeNote}${supNote}${activeCount > 0 ? ` — ${activeCount} NOT baselined` : ''}`
5063
- );
5064
- for (const edge of summary.edges.slice(0, 3)) line(' ', color.dim(`${edge.count} ${edge.edge}`));
5065
- if (summary.concentrated) {
5066
- line(warn, color.dim(`${Math.round(summary.dominantShare * 100)}% on one edge (${summary.dominant}) — likely a contract fix, not debt`));
5067
- }
5068
- if (activeCount > 0) {
5069
- actions.push(
5070
- `resolve the non-baselined violations — see the classified plan (${arkCommand(root, 'ark-check', '--plan')}), then /ark-fix`
5071
- );
5072
- }
5073
- }
5074
-
5075
- console.log('');
5076
- console.log(color.bold('Gates & skills'));
5077
- if (gatesMissing.length === 0) line(ok, 'Gate files present (AGENTS.md, .mcp.json, CI, write gate)');
5078
- else {
5079
- line(bad, `Missing gates: ${gatesMissing.join(', ')}`);
5080
- actions.push(`install gates (${arkCommand(root, 'ark-check', '--install-agent-gates')})`);
5081
- }
5082
- if (missingSkills + staleSkills === 0) line(ok, '/ark-* skills current for detected tools');
5083
- else {
5084
- line(warn, `${missingSkills} missing / ${staleSkills} outdated /ark-* skill(s) for ${skillGaps.map((g) => g.tool).join(', ')}`);
5085
- actions.push('refresh /ark-* skills (--install-agent-gates --skills-only --force)');
5086
- }
5087
-
5088
- console.log('');
5089
- console.log(color.bold('Baseline'));
5090
- if (!baseline.exists) {
5091
- line(violations.length > 0 ? warn : ok, violations.length > 0 ? 'No baseline — adopting a dirty repo? freeze with --update-baseline' : 'No baseline (nothing to freeze)');
5092
- } else {
5093
- // Baseline keys are line-agnostic, so N keys can suppress ≥N violations — label as keys
5094
- // to avoid an apparent mismatch with the "frozen" violation count above.
5095
- line(ok, `${baseline.keys.size} frozen key(s)`);
5096
- if (staleBaseline > 0) {
5097
- line(warn, `${staleBaseline} stale entr(y/ies) no longer occur — tighten with --update-baseline`);
5098
- actions.push('tighten the baseline (--update-baseline)');
5099
- }
5100
- }
5101
-
5102
- console.log('');
5103
- console.log(color.bold('Command runners'));
5104
- if (staleRunners.length === 0) line(ok, 'Emitted commands match the package manager');
5105
- else {
5106
- line(warn, `Stale runner in ${staleRunners.join(', ')}`);
5107
- actions.push(`migrate command runners (${arkCommand(root, 'ark-check', '--install-agent-gates --migrate-commands')})`);
5108
- }
5109
-
5110
- // Adoption completeness (hosts, MCP health, codex home, core optionality, origin, baseline policy)
5111
- console.log('');
5112
- console.log(color.bold('Adoption (separate from fitness score)'));
5113
- if (adoption.gaps.length === 0 && !adoption.layerBalance) {
5114
- line(ok, 'Hosts, MCP argv, core optionality, origin report, and baseline policy look complete');
5115
- } else {
5116
- for (const gap of adoption.gaps) {
5117
- const mark = gap.severity === 'warn' ? warn : gap.severity === 'info' ? warn : bad;
5118
- line(mark, gap.message);
5119
- if (gap.fix) line(' ', color.dim(`Fix: ${gap.fix}`));
5120
- actions.push(gap.fix || gap.message);
5121
- }
5122
- if (adoption.layerBalance) {
5123
- line(warn, color.dim(adoption.layerBalance.educational));
5124
- }
5125
- }
5126
- if (adoption.baseline) {
5127
- line(
5128
- ' ',
5129
- color.dim(
5130
- `Baseline policy: ${adoption.baseline.signal}` +
5131
- (adoption.baseline.primaryPathUsesBaseline
5132
- ? ' · primary path uses --baseline'
5133
- : ' · primary path does not use --baseline')
5134
- )
5135
- );
5136
- }
5137
- if (adoption.originReport.present) {
5138
- line(ok, 'Origin architecture snapshot present (.ark/reports/origin.json)');
5139
- }
5140
-
5141
- console.log('');
5142
- if (actions.length === 0) {
5143
- console.log(color.green('✔ Healthy — nothing to do.'));
5144
- } else {
5145
- console.log(color.bold(`Top actions (${actions.length}):`));
5146
- actions.forEach((action, index) => console.log(` ${index + 1}. ${action}`));
5147
- }
5148
- }
5149
1473
 
5150
1474
  async function main() {
5151
1475
  const args = parseArgs(process.argv);
1476
+ if (args.version) {
1477
+ console.log(arkPackageVersion());
1478
+ process.exit(0);
1479
+ }
5152
1480
  if (args.help) {
5153
1481
  console.log(usage());
5154
1482
  return;
@@ -5627,11 +1955,10 @@ async function main() {
5627
1955
  noArchive: Boolean(args.noArchive),
5628
1956
  });
5629
1957
  if (!args.json) {
5630
- const rel = path.relative(root, reportPath) || reportPath;
5631
- console.log(`${color.green('✎')} Wrote HTML report: ${rel}`);
1958
+ console.log(`${color.green('✎')} Wrote HTML report: ${displayPathFromRoot(root, reportPath)}`);
5632
1959
  if (archive.createdOrigin) {
5633
1960
  console.log(
5634
- `${color.green('✎')} Origin snapshot saved (first report): ${path.relative(root, archive.originJson) || archive.originJson}`
1961
+ `${color.green('✎')} Origin snapshot saved (first report): ${displayPathFromRoot(root, archive.originJson)}`
5635
1962
  );
5636
1963
  console.log(
5637
1964
  color.dim(' Future reports will show evolution vs this starting point (.ark/reports/).')