arkgate 2.5.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ark-check.mjs CHANGED
@@ -25,11 +25,15 @@ import {
25
25
  writeAdoptionPlan,
26
26
  classifyRemediation,
27
27
  detectPackageManager,
28
+ detectWorkspaces,
29
+ detectTsPackageRoots,
30
+ resolveIncludeRoots,
28
31
  execCommandParts,
29
32
  execRunner,
30
33
  formatArchitectureRecommendationHuman,
31
34
  globToRegExp,
32
35
  installDevHint,
36
+ isScanExcludedRelative,
33
37
  presentLockfiles,
34
38
  layerForFile,
35
39
  looksLikeIntent,
@@ -41,6 +45,71 @@ import {
41
45
  typescriptUsabilityHint,
42
46
  } from './ark-shared.mjs';
43
47
 
48
+ import {
49
+ runInstallAgentGates,
50
+ runMigrateCommands,
51
+ loadTypeScript,
52
+ collectAdoptionGaps,
53
+ detectSkillGaps,
54
+ detectCodexHomeGap,
55
+ missingGates,
56
+ staleRunnerGateFiles,
57
+ brokenMcpGateFiles,
58
+ readJson,
59
+ readPackageJson,
60
+ hasCheckArchitectureScript,
61
+ hasArkWorkflow,
62
+ checkArchitectureScriptSnippet,
63
+ arkCheckCommand,
64
+ arkPackageVersion,
65
+ agentInstructions,
66
+ packageManager,
67
+ REQUIRED_GATE_FILES,
68
+ codexPromptsDir,
69
+ } from './lib/agent-gates.mjs';
70
+ import {
71
+ detectEnforcement,
72
+ renderHtmlReport,
73
+ renderBeginnerHtmlReport,
74
+ archiveReportSnapshots,
75
+ buildReportSnapshot,
76
+ computeReportFitness,
77
+ reportsDir,
78
+ readJsonSafe,
79
+ } from './lib/html-report.mjs';
80
+ import {
81
+ computeCoverage,
82
+ runCoverage,
83
+ runPlan,
84
+ runDoctor,
85
+ buildRemediationPlan,
86
+ } from './lib/doctor-plan.mjs';
87
+ import {
88
+ baselineKey,
89
+ readBaseline,
90
+ summarizeViolations,
91
+ violationEdge,
92
+ writeBaseline,
93
+ printViolation,
94
+ printViolationBreakdown,
95
+ CONCENTRATION_MIN_VIOLATIONS,
96
+ } from './lib/violations.mjs';
97
+ import {
98
+ buildUnclassifiedSuggestions,
99
+ suggestLayerForDir,
100
+ suggestLayerForPath,
101
+ detectBestFitModel,
102
+ dirSegmentsFromGlob,
103
+ } from './lib/suggestions.mjs';
104
+ import {
105
+ ARCHITECTURE_PRESETS,
106
+ CANONICAL_LAYER_NAMES,
107
+ denyUpward,
108
+ presetWithOverlays,
109
+ FRAMEWORK_INTERNAL_EXCLUDE,
110
+ } from './lib/presets.mjs';
111
+
112
+
44
113
  function parseArgs(argv) {
45
114
  const args = {
46
115
  root: process.cwd(),
@@ -69,6 +138,8 @@ function parseArgs(argv) {
69
138
  applyPolicyPack: undefined,
70
139
  watch: false,
71
140
  beginner: false,
141
+ version: false,
142
+ help: false,
72
143
  };
73
144
  for (let i = 2; i < argv.length; i += 1) {
74
145
  const arg = argv[i];
@@ -101,6 +172,9 @@ function parseArgs(argv) {
101
172
  else if (arg === '--write-plan') args.writePlan = true;
102
173
  else if (arg === '--list-policy-packs') args.listPolicyPacks = true;
103
174
  else if (arg === '--apply-policy-pack') args.applyPolicyPack = argv[++i];
175
+ else if (arg === '--suggest-include') args.suggestInclude = true;
176
+ else if (arg === '--adopt-contract') args.adoptContract = true;
177
+ else if (arg === '--write') args.write = true;
104
178
  else if (arg === '--watch') args.watch = true;
105
179
  else if (arg === '--beginner') args.beginner = true;
106
180
  else if (arg === '--codex-home') args.codexHome = true;
@@ -124,18 +198,32 @@ function parseArgs(argv) {
124
198
  else if (arg === '--print-config') args.printConfig = argv[++i];
125
199
  else if (arg === '--tsconfig') args.tsconfig = argv[++i];
126
200
  else if (arg === '--help' || arg === '-h') args.help = true;
201
+ else if (arg === '--version' || arg === '-V') args.version = true;
127
202
  }
128
203
  return args;
129
204
  }
130
205
 
206
+ /** Path shown to humans: project-relative when inside root, absolute otherwise (no `../../..`). */
207
+ function displayPathFromRoot(root, absPath) {
208
+ const rel = path.relative(root, absPath);
209
+ if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
210
+ return absPath;
211
+ }
212
+ return rel.split(path.sep).join('/');
213
+ }
214
+
131
215
  function usage() {
132
216
  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]',
217
+ 'Usage: arkgate-check | ark-check (identical bins; product name ArkGate)',
218
+ ' ark-check --version',
219
+ ' 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
220
  ' ark-check --coverage [--json] per-layer file counts + full unclassified list (report only, exit 0)',
135
221
  ' ark-check --plan [--json] classified remediation plan (mechanical-safe / judgment / deferred) + goal; report only',
136
222
  ' ark-check --recommend [--json] [--write-plan] application-shape plan; --write-plan emits ark-adoption-plan.json',
137
- ' ark-check --list-policy-packs enthusiast preset configs (hexagonal, layered, feature-sliced, monorepo)',
223
+ ' ark-check --list-policy-packs enthusiast preset configs (hexagonal, layered, feature-sliced, monorepo, ui-surface)',
138
224
  ' ark-check --apply-policy-pack <id> [--force] write ark.config.json from templates/policy-packs/ (uses preset factory)',
225
+ ' ark-check --suggest-include [--json] propose include roots (TS packages / workspaces)',
226
+ ' ark-check --adopt-contract [--write] expand include + UI patterns from ungoverned dirs (contract adopt)',
139
227
  ' ark-check --watch re-run the check when governed files change (debounced)',
140
228
  ' ark-check --report [file.html] [--beginner] [--reset-origin] [--no-archive]',
141
229
  ' HTML report + snapshots under .ark/reports/ (origin once, latest each run, history JSON)',
@@ -170,6 +258,7 @@ function usage() {
170
258
  'Config shape:',
171
259
  '{',
172
260
  ' "include": ["src"],',
261
+ ' // optional: "exclude": ["**/vendor/**"], "excludeGenerated": false (default skips *.gen.ts / *.generated.ts)',
173
262
  ' "layers": [',
174
263
  ' { "name": "DomainModel", "patterns": ["src/domain/**"], "intentPrefixes": ["Domain."],',
175
264
  ' "forbiddenGlobals": ["fetch", "process", "Date.now", "Math.random"] }',
@@ -210,57 +299,6 @@ function usage() {
210
299
  ].join('\n');
211
300
  }
212
301
 
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
302
 
265
303
  function readConfig(root, configPath) {
266
304
  const fullPath = path.isAbsolute(configPath)
@@ -278,6 +316,9 @@ function readConfig(root, configPath) {
278
316
  include: raw.include ?? ['src'],
279
317
  layers: raw.layers ?? [],
280
318
  rules: raw.rules ?? DEFAULT_RULES,
319
+ ...(raw.exclude ? { exclude: raw.exclude } : {}),
320
+ ...(raw.excludeGenerated !== undefined ? { excludeGenerated: raw.excludeGenerated } : {}),
321
+ ...(raw.cyclePolicy ? { cyclePolicy: raw.cyclePolicy } : {}),
281
322
  };
282
323
  }
283
324
 
@@ -334,380 +375,13 @@ function uncoveredDirectories(root, srcDir, layers) {
334
375
  });
335
376
  }
336
377
 
337
- // Reads workspace globs from package.json (npm/yarn/bun `workspaces`, array or
338
- // `{ packages: [] }`) and pnpm-workspace.yaml, returning the distinct base directories
339
- // (the glob prefix before the first `*`), e.g. "packages/*" -> "packages". Empty when
340
- // the project declares no workspaces — the signal that says "this is a monorepo".
341
- function detectWorkspaces(root) {
342
- const dirs = new Set();
343
- const addGlob = (glob) => {
344
- if (typeof glob !== 'string') return;
345
- const beforeStar = glob.split('*')[0].replace(/\/+$/, '');
346
- if (beforeStar && beforeStar !== '.') dirs.add(normalize(beforeStar));
347
- };
348
- const pkg = readPackageJson(root);
349
- const ws = Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages;
350
- if (Array.isArray(ws)) ws.forEach(addGlob);
351
- const pnpmFile = path.join(root, 'pnpm-workspace.yaml');
352
- if (fs.existsSync(pnpmFile)) {
353
- // Minimal read (no YAML dep): collect list items under the top-level `packages:` key
354
- // ONLY. pnpm files also carry other list-valued keys (onlyBuiltDependencies, catalog,
355
- // …) whose items are NOT workspace globs — a key-agnostic scan would pull those in.
356
- let inPackages = false;
357
- for (const line of fs.readFileSync(pnpmFile, 'utf8').split('\n')) {
358
- const keyMatch = line.match(/^([A-Za-z0-9_-]+):/); // top-level key (no indentation)
359
- if (keyMatch) {
360
- inPackages = keyMatch[1] === 'packages';
361
- continue;
362
- }
363
- if (!inPackages) continue;
364
- const item = line.match(/^\s+-\s*['"]?([^'"#]+?)['"]?\s*$/); // indented list item
365
- if (item) addGlob(item[1].trim());
366
- }
367
- }
368
- return [...dirs];
369
- }
378
+ // detectWorkspaces: shared implementation in ark-shared.mjs (npm/pnpm/rush/lerna +
379
+ // conventional multi-package roots).
370
380
 
371
381
  // Deny every "upward" edge for an ordered layer list (index 0 = outermost/top,
372
382
  // which may import everything below it). Inner/lower layers must not import outer
373
383
  // 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
384
 
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
385
  function proposeForUncovered(root, srcDir, layers) {
712
386
  const proposals = [];
713
387
  for (const top of uncoveredDirectories(root, srcDir, layers)) {
@@ -763,7 +437,10 @@ function buildConfigFromPolicyPack(packId, root) {
763
437
  `Policy pack "${packId}" references unknown preset "${pack.preset}".`
764
438
  );
765
439
  }
766
- const workspaces = pack.preset === 'monorepo' ? detectWorkspaces(root) : [];
440
+ const workspaces =
441
+ pack.preset === 'monorepo' || pack.preset === 'ui-surface'
442
+ ? resolveIncludeRoots(root)
443
+ : [];
767
444
  const config = factory(workspaces, root);
768
445
  if (pack.layerDescriptions) {
769
446
  for (const layer of config.layers) {
@@ -861,7 +538,7 @@ const THIN_COVERAGE_PERCENT = 50;
861
538
  function maybeWarnBrownfield(root, config) {
862
539
  let files;
863
540
  try {
864
- files = (config.include ?? []).flatMap((entry) => walk(path.join(root, entry)));
541
+ files = collectGovernedFiles(root, config);
865
542
  } catch {
866
543
  return false;
867
544
  }
@@ -885,6 +562,144 @@ function maybeWarnBrownfield(root, config) {
885
562
  return true;
886
563
  }
887
564
 
565
+ /** Propose include roots (workspaces + nested TS packages) — contract-adopt primitive. */
566
+ function runSuggestInclude(args) {
567
+ const root = args.root;
568
+ const workspaces = detectWorkspaces(root);
569
+ const tsPackages = detectTsPackageRoots(root);
570
+ const include = resolveIncludeRoots(root);
571
+ const payload = {
572
+ ok: true,
573
+ workspaces,
574
+ tsPackages,
575
+ suggestedInclude: include.length > 0 ? include : tsPackages.length > 0 ? tsPackages : ['src'],
576
+ note:
577
+ include.length === 0 && tsPackages.length === 0
578
+ ? 'No TS packages or workspaces found — default suggestion is src/ (create it or pass include by hand).'
579
+ : 'Use these paths as ark.config.json "include". Prefer --adopt-contract --write to expand patterns too.',
580
+ };
581
+ if (args.json) {
582
+ console.log(JSON.stringify(payload, null, 2));
583
+ return;
584
+ }
585
+ console.log(color.bold('Suggested include roots'));
586
+ console.log(` workspaces: ${workspaces.join(', ') || '(none)'}`);
587
+ console.log(` tsPackages: ${tsPackages.join(', ') || '(none)'}`);
588
+ console.log(` suggestedInclude: ${payload.suggestedInclude.join(', ')}`);
589
+ console.log(color.dim(payload.note));
590
+ }
591
+
592
+ /**
593
+ * Contract-adopt: expand include + presentation patterns from ungoverned proposals.
594
+ * Read-only unless --write. Does not weaken rules or baseline violations.
595
+ */
596
+ function runAdoptContract(args) {
597
+ const root = args.root;
598
+ const configPath = path.isAbsolute(args.config)
599
+ ? args.config
600
+ : path.join(root, args.config);
601
+ let config;
602
+ try {
603
+ config = fs.existsSync(configPath)
604
+ ? readConfig(root, args.config)
605
+ : {
606
+ include: ['src'],
607
+ layers: ARCHITECTURE_PRESETS['ui-surface']([], root).layers,
608
+ rules: ARCHITECTURE_PRESETS['ui-surface']([], root).rules,
609
+ };
610
+ } catch (error) {
611
+ console.error(error instanceof Error ? error.message : String(error));
612
+ process.exitCode = 2;
613
+ return;
614
+ }
615
+ const suggestedInclude = resolveIncludeRoots(root);
616
+ const tsPackages = detectTsPackageRoots(root);
617
+ const nextInclude = [
618
+ ...new Set([
619
+ ...(config.include || []),
620
+ ...(suggestedInclude.length > 0 ? suggestedInclude : tsPackages),
621
+ ]),
622
+ ].filter(Boolean);
623
+ const files = collectGovernedFiles(root, { ...config, include: nextInclude.length ? nextInclude : config.include });
624
+ const cov = computeCoverage(root, { ...config, include: nextInclude.length ? nextInclude : config.include }, files, config.rules || []);
625
+ const uiPatterns = [
626
+ '**/components/**',
627
+ '**/hooks/**',
628
+ '**/lib/**',
629
+ '**/routes/**',
630
+ '**/app/**',
631
+ '**/pages/**',
632
+ ];
633
+ const layers = (config.layers || []).map((layer) => {
634
+ if (layer.name !== 'PresentationAdapters') return layer;
635
+ const patterns = [...new Set([...(layer.patterns || []), ...uiPatterns])];
636
+ return { ...layer, patterns };
637
+ });
638
+ // If no PresentationAdapters layer, leave layers as-is (don't invent full profile).
639
+ const proposal = {
640
+ ok: true,
641
+ before: {
642
+ include: config.include || [],
643
+ governedPercent: null,
644
+ totalFiles: null,
645
+ },
646
+ after: {
647
+ include: nextInclude.length > 0 ? nextInclude : config.include,
648
+ presentationPatterns: uiPatterns,
649
+ totalFiles: cov.totalFiles,
650
+ governedPercent: cov.governed.percent,
651
+ unclassified: cov.unclassified.count,
652
+ },
653
+ wrote: false,
654
+ };
655
+ // Compute before coverage for honesty.
656
+ try {
657
+ const beforeFiles = collectGovernedFiles(root, config);
658
+ const beforeCov = computeCoverage(root, config, beforeFiles, config.rules || []);
659
+ proposal.before.totalFiles = beforeCov.totalFiles;
660
+ proposal.before.governedPercent = beforeCov.governed.percent;
661
+ } catch {
662
+ /* ignore */
663
+ }
664
+
665
+ if (args.write) {
666
+ const next = {
667
+ ...config,
668
+ include: proposal.after.include,
669
+ layers,
670
+ };
671
+ fs.writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`);
672
+ proposal.wrote = true;
673
+ }
674
+
675
+ if (args.json) {
676
+ console.log(JSON.stringify(proposal, null, 2));
677
+ return;
678
+ }
679
+ console.log(color.bold('Contract adopt (coverage first)'));
680
+ console.log(
681
+ ` before: include=[${(proposal.before.include || []).join(', ')}] governed=${proposal.before.governedPercent ?? '?'}% files=${proposal.before.totalFiles ?? '?'}`
682
+ );
683
+ console.log(
684
+ ` after: include=[${(proposal.after.include || []).join(', ')}] governed=${proposal.after.governedPercent}% files=${proposal.after.totalFiles} unclassified=${proposal.after.unclassified}`
685
+ );
686
+ console.log(` presentation patterns += ${uiPatterns.join(', ')}`);
687
+ if (proposal.wrote) {
688
+ console.log(color.green(` wrote ${path.relative(root, configPath) || args.config}`));
689
+ console.log(color.dim(` Next: ${arkCommand(root, 'ark-check', '--coverage')} then --plan`));
690
+ } else {
691
+ console.log(color.dim(' Dry-run only. Re-run with --write to apply (does not weaken rules).'));
692
+ }
693
+ if ((proposal.after.totalFiles ?? 0) === 0) {
694
+ console.log(
695
+ color.yellow(
696
+ ' Empty scope remains — no TS packages found. Point include at your package roots manually.'
697
+ )
698
+ );
699
+ process.exitCode = 1;
700
+ }
701
+ }
702
+
888
703
  function runInit(args) {
889
704
  const configPath = path.isAbsolute(args.config)
890
705
  ? args.config
@@ -905,7 +720,12 @@ function runInit(args) {
905
720
  process.exitCode = 2;
906
721
  return;
907
722
  }
908
- const finalConfig = factory(detectWorkspaces(args.root), args.root);
723
+ const finalConfig = factory(
724
+ args.preset === 'monorepo' || args.preset === 'ui-surface'
725
+ ? resolveIncludeRoots(args.root)
726
+ : detectWorkspaces(args.root),
727
+ args.root
728
+ );
909
729
  fs.writeFileSync(configPath, `${JSON.stringify(finalConfig, null, 2)}\n`);
910
730
  console.log(`Wrote ${configPath} (${args.preset} preset)`);
911
731
  if (finalConfig.frameworkOverlay) {
@@ -933,8 +753,14 @@ function runInit(args) {
933
753
  // When no conventional src/ layout is found, a `workspaces` declaration means this is a
934
754
  // monorepo — the src/** 11-layer starter would match nothing there, so use the
935
755
  // cross-package monorepo profile anchored at the real workspace roots instead.
936
- const workspaces = greenfield ? detectWorkspaces(args.root) : [];
937
- const mode = !greenfield ? 'detected' : workspaces.length > 0 ? 'monorepo' : 'greenfield';
756
+ const includeRoots = greenfield ? resolveIncludeRoots(args.root) : [];
757
+ const tsPackages = greenfield ? detectTsPackageRoots(args.root) : [];
758
+ // Prefer monorepo/ui when nested TS packages exist without a conventional src layout.
759
+ const mode = !greenfield
760
+ ? 'detected'
761
+ : includeRoots.length > 0 || tsPackages.length > 0
762
+ ? 'monorepo'
763
+ : 'greenfield';
938
764
  // Greenfield: anchor the starter profile at src/ (the convention a fresh project will
939
765
  // scaffold under) even when src/ doesn't exist yet — the layers are optional, so the
940
766
  // check passes today and governance switches on the moment src/domain/ etc. appear.
@@ -943,7 +769,10 @@ function runInit(args) {
943
769
  mode === 'detected'
944
770
  ? applyFrameworkLayoutOverlays(config, args.root)
945
771
  : mode === 'monorepo'
946
- ? ARCHITECTURE_PRESETS.monorepo(workspaces, args.root)
772
+ ? ARCHITECTURE_PRESETS.monorepo(
773
+ includeRoots.length > 0 ? includeRoots : tsPackages,
774
+ args.root
775
+ )
947
776
  : createElevenLayerConfig({
948
777
  rootDir: srcDir === '.' ? 'src' : srcDir,
949
778
  root: args.root,
@@ -954,10 +783,11 @@ function runInit(args) {
954
783
  console.log(`Wrote ${configPath}`);
955
784
  console.log('');
956
785
  if (mode === 'monorepo') {
957
- console.log(`Monorepo detected (workspaces: ${workspaces.join(', ')}). Generated a cross-package`);
958
- console.log('profile matching domain/application/presentation/persistence directories in any');
959
- console.log('package. Every layer is optional, so the strict check passes now and each switches');
960
- console.log('on as matching directories gain files. Adjust patterns to your naming if they differ:');
786
+ const roots = finalConfig.include?.join(', ') || '(none)';
787
+ console.log(`Multi-package / TS package surface detected (include: ${roots}). Generated a`);
788
+ console.log('cross-package profile matching domain/application/presentation/persistence dirs');
789
+ console.log('in any package. Every layer is optional, so the strict check passes now and each');
790
+ console.log('switches on as matching directories gain files. Adjust patterns to your naming:');
961
791
  for (const layer of finalConfig.layers) {
962
792
  console.log(` ${layer.name}: ${layer.patterns.join(', ')}`);
963
793
  }
@@ -986,1521 +816,55 @@ function runInit(args) {
986
816
  for (const layer of finalConfig.layers) {
987
817
  console.log(` ${layer.name}: ${layer.patterns.join(', ')}`);
988
818
  }
989
- const detected = new Set(finalConfig.layers.map((layer) => layer.name));
990
- const suggested = DEFAULT_INTENT_PREFIXES.filter((entry) => !detected.has(entry.layer));
991
- if (suggested.length > 0) {
992
- console.log('');
993
- console.log('Suggested layers from the 11-layer profile (not detected — conventional');
994
- console.log('directories shown; create one and re-run --init, or add the layer by hand):');
995
- for (const entry of suggested) {
996
- const dirs = (DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? [])
997
- .map((directory) => `${srcDir}/${directory}`)
998
- .join(', ');
999
- console.log(` ${entry.layer}: ${dirs}`);
1000
- }
1001
- }
1002
- const proposals = proposeForUncovered(args.root, srcDir, finalConfig.layers);
1003
- if (proposals.length > 0) {
1004
- const recognized = proposals.filter((p) => !p.unrecognized);
1005
- const unrecognized = proposals.filter((p) => p.unrecognized);
1006
- console.log('');
1007
- console.log('Ungoverned directories — Ark enforces NOTHING here until they are classified.');
1008
- console.log('A green check ignores this code; it is not "clean", it is unchecked.');
1009
- if (recognized.length > 0) {
1010
- console.log('');
1011
- console.log('Proposed layer for each (from the 11-layer profile + presets — apply via /ark-contract):');
1012
- for (const p of recognized) {
1013
- const alt = p.alternatives?.length ? ` (or ${p.alternatives.join(' / ')} — confirm)` : '';
1014
- console.log(` ${p.dir}/ → ${p.layer}${alt}`);
1015
- }
1016
- }
1017
- if (unrecognized.length > 0) {
1018
- console.log('');
1019
- console.log(`Not recognized — you decide the layer: ${unrecognized.map((p) => p.dir).join(', ')}`);
1020
- }
1021
- const fit = detectBestFitModel(
1022
- [
1023
- ...finalConfig.layers.flatMap((l) => (l.patterns ?? []).map((p) => dirSegmentsFromGlob(p).pop())),
1024
- ...proposals.map((p) => p.dir.split('/').pop()),
1025
- ].filter(Boolean)
1026
- );
1027
- if (fit) {
1028
- console.log('');
1029
- console.log(`Closest starter model: ${fit.name} — \`ark init --preset ${fit.name} --force\` to start from its rule set.`);
1030
- }
1031
- }
1032
- }
1033
- maybeWarnBrownfield(args.root, finalConfig);
1034
- printInitNextSteps(args.root);
1035
- }
1036
-
1037
- function ensureDirForFile(file) {
1038
- fs.mkdirSync(path.dirname(file), { recursive: true });
1039
- }
1040
-
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' };
1052
- }
1053
- }
1054
-
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
- });
1108
-
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
- }
1145
-
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
- };
1180
- }
1181
-
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);
1188
- }
1189
-
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}`;
1217
- }
1218
-
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
- `;
1240
- }
1241
-
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`;
1252
- }
1253
-
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
- `;
1274
- }
1275
-
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
- `;
1295
- }
1296
-
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
- `;
1314
- }
1315
-
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 };
1335
- }
1336
- return { kind: 'default', value: DEFAULT_CI_NODE_VERSION };
1337
- }
1338
-
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' });
819
+ const detected = new Set(finalConfig.layers.map((layer) => layer.name));
820
+ const suggested = DEFAULT_INTENT_PREFIXES.filter((entry) => !detected.has(entry.layer));
821
+ if (suggested.length > 0) {
822
+ console.log('');
823
+ console.log('Suggested layers from the 11-layer profile (not detected — conventional');
824
+ console.log('directories shown; create one and re-run --init, or add the layer by hand):');
825
+ for (const entry of suggested) {
826
+ const dirs = (DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? [])
827
+ .map((directory) => `${srcDir}/${directory}`)
828
+ .join(', ');
829
+ console.log(` ${entry.layer}: ${dirs}`);
830
+ }
2425
831
  }
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' });
832
+ const proposals = proposeForUncovered(args.root, srcDir, finalConfig.layers);
833
+ if (proposals.length > 0) {
834
+ const recognized = proposals.filter((p) => !p.unrecognized);
835
+ const unrecognized = proposals.filter((p) => p.unrecognized);
836
+ console.log('');
837
+ console.log('Ungoverned directories Ark enforces NOTHING here until they are classified.');
838
+ console.log('A green check ignores this code; it is not "clean", it is unchecked.');
839
+ if (recognized.length > 0) {
840
+ console.log('');
841
+ console.log('Proposed layer for each (from the 11-layer profile + presets — apply via /ark-contract):');
842
+ for (const p of recognized) {
843
+ const alt = p.alternatives?.length ? ` (or ${p.alternatives.join(' / ')} — confirm)` : '';
844
+ console.log(` ${p.dir}/ → ${p.layer}${alt}`);
2446
845
  }
2447
846
  }
847
+ if (unrecognized.length > 0) {
848
+ console.log('');
849
+ console.log(`Not recognized — you decide the layer: ${unrecognized.map((p) => p.dir).join(', ')}`);
850
+ }
851
+ const fit = detectBestFitModel(
852
+ [
853
+ ...finalConfig.layers.flatMap((l) => (l.patterns ?? []).map((p) => dirSegmentsFromGlob(p).pop())),
854
+ ...proposals.map((p) => p.dir.split('/').pop()),
855
+ ].filter(Boolean)
856
+ );
857
+ if (fit) {
858
+ console.log('');
859
+ console.log(`Closest starter model: ${fit.name} — \`ark init --preset ${fit.name} --force\` to start from its rule set.`);
860
+ }
2448
861
  }
2449
862
  }
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);
863
+ maybeWarnBrownfield(args.root, finalConfig);
864
+ printInitNextSteps(args.root);
2502
865
  }
2503
866
 
867
+
2504
868
  function readManifest(root, manifestPath) {
2505
869
  if (!manifestPath) return undefined;
2506
870
  const fullPath = path.isAbsolute(manifestPath)
@@ -2561,6 +925,15 @@ function walk(dir, files = []) {
2561
925
  return files;
2562
926
  }
2563
927
 
928
+ /** Walk include roots then drop codegen / config.exclude (universal scan filter). */
929
+ function collectGovernedFiles(root, config) {
930
+ const raw = (config.include ?? []).flatMap((entry) => walk(path.join(root, entry)));
931
+ return raw.filter((abs) => {
932
+ const rel = normalize(path.relative(root, abs));
933
+ return !isScanExcludedRelative(rel, config);
934
+ });
935
+ }
936
+
2564
937
  function normalize(value) {
2565
938
  return value.split(path.sep).join('/');
2566
939
  }
@@ -2677,11 +1050,16 @@ function collectConfigWarnings(root, config, files, rules, manifest) {
2677
1050
  return re.test(rel);
2678
1051
  });
2679
1052
  if (!matched && !layer.optional) {
1053
+ // Advisory only under --strict-config: monorepo/Next presets ship many optional-looking
1054
+ // globs (e.g. src/layouts/**, app/**) that never match when include is ["frontend"].
1055
+ // Failing the release gate on dead preset globs caused false CI red while architecture
1056
+ // edges were clean (deer-flow host validation). Real safety is import violations +
1057
+ // CONFIG_UNCLASSIFIED_FILES / invalid patterns.
2680
1058
  warnings.push(
2681
1059
  configWarning(
2682
1060
  'CONFIG_LAYER_PATTERN_NO_MATCHES',
2683
1061
  `Layer "${layer.name}" pattern "${pattern}" matched no included files.`,
2684
- { layer: layer.name, pattern }
1062
+ { layer: layer.name, pattern, failsStrict: false }
2685
1063
  )
2686
1064
  );
2687
1065
  }
@@ -3142,1532 +1520,86 @@ function isArkPublishCandidate(ts, node) {
3142
1520
  function publishSourceLiteral(ts, node) {
3143
1521
  if (!ts.isCallExpression(node)) return undefined;
3144
1522
  const [firstArg, secondArg, thirdArg] = node.arguments;
3145
- const rawMetadata = objectPropertyValue(ts, firstArg, 'metadata');
3146
- return (
3147
- stringLiteralText(ts, objectPropertyValue(ts, rawMetadata, 'source')) ??
3148
- stringLiteralText(ts, objectPropertyValue(ts, secondArg, 'source')) ??
3149
- stringLiteralText(ts, objectPropertyValue(ts, thirdArg, 'source'))
3150
- );
3151
- }
3152
-
3153
- function publishHasSource(ts, node) {
3154
- if (!ts.isCallExpression(node)) return false;
3155
- const [firstArg, secondArg, thirdArg] = node.arguments;
3156
- return (
3157
- objectHasMetadataSource(ts, firstArg) ||
3158
- objectHasProperty(ts, secondArg, 'source') ||
3159
- objectHasProperty(ts, thirdArg, 'source')
3160
- );
3161
- }
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
- const useColor = process.stderr.isTTY && !process.env.NO_COLOR;
3194
- const color = {
3195
- red: (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
3196
- 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
- }
3328
-
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
- function detectCycles(graph) {
3334
- let index = 0;
3335
- const indices = new Map();
3336
- const low = new Map();
3337
- const onStack = new Set();
3338
- const stack = [];
3339
- const components = [];
3340
-
3341
- // ponytail: recursive Tarjan; make it iterative only if a real repo blows the stack.
3342
- const strongconnect = (v) => {
3343
- indices.set(v, index);
3344
- low.set(v, index);
3345
- index += 1;
3346
- stack.push(v);
3347
- onStack.add(v);
3348
- for (const w of [...(graph.get(v) ?? [])].sort()) {
3349
- if (!graph.has(w)) continue;
3350
- if (!indices.has(w)) {
3351
- strongconnect(w);
3352
- low.set(v, Math.min(low.get(v), low.get(w)));
3353
- } else if (onStack.has(w)) {
3354
- low.set(v, Math.min(low.get(v), indices.get(w)));
3355
- }
3356
- }
3357
- if (low.get(v) === indices.get(v)) {
3358
- const comp = [];
3359
- let w;
3360
- do {
3361
- w = stack.pop();
3362
- onStack.delete(w);
3363
- comp.push(w);
3364
- } while (w !== v);
3365
- if (comp.length > 1) components.push(comp.sort());
3366
- }
3367
- };
3368
-
3369
- for (const v of [...graph.keys()].sort()) {
3370
- if (!indices.has(v)) strongconnect(v);
3371
- }
3372
-
3373
- return components
3374
- .sort((a, b) => a[0].localeCompare(b[0]))
3375
- .map((members) => ({
3376
- ruleId: 'CIRCULAR_DEPENDENCY',
3377
- file: members[0],
3378
- line: 1,
3379
- target: members.join(' → '),
3380
- message: `Circular dependency among ${members.length} files: ${members.join(' → ')} → ${members[0]}.`,
3381
- }));
3382
- }
3383
-
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)
1523
+ const rawMetadata = objectPropertyValue(ts, firstArg, 'metadata');
1524
+ return (
1525
+ stringLiteralText(ts, objectPropertyValue(ts, rawMetadata, 'source')) ??
1526
+ stringLiteralText(ts, objectPropertyValue(ts, secondArg, 'source')) ??
1527
+ stringLiteralText(ts, objectPropertyValue(ts, thirdArg, 'source'))
3769
1528
  );
1529
+ }
3770
1530
 
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
1531
+ function publishHasSource(ts, node) {
1532
+ if (!ts.isCallExpression(node)) return false;
1533
+ const [firstArg, secondArg, thirdArg] = node.arguments;
1534
+ return (
1535
+ objectHasMetadataSource(ts, firstArg) ||
1536
+ objectHasProperty(ts, secondArg, 'source') ||
1537
+ objectHasProperty(ts, thirdArg, 'source')
3834
1538
  );
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
- }
1539
+ }
1540
+ const useColor = process.stderr.isTTY && !process.env.NO_COLOR;
1541
+ const color = {
1542
+ red: (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
1543
+ yellow: (s) => (useColor ? `\x1b[33m${s}\x1b[0m` : s),
1544
+ green: (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
1545
+ dim: (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
1546
+ bold: (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
1547
+ };
3862
1548
 
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
- }
1549
+ function detectCycles(graph) {
1550
+ let index = 0;
1551
+ const indices = new Map();
1552
+ const low = new Map();
1553
+ const onStack = new Set();
1554
+ const stack = [];
1555
+ const components = [];
3879
1556
 
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
- }
1557
+ // ponytail: recursive Tarjan; make it iterative only if a real repo blows the stack.
1558
+ const strongconnect = (v) => {
1559
+ indices.set(v, index);
1560
+ low.set(v, index);
1561
+ index += 1;
1562
+ stack.push(v);
1563
+ onStack.add(v);
1564
+ for (const w of [...(graph.get(v) ?? [])].sort()) {
1565
+ if (!graph.has(w)) continue;
1566
+ if (!indices.has(w)) {
1567
+ strongconnect(w);
1568
+ low.set(v, Math.min(low.get(v), low.get(w)));
1569
+ } else if (onStack.has(w)) {
1570
+ low.set(v, Math.min(low.get(v), indices.get(w)));
3894
1571
  }
3895
1572
  }
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>'
1573
+ if (low.get(v) === indices.get(v)) {
1574
+ const comp = [];
1575
+ let w;
1576
+ do {
1577
+ w = stack.pop();
1578
+ onStack.delete(w);
1579
+ comp.push(w);
1580
+ } while (w !== v);
1581
+ if (comp.length > 1) components.push(comp.sort());
4590
1582
  }
1583
+ };
4591
1584
 
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
- }
1585
+ for (const v of [...graph.keys()].sort()) {
1586
+ if (!indices.has(v)) strongconnect(v);
1587
+ }
4639
1588
 
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
- `;
1589
+ return components
1590
+ .sort((a, b) => a[0].localeCompare(b[0]))
1591
+ .map((members) => ({
1592
+ ruleId: 'CIRCULAR_DEPENDENCY',
1593
+ file: members[0],
1594
+ line: 1,
1595
+ target: members.join(' → '),
1596
+ message: `Circular dependency among ${members.length} files: ${members.join(' → ')} → ${members[0]}.`,
1597
+ // Graph is value/runtime edges only (type-only imports omitted).
1598
+ cycleKind: 'value',
1599
+ }));
4669
1600
  }
4670
1601
 
1602
+
4671
1603
  function moduleSpecifierFromCall(ts, node) {
4672
1604
  if (!ts.isCallExpression(node)) return undefined;
4673
1605
 
@@ -4692,463 +1624,13 @@ function moduleSpecifierFromCall(ts, node) {
4692
1624
  // Pure coverage computation (glob-only, no TypeScript): the object both `--coverage` and
4693
1625
  // `--doctor` render. `governed` is the headline honesty number — the share of in-scope code
4694
1626
  // 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
1627
 
5150
1628
  async function main() {
5151
1629
  const args = parseArgs(process.argv);
1630
+ if (args.version) {
1631
+ console.log(arkPackageVersion());
1632
+ process.exit(0);
1633
+ }
5152
1634
  if (args.help) {
5153
1635
  console.log(usage());
5154
1636
  return;
@@ -5181,6 +1663,16 @@ async function main() {
5181
1663
  return;
5182
1664
  }
5183
1665
 
1666
+ if (args.suggestInclude) {
1667
+ runSuggestInclude(args);
1668
+ return;
1669
+ }
1670
+
1671
+ if (args.adoptContract) {
1672
+ runAdoptContract(args);
1673
+ return;
1674
+ }
1675
+
5184
1676
  if (args.recommend) {
5185
1677
  try {
5186
1678
  const recommendation = buildArchitectureRecommendation(args.root);
@@ -5254,7 +1746,7 @@ async function main() {
5254
1746
  const config = readConfig(root, args.config);
5255
1747
  const manifest = readManifest(root, args.manifest);
5256
1748
  const rules = manifest?.architecture?.rules ?? config.rules;
5257
- const files = config.include.flatMap((entry) => walk(path.join(root, entry)));
1749
+ const files = collectGovernedFiles(root, config);
5258
1750
 
5259
1751
  // --coverage is a pure glob/report view (no TypeScript resolver), so serve it BEFORE the
5260
1752
  // TS import: the report must work — and exit 0 — even when typescript isn't installed.
@@ -5461,7 +1953,11 @@ async function main() {
5461
1953
  const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
5462
1954
  if (target && targetLayer) {
5463
1955
  const relTarget = normalize(path.relative(root, target));
5464
- if (relTarget !== relFile) importGraph.get(relFile).add(relTarget);
1956
+ // Cycle graph is runtime coupling only. Type-only imports are erased by TS and
1957
+ // must not form CIRCULAR_DEPENDENCY (e.g. codegen `import type` back-edges).
1958
+ if (relTarget !== relFile && !edge.typeOnly) {
1959
+ importGraph.get(relFile).add(relTarget);
1960
+ }
5465
1961
  }
5466
1962
  const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
5467
1963
  if (rule) {
@@ -5495,7 +1991,26 @@ async function main() {
5495
1991
 
5496
1992
  if (cacheKey) saveScanCache(root, cacheKey, nextCacheFiles);
5497
1993
 
5498
- violations.push(...detectCycles(importGraph));
1994
+ // cyclePolicy: strict (default) | soft (advisory only, never fails --strict-config) | off
1995
+ const cyclePolicy = String(config.cyclePolicy || 'strict').toLowerCase();
1996
+ if (cyclePolicy !== 'off') {
1997
+ const cycles = detectCycles(importGraph);
1998
+ if (cyclePolicy === 'soft' || cyclePolicy === 'framework-soft') {
1999
+ for (const c of cycles) {
2000
+ // failsStrict: false — soft cycles must NOT trip --strict-config / check:architecture.
2001
+ // Only CONFIG_* (and similar) warnings fail under --strict-config.
2002
+ warnings.push({
2003
+ ruleId: 'CIRCULAR_DEPENDENCY',
2004
+ message: `${c.message} (soft cycle policy — advisory only; set cyclePolicy: "strict" to fail the check)`,
2005
+ file: c.file,
2006
+ target: c.target,
2007
+ failsStrict: false,
2008
+ });
2009
+ }
2010
+ } else {
2011
+ violations.push(...cycles);
2012
+ }
2013
+ }
5499
2014
 
5500
2015
  if (args.doctor) {
5501
2016
  runDoctor(root, config, files, rules, violations, args.json, {
@@ -5520,6 +2035,24 @@ async function main() {
5520
2035
  process.exitCode = 2;
5521
2036
  return;
5522
2037
  }
2038
+ const baselineName = args.baseline || '.ark-baseline.json';
2039
+ const fullBaselinePath = path.isAbsolute(baselineName)
2040
+ ? baselineName
2041
+ : path.join(root, baselineName);
2042
+ // Zero debt: do not leave an empty baseline file (unclear policy — "is ratchet on?").
2043
+ // Delete any existing empty/orphan baseline so doctor/CI stay honest.
2044
+ if (violations.length === 0) {
2045
+ if (fs.existsSync(fullBaselinePath)) {
2046
+ fs.unlinkSync(fullBaselinePath);
2047
+ console.log(
2048
+ `No violations to freeze — removed empty baseline ${fullBaselinePath} (zero debt; no ratchet file needed).`
2049
+ );
2050
+ } else {
2051
+ console.log('No violations to freeze — baseline not written (zero debt).');
2052
+ }
2053
+ console.log('Gate with: ark-check --root . --config ark.config.json --strict-config');
2054
+ return;
2055
+ }
5523
2056
  const { fullPath, count } = writeBaseline(root, args.baseline, violations);
5524
2057
  console.log(`Wrote ${fullPath} with ${count} frozen violation key(s).`);
5525
2058
  console.log('Commit it and gate CI with: ark-check --baseline (only NEW violations fail).');
@@ -5549,7 +2082,10 @@ async function main() {
5549
2082
  }
5550
2083
  }
5551
2084
 
5552
- const ok = activeViolations.length === 0 && (!args.strictConfig || warnings.length === 0);
2085
+ // Soft/advisory warnings (failsStrict === false) never fail --strict-config.
2086
+ const strictWarnings = warnings.filter((w) => w.failsStrict !== false);
2087
+ const ok =
2088
+ activeViolations.length === 0 && (!args.strictConfig || strictWarnings.length === 0);
5553
2089
 
5554
2090
  if (args.plan) {
5555
2091
  const cov = computeCoverage(root, config, files, rules);
@@ -5627,11 +2163,10 @@ async function main() {
5627
2163
  noArchive: Boolean(args.noArchive),
5628
2164
  });
5629
2165
  if (!args.json) {
5630
- const rel = path.relative(root, reportPath) || reportPath;
5631
- console.log(`${color.green('✎')} Wrote HTML report: ${rel}`);
2166
+ console.log(`${color.green('✎')} Wrote HTML report: ${displayPathFromRoot(root, reportPath)}`);
5632
2167
  if (archive.createdOrigin) {
5633
2168
  console.log(
5634
- `${color.green('✎')} Origin snapshot saved (first report): ${path.relative(root, archive.originJson) || archive.originJson}`
2169
+ `${color.green('✎')} Origin snapshot saved (first report): ${displayPathFromRoot(root, archive.originJson)}`
5635
2170
  );
5636
2171
  console.log(
5637
2172
  color.dim(' Future reports will show evolution vs this starting point (.ark/reports/).')
@@ -5696,11 +2231,16 @@ async function main() {
5696
2231
  );
5697
2232
  }
5698
2233
  if (activeViolations.length === 0) {
2234
+ const advisoryOnly = warnings.length > 0 && strictWarnings.length === 0;
5699
2235
  if (warnings.length === 0) {
5700
2236
  console.log(`${color.green('✔')} Ark check passed.${baselineNote}`);
5701
- } else if (args.strictConfig) {
2237
+ } else if (args.strictConfig && strictWarnings.length > 0) {
5702
2238
  console.error(
5703
- `${color.red('✖')} Ark check failed with ${warnings.length} config warning(s).${baselineNote}`
2239
+ `${color.red('✖')} Ark check failed with ${strictWarnings.length} config warning(s).${baselineNote}`
2240
+ );
2241
+ } else if (advisoryOnly) {
2242
+ console.log(
2243
+ `${color.green('✔')} Ark check passed with ${warnings.length} advisory warning(s).${baselineNote}`
5704
2244
  );
5705
2245
  } else {
5706
2246
  console.log(