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