arkgate 2.6.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +97 -0
- package/README.md +98 -70
- package/bin/ark-check.mjs +240 -1001
- package/bin/ark-layer-match.mjs +153 -147
- package/bin/ark-mcp.mjs +102 -5
- package/bin/ark-shared.mjs +304 -165
- package/bin/ark.mjs +44 -34
- package/bin/lib/agent-gates.mjs +448 -15
- package/bin/lib/architecture-scan.mjs +279 -0
- package/bin/lib/ast-scan.mjs +199 -0
- package/bin/lib/baseline-key.mjs +23 -0
- package/bin/lib/config-warnings.mjs +228 -0
- package/bin/lib/doctor-plan.mjs +11 -4
- package/bin/lib/graph-cycles.mjs +56 -0
- package/bin/lib/presets.mjs +75 -4
- package/bin/lib/remediation.mjs +150 -0
- package/bin/lib/scan-files.mjs +69 -0
- package/bin/lib/ts-resolve.mjs +215 -0
- package/bin/lib/violations.mjs +3 -9
- package/dist/eslint/index.cjs +21 -3
- package/dist/eslint/index.cjs.map +1 -1
- package/dist/eslint/index.d.cts +5 -3
- package/dist/eslint/index.d.ts +5 -3
- package/dist/eslint/index.js +21 -3
- 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 +3 -3
- package/dist/index.d.ts +3 -3
- 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.d.cts +1 -1
- package/dist/nestjs/index.d.ts +1 -1
- package/dist/nestjs/index.js +1 -1
- package/dist/nestjs/index.js.map +1 -1
- package/dist/runtime/index.cjs +3080 -0
- package/dist/runtime/index.cjs.map +1 -0
- package/dist/runtime/index.d.cts +2 -0
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +2998 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/{types-DpdVN7Lm.d.cts → types-CP3KkwZt.d.cts} +1 -1
- package/dist/{types-DpdVN7Lm.d.ts → types-CP3KkwZt.d.ts} +1 -1
- package/docs/agent-guide.md +67 -1
- package/docs/migrate-from-ark-runtime-kernel.md +4 -2
- package/docs/package-surface.md +72 -0
- package/docs/production-hardening.md +3 -0
- package/package.json +11 -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
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
3
|
import fs from 'node:fs';
|
|
5
|
-
import os from 'node:os';
|
|
6
4
|
import path from 'node:path';
|
|
7
5
|
import { fileURLToPath } from 'node:url';
|
|
8
6
|
|
|
@@ -15,7 +13,6 @@ import {
|
|
|
15
13
|
DEFAULT_RULES,
|
|
16
14
|
applyFrameworkLayoutOverlays,
|
|
17
15
|
arkCommand,
|
|
18
|
-
collectForbiddenGlobalUses,
|
|
19
16
|
ADOPTION_PLAN_FILENAME,
|
|
20
17
|
buildArchitectureRecommendation,
|
|
21
18
|
createElevenLayerConfig,
|
|
@@ -23,43 +20,27 @@ import {
|
|
|
23
20
|
listPolicyPackIds,
|
|
24
21
|
loadPolicyPackMeta,
|
|
25
22
|
writeAdoptionPlan,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
execRunner,
|
|
23
|
+
detectWorkspaces,
|
|
24
|
+
detectTsPackageRoots,
|
|
25
|
+
resolveIncludeRoots,
|
|
30
26
|
formatArchitectureRecommendationHuman,
|
|
31
|
-
globToRegExp,
|
|
32
27
|
installDevHint,
|
|
33
|
-
presentLockfiles,
|
|
34
28
|
layerForFile,
|
|
35
|
-
looksLikeIntent,
|
|
36
|
-
patternSpecificity,
|
|
37
|
-
resolveIntentLayer,
|
|
38
|
-
resolveOperatingMode,
|
|
39
|
-
shouldShowNewHereNudge,
|
|
40
|
-
usableTypescript,
|
|
41
|
-
typescriptUsabilityHint,
|
|
42
29
|
} from './ark-shared.mjs';
|
|
43
30
|
|
|
44
31
|
import {
|
|
45
32
|
runInstallAgentGates,
|
|
46
|
-
runMigrateCommands,
|
|
47
33
|
loadTypeScript,
|
|
48
|
-
collectAdoptionGaps,
|
|
49
34
|
detectSkillGaps,
|
|
50
35
|
detectCodexHomeGap,
|
|
51
36
|
missingGates,
|
|
52
37
|
staleRunnerGateFiles,
|
|
53
38
|
brokenMcpGateFiles,
|
|
54
39
|
readJson,
|
|
55
|
-
readPackageJson,
|
|
56
40
|
hasCheckArchitectureScript,
|
|
57
|
-
hasArkWorkflow,
|
|
58
41
|
checkArchitectureScriptSnippet,
|
|
59
42
|
arkCheckCommand,
|
|
60
43
|
arkPackageVersion,
|
|
61
|
-
agentInstructions,
|
|
62
|
-
packageManager,
|
|
63
44
|
REQUIRED_GATE_FILES,
|
|
64
45
|
codexPromptsDir,
|
|
65
46
|
} from './lib/agent-gates.mjs';
|
|
@@ -78,33 +59,35 @@ import {
|
|
|
78
59
|
runCoverage,
|
|
79
60
|
runPlan,
|
|
80
61
|
runDoctor,
|
|
81
|
-
buildRemediationPlan,
|
|
82
62
|
} from './lib/doctor-plan.mjs';
|
|
83
63
|
import {
|
|
84
64
|
baselineKey,
|
|
85
65
|
readBaseline,
|
|
86
66
|
summarizeViolations,
|
|
87
|
-
violationEdge,
|
|
88
67
|
writeBaseline,
|
|
89
68
|
printViolation,
|
|
90
69
|
printViolationBreakdown,
|
|
91
70
|
CONCENTRATION_MIN_VIOLATIONS,
|
|
92
71
|
} from './lib/violations.mjs';
|
|
93
72
|
import {
|
|
94
|
-
buildUnclassifiedSuggestions,
|
|
95
73
|
suggestLayerForDir,
|
|
96
|
-
suggestLayerForPath,
|
|
97
74
|
detectBestFitModel,
|
|
98
75
|
dirSegmentsFromGlob,
|
|
99
76
|
} from './lib/suggestions.mjs';
|
|
100
77
|
import {
|
|
101
78
|
ARCHITECTURE_PRESETS,
|
|
102
|
-
CANONICAL_LAYER_NAMES,
|
|
103
|
-
denyUpward,
|
|
104
|
-
presetWithOverlays,
|
|
105
|
-
FRAMEWORK_INTERNAL_EXCLUDE,
|
|
106
79
|
} from './lib/presets.mjs';
|
|
107
80
|
|
|
81
|
+
import {
|
|
82
|
+
collectGovernedFiles,
|
|
83
|
+
normalize,
|
|
84
|
+
walk,
|
|
85
|
+
} from './lib/scan-files.mjs';
|
|
86
|
+
import {
|
|
87
|
+
configWarning,
|
|
88
|
+
} from './lib/config-warnings.mjs';
|
|
89
|
+
import { runArchitectureScan } from './lib/architecture-scan.mjs';
|
|
90
|
+
|
|
108
91
|
|
|
109
92
|
function parseArgs(argv) {
|
|
110
93
|
const args = {
|
|
@@ -168,6 +151,9 @@ function parseArgs(argv) {
|
|
|
168
151
|
else if (arg === '--write-plan') args.writePlan = true;
|
|
169
152
|
else if (arg === '--list-policy-packs') args.listPolicyPacks = true;
|
|
170
153
|
else if (arg === '--apply-policy-pack') args.applyPolicyPack = argv[++i];
|
|
154
|
+
else if (arg === '--suggest-include') args.suggestInclude = true;
|
|
155
|
+
else if (arg === '--adopt-contract') args.adoptContract = true;
|
|
156
|
+
else if (arg === '--write') args.write = true;
|
|
171
157
|
else if (arg === '--watch') args.watch = true;
|
|
172
158
|
else if (arg === '--beginner') args.beginner = true;
|
|
173
159
|
else if (arg === '--codex-home') args.codexHome = true;
|
|
@@ -213,8 +199,10 @@ function usage() {
|
|
|
213
199
|
' ark-check --coverage [--json] per-layer file counts + full unclassified list (report only, exit 0)',
|
|
214
200
|
' ark-check --plan [--json] classified remediation plan (mechanical-safe / judgment / deferred) + goal; report only',
|
|
215
201
|
' 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)',
|
|
202
|
+
' ark-check --list-policy-packs enthusiast preset configs (hexagonal, layered, feature-sliced, monorepo, ui-surface)',
|
|
217
203
|
' ark-check --apply-policy-pack <id> [--force] write ark.config.json from templates/policy-packs/ (uses preset factory)',
|
|
204
|
+
' ark-check --suggest-include [--json] propose include roots (TS packages / workspaces)',
|
|
205
|
+
' ark-check --adopt-contract [--write] expand include + UI patterns from ungoverned dirs (contract adopt)',
|
|
218
206
|
' ark-check --watch re-run the check when governed files change (debounced)',
|
|
219
207
|
' ark-check --report [file.html] [--beginner] [--reset-origin] [--no-archive]',
|
|
220
208
|
' HTML report + snapshots under .ark/reports/ (origin once, latest each run, history JSON)',
|
|
@@ -249,6 +237,7 @@ function usage() {
|
|
|
249
237
|
'Config shape:',
|
|
250
238
|
'{',
|
|
251
239
|
' "include": ["src"],',
|
|
240
|
+
' // optional: "exclude": ["**/vendor/**"], "excludeGenerated": false (default skips *.gen.ts / *.generated.ts)',
|
|
252
241
|
' "layers": [',
|
|
253
242
|
' { "name": "DomainModel", "patterns": ["src/domain/**"], "intentPrefixes": ["Domain."],',
|
|
254
243
|
' "forbiddenGlobals": ["fetch", "process", "Date.now", "Math.random"] }',
|
|
@@ -289,7 +278,6 @@ function usage() {
|
|
|
289
278
|
].join('\n');
|
|
290
279
|
}
|
|
291
280
|
|
|
292
|
-
|
|
293
281
|
function readConfig(root, configPath) {
|
|
294
282
|
const fullPath = path.isAbsolute(configPath)
|
|
295
283
|
? configPath
|
|
@@ -306,6 +294,9 @@ function readConfig(root, configPath) {
|
|
|
306
294
|
include: raw.include ?? ['src'],
|
|
307
295
|
layers: raw.layers ?? [],
|
|
308
296
|
rules: raw.rules ?? DEFAULT_RULES,
|
|
297
|
+
...(raw.exclude ? { exclude: raw.exclude } : {}),
|
|
298
|
+
...(raw.excludeGenerated !== undefined ? { excludeGenerated: raw.excludeGenerated } : {}),
|
|
299
|
+
...(raw.cyclePolicy ? { cyclePolicy: raw.cyclePolicy } : {}),
|
|
309
300
|
};
|
|
310
301
|
}
|
|
311
302
|
|
|
@@ -362,39 +353,8 @@ function uncoveredDirectories(root, srcDir, layers) {
|
|
|
362
353
|
});
|
|
363
354
|
}
|
|
364
355
|
|
|
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
|
-
}
|
|
356
|
+
// detectWorkspaces: shared implementation in ark-shared.mjs (npm/pnpm/rush/lerna +
|
|
357
|
+
// conventional multi-package roots).
|
|
398
358
|
|
|
399
359
|
// Deny every "upward" edge for an ordered layer list (index 0 = outermost/top,
|
|
400
360
|
// which may import everything below it). Inner/lower layers must not import outer
|
|
@@ -455,7 +415,10 @@ function buildConfigFromPolicyPack(packId, root) {
|
|
|
455
415
|
`Policy pack "${packId}" references unknown preset "${pack.preset}".`
|
|
456
416
|
);
|
|
457
417
|
}
|
|
458
|
-
const workspaces =
|
|
418
|
+
const workspaces =
|
|
419
|
+
pack.preset === 'monorepo' || pack.preset === 'ui-surface'
|
|
420
|
+
? resolveIncludeRoots(root)
|
|
421
|
+
: [];
|
|
459
422
|
const config = factory(workspaces, root);
|
|
460
423
|
if (pack.layerDescriptions) {
|
|
461
424
|
for (const layer of config.layers) {
|
|
@@ -553,7 +516,7 @@ const THIN_COVERAGE_PERCENT = 50;
|
|
|
553
516
|
function maybeWarnBrownfield(root, config) {
|
|
554
517
|
let files;
|
|
555
518
|
try {
|
|
556
|
-
files = (
|
|
519
|
+
files = collectGovernedFiles(root, config);
|
|
557
520
|
} catch {
|
|
558
521
|
return false;
|
|
559
522
|
}
|
|
@@ -577,6 +540,144 @@ function maybeWarnBrownfield(root, config) {
|
|
|
577
540
|
return true;
|
|
578
541
|
}
|
|
579
542
|
|
|
543
|
+
/** Propose include roots (workspaces + nested TS packages) — contract-adopt primitive. */
|
|
544
|
+
function runSuggestInclude(args) {
|
|
545
|
+
const root = args.root;
|
|
546
|
+
const workspaces = detectWorkspaces(root);
|
|
547
|
+
const tsPackages = detectTsPackageRoots(root);
|
|
548
|
+
const include = resolveIncludeRoots(root);
|
|
549
|
+
const payload = {
|
|
550
|
+
ok: true,
|
|
551
|
+
workspaces,
|
|
552
|
+
tsPackages,
|
|
553
|
+
suggestedInclude: include.length > 0 ? include : tsPackages.length > 0 ? tsPackages : ['src'],
|
|
554
|
+
note:
|
|
555
|
+
include.length === 0 && tsPackages.length === 0
|
|
556
|
+
? 'No TS packages or workspaces found — default suggestion is src/ (create it or pass include by hand).'
|
|
557
|
+
: 'Use these paths as ark.config.json "include". Prefer --adopt-contract --write to expand patterns too.',
|
|
558
|
+
};
|
|
559
|
+
if (args.json) {
|
|
560
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
console.log(color.bold('Suggested include roots'));
|
|
564
|
+
console.log(` workspaces: ${workspaces.join(', ') || '(none)'}`);
|
|
565
|
+
console.log(` tsPackages: ${tsPackages.join(', ') || '(none)'}`);
|
|
566
|
+
console.log(` suggestedInclude: ${payload.suggestedInclude.join(', ')}`);
|
|
567
|
+
console.log(color.dim(payload.note));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Contract-adopt: expand include + presentation patterns from ungoverned proposals.
|
|
572
|
+
* Read-only unless --write. Does not weaken rules or baseline violations.
|
|
573
|
+
*/
|
|
574
|
+
function runAdoptContract(args) {
|
|
575
|
+
const root = args.root;
|
|
576
|
+
const configPath = path.isAbsolute(args.config)
|
|
577
|
+
? args.config
|
|
578
|
+
: path.join(root, args.config);
|
|
579
|
+
let config;
|
|
580
|
+
try {
|
|
581
|
+
config = fs.existsSync(configPath)
|
|
582
|
+
? readConfig(root, args.config)
|
|
583
|
+
: {
|
|
584
|
+
include: ['src'],
|
|
585
|
+
layers: ARCHITECTURE_PRESETS['ui-surface']([], root).layers,
|
|
586
|
+
rules: ARCHITECTURE_PRESETS['ui-surface']([], root).rules,
|
|
587
|
+
};
|
|
588
|
+
} catch (error) {
|
|
589
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
590
|
+
process.exitCode = 2;
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const suggestedInclude = resolveIncludeRoots(root);
|
|
594
|
+
const tsPackages = detectTsPackageRoots(root);
|
|
595
|
+
const nextInclude = [
|
|
596
|
+
...new Set([
|
|
597
|
+
...(config.include || []),
|
|
598
|
+
...(suggestedInclude.length > 0 ? suggestedInclude : tsPackages),
|
|
599
|
+
]),
|
|
600
|
+
].filter(Boolean);
|
|
601
|
+
const files = collectGovernedFiles(root, { ...config, include: nextInclude.length ? nextInclude : config.include });
|
|
602
|
+
const cov = computeCoverage(root, { ...config, include: nextInclude.length ? nextInclude : config.include }, files, config.rules || []);
|
|
603
|
+
const uiPatterns = [
|
|
604
|
+
'**/components/**',
|
|
605
|
+
'**/hooks/**',
|
|
606
|
+
'**/lib/**',
|
|
607
|
+
'**/routes/**',
|
|
608
|
+
'**/app/**',
|
|
609
|
+
'**/pages/**',
|
|
610
|
+
];
|
|
611
|
+
const layers = (config.layers || []).map((layer) => {
|
|
612
|
+
if (layer.name !== 'PresentationAdapters') return layer;
|
|
613
|
+
const patterns = [...new Set([...(layer.patterns || []), ...uiPatterns])];
|
|
614
|
+
return { ...layer, patterns };
|
|
615
|
+
});
|
|
616
|
+
// If no PresentationAdapters layer, leave layers as-is (don't invent full profile).
|
|
617
|
+
const proposal = {
|
|
618
|
+
ok: true,
|
|
619
|
+
before: {
|
|
620
|
+
include: config.include || [],
|
|
621
|
+
governedPercent: null,
|
|
622
|
+
totalFiles: null,
|
|
623
|
+
},
|
|
624
|
+
after: {
|
|
625
|
+
include: nextInclude.length > 0 ? nextInclude : config.include,
|
|
626
|
+
presentationPatterns: uiPatterns,
|
|
627
|
+
totalFiles: cov.totalFiles,
|
|
628
|
+
governedPercent: cov.governed.percent,
|
|
629
|
+
unclassified: cov.unclassified.count,
|
|
630
|
+
},
|
|
631
|
+
wrote: false,
|
|
632
|
+
};
|
|
633
|
+
// Compute before coverage for honesty.
|
|
634
|
+
try {
|
|
635
|
+
const beforeFiles = collectGovernedFiles(root, config);
|
|
636
|
+
const beforeCov = computeCoverage(root, config, beforeFiles, config.rules || []);
|
|
637
|
+
proposal.before.totalFiles = beforeCov.totalFiles;
|
|
638
|
+
proposal.before.governedPercent = beforeCov.governed.percent;
|
|
639
|
+
} catch {
|
|
640
|
+
/* ignore */
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (args.write) {
|
|
644
|
+
const next = {
|
|
645
|
+
...config,
|
|
646
|
+
include: proposal.after.include,
|
|
647
|
+
layers,
|
|
648
|
+
};
|
|
649
|
+
fs.writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
650
|
+
proposal.wrote = true;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (args.json) {
|
|
654
|
+
console.log(JSON.stringify(proposal, null, 2));
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
console.log(color.bold('Contract adopt (coverage first)'));
|
|
658
|
+
console.log(
|
|
659
|
+
` before: include=[${(proposal.before.include || []).join(', ')}] governed=${proposal.before.governedPercent ?? '?'}% files=${proposal.before.totalFiles ?? '?'}`
|
|
660
|
+
);
|
|
661
|
+
console.log(
|
|
662
|
+
` after: include=[${(proposal.after.include || []).join(', ')}] governed=${proposal.after.governedPercent}% files=${proposal.after.totalFiles} unclassified=${proposal.after.unclassified}`
|
|
663
|
+
);
|
|
664
|
+
console.log(` presentation patterns += ${uiPatterns.join(', ')}`);
|
|
665
|
+
if (proposal.wrote) {
|
|
666
|
+
console.log(color.green(` wrote ${path.relative(root, configPath) || args.config}`));
|
|
667
|
+
console.log(color.dim(` Next: ${arkCommand(root, 'ark-check', '--coverage')} then --plan`));
|
|
668
|
+
} else {
|
|
669
|
+
console.log(color.dim(' Dry-run only. Re-run with --write to apply (does not weaken rules).'));
|
|
670
|
+
}
|
|
671
|
+
if ((proposal.after.totalFiles ?? 0) === 0) {
|
|
672
|
+
console.log(
|
|
673
|
+
color.yellow(
|
|
674
|
+
' Empty scope remains — no TS packages found. Point include at your package roots manually.'
|
|
675
|
+
)
|
|
676
|
+
);
|
|
677
|
+
process.exitCode = 1;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
580
681
|
function runInit(args) {
|
|
581
682
|
const configPath = path.isAbsolute(args.config)
|
|
582
683
|
? args.config
|
|
@@ -597,7 +698,12 @@ function runInit(args) {
|
|
|
597
698
|
process.exitCode = 2;
|
|
598
699
|
return;
|
|
599
700
|
}
|
|
600
|
-
const finalConfig = factory(
|
|
701
|
+
const finalConfig = factory(
|
|
702
|
+
args.preset === 'monorepo' || args.preset === 'ui-surface'
|
|
703
|
+
? resolveIncludeRoots(args.root)
|
|
704
|
+
: detectWorkspaces(args.root),
|
|
705
|
+
args.root
|
|
706
|
+
);
|
|
601
707
|
fs.writeFileSync(configPath, `${JSON.stringify(finalConfig, null, 2)}\n`);
|
|
602
708
|
console.log(`Wrote ${configPath} (${args.preset} preset)`);
|
|
603
709
|
if (finalConfig.frameworkOverlay) {
|
|
@@ -625,8 +731,14 @@ function runInit(args) {
|
|
|
625
731
|
// When no conventional src/ layout is found, a `workspaces` declaration means this is a
|
|
626
732
|
// monorepo — the src/** 11-layer starter would match nothing there, so use the
|
|
627
733
|
// cross-package monorepo profile anchored at the real workspace roots instead.
|
|
628
|
-
const
|
|
629
|
-
const
|
|
734
|
+
const includeRoots = greenfield ? resolveIncludeRoots(args.root) : [];
|
|
735
|
+
const tsPackages = greenfield ? detectTsPackageRoots(args.root) : [];
|
|
736
|
+
// Prefer monorepo/ui when nested TS packages exist without a conventional src layout.
|
|
737
|
+
const mode = !greenfield
|
|
738
|
+
? 'detected'
|
|
739
|
+
: includeRoots.length > 0 || tsPackages.length > 0
|
|
740
|
+
? 'monorepo'
|
|
741
|
+
: 'greenfield';
|
|
630
742
|
// Greenfield: anchor the starter profile at src/ (the convention a fresh project will
|
|
631
743
|
// scaffold under) even when src/ doesn't exist yet — the layers are optional, so the
|
|
632
744
|
// check passes today and governance switches on the moment src/domain/ etc. appear.
|
|
@@ -635,7 +747,10 @@ function runInit(args) {
|
|
|
635
747
|
mode === 'detected'
|
|
636
748
|
? applyFrameworkLayoutOverlays(config, args.root)
|
|
637
749
|
: mode === 'monorepo'
|
|
638
|
-
? ARCHITECTURE_PRESETS.monorepo(
|
|
750
|
+
? ARCHITECTURE_PRESETS.monorepo(
|
|
751
|
+
includeRoots.length > 0 ? includeRoots : tsPackages,
|
|
752
|
+
args.root
|
|
753
|
+
)
|
|
639
754
|
: createElevenLayerConfig({
|
|
640
755
|
rootDir: srcDir === '.' ? 'src' : srcDir,
|
|
641
756
|
root: args.root,
|
|
@@ -646,10 +761,11 @@ function runInit(args) {
|
|
|
646
761
|
console.log(`Wrote ${configPath}`);
|
|
647
762
|
console.log('');
|
|
648
763
|
if (mode === 'monorepo') {
|
|
649
|
-
|
|
650
|
-
console.log(
|
|
651
|
-
console.log('package
|
|
652
|
-
console.log('
|
|
764
|
+
const roots = finalConfig.include?.join(', ') || '(none)';
|
|
765
|
+
console.log(`Multi-package / TS package surface detected (include: ${roots}). Generated a`);
|
|
766
|
+
console.log('cross-package profile matching domain/application/presentation/persistence dirs');
|
|
767
|
+
console.log('in any package. Every layer is optional, so the strict check passes now and each');
|
|
768
|
+
console.log('switches on as matching directories gain files. Adjust patterns to your naming:');
|
|
653
769
|
for (const layer of finalConfig.layers) {
|
|
654
770
|
console.log(` ${layer.name}: ${layer.patterns.join(', ')}`);
|
|
655
771
|
}
|
|
@@ -726,7 +842,6 @@ function runInit(args) {
|
|
|
726
842
|
printInitNextSteps(args.root);
|
|
727
843
|
}
|
|
728
844
|
|
|
729
|
-
|
|
730
845
|
function readManifest(root, manifestPath) {
|
|
731
846
|
if (!manifestPath) return undefined;
|
|
732
847
|
const fullPath = path.isAbsolute(manifestPath)
|
|
@@ -738,653 +853,6 @@ function readManifest(root, manifestPath) {
|
|
|
738
853
|
return readJson(fullPath);
|
|
739
854
|
}
|
|
740
855
|
|
|
741
|
-
const SOURCE_FILE_NAME = /\.[cm]?[tj]sx?$/;
|
|
742
|
-
|
|
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;
|
|
748
|
-
|
|
749
|
-
function isGovernableSourceFile(name) {
|
|
750
|
-
return SOURCE_FILE_NAME.test(name) && !name.endsWith('.d.ts') && !TEST_FILE_NAME.test(name);
|
|
751
|
-
}
|
|
752
|
-
|
|
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
|
-
);
|
|
765
|
-
}
|
|
766
|
-
|
|
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;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
function normalize(value) {
|
|
791
|
-
return value.split(path.sep).join('/');
|
|
792
|
-
}
|
|
793
|
-
|
|
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 }));
|
|
800
|
-
}
|
|
801
|
-
|
|
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);
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
function isBlocked(rules, from, to) {
|
|
819
|
-
return rules.find((rule) => !rule.allowed && rule.from === from && rule.to === to);
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
function configWarning(ruleId, message, extra = {}) {
|
|
823
|
-
return { ruleId, message, ...extra };
|
|
824
|
-
}
|
|
825
|
-
|
|
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
|
-
);
|
|
844
|
-
}
|
|
845
|
-
|
|
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);
|
|
857
|
-
|
|
858
|
-
if (
|
|
859
|
-
layer.forbiddenGlobals !== undefined &&
|
|
860
|
-
(!Array.isArray(layer.forbiddenGlobals) ||
|
|
861
|
-
layer.forbiddenGlobals.some((entry) => typeof entry !== 'string'))
|
|
862
|
-
) {
|
|
863
|
-
warnings.push(
|
|
864
|
-
configWarning(
|
|
865
|
-
'CONFIG_INVALID_FORBIDDEN_GLOBALS',
|
|
866
|
-
`Layer "${layer.name}" has an invalid forbiddenGlobals value; expected an array of strings (e.g. ["fetch", "Date.now"]). The entry is ignored.`,
|
|
867
|
-
{ layer: layer.name }
|
|
868
|
-
)
|
|
869
|
-
);
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
const patterns = Array.isArray(layer.patterns) ? layer.patterns : [];
|
|
873
|
-
if (patterns.length === 0) {
|
|
874
|
-
warnings.push(
|
|
875
|
-
configWarning(
|
|
876
|
-
'CONFIG_LAYER_WITHOUT_PATTERNS',
|
|
877
|
-
`Layer "${layer.name}" has no file patterns and will never classify files.`,
|
|
878
|
-
{ layer: layer.name }
|
|
879
|
-
)
|
|
880
|
-
);
|
|
881
|
-
continue;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
for (const pattern of patterns) {
|
|
885
|
-
let re;
|
|
886
|
-
try {
|
|
887
|
-
re = globToRegExp(pattern);
|
|
888
|
-
} catch (err) {
|
|
889
|
-
warnings.push(
|
|
890
|
-
configWarning(
|
|
891
|
-
'CONFIG_INVALID_LAYER_PATTERN',
|
|
892
|
-
`Layer "${layer.name}" has an invalid pattern "${pattern}": ${
|
|
893
|
-
err instanceof Error ? err.message : String(err)
|
|
894
|
-
}`,
|
|
895
|
-
{ layer: layer.name, pattern }
|
|
896
|
-
)
|
|
897
|
-
);
|
|
898
|
-
continue;
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
const matched = files.some((file) => {
|
|
902
|
-
const rel = normalize(path.relative(root, file));
|
|
903
|
-
return re.test(rel);
|
|
904
|
-
});
|
|
905
|
-
if (!matched && !layer.optional) {
|
|
906
|
-
warnings.push(
|
|
907
|
-
configWarning(
|
|
908
|
-
'CONFIG_LAYER_PATTERN_NO_MATCHES',
|
|
909
|
-
`Layer "${layer.name}" pattern "${pattern}" matched no included files.`,
|
|
910
|
-
{ layer: layer.name, pattern }
|
|
911
|
-
)
|
|
912
|
-
);
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
for (const name of duplicateLayers) {
|
|
918
|
-
warnings.push(
|
|
919
|
-
configWarning(
|
|
920
|
-
'CONFIG_DUPLICATE_LAYER',
|
|
921
|
-
`Layer "${name}" is configured more than once.`,
|
|
922
|
-
{ layer: name }
|
|
923
|
-
)
|
|
924
|
-
);
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
if (knownLayers.size > 0) {
|
|
928
|
-
for (const rule of rules ?? []) {
|
|
929
|
-
if (rule.from && !knownLayers.has(rule.from)) {
|
|
930
|
-
warnings.push(
|
|
931
|
-
configWarning(
|
|
932
|
-
'CONFIG_RULE_UNKNOWN_FROM_LAYER',
|
|
933
|
-
`Rule references unknown source layer "${rule.from}".`,
|
|
934
|
-
{ fromLayer: rule.from, toLayer: rule.to }
|
|
935
|
-
)
|
|
936
|
-
);
|
|
937
|
-
}
|
|
938
|
-
if (rule.to && !knownLayers.has(rule.to)) {
|
|
939
|
-
warnings.push(
|
|
940
|
-
configWarning(
|
|
941
|
-
'CONFIG_RULE_UNKNOWN_TO_LAYER',
|
|
942
|
-
`Rule references unknown target layer "${rule.to}".`,
|
|
943
|
-
{ fromLayer: rule.from, toLayer: rule.to }
|
|
944
|
-
)
|
|
945
|
-
);
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
// Ambiguous overlap: a file matched by two different layers at the SAME top specificity.
|
|
951
|
-
// layerForFile breaks the tie by declaration order, but the config is genuinely undecided
|
|
952
|
-
// (unlike a facade split, where the surface pattern is strictly more specific and wins
|
|
953
|
-
// cleanly). Surface the layer pairs so the author disambiguates instead of relying on order.
|
|
954
|
-
const ambiguousPairs = new Set();
|
|
955
|
-
if (layers.length > 1) {
|
|
956
|
-
for (const file of files) {
|
|
957
|
-
const rel = normalize(path.relative(root, file));
|
|
958
|
-
let topScore = -1;
|
|
959
|
-
let topLayers = [];
|
|
960
|
-
for (const layer of layers) {
|
|
961
|
-
for (const pattern of layer.patterns ?? []) {
|
|
962
|
-
if (!globToRegExp(pattern).test(rel)) continue;
|
|
963
|
-
const score = patternSpecificity(pattern);
|
|
964
|
-
if (score > topScore) {
|
|
965
|
-
topScore = score;
|
|
966
|
-
topLayers = [layer.name];
|
|
967
|
-
} else if (score === topScore && !topLayers.includes(layer.name)) {
|
|
968
|
-
topLayers.push(layer.name);
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
if (topLayers.length > 1) {
|
|
973
|
-
ambiguousPairs.add([...topLayers].sort().join(' + '));
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
if (ambiguousPairs.size > 0) {
|
|
978
|
-
warnings.push(
|
|
979
|
-
configWarning(
|
|
980
|
-
'CONFIG_AMBIGUOUS_LAYERS',
|
|
981
|
-
`Some files match multiple layers at equal specificity; classification falls back to declaration order. Disambiguate the overlapping patterns: ${[...ambiguousPairs].join(', ')}.`,
|
|
982
|
-
{ pairs: [...ambiguousPairs] }
|
|
983
|
-
)
|
|
984
|
-
);
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
const unclassified = files.filter((file) => !layerForFile(root, file, layers));
|
|
988
|
-
if (unclassified.length > 0) {
|
|
989
|
-
warnings.push(
|
|
990
|
-
configWarning(
|
|
991
|
-
'CONFIG_UNCLASSIFIED_FILES',
|
|
992
|
-
`${unclassified.length} included source file(s) are not matched by any configured layer; ark-check will not enforce import rules for those source files.`,
|
|
993
|
-
{
|
|
994
|
-
count: unclassified.length,
|
|
995
|
-
samples: unclassified.slice(0, 5).map((file) => normalize(path.relative(root, file))),
|
|
996
|
-
}
|
|
997
|
-
)
|
|
998
|
-
);
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
return warnings;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
function createModuleResolutionHost(ts) {
|
|
1005
|
-
const sys = ts?.sys;
|
|
1006
|
-
const fileExists = (f) => {
|
|
1007
|
-
if (sys?.fileExists) return sys.fileExists(f);
|
|
1008
|
-
return fs.existsSync(f);
|
|
1009
|
-
};
|
|
1010
|
-
const readFile = (f) => {
|
|
1011
|
-
if (sys?.readFile) return sys.readFile(f);
|
|
1012
|
-
try {
|
|
1013
|
-
return fs.readFileSync(f, 'utf8');
|
|
1014
|
-
} catch {
|
|
1015
|
-
return undefined;
|
|
1016
|
-
}
|
|
1017
|
-
};
|
|
1018
|
-
const directoryExists = (d) => {
|
|
1019
|
-
if (sys?.directoryExists) return sys.directoryExists(d);
|
|
1020
|
-
try {
|
|
1021
|
-
return fs.statSync(d).isDirectory();
|
|
1022
|
-
} catch {
|
|
1023
|
-
return false;
|
|
1024
|
-
}
|
|
1025
|
-
};
|
|
1026
|
-
return {
|
|
1027
|
-
fileExists,
|
|
1028
|
-
readFile,
|
|
1029
|
-
directoryExists,
|
|
1030
|
-
getCurrentDirectory: () =>
|
|
1031
|
-
sys?.getCurrentDirectory ? sys.getCurrentDirectory() : process.cwd(),
|
|
1032
|
-
getDirectories: (d) => {
|
|
1033
|
-
if (sys?.getDirectories) return sys.getDirectories(d);
|
|
1034
|
-
try {
|
|
1035
|
-
return fs
|
|
1036
|
-
.readdirSync(d, { withFileTypes: true })
|
|
1037
|
-
.filter((e) => e.isDirectory())
|
|
1038
|
-
.map((e) => e.name);
|
|
1039
|
-
} catch {
|
|
1040
|
-
return [];
|
|
1041
|
-
}
|
|
1042
|
-
},
|
|
1043
|
-
realpath: sys?.realpath ? (p) => sys.realpath(p) : undefined,
|
|
1044
|
-
useCaseSensitiveFileNames: sys?.useCaseSensitiveFileNames ?? true,
|
|
1045
|
-
};
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
function parseTsconfig(ts, configPath) {
|
|
1049
|
-
const host = createModuleResolutionHost(ts);
|
|
1050
|
-
const read = ts.readConfigFile(configPath, host.readFile);
|
|
1051
|
-
if (read.error) return {};
|
|
1052
|
-
// parseJsonConfigFileContent wants a ParseConfigHost-like object; our resolution host
|
|
1053
|
-
// is enough for option extraction.
|
|
1054
|
-
const parsed = ts.parseJsonConfigFileContent(
|
|
1055
|
-
read.config,
|
|
1056
|
-
{
|
|
1057
|
-
useCaseSensitiveFileNames: host.useCaseSensitiveFileNames,
|
|
1058
|
-
readDirectory: ts.sys?.readDirectory
|
|
1059
|
-
? (...args) => ts.sys.readDirectory(...args)
|
|
1060
|
-
: () => [],
|
|
1061
|
-
fileExists: host.fileExists,
|
|
1062
|
-
readFile: host.readFile,
|
|
1063
|
-
},
|
|
1064
|
-
path.dirname(configPath)
|
|
1065
|
-
);
|
|
1066
|
-
return parsed.options;
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
/**
|
|
1070
|
-
* Compiler options for a given source file. With --tsconfig every file uses that one
|
|
1071
|
-
* config; otherwise each file uses the NEAREST tsconfig.json above it (like tsc does),
|
|
1072
|
-
* so monorepo packages with per-package path aliases resolve correctly under one --root.
|
|
1073
|
-
*/
|
|
1074
|
-
function createCompilerOptionsLookup(ts, root, tsconfigArg) {
|
|
1075
|
-
if (tsconfigArg) {
|
|
1076
|
-
const configPath = path.isAbsolute(tsconfigArg) ? tsconfigArg : path.join(root, tsconfigArg);
|
|
1077
|
-
const options = fs.existsSync(configPath) ? parseTsconfig(ts, configPath) : {};
|
|
1078
|
-
return () => options;
|
|
1079
|
-
}
|
|
1080
|
-
const byDir = new Map();
|
|
1081
|
-
const byConfig = new Map();
|
|
1082
|
-
return (file) => {
|
|
1083
|
-
const dir = path.dirname(file);
|
|
1084
|
-
if (byDir.has(dir)) return byDir.get(dir);
|
|
1085
|
-
const configPath = ts.findConfigFile(dir, ts.sys.fileExists, 'tsconfig.json');
|
|
1086
|
-
let options = {};
|
|
1087
|
-
if (configPath) {
|
|
1088
|
-
if (!byConfig.has(configPath)) byConfig.set(configPath, parseTsconfig(ts, configPath));
|
|
1089
|
-
options = byConfig.get(configPath);
|
|
1090
|
-
}
|
|
1091
|
-
byDir.set(dir, options);
|
|
1092
|
-
return options;
|
|
1093
|
-
};
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
/**
|
|
1097
|
-
* Per-file scan cache. A cache entry stores the parsed file's content-derived results:
|
|
1098
|
-
* content violations (forbidden globals, publish checks, intent references) and the list
|
|
1099
|
-
* of module-edge specifiers. Edges are NEVER cached as violations — they are re-resolved
|
|
1100
|
-
* against the live filesystem every run, because resolution depends on files and tsconfigs
|
|
1101
|
-
* outside the cached file. The whole cache is keyed by the config+manifest contents, so
|
|
1102
|
-
* any rule change invalidates everything.
|
|
1103
|
-
*/
|
|
1104
|
-
function scanCachePath(root) {
|
|
1105
|
-
return path.join(root, 'node_modules', '.cache', 'ark-check.json');
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
function scanCacheKey(root, args) {
|
|
1109
|
-
const read = (p) => {
|
|
1110
|
-
try {
|
|
1111
|
-
return fs.readFileSync(p, 'utf8');
|
|
1112
|
-
} catch {
|
|
1113
|
-
return '';
|
|
1114
|
-
}
|
|
1115
|
-
};
|
|
1116
|
-
const configPath = path.isAbsolute(args.config) ? args.config : path.join(root, args.config);
|
|
1117
|
-
const manifestPath = args.manifest
|
|
1118
|
-
? path.isAbsolute(args.manifest)
|
|
1119
|
-
? args.manifest
|
|
1120
|
-
: path.join(root, args.manifest)
|
|
1121
|
-
: undefined;
|
|
1122
|
-
// Bump this schema tag whenever the cached scan shape changes, so a warm cache from an
|
|
1123
|
-
// older Ark can't feed stale entries to new logic. v2: typeOnly on edges. v3: per-file
|
|
1124
|
-
// exportsOnlyTypes (target-module type-only export detection for plan classifier).
|
|
1125
|
-
return crypto
|
|
1126
|
-
.createHash('sha1')
|
|
1127
|
-
.update(`ark-check-cache-v3\0${read(configPath)}\0${manifestPath ? read(manifestPath) : ''}`)
|
|
1128
|
-
.digest('hex');
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
function loadScanCache(root, key) {
|
|
1132
|
-
try {
|
|
1133
|
-
const data = JSON.parse(fs.readFileSync(scanCachePath(root), 'utf8'));
|
|
1134
|
-
return data.key === key && data.files && typeof data.files === 'object' ? data.files : undefined;
|
|
1135
|
-
} catch {
|
|
1136
|
-
return undefined;
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
function saveScanCache(root, key, files) {
|
|
1141
|
-
try {
|
|
1142
|
-
const target = scanCachePath(root);
|
|
1143
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
1144
|
-
fs.writeFileSync(target, JSON.stringify({ key, files }));
|
|
1145
|
-
} catch {
|
|
1146
|
-
// cache is best-effort: read-only filesystems just re-parse every run
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
/**
|
|
1151
|
-
* Fallback resolver for extensionless relative imports whose on-disk target uses an
|
|
1152
|
-
* extension `ts.resolveModuleName` won't resolve without a matching tsconfig
|
|
1153
|
-
* (notably `.mts`/`.cts`). Mirrors the classic candidate list.
|
|
1154
|
-
*/
|
|
1155
|
-
function isFile(candidate) {
|
|
1156
|
-
try {
|
|
1157
|
-
return fs.statSync(candidate).isFile();
|
|
1158
|
-
} catch {
|
|
1159
|
-
return false;
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
function resolveRelativeFallback(fromFile, specifier) {
|
|
1164
|
-
const base = path.resolve(path.dirname(fromFile), specifier);
|
|
1165
|
-
const candidates = [
|
|
1166
|
-
base, // only used when the specifier already carries an extension (isFile filters dirs)
|
|
1167
|
-
`${base}.ts`,
|
|
1168
|
-
`${base}.tsx`,
|
|
1169
|
-
`${base}.mts`,
|
|
1170
|
-
`${base}.cts`,
|
|
1171
|
-
`${base}.js`,
|
|
1172
|
-
`${base}.jsx`,
|
|
1173
|
-
`${base}.mjs`,
|
|
1174
|
-
`${base}.cjs`,
|
|
1175
|
-
path.join(base, 'index.ts'),
|
|
1176
|
-
path.join(base, 'index.tsx'),
|
|
1177
|
-
path.join(base, 'index.mts'),
|
|
1178
|
-
path.join(base, 'index.cts'),
|
|
1179
|
-
];
|
|
1180
|
-
// isFile (not existsSync) so a directory named like the specifier never shadows the
|
|
1181
|
-
// real module file — e.g. `./foo` must not resolve to a `foo/` directory before `foo.mts`.
|
|
1182
|
-
return candidates.find(isFile);
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
/**
|
|
1186
|
-
* Resolve any import specifier (relative, tsconfig path-alias, or package) to a source
|
|
1187
|
-
* file using TypeScript's module resolver, returning the resolved file (or undefined for
|
|
1188
|
-
* unresolved / declaration-only targets).
|
|
1189
|
-
*
|
|
1190
|
-
* ark-check governs one project rooted at --root. A resolved target is skipped when its
|
|
1191
|
-
* path RELATIVE TO ROOT either escapes the root (leading `..`) or contains a `node_modules`
|
|
1192
|
-
* segment. Using the root-relative path (not an absolute substring) means a project that
|
|
1193
|
-
* itself lives under a node_modules segment is still governed, while a broad catch-all
|
|
1194
|
-
* pattern (`**`) can't false-flag vendored deps or files outside the project. Monorepos can
|
|
1195
|
-
* run under a single --root (per-package tsconfigs are honored via the nearest-tsconfig
|
|
1196
|
-
* lookup); edges that resolve outside the root are still skipped.
|
|
1197
|
-
*/
|
|
1198
|
-
function resolveImport(ts, specifier, containingFile, options, host, root) {
|
|
1199
|
-
const res = ts.resolveModuleName(specifier, containingFile, options, host);
|
|
1200
|
-
let file = res.resolvedModule?.resolvedFileName;
|
|
1201
|
-
if (!file && specifier.startsWith('.')) {
|
|
1202
|
-
file = resolveRelativeFallback(containingFile, specifier);
|
|
1203
|
-
}
|
|
1204
|
-
if (!file) return undefined;
|
|
1205
|
-
if (file.endsWith('.d.ts')) return undefined;
|
|
1206
|
-
const abs = path.resolve(file);
|
|
1207
|
-
const segments = path.relative(root, abs).split(path.sep);
|
|
1208
|
-
if (segments[0] === '..' || segments.includes('node_modules')) return undefined;
|
|
1209
|
-
return abs;
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
function lineOf(sourceFile, pos) {
|
|
1213
|
-
return sourceFile.getLineAndCharacterOfPosition(pos).line + 1;
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
function textOfModuleSpecifier(node) {
|
|
1217
|
-
return node.moduleSpecifier && typeof node.moduleSpecifier.text === 'string'
|
|
1218
|
-
? node.moduleSpecifier.text
|
|
1219
|
-
: undefined;
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
// True when an import/export edge carries ONLY types (`import type …`, or a named import
|
|
1223
|
-
// where every binding is `type`-qualified). Type-only edges are erased at compile time —
|
|
1224
|
-
// they create no runtime coupling, only a design/type-placement dependency — so callers can
|
|
1225
|
-
// rank them below real value imports in a burn-down. A side-effect import (`import "x"`) or
|
|
1226
|
-
// any default/namespace/value binding is NOT type-only.
|
|
1227
|
-
function isTypeOnlyModuleReference(ts, node) {
|
|
1228
|
-
if (ts.isImportDeclaration(node)) {
|
|
1229
|
-
const clause = node.importClause;
|
|
1230
|
-
if (!clause) return false; // side-effect import — runtime edge
|
|
1231
|
-
if (clause.isTypeOnly) return true; // `import type …`
|
|
1232
|
-
const named = clause.namedBindings;
|
|
1233
|
-
if (named && ts.isNamedImports(named) && named.elements.length > 0) {
|
|
1234
|
-
return named.elements.every((element) => element.isTypeOnly);
|
|
1235
|
-
}
|
|
1236
|
-
return false; // default or namespace binding of a value
|
|
1237
|
-
}
|
|
1238
|
-
if (ts.isExportDeclaration(node)) {
|
|
1239
|
-
if (node.isTypeOnly) return true;
|
|
1240
|
-
const clause = node.exportClause;
|
|
1241
|
-
if (clause && ts.isNamedExports(clause) && clause.elements.length > 0) {
|
|
1242
|
-
return clause.elements.every((element) => element.isTypeOnly);
|
|
1243
|
-
}
|
|
1244
|
-
return false;
|
|
1245
|
-
}
|
|
1246
|
-
return false;
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
/**
|
|
1250
|
-
* True when a module is a pure type-surface file: only type/interface exports and
|
|
1251
|
-
* type-only imports. Conservative false (→ judgment) when:
|
|
1252
|
-
* - any top-level runtime statement (value decls, expression stmts, side-effect imports)
|
|
1253
|
-
* - ambiguous `export { X }` without type keyword, export *, default/export=
|
|
1254
|
-
* Used so static value-syntax `import { T }` of a pure-type module can be mechanical-safe
|
|
1255
|
-
* (convert to `import type`). Never trust this for require()/import() edges.
|
|
1256
|
-
*/
|
|
1257
|
-
function sourceFileExportsOnlyTypes(ts, sourceFile) {
|
|
1258
|
-
let sawTypeExport = false;
|
|
1259
|
-
const hasExportModifier = (node) =>
|
|
1260
|
-
Array.isArray(node.modifiers) &&
|
|
1261
|
-
node.modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
1262
|
-
|
|
1263
|
-
for (const stmt of sourceFile.statements) {
|
|
1264
|
-
// Type-only imports OK; value or side-effect imports mean runtime load of deps.
|
|
1265
|
-
if (ts.isImportDeclaration(stmt)) {
|
|
1266
|
-
if (!isTypeOnlyModuleReference(ts, stmt)) return false;
|
|
1267
|
-
continue;
|
|
1268
|
-
}
|
|
1269
|
-
if (typeof ts.isImportEqualsDeclaration === 'function' && ts.isImportEqualsDeclaration(stmt)) {
|
|
1270
|
-
return false;
|
|
1271
|
-
}
|
|
1272
|
-
if (ts.isExportDeclaration(stmt)) {
|
|
1273
|
-
if (stmt.isTypeOnly) {
|
|
1274
|
-
sawTypeExport = true;
|
|
1275
|
-
continue;
|
|
1276
|
-
}
|
|
1277
|
-
// export * from '…' can re-export values — not provably type-only.
|
|
1278
|
-
if (!stmt.exportClause) return false;
|
|
1279
|
-
if (ts.isNamespaceExport(stmt.exportClause)) return false;
|
|
1280
|
-
if (ts.isNamedExports(stmt.exportClause)) {
|
|
1281
|
-
if (stmt.exportClause.elements.length === 0) return false;
|
|
1282
|
-
for (const el of stmt.exportClause.elements) {
|
|
1283
|
-
if (!el.isTypeOnly) return false; // bare `export { X }` — ambiguous without checker
|
|
1284
|
-
}
|
|
1285
|
-
sawTypeExport = true;
|
|
1286
|
-
continue;
|
|
1287
|
-
}
|
|
1288
|
-
return false;
|
|
1289
|
-
}
|
|
1290
|
-
if (ts.isExportAssignment(stmt)) return false; // export = / export default expr
|
|
1291
|
-
if (ts.isTypeAliasDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)) {
|
|
1292
|
-
if (hasExportModifier(stmt)) sawTypeExport = true;
|
|
1293
|
-
continue;
|
|
1294
|
-
}
|
|
1295
|
-
// Any other top-level statement (const/fn/class/enum, console.log, if, …) is runtime.
|
|
1296
|
-
return false;
|
|
1297
|
-
}
|
|
1298
|
-
return sawTypeExport;
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
function propertyName(ts, node) {
|
|
1302
|
-
if (!node) return undefined;
|
|
1303
|
-
if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) return node.text;
|
|
1304
|
-
return undefined;
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
function objectProperty(ts, node, name) {
|
|
1308
|
-
if (!node || !ts.isObjectLiteralExpression(node)) return undefined;
|
|
1309
|
-
return node.properties.find((property) => {
|
|
1310
|
-
if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) {
|
|
1311
|
-
return false;
|
|
1312
|
-
}
|
|
1313
|
-
return propertyName(ts, property.name) === name;
|
|
1314
|
-
});
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
function objectHasProperty(ts, node, name) {
|
|
1318
|
-
return objectProperty(ts, node, name) !== undefined;
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
function objectPropertyValue(ts, node, name) {
|
|
1322
|
-
const property = objectProperty(ts, node, name);
|
|
1323
|
-
return property && ts.isPropertyAssignment(property)
|
|
1324
|
-
? property.initializer
|
|
1325
|
-
: undefined;
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
function objectHasMetadataSource(ts, node) {
|
|
1329
|
-
const metadata = objectPropertyValue(ts, node, 'metadata');
|
|
1330
|
-
return objectHasProperty(ts, metadata, 'source');
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
|
-
function stringLiteralText(ts, node) {
|
|
1334
|
-
return node && ts.isStringLiteralLike(node) ? node.text : undefined;
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
function isPublishCall(ts, node) {
|
|
1338
|
-
if (!ts.isCallExpression(node)) return false;
|
|
1339
|
-
const expression = node.expression;
|
|
1340
|
-
if (ts.isPropertyAccessExpression(expression)) {
|
|
1341
|
-
return expression.name.text === 'publish';
|
|
1342
|
-
}
|
|
1343
|
-
return ts.isIdentifier(expression) && expression.text === 'publish';
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
function looksLikeIntentCreatorExpression(ts, node) {
|
|
1347
|
-
if (!node) return false;
|
|
1348
|
-
if (ts.isIdentifier(node)) {
|
|
1349
|
-
return /^[A-Z]/.test(node.text);
|
|
1350
|
-
}
|
|
1351
|
-
if (ts.isPropertyAccessExpression(node)) {
|
|
1352
|
-
return looksLikeIntentCreatorExpression(ts, node.name);
|
|
1353
|
-
}
|
|
1354
|
-
return false;
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
function isArkPublishCandidate(ts, node) {
|
|
1358
|
-
if (!ts.isCallExpression(node)) return false;
|
|
1359
|
-
const firstArg = node.arguments[0];
|
|
1360
|
-
const rawIntent = stringLiteralText(ts, firstArg);
|
|
1361
|
-
return (
|
|
1362
|
-
(rawIntent !== undefined && looksLikeIntent(rawIntent)) ||
|
|
1363
|
-
objectHasProperty(ts, firstArg, 'intent') ||
|
|
1364
|
-
looksLikeIntentCreatorExpression(ts, firstArg)
|
|
1365
|
-
);
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
function publishSourceLiteral(ts, node) {
|
|
1369
|
-
if (!ts.isCallExpression(node)) return undefined;
|
|
1370
|
-
const [firstArg, secondArg, thirdArg] = node.arguments;
|
|
1371
|
-
const rawMetadata = objectPropertyValue(ts, firstArg, 'metadata');
|
|
1372
|
-
return (
|
|
1373
|
-
stringLiteralText(ts, objectPropertyValue(ts, rawMetadata, 'source')) ??
|
|
1374
|
-
stringLiteralText(ts, objectPropertyValue(ts, secondArg, 'source')) ??
|
|
1375
|
-
stringLiteralText(ts, objectPropertyValue(ts, thirdArg, 'source'))
|
|
1376
|
-
);
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
function publishHasSource(ts, node) {
|
|
1380
|
-
if (!ts.isCallExpression(node)) return false;
|
|
1381
|
-
const [firstArg, secondArg, thirdArg] = node.arguments;
|
|
1382
|
-
return (
|
|
1383
|
-
objectHasMetadataSource(ts, firstArg) ||
|
|
1384
|
-
objectHasProperty(ts, secondArg, 'source') ||
|
|
1385
|
-
objectHasProperty(ts, thirdArg, 'source')
|
|
1386
|
-
);
|
|
1387
|
-
}
|
|
1388
856
|
const useColor = process.stderr.isTTY && !process.env.NO_COLOR;
|
|
1389
857
|
const color = {
|
|
1390
858
|
red: (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
|
|
@@ -1394,83 +862,6 @@ const color = {
|
|
|
1394
862
|
bold: (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
|
|
1395
863
|
};
|
|
1396
864
|
|
|
1397
|
-
function detectCycles(graph) {
|
|
1398
|
-
let index = 0;
|
|
1399
|
-
const indices = new Map();
|
|
1400
|
-
const low = new Map();
|
|
1401
|
-
const onStack = new Set();
|
|
1402
|
-
const stack = [];
|
|
1403
|
-
const components = [];
|
|
1404
|
-
|
|
1405
|
-
// ponytail: recursive Tarjan; make it iterative only if a real repo blows the stack.
|
|
1406
|
-
const strongconnect = (v) => {
|
|
1407
|
-
indices.set(v, index);
|
|
1408
|
-
low.set(v, index);
|
|
1409
|
-
index += 1;
|
|
1410
|
-
stack.push(v);
|
|
1411
|
-
onStack.add(v);
|
|
1412
|
-
for (const w of [...(graph.get(v) ?? [])].sort()) {
|
|
1413
|
-
if (!graph.has(w)) continue;
|
|
1414
|
-
if (!indices.has(w)) {
|
|
1415
|
-
strongconnect(w);
|
|
1416
|
-
low.set(v, Math.min(low.get(v), low.get(w)));
|
|
1417
|
-
} else if (onStack.has(w)) {
|
|
1418
|
-
low.set(v, Math.min(low.get(v), indices.get(w)));
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1421
|
-
if (low.get(v) === indices.get(v)) {
|
|
1422
|
-
const comp = [];
|
|
1423
|
-
let w;
|
|
1424
|
-
do {
|
|
1425
|
-
w = stack.pop();
|
|
1426
|
-
onStack.delete(w);
|
|
1427
|
-
comp.push(w);
|
|
1428
|
-
} while (w !== v);
|
|
1429
|
-
if (comp.length > 1) components.push(comp.sort());
|
|
1430
|
-
}
|
|
1431
|
-
};
|
|
1432
|
-
|
|
1433
|
-
for (const v of [...graph.keys()].sort()) {
|
|
1434
|
-
if (!indices.has(v)) strongconnect(v);
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
|
-
return components
|
|
1438
|
-
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
1439
|
-
.map((members) => ({
|
|
1440
|
-
ruleId: 'CIRCULAR_DEPENDENCY',
|
|
1441
|
-
file: members[0],
|
|
1442
|
-
line: 1,
|
|
1443
|
-
target: members.join(' → '),
|
|
1444
|
-
message: `Circular dependency among ${members.length} files: ${members.join(' → ')} → ${members[0]}.`,
|
|
1445
|
-
}));
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
function moduleSpecifierFromCall(ts, node) {
|
|
1450
|
-
if (!ts.isCallExpression(node)) return undefined;
|
|
1451
|
-
|
|
1452
|
-
if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
1453
|
-
const first = node.arguments[0];
|
|
1454
|
-
const value = stringLiteralText(ts, first);
|
|
1455
|
-
return value ? { value, kind: 'dynamic-import' } : undefined;
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
if (ts.isIdentifier(node.expression) && node.expression.text === 'require') {
|
|
1459
|
-
const first = node.arguments[0];
|
|
1460
|
-
const value = stringLiteralText(ts, first);
|
|
1461
|
-
return value ? { value, kind: 'require' } : undefined;
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
return undefined;
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
// --coverage: a standalone visibility report (never changes the exit code). Answers
|
|
1468
|
-
// "which files does each layer actually govern, and what is slipping through?" — the
|
|
1469
|
-
// data the /ark-coverage skill otherwise has to hand-roll with find/readdir walks.
|
|
1470
|
-
// Pure coverage computation (glob-only, no TypeScript): the object both `--coverage` and
|
|
1471
|
-
// `--doctor` render. `governed` is the headline honesty number — the share of in-scope code
|
|
1472
|
-
// Ark actually enforces rules on; `suggestions` proposes a layer for each ungoverned dir.
|
|
1473
|
-
|
|
1474
865
|
async function main() {
|
|
1475
866
|
const args = parseArgs(process.argv);
|
|
1476
867
|
if (args.version) {
|
|
@@ -1509,6 +900,16 @@ async function main() {
|
|
|
1509
900
|
return;
|
|
1510
901
|
}
|
|
1511
902
|
|
|
903
|
+
if (args.suggestInclude) {
|
|
904
|
+
runSuggestInclude(args);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (args.adoptContract) {
|
|
909
|
+
runAdoptContract(args);
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
1512
913
|
if (args.recommend) {
|
|
1513
914
|
try {
|
|
1514
915
|
const recommendation = buildArchitectureRecommendation(args.root);
|
|
@@ -1582,7 +983,7 @@ async function main() {
|
|
|
1582
983
|
const config = readConfig(root, args.config);
|
|
1583
984
|
const manifest = readManifest(root, args.manifest);
|
|
1584
985
|
const rules = manifest?.architecture?.rules ?? config.rules;
|
|
1585
|
-
const files =
|
|
986
|
+
const files = collectGovernedFiles(root, config);
|
|
1586
987
|
|
|
1587
988
|
// --coverage is a pure glob/report view (no TypeScript resolver), so serve it BEFORE the
|
|
1588
989
|
// TS import: the report must work — and exit 0 — even when typescript isn't installed.
|
|
@@ -1627,203 +1028,15 @@ async function main() {
|
|
|
1627
1028
|
);
|
|
1628
1029
|
}
|
|
1629
1030
|
|
|
1630
|
-
const
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
// Parses one file and returns its cacheable scan result: violations derived purely from
|
|
1641
|
-
// the file's content (+config/manifest, hashed into the cache key) and the module-edge
|
|
1642
|
-
// specifiers found, which the driver loop below resolves fresh on every run.
|
|
1643
|
-
function scanSourceFile(file, sourceLayer) {
|
|
1644
|
-
const source = fs.readFileSync(file, 'utf8');
|
|
1645
|
-
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
|
|
1646
|
-
const violations = [];
|
|
1647
|
-
const edges = [];
|
|
1648
|
-
|
|
1649
|
-
const layerConfig = config.layers.find((layer) => layer.name === sourceLayer);
|
|
1650
|
-
const forbiddenGlobals = Array.isArray(layerConfig?.forbiddenGlobals)
|
|
1651
|
-
? layerConfig.forbiddenGlobals.filter((entry) => typeof entry === 'string')
|
|
1652
|
-
: [];
|
|
1653
|
-
for (const use of collectForbiddenGlobalUses(ts, sourceFile, forbiddenGlobals)) {
|
|
1654
|
-
violations.push({
|
|
1655
|
-
ruleId: 'FORBIDDEN_GLOBAL',
|
|
1656
|
-
file: normalize(path.relative(root, file)),
|
|
1657
|
-
line: lineOf(sourceFile, use.node.getStart(sourceFile)),
|
|
1658
|
-
fromLayer: sourceLayer,
|
|
1659
|
-
target: use.name,
|
|
1660
|
-
message: `${sourceLayer} must not use the ambient global "${use.name}".`,
|
|
1661
|
-
});
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
|
-
const checkModuleEdge = (specifier, node, kind, typeOnly = false) => {
|
|
1665
|
-
edges.push({ specifier, line: lineOf(sourceFile, node.getStart(sourceFile)), kind, typeOnly });
|
|
1666
|
-
};
|
|
1667
|
-
|
|
1668
|
-
const visit = (node) => {
|
|
1669
|
-
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
|
1670
|
-
const specifier = textOfModuleSpecifier(node);
|
|
1671
|
-
if (specifier) {
|
|
1672
|
-
checkModuleEdge(
|
|
1673
|
-
specifier,
|
|
1674
|
-
node,
|
|
1675
|
-
ts.isImportDeclaration(node) ? 'import' : 'export',
|
|
1676
|
-
isTypeOnlyModuleReference(ts, node)
|
|
1677
|
-
);
|
|
1678
|
-
}
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
if (ts.isCallExpression(node)) {
|
|
1682
|
-
const moduleCall = moduleSpecifierFromCall(ts, node);
|
|
1683
|
-
if (moduleCall) {
|
|
1684
|
-
checkModuleEdge(moduleCall.value, node, moduleCall.kind);
|
|
1685
|
-
}
|
|
1686
|
-
|
|
1687
|
-
if (isPublishCall(ts, node)) {
|
|
1688
|
-
const firstArg = node.arguments[0];
|
|
1689
|
-
const rawIntent = stringLiteralText(ts, firstArg);
|
|
1690
|
-
if (
|
|
1691
|
-
(rawIntent && looksLikeIntent(rawIntent)) ||
|
|
1692
|
-
objectHasProperty(ts, firstArg, 'intent')
|
|
1693
|
-
) {
|
|
1694
|
-
violations.push({
|
|
1695
|
-
ruleId: 'RAW_EVENT_PUBLISH',
|
|
1696
|
-
file: normalize(path.relative(root, file)),
|
|
1697
|
-
line: lineOf(sourceFile, node.getStart(sourceFile)),
|
|
1698
|
-
message:
|
|
1699
|
-
'Publish through a registered intent creator; raw event objects or intent strings bypass Ark contracts and tooling.',
|
|
1700
|
-
});
|
|
1701
|
-
}
|
|
1702
|
-
|
|
1703
|
-
if (isArkPublishCandidate(ts, node) && !publishHasSource(ts, node)) {
|
|
1704
|
-
violations.push({
|
|
1705
|
-
ruleId: 'PUBLISH_MISSING_SOURCE',
|
|
1706
|
-
file: normalize(path.relative(root, file)),
|
|
1707
|
-
line: lineOf(sourceFile, node.getStart(sourceFile)),
|
|
1708
|
-
fromLayer: sourceLayer,
|
|
1709
|
-
message: 'Strict Ark publish calls must include metadata.source.',
|
|
1710
|
-
});
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
const sourceIntent = publishSourceLiteral(ts, node);
|
|
1714
|
-
if (sourceIntent && looksLikeIntent(sourceIntent)) {
|
|
1715
|
-
const sourceIntentLayer = layerForIntent(
|
|
1716
|
-
sourceIntent,
|
|
1717
|
-
config.layers,
|
|
1718
|
-
manifestIntentLayers
|
|
1719
|
-
);
|
|
1720
|
-
if (sourceIntentLayer && sourceIntentLayer !== sourceLayer) {
|
|
1721
|
-
violations.push({
|
|
1722
|
-
ruleId: 'PUBLISH_SOURCE_LAYER_MISMATCH',
|
|
1723
|
-
file: normalize(path.relative(root, file)),
|
|
1724
|
-
line: lineOf(sourceFile, node.getStart(sourceFile)),
|
|
1725
|
-
fromLayer: sourceLayer,
|
|
1726
|
-
toLayer: sourceIntentLayer,
|
|
1727
|
-
target: sourceIntent,
|
|
1728
|
-
message:
|
|
1729
|
-
`Publish source "${sourceIntent}" resolves to ${sourceIntentLayer}, but the publishing file is classified as ${sourceLayer}.`,
|
|
1730
|
-
});
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
if (ts.isStringLiteralLike(node) && looksLikeIntent(node.text)) {
|
|
1737
|
-
const targetLayer = layerForIntent(node.text, config.layers, manifestIntentLayers);
|
|
1738
|
-
const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
|
|
1739
|
-
if (rule) {
|
|
1740
|
-
violations.push({
|
|
1741
|
-
ruleId: 'LAYER_INTENT_REFERENCE_VIOLATION',
|
|
1742
|
-
file: normalize(path.relative(root, file)),
|
|
1743
|
-
line: lineOf(sourceFile, node.getStart(sourceFile)),
|
|
1744
|
-
fromLayer: sourceLayer,
|
|
1745
|
-
toLayer: targetLayer,
|
|
1746
|
-
target: node.text,
|
|
1747
|
-
message:
|
|
1748
|
-
rule.message ??
|
|
1749
|
-
`${sourceLayer} must not reference ${targetLayer} intent ${node.text}.`,
|
|
1750
|
-
});
|
|
1751
|
-
}
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
ts.forEachChild(node, visit);
|
|
1755
|
-
};
|
|
1756
|
-
visit(sourceFile);
|
|
1757
|
-
return {
|
|
1758
|
-
contentViolations: violations,
|
|
1759
|
-
edges,
|
|
1760
|
-
exportsOnlyTypes: sourceFileExportsOnlyTypes(ts, sourceFile),
|
|
1761
|
-
};
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
|
-
// Pass 1: scan every governed file into nextCacheFiles (needs complete map before
|
|
1765
|
-
// targetTypeOnlyExports can be resolved for import edges).
|
|
1766
|
-
const importGraph = new Map();
|
|
1767
|
-
const scanned = []; // { file, sourceLayer, relFile, entry }
|
|
1768
|
-
for (const file of files) {
|
|
1769
|
-
const sourceLayer = layerForFile(root, file, config.layers);
|
|
1770
|
-
if (!sourceLayer) continue;
|
|
1771
|
-
const relFile = normalize(path.relative(root, file));
|
|
1772
|
-
if (!importGraph.has(relFile)) importGraph.set(relFile, new Set());
|
|
1773
|
-
const stat = fs.statSync(file);
|
|
1774
|
-
const fileKey = `${stat.mtimeMs}:${stat.size}`;
|
|
1775
|
-
const cached = cachedFiles?.[relFile];
|
|
1776
|
-
const entry =
|
|
1777
|
-
cached && cached.fileKey === fileKey
|
|
1778
|
-
? cached
|
|
1779
|
-
: { fileKey, ...scanSourceFile(file, sourceLayer) };
|
|
1780
|
-
nextCacheFiles[relFile] = entry;
|
|
1781
|
-
scanned.push({ file, sourceLayer, relFile, entry });
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
// Pass 2: content violations + layer edges (with target type-export surface).
|
|
1785
|
-
for (const { file, sourceLayer, relFile, entry } of scanned) {
|
|
1786
|
-
violations.push(...entry.contentViolations);
|
|
1787
|
-
for (const edge of entry.edges) {
|
|
1788
|
-
const target = resolveImport(ts, edge.specifier, file, compilerOptionsFor(file), moduleHost, root);
|
|
1789
|
-
const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
|
|
1790
|
-
if (target && targetLayer) {
|
|
1791
|
-
const relTarget = normalize(path.relative(root, target));
|
|
1792
|
-
if (relTarget !== relFile) importGraph.get(relFile).add(relTarget);
|
|
1793
|
-
}
|
|
1794
|
-
const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
|
|
1795
|
-
if (rule) {
|
|
1796
|
-
const relTarget = normalize(path.relative(root, target));
|
|
1797
|
-
// After pass 1 every in-scope target is in nextCacheFiles. Missing → not type-only.
|
|
1798
|
-
// targetTypeOnlyExports only for static import/export declarations — never require()
|
|
1799
|
-
// or dynamic import(), which always load the module at runtime (side effects matter).
|
|
1800
|
-
const targetCached = nextCacheFiles[relTarget];
|
|
1801
|
-
const staticEdge = edge.kind === 'import' || edge.kind === 'export';
|
|
1802
|
-
const targetTypeOnlyExports =
|
|
1803
|
-
staticEdge && Boolean(targetCached?.exportsOnlyTypes) && !edge.typeOnly;
|
|
1804
|
-
// Importer is itself a pure type-surface file (no runtime body) — enables
|
|
1805
|
-
// pure-type-file-relocate classification when the edge is type-only.
|
|
1806
|
-
const sourcePureTypeModule = Boolean(entry.exportsOnlyTypes);
|
|
1807
|
-
violations.push({
|
|
1808
|
-
ruleId: 'LAYER_IMPORT_VIOLATION',
|
|
1809
|
-
file: relFile,
|
|
1810
|
-
line: edge.line,
|
|
1811
|
-
fromLayer: sourceLayer,
|
|
1812
|
-
toLayer: targetLayer,
|
|
1813
|
-
target: relTarget,
|
|
1814
|
-
...(edge.typeOnly ? { typeOnly: true } : {}),
|
|
1815
|
-
...(targetTypeOnlyExports ? { targetTypeOnlyExports: true } : {}),
|
|
1816
|
-
...(sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
|
|
1817
|
-
...(edge.kind ? { edgeKind: edge.kind } : {}),
|
|
1818
|
-
message: rule.message ?? `${sourceLayer} must not ${edge.kind} ${targetLayer}.`,
|
|
1819
|
-
});
|
|
1820
|
-
}
|
|
1821
|
-
}
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
if (cacheKey) saveScanCache(root, cacheKey, nextCacheFiles);
|
|
1825
|
-
|
|
1826
|
-
violations.push(...detectCycles(importGraph));
|
|
1031
|
+
const { violations, warnings } = runArchitectureScan({
|
|
1032
|
+
root,
|
|
1033
|
+
config,
|
|
1034
|
+
manifest,
|
|
1035
|
+
rules,
|
|
1036
|
+
files,
|
|
1037
|
+
ts,
|
|
1038
|
+
args,
|
|
1039
|
+
});
|
|
1827
1040
|
|
|
1828
1041
|
if (args.doctor) {
|
|
1829
1042
|
runDoctor(root, config, files, rules, violations, args.json, {
|
|
@@ -1848,6 +1061,24 @@ async function main() {
|
|
|
1848
1061
|
process.exitCode = 2;
|
|
1849
1062
|
return;
|
|
1850
1063
|
}
|
|
1064
|
+
const baselineName = args.baseline || '.ark-baseline.json';
|
|
1065
|
+
const fullBaselinePath = path.isAbsolute(baselineName)
|
|
1066
|
+
? baselineName
|
|
1067
|
+
: path.join(root, baselineName);
|
|
1068
|
+
// Zero debt: do not leave an empty baseline file (unclear policy — "is ratchet on?").
|
|
1069
|
+
// Delete any existing empty/orphan baseline so doctor/CI stay honest.
|
|
1070
|
+
if (violations.length === 0) {
|
|
1071
|
+
if (fs.existsSync(fullBaselinePath)) {
|
|
1072
|
+
fs.unlinkSync(fullBaselinePath);
|
|
1073
|
+
console.log(
|
|
1074
|
+
`No violations to freeze — removed empty baseline ${fullBaselinePath} (zero debt; no ratchet file needed).`
|
|
1075
|
+
);
|
|
1076
|
+
} else {
|
|
1077
|
+
console.log('No violations to freeze — baseline not written (zero debt).');
|
|
1078
|
+
}
|
|
1079
|
+
console.log('Gate with: ark-check --root . --config ark.config.json --strict-config');
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1851
1082
|
const { fullPath, count } = writeBaseline(root, args.baseline, violations);
|
|
1852
1083
|
console.log(`Wrote ${fullPath} with ${count} frozen violation key(s).`);
|
|
1853
1084
|
console.log('Commit it and gate CI with: ark-check --baseline (only NEW violations fail).');
|
|
@@ -1877,7 +1108,10 @@ async function main() {
|
|
|
1877
1108
|
}
|
|
1878
1109
|
}
|
|
1879
1110
|
|
|
1880
|
-
|
|
1111
|
+
// Soft/advisory warnings (failsStrict === false) never fail --strict-config.
|
|
1112
|
+
const strictWarnings = warnings.filter((w) => w.failsStrict !== false);
|
|
1113
|
+
const ok =
|
|
1114
|
+
activeViolations.length === 0 && (!args.strictConfig || strictWarnings.length === 0);
|
|
1881
1115
|
|
|
1882
1116
|
if (args.plan) {
|
|
1883
1117
|
const cov = computeCoverage(root, config, files, rules);
|
|
@@ -2023,11 +1257,16 @@ async function main() {
|
|
|
2023
1257
|
);
|
|
2024
1258
|
}
|
|
2025
1259
|
if (activeViolations.length === 0) {
|
|
1260
|
+
const advisoryOnly = warnings.length > 0 && strictWarnings.length === 0;
|
|
2026
1261
|
if (warnings.length === 0) {
|
|
2027
1262
|
console.log(`${color.green('✔')} Ark check passed.${baselineNote}`);
|
|
2028
|
-
} else if (args.strictConfig) {
|
|
1263
|
+
} else if (args.strictConfig && strictWarnings.length > 0) {
|
|
2029
1264
|
console.error(
|
|
2030
|
-
`${color.red('✖')} Ark check failed with ${
|
|
1265
|
+
`${color.red('✖')} Ark check failed with ${strictWarnings.length} config warning(s).${baselineNote}`
|
|
1266
|
+
);
|
|
1267
|
+
} else if (advisoryOnly) {
|
|
1268
|
+
console.log(
|
|
1269
|
+
`${color.green('✔')} Ark check passed with ${warnings.length} advisory warning(s).${baselineNote}`
|
|
2031
1270
|
);
|
|
2032
1271
|
} else {
|
|
2033
1272
|
console.log(
|