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