arkgate 2.12.0 → 3.0.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 +122 -0
- package/README.md +90 -51
- package/bin/ark-check.mjs +156 -39
- package/bin/ark-mcp.mjs +119 -6
- package/bin/ark-shared.mjs +216 -129
- package/bin/ark.mjs +134 -34
- package/bin/lib/adapter-contract.mjs +93 -0
- package/bin/lib/agent-gates.mjs +13 -0
- package/bin/lib/analysis-engine.mjs +1171 -0
- package/bin/lib/architecture-scan.mjs +84 -127
- package/bin/lib/ci-and-commands.mjs +40 -3
- package/bin/lib/codex-home.mjs +7 -0
- package/bin/lib/config-contract.mjs +331 -0
- package/bin/lib/config-warnings.mjs +7 -205
- package/bin/lib/doctor-plan.mjs +43 -16
- package/bin/lib/enforcement-profiles.mjs +97 -0
- package/bin/lib/field-install.mjs +67 -10
- package/bin/lib/gate-files.mjs +42 -3
- package/bin/lib/graph-cycles.mjs +4 -54
- package/bin/lib/hook-templates.mjs +33 -1
- package/bin/lib/host-support-matrix.mjs +83 -0
- package/bin/lib/install-migrate.mjs +99 -30
- package/bin/lib/mcp-adoption.mjs +35 -3
- package/bin/lib/open-html.mjs +75 -0
- package/bin/lib/presets.mjs +45 -4
- package/bin/lib/safety-diagnostics.mjs +36 -15
- package/bin/lib/scan-files.mjs +12 -1
- package/bin/lib/skill-install.mjs +72 -1
- package/bin/lib/source-policy.mjs +36 -0
- package/bin/lib/start-preview.mjs +271 -0
- package/bin/lib/ts-resolve.mjs +13 -3
- package/bin/lib/weakest-link.mjs +417 -0
- package/bin/lib/write-path-capabilities.mjs +186 -0
- package/bin/lib/write-path-detect.mjs +62 -99
- package/compat/nestjs.cjs +2 -0
- package/compat/nestjs.d.ts +2 -0
- package/compat/nestjs.js +1 -0
- package/compat/runtime.cjs +2 -0
- package/compat/runtime.d.ts +2 -0
- package/compat/runtime.js +1 -0
- package/dist/configContract-BxSIwVRo.d.cts +259 -0
- package/dist/configContract-BxSIwVRo.d.ts +259 -0
- package/dist/eslint/index.cjs +500 -61
- package/dist/eslint/index.d.cts +36 -20
- package/dist/eslint/index.d.ts +36 -20
- package/dist/eslint/index.js +500 -61
- package/dist/index.cjs +1349 -2741
- package/dist/index.d.cts +449 -483
- package/dist/index.d.ts +449 -483
- package/dist/index.js +1325 -2687
- package/docs/agent-guide.md +58 -34
- package/docs/ai-gates.md +79 -21
- package/docs/configuration.md +97 -0
- package/docs/enthusiast/README.md +3 -3
- package/docs/enthusiast/how-to-agent-gates.md +7 -3
- package/docs/migrate-from-ark-runtime-kernel.md +5 -3
- package/docs/package-surface.md +19 -19
- package/docs/production-hardening.md +31 -5
- package/docs/threat-model.md +65 -0
- package/docs/typescript-support.md +30 -3
- package/package.json +46 -11
- package/schemas/ark.analysis-result.schema.json +91 -0
- package/schemas/ark.config.schema.json +750 -0
- package/server.json +2 -2
- package/templates/hooks/pre-commit-ark +37 -0
- package/templates/skills/ark-architect.md +3 -2
- package/templates/skills/ark-coverage.md +2 -2
- package/templates/skills/ark-runtime.md +8 -5
- package/templates/skills/ark-upgrade.md +36 -16
- package/tests/fixtures/ts-consumer/ark.config.json +2 -0
- package/dist/eslint/index.cjs.map +0 -1
- package/dist/eslint/index.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/nestjs/index.cjs +0 -2498
- package/dist/nestjs/index.cjs.map +0 -1
- package/dist/nestjs/index.d.cts +0 -22
- package/dist/nestjs/index.d.ts +0 -22
- package/dist/nestjs/index.js +0 -2474
- package/dist/nestjs/index.js.map +0 -1
- package/dist/runtime/index.cjs +0 -3352
- package/dist/runtime/index.cjs.map +0 -1
- package/dist/runtime/index.d.cts +0 -2
- package/dist/runtime/index.d.ts +0 -2
- package/dist/runtime/index.js +0 -3270
- package/dist/runtime/index.js.map +0 -1
- package/dist/types-BZ17b9i5.d.cts +0 -1068
- package/dist/types-BZ17b9i5.d.ts +0 -1068
package/bin/ark-shared.mjs
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_ARK_CONFIG_RULES,
|
|
6
|
+
withArkConfigMetadata,
|
|
7
|
+
} from './lib/config-contract.mjs';
|
|
8
|
+
import { collectForbiddenCapabilityUses } from './lib/analysis-engine.mjs';
|
|
9
|
+
import { looksLikeArkIntent } from './lib/source-policy.mjs';
|
|
4
10
|
|
|
5
11
|
/**
|
|
6
|
-
* Default
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* CLIs run standalone with only `typescript` present, no build step.
|
|
12
|
+
* Default intent-prefix map shared by both CLIs and the ark-mcp write-path gate. The rule
|
|
13
|
+
* matrix comes from the generated Domain config contract above. Prefixes mirror the runtime
|
|
14
|
+
* profile but stay in this standalone CLI module because the CLIs run without a build step.
|
|
10
15
|
*/
|
|
11
16
|
export const DEFAULT_INTENT_PREFIXES = [
|
|
12
17
|
{ layer: 'DomainModel', prefixes: ['Domain.'] },
|
|
@@ -41,35 +46,7 @@ export const DEFAULT_LAYER_DIRECTORIES = {
|
|
|
41
46
|
Kernel: ['kernel'],
|
|
42
47
|
};
|
|
43
48
|
|
|
44
|
-
const
|
|
45
|
-
{ from: 'PresentationAdapters', to: 'ApplicationOrchestration' },
|
|
46
|
-
{ from: 'ApplicationOrchestration', to: 'DomainModel' },
|
|
47
|
-
{ from: 'WorkflowSagaEngine', to: 'ApplicationOrchestration' },
|
|
48
|
-
{ from: 'WorkflowSagaEngine', to: 'DomainModel' },
|
|
49
|
-
{ from: 'BackgroundJobsScheduling', to: 'ApplicationOrchestration' },
|
|
50
|
-
];
|
|
51
|
-
|
|
52
|
-
function flowKey(from, to) {
|
|
53
|
-
return `${from}->${to}`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function createStrictDenyRules(layers, allowedFlows) {
|
|
57
|
-
const allowed = new Set(allowedFlows.map((flow) => flowKey(flow.from, flow.to)));
|
|
58
|
-
const rules = [];
|
|
59
|
-
for (const from of layers) {
|
|
60
|
-
for (const to of layers) {
|
|
61
|
-
if (from.layer === to.layer) continue;
|
|
62
|
-
if (allowed.has(flowKey(from.layer, to.layer))) continue;
|
|
63
|
-
rules.push({ from: from.layer, to: to.layer, allowed: false });
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return rules;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export const DEFAULT_RULES = createStrictDenyRules(
|
|
70
|
-
DEFAULT_INTENT_PREFIXES,
|
|
71
|
-
DEFAULT_ALLOWED_FLOWS
|
|
72
|
-
);
|
|
49
|
+
export const DEFAULT_RULES = DEFAULT_ARK_CONFIG_RULES;
|
|
73
50
|
|
|
74
51
|
/**
|
|
75
52
|
* Default ambient globals forbidden in the domain layer: a pure domain does no I/O and is
|
|
@@ -82,7 +59,7 @@ export function createElevenLayerConfig(options = {}) {
|
|
|
82
59
|
const rootDir = options.rootDir ?? 'src';
|
|
83
60
|
const optional = options.optionalLayers ?? true;
|
|
84
61
|
const prefix = rootDir === '.' ? '' : `${rootDir}/`;
|
|
85
|
-
const config = {
|
|
62
|
+
const config = withArkConfigMetadata({
|
|
86
63
|
include: options.include ?? [rootDir],
|
|
87
64
|
layers: DEFAULT_INTENT_PREFIXES.map((entry) => ({
|
|
88
65
|
name: entry.layer,
|
|
@@ -96,7 +73,7 @@ export function createElevenLayerConfig(options = {}) {
|
|
|
96
73
|
: {}),
|
|
97
74
|
})),
|
|
98
75
|
rules: DEFAULT_RULES,
|
|
99
|
-
};
|
|
76
|
+
});
|
|
100
77
|
// When a project root is known, overlay Nest/Next/express filename conventions so a
|
|
101
78
|
// flat framework starter is governed on day one (not "0% governed / false green").
|
|
102
79
|
if (options.root) return applyFrameworkLayoutOverlays(config, options.root);
|
|
@@ -134,7 +111,7 @@ export function applyFrameworkLayoutOverlays(config, root) {
|
|
|
134
111
|
try {
|
|
135
112
|
signals = collectRepoShapeSignals(root);
|
|
136
113
|
} catch {
|
|
137
|
-
return config;
|
|
114
|
+
return withArkConfigMetadata(config);
|
|
138
115
|
}
|
|
139
116
|
|
|
140
117
|
const next = {
|
|
@@ -155,6 +132,40 @@ export function applyFrameworkLayoutOverlays(config, root) {
|
|
|
155
132
|
}
|
|
156
133
|
};
|
|
157
134
|
|
|
135
|
+
// Framework-free application layouts are common in clean-room starters and
|
|
136
|
+
// brownfield products. Govern their explicit source conventions without
|
|
137
|
+
// claiming a framework that package.json does not declare.
|
|
138
|
+
if (signals.apiSurface && !signals.nestFramework && !signals.expressLike) {
|
|
139
|
+
ensureInclude('src');
|
|
140
|
+
mergeLayerPatterns(next, 'PresentationAdapters', [
|
|
141
|
+
'src/**/routes/**',
|
|
142
|
+
'src/**/controllers/**',
|
|
143
|
+
'src/**/http/**',
|
|
144
|
+
'src/**/api/**',
|
|
145
|
+
]);
|
|
146
|
+
mergeLayerPatterns(next, 'ApplicationOrchestration', [
|
|
147
|
+
'src/**/services/**',
|
|
148
|
+
'src/**/use-cases/**',
|
|
149
|
+
'src/**/usecases/**',
|
|
150
|
+
]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (signals.ui && !signals.nextFramework) {
|
|
154
|
+
ensureInclude('src');
|
|
155
|
+
mergeLayerPatterns(next, 'PresentationAdapters', [
|
|
156
|
+
'src/**/components/**',
|
|
157
|
+
'src/**/layouts/**',
|
|
158
|
+
'src/**/ui/**',
|
|
159
|
+
'src/**/routes/**',
|
|
160
|
+
'src/**/pages/**',
|
|
161
|
+
]);
|
|
162
|
+
// Match the existing ui-surface profile: UI components may consume their
|
|
163
|
+
// display-domain models, while the domain still cannot depend on UI.
|
|
164
|
+
next.rules = next.rules.filter(
|
|
165
|
+
(rule) => !(rule.from === 'PresentationAdapters' && rule.to === 'DomainModel' && rule.allowed === false)
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
158
169
|
if (signals.nestFramework) {
|
|
159
170
|
ensureInclude('src');
|
|
160
171
|
// Nest flat + modular conventions (controllers/services next to modules).
|
|
@@ -337,14 +348,33 @@ export function applyFrameworkLayoutOverlays(config, root) {
|
|
|
337
348
|
if (signals.libraryOnly && !signals.nestFramework && !signals.nextFramework) {
|
|
338
349
|
ensureInclude('src');
|
|
339
350
|
ensureInclude('lib');
|
|
340
|
-
|
|
351
|
+
// Published libraries commonly expose a root entrypoint instead of src/. Include that
|
|
352
|
+
// real surface, but not test files: a public package entrypoint is application code and
|
|
353
|
+
// must not remain outside the contract simply because it is JavaScript or root-level.
|
|
354
|
+
ensureInclude('.');
|
|
355
|
+
mergeLayerPatterns(next, 'DomainModel', [
|
|
356
|
+
'src/**/*.ts',
|
|
357
|
+
'src/**/*.tsx',
|
|
358
|
+
'src/**/*.js',
|
|
359
|
+
'src/**/*.mjs',
|
|
360
|
+
'src/**/*.cjs',
|
|
361
|
+
'lib/**/*.ts',
|
|
362
|
+
'lib/**/*.js',
|
|
363
|
+
'lib/**/*.mjs',
|
|
364
|
+
'lib/**/*.cjs',
|
|
365
|
+
'*.ts',
|
|
366
|
+
'*.tsx',
|
|
367
|
+
'*.js',
|
|
368
|
+
'*.mjs',
|
|
369
|
+
'*.cjs',
|
|
370
|
+
]);
|
|
341
371
|
// Prefer domain over application for a single-folder lib: only domain if no split.
|
|
342
372
|
next.frameworkOverlay = next.frameworkOverlay
|
|
343
373
|
? `${next.frameworkOverlay}+library`
|
|
344
374
|
: 'library';
|
|
345
375
|
}
|
|
346
376
|
|
|
347
|
-
return next;
|
|
377
|
+
return withArkConfigMetadata(next);
|
|
348
378
|
}
|
|
349
379
|
|
|
350
380
|
/**
|
|
@@ -398,45 +428,8 @@ export function resolveOperatingMode({
|
|
|
398
428
|
return 'suggest';
|
|
399
429
|
}
|
|
400
430
|
|
|
401
|
-
/**
|
|
402
|
-
* Find uses of forbidden ambient globals in a TypeScript source file.
|
|
403
|
-
*
|
|
404
|
-
* Detection is deliberately positional, not scope-aware (kept in sync with
|
|
405
|
-
* `collectForbiddenGlobalUses` in src/kernel/ai-gate/AICodeGate.ts — the CLIs must not
|
|
406
|
-
* import from dist):
|
|
407
|
-
* - a dotted entry ("Date.now") flags `Date.now` property accesses
|
|
408
|
-
* - a bare entry ("console", "fetch") flags property accesses on it (`console.log`),
|
|
409
|
-
* direct calls (`fetch(...)`), and constructions (`new WebSocket(...)`)
|
|
410
|
-
* Bare identifier mentions in other positions (types, shadowed locals, import names) are
|
|
411
|
-
* NOT flagged, trading a little recall for near-zero false positives without a type checker.
|
|
412
|
-
*
|
|
413
|
-
* Returns [{ name, node }] where `name` is the matched forbidden entry.
|
|
414
|
-
*/
|
|
415
431
|
export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
|
|
416
|
-
|
|
417
|
-
if (entries.size === 0) return [];
|
|
418
|
-
const uses = [];
|
|
419
|
-
|
|
420
|
-
const visit = (node) => {
|
|
421
|
-
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
|
|
422
|
-
const dotted = `${node.expression.text}.${node.name.text}`;
|
|
423
|
-
if (entries.has(dotted)) {
|
|
424
|
-
uses.push({ name: dotted, node });
|
|
425
|
-
} else if (entries.has(node.expression.text)) {
|
|
426
|
-
uses.push({ name: node.expression.text, node });
|
|
427
|
-
}
|
|
428
|
-
} else if (
|
|
429
|
-
(ts.isCallExpression(node) || ts.isNewExpression(node)) &&
|
|
430
|
-
node.expression &&
|
|
431
|
-
ts.isIdentifier(node.expression) &&
|
|
432
|
-
entries.has(node.expression.text)
|
|
433
|
-
) {
|
|
434
|
-
uses.push({ name: node.expression.text, node });
|
|
435
|
-
}
|
|
436
|
-
ts.forEachChild(node, visit);
|
|
437
|
-
};
|
|
438
|
-
visit(sourceFile);
|
|
439
|
-
return uses;
|
|
432
|
+
return collectForbiddenCapabilityUses(ts, sourceFile, forbidden ?? []);
|
|
440
433
|
}
|
|
441
434
|
|
|
442
435
|
// Layer glob matching — generated from canonical src/domain/layerMatch.ts (see generate:layer-match).
|
|
@@ -479,17 +472,8 @@ export function resolveIntentLayer(intent, layers) {
|
|
|
479
472
|
return sorted.find((layer) => layer.prefixes.some((prefix) => intent.startsWith(prefix)))?.name;
|
|
480
473
|
}
|
|
481
474
|
|
|
482
|
-
/**
|
|
483
|
-
* Intent-name recognizer. Kept deliberately in sync with `looksLikeIntentName` in
|
|
484
|
-
* src/kernel/ai-gate/AICodeGate.ts: the two live in separate layers on purpose — the
|
|
485
|
-
* CLIs run standalone (with only `typescript` present, no build), so they must not
|
|
486
|
-
* import from the compiled library. Update both if the layer prefixes change.
|
|
487
|
-
*/
|
|
488
|
-
const INTENT_NAME =
|
|
489
|
-
/^(Domain|Application|Adapter|Workflow|Job|Presentation|Reporting|Metadata|Security|Audit|Observability|Kernel)\.[A-Za-z0-9_.]+$/;
|
|
490
|
-
|
|
491
475
|
export function looksLikeIntent(value) {
|
|
492
|
-
return
|
|
476
|
+
return looksLikeArkIntent(value);
|
|
493
477
|
}
|
|
494
478
|
|
|
495
479
|
/**
|
|
@@ -938,6 +922,85 @@ function isSourceFile(name) {
|
|
|
938
922
|
return /\.(tsx?|jsx?|mjsx?|cjsx?|mts|cts)$/i.test(name);
|
|
939
923
|
}
|
|
940
924
|
|
|
925
|
+
function readJsonc(file) {
|
|
926
|
+
try {
|
|
927
|
+
const text = fs
|
|
928
|
+
.readFileSync(file, 'utf8')
|
|
929
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
930
|
+
.replace(/^\s*\/\/.*$/gm, '')
|
|
931
|
+
.replace(/,\s*([}\]])/g, '$1');
|
|
932
|
+
return JSON.parse(text);
|
|
933
|
+
} catch {
|
|
934
|
+
return null;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function packageRole(rel, pkg) {
|
|
939
|
+
const tokens = rel.toLowerCase().split('/');
|
|
940
|
+
if (tokens.some((token) => ['docs', 'documentation', 'website', 'examples', 'example', 'tests', 'test', 'fixtures'].includes(token))) {
|
|
941
|
+
return tokens.some((token) => token.startsWith('doc') || token === 'website')
|
|
942
|
+
? 'docs'
|
|
943
|
+
: tokens.some((token) => token.startsWith('test') || token === 'fixtures')
|
|
944
|
+
? 'test'
|
|
945
|
+
: 'example';
|
|
946
|
+
}
|
|
947
|
+
if (pkg?.bin) return 'cli';
|
|
948
|
+
if (pkg?.exports || pkg?.main || pkg?.module || pkg?.types || pkg?.typings) return 'library';
|
|
949
|
+
return 'application';
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function entrypointDirs(pkg) {
|
|
953
|
+
const values = [];
|
|
954
|
+
const add = (value) => {
|
|
955
|
+
if (typeof value === 'string' && !value.includes('*')) values.push(value);
|
|
956
|
+
else if (value && typeof value === 'object') Object.values(value).forEach(add);
|
|
957
|
+
};
|
|
958
|
+
for (const key of ['main', 'module', 'types', 'typings', 'exports']) add(pkg?.[key]);
|
|
959
|
+
return values.map((value) => path.posix.dirname(value.replace(/^\.\//, ''))).filter((dir) => dir !== '.');
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** Package/import units and their explicit source roots, before framework inference. */
|
|
963
|
+
export function discoverRepoUnits(root) {
|
|
964
|
+
const packageRels = new Set(['.']);
|
|
965
|
+
for (const rel of detectTsPackageRoots(root)) packageRels.add(rel);
|
|
966
|
+
const units = [];
|
|
967
|
+
for (const rel of packageRels) {
|
|
968
|
+
const abs = rel === '.' ? root : path.join(root, rel);
|
|
969
|
+
const pkg = readPackageJson(abs);
|
|
970
|
+
if (!pkg && rel !== '.') continue;
|
|
971
|
+
const roots = new Set();
|
|
972
|
+
for (const name of ['src', 'source']) {
|
|
973
|
+
if (fs.existsSync(path.join(abs, name))) roots.add(name);
|
|
974
|
+
}
|
|
975
|
+
if (rel === '.') {
|
|
976
|
+
for (const name of ['api', 'lib', 'app', 'frontend', 'web', 'client']) {
|
|
977
|
+
if (dirHasTsSources(path.join(abs, name), 3)) roots.add(name);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
|
981
|
+
const config = readJsonc(path.join(abs, name));
|
|
982
|
+
const rootDir = config?.compilerOptions?.rootDir;
|
|
983
|
+
if (typeof rootDir === 'string') roots.add(normalizeRel(rootDir));
|
|
984
|
+
for (const ref of config?.references ?? []) {
|
|
985
|
+
if (typeof ref?.path === 'string') roots.add(normalizeRel(ref.path).replace(/^\.\//, ''));
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
for (const dir of entrypointDirs(pkg)) {
|
|
989
|
+
if (!['dist', 'build', 'lib'].includes(dir.split('/')[0])) roots.add(normalizeRel(dir));
|
|
990
|
+
}
|
|
991
|
+
if (roots.size === 0 && dirHasTsSources(abs, 2)) roots.add('.');
|
|
992
|
+
const productionDeps = { ...(pkg?.dependencies ?? {}), ...(pkg?.peerDependencies ?? {}), ...(pkg?.optionalDependencies ?? {}) };
|
|
993
|
+
units.push({
|
|
994
|
+
root: rel,
|
|
995
|
+
role: packageRole(rel, pkg),
|
|
996
|
+
sourceRoots: [...roots],
|
|
997
|
+
productionDeps,
|
|
998
|
+
devDependencies: { ...(pkg?.devDependencies ?? {}) },
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
return units;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
941
1004
|
function walkSourceFiles(dir, files = [], depth = 0) {
|
|
942
1005
|
if (depth > 12) return files;
|
|
943
1006
|
const stat = fs.statSync(dir, { throwIfNoEntry: false });
|
|
@@ -1016,36 +1079,9 @@ function countTsxFiles(files) {
|
|
|
1016
1079
|
*/
|
|
1017
1080
|
export function collectAggregatedDeps(root) {
|
|
1018
1081
|
const deps = {};
|
|
1019
|
-
const
|
|
1020
|
-
if (
|
|
1021
|
-
|
|
1022
|
-
const block = pkg[key];
|
|
1023
|
-
if (block && typeof block === 'object') Object.assign(deps, block);
|
|
1024
|
-
}
|
|
1025
|
-
};
|
|
1026
|
-
mergePkg(readPackageJson(root));
|
|
1027
|
-
let entries = [];
|
|
1028
|
-
try {
|
|
1029
|
-
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
1030
|
-
} catch {
|
|
1031
|
-
return deps;
|
|
1032
|
-
}
|
|
1033
|
-
for (const entry of entries) {
|
|
1034
|
-
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
1035
|
-
const childRoot = path.join(root, entry.name);
|
|
1036
|
-
mergePkg(readPackageJson(childRoot));
|
|
1037
|
-
// One level under conventional multi-package roots
|
|
1038
|
-
if (['packages', 'apps', 'services', 'plugins', 'packages-internal'].includes(entry.name)) {
|
|
1039
|
-
try {
|
|
1040
|
-
for (const sub of fs.readdirSync(childRoot, { withFileTypes: true })) {
|
|
1041
|
-
if (sub.isDirectory() && !sub.name.startsWith('.')) {
|
|
1042
|
-
mergePkg(readPackageJson(path.join(childRoot, sub.name)));
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
} catch {
|
|
1046
|
-
/* ignore */
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1082
|
+
for (const unit of discoverRepoUnits(root)) {
|
|
1083
|
+
if (['docs', 'example', 'test'].includes(unit.role)) continue;
|
|
1084
|
+
Object.assign(deps, unit.productionDeps);
|
|
1049
1085
|
}
|
|
1050
1086
|
return deps;
|
|
1051
1087
|
}
|
|
@@ -1056,27 +1092,32 @@ export function collectAggregatedDeps(root) {
|
|
|
1056
1092
|
*/
|
|
1057
1093
|
export function collectRepoShapeSignals(root) {
|
|
1058
1094
|
const pkg = readPackageJson(root);
|
|
1095
|
+
const repoUnits = discoverRepoUnits(root);
|
|
1059
1096
|
const workspaceDirs = detectWorkspaces(root);
|
|
1060
1097
|
const workspaces = workspaceDirs.length > 0;
|
|
1061
1098
|
// Include frontend/web/client — common Next monorepo app folders.
|
|
1062
|
-
const candidateScanDirs =
|
|
1063
|
-
'
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
'apps',
|
|
1068
|
-
'frontend',
|
|
1069
|
-
'web',
|
|
1070
|
-
'client',
|
|
1071
|
-
'app',
|
|
1072
|
-
...workspaceDirs,
|
|
1073
|
-
];
|
|
1099
|
+
const candidateScanDirs = repoUnits
|
|
1100
|
+
.filter((unit) => !['docs', 'example', 'test'].includes(unit.role))
|
|
1101
|
+
.flatMap((unit) => unit.sourceRoots.map((sourceRoot) =>
|
|
1102
|
+
normalizeRel(path.posix.join(unit.root === '.' ? '' : unit.root, sourceRoot))
|
|
1103
|
+
));
|
|
1074
1104
|
const srcDirs = [...new Set(candidateScanDirs)].filter((d) =>
|
|
1075
1105
|
fs.existsSync(path.join(root, d))
|
|
1076
1106
|
);
|
|
1077
1107
|
const scanRoots = srcDirs.length > 0 ? srcDirs.map((d) => path.join(root, d)) : [root];
|
|
1078
|
-
const sourceFiles = scanRoots.flatMap((dir) => walkSourceFiles(dir));
|
|
1108
|
+
const sourceFiles = [...new Set(scanRoots.flatMap((dir) => walkSourceFiles(dir)))];
|
|
1079
1109
|
const sourceFileCount = sourceFiles.length;
|
|
1110
|
+
const excludedUnitRoots = repoUnits
|
|
1111
|
+
.filter((unit) => ['docs', 'example', 'test'].includes(unit.role) && unit.root !== '.')
|
|
1112
|
+
.map((unit) => `${unit.root}/`);
|
|
1113
|
+
const productionFiles = walkSourceFiles(root).filter((file) => {
|
|
1114
|
+
const rel = path.relative(root, file).split(path.sep).join('/');
|
|
1115
|
+
return !excludedUnitRoots.some((prefix) => rel.startsWith(prefix));
|
|
1116
|
+
});
|
|
1117
|
+
const discoveredFileSet = new Set(sourceFiles.map((file) => path.resolve(file)));
|
|
1118
|
+
const projectedGovernedCoverage = productionFiles.length === 0
|
|
1119
|
+
? 0
|
|
1120
|
+
: Math.round((productionFiles.filter((file) => discoveredFileSet.has(path.resolve(file))).length / productionFiles.length) * 100);
|
|
1080
1121
|
const tinyTree = sourceFileCount < 3;
|
|
1081
1122
|
|
|
1082
1123
|
// Nested package.json deps (not root-only) — critical for monorepo Next under frontend/
|
|
@@ -1214,6 +1255,8 @@ export function collectRepoShapeSignals(root) {
|
|
|
1214
1255
|
}
|
|
1215
1256
|
|
|
1216
1257
|
return {
|
|
1258
|
+
repoUnits,
|
|
1259
|
+
discoveredRoots: srcDirs,
|
|
1217
1260
|
workspaces,
|
|
1218
1261
|
workspaceDirs,
|
|
1219
1262
|
ui,
|
|
@@ -1231,6 +1274,7 @@ export function collectRepoShapeSignals(root) {
|
|
|
1231
1274
|
libraryOnly,
|
|
1232
1275
|
tinyTree,
|
|
1233
1276
|
sourceFileCount,
|
|
1277
|
+
projectedGovernedCoverage,
|
|
1234
1278
|
domain,
|
|
1235
1279
|
application,
|
|
1236
1280
|
domainHeavy,
|
|
@@ -1317,6 +1361,16 @@ export function whyFromMatchedSignals(signals, matched) {
|
|
|
1317
1361
|
return why;
|
|
1318
1362
|
}
|
|
1319
1363
|
|
|
1364
|
+
function evidenceFromMatchedSignals(signals, matched) {
|
|
1365
|
+
return (matched ?? []).flatMap((token) => {
|
|
1366
|
+
const negative = token.startsWith('!');
|
|
1367
|
+
const signal = negative ? token.slice(1) : token;
|
|
1368
|
+
const label = negative ? NEGATIVE_SIGNAL_WHY[signal] : SIGNAL_WHY[signal];
|
|
1369
|
+
if (!label || !signals[signal]) return [];
|
|
1370
|
+
return [{ signal, effect: negative ? 'negative' : 'positive', explanation: label(signals) }];
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1320
1374
|
export function defaultPlaybookPath() {
|
|
1321
1375
|
return path.join(
|
|
1322
1376
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -1401,6 +1455,11 @@ export function scoreArchetypes(signals, playbook) {
|
|
|
1401
1455
|
label: fallback.label,
|
|
1402
1456
|
preset: fallback.preset,
|
|
1403
1457
|
confidence,
|
|
1458
|
+
requiresConfirmation: true,
|
|
1459
|
+
confirmationReasons: [
|
|
1460
|
+
`projected governed coverage is ${signals.projectedGovernedCoverage ?? 0}% (below 90%)`,
|
|
1461
|
+
'no archetype received a positive score',
|
|
1462
|
+
],
|
|
1404
1463
|
phases: fallback.phases,
|
|
1405
1464
|
analogy: fallback.analogy,
|
|
1406
1465
|
antiPatterns: fallback.antiPatterns,
|
|
@@ -1422,6 +1481,10 @@ export function scoreArchetypes(signals, playbook) {
|
|
|
1422
1481
|
if (thinTs) {
|
|
1423
1482
|
confidence = Math.min(confidence, 0.28);
|
|
1424
1483
|
}
|
|
1484
|
+
const closeRecommendations = Boolean(second && top.score - second.score <= 2);
|
|
1485
|
+
const lowProjectedCoverage = (signals.projectedGovernedCoverage ?? 0) < 90;
|
|
1486
|
+
if (closeRecommendations) confidence = Math.min(confidence, 0.49);
|
|
1487
|
+
if (lowProjectedCoverage) confidence = Math.min(confidence, 0.49);
|
|
1425
1488
|
|
|
1426
1489
|
return {
|
|
1427
1490
|
ranked: scored,
|
|
@@ -1436,6 +1499,12 @@ export function scoreArchetypes(signals, playbook) {
|
|
|
1436
1499
|
'TypeScript/JS surface is thin or missing — treat the archetype as a weak hint. Prefer ark-check --suggest-include / --adopt-contract on the real package roots before scaffolding.',
|
|
1437
1500
|
}
|
|
1438
1501
|
: {}),
|
|
1502
|
+
requiresConfirmation: closeRecommendations || thinTs || lowProjectedCoverage,
|
|
1503
|
+
confirmationReasons: [
|
|
1504
|
+
...(closeRecommendations ? ['top recommendations are within 2 score points'] : []),
|
|
1505
|
+
...(lowProjectedCoverage ? [`projected governed coverage is ${signals.projectedGovernedCoverage}% (below 90%)`] : []),
|
|
1506
|
+
...(thinTs ? ['the discovered source surface is thin'] : []),
|
|
1507
|
+
],
|
|
1439
1508
|
phases: top.phases,
|
|
1440
1509
|
analogy: top.analogy,
|
|
1441
1510
|
antiPatterns: top.antiPatterns,
|
|
@@ -1473,6 +1542,8 @@ export function buildArchitectureRecommendation(root, options = {}) {
|
|
|
1473
1542
|
label: result.label,
|
|
1474
1543
|
preset: result.preset,
|
|
1475
1544
|
confidence: result.confidence,
|
|
1545
|
+
requiresConfirmation: result.requiresConfirmation ?? false,
|
|
1546
|
+
confirmationReasons: result.confirmationReasons ?? [],
|
|
1476
1547
|
...(result.thinTsSurface ? { thinTsSurface: true, caution: result.caution } : {}),
|
|
1477
1548
|
phases: result.phases,
|
|
1478
1549
|
adoptInOrder,
|
|
@@ -1480,6 +1551,7 @@ export function buildArchitectureRecommendation(root, options = {}) {
|
|
|
1480
1551
|
antiPatterns: result.antiPatterns,
|
|
1481
1552
|
books: result.books,
|
|
1482
1553
|
why: whyFromMatchedSignals(signals, result.matched),
|
|
1554
|
+
evidence: evidenceFromMatchedSignals(signals, result.matched),
|
|
1483
1555
|
matchedSignals: result.matched,
|
|
1484
1556
|
runnerUp: result.runnerUp,
|
|
1485
1557
|
toolHints: signals.toolHints,
|
|
@@ -1487,6 +1559,15 @@ export function buildArchitectureRecommendation(root, options = {}) {
|
|
|
1487
1559
|
policyPack: policyPackId,
|
|
1488
1560
|
signals: {
|
|
1489
1561
|
sourceFileCount: signals.sourceFileCount,
|
|
1562
|
+
projectedGovernedCoverage: signals.projectedGovernedCoverage,
|
|
1563
|
+
discoveredRoots: signals.discoveredRoots,
|
|
1564
|
+
packageUnits: signals.repoUnits.map((unit) => ({
|
|
1565
|
+
root: unit.root,
|
|
1566
|
+
role: unit.role,
|
|
1567
|
+
sourceRoots: unit.sourceRoots,
|
|
1568
|
+
productionDependencies: Object.keys(unit.productionDeps).sort(),
|
|
1569
|
+
devOnlyDependencies: Object.keys(unit.devDependencies).sort(),
|
|
1570
|
+
})),
|
|
1490
1571
|
workspaces: signals.workspaces,
|
|
1491
1572
|
ui: signals.ui,
|
|
1492
1573
|
apiSurface: signals.apiSurface,
|
|
@@ -1583,6 +1664,10 @@ export function formatArchitectureRecommendationHuman(recommendation) {
|
|
|
1583
1664
|
lines.push('');
|
|
1584
1665
|
lines.push(`Archetype: ${recommendation.archetype} — ${recommendation.label}`);
|
|
1585
1666
|
lines.push(`Preset: ${recommendation.preset} (confidence ${recommendation.confidence})`);
|
|
1667
|
+
if (recommendation.requiresConfirmation) {
|
|
1668
|
+
lines.push('⚠ Confirmation required before applying this recommendation.');
|
|
1669
|
+
for (const reason of recommendation.confirmationReasons ?? []) lines.push(` - ${reason}`);
|
|
1670
|
+
}
|
|
1586
1671
|
if (recommendation.galleryStarter) {
|
|
1587
1672
|
lines.push(`Gallery starter: ${recommendation.galleryStarter}`);
|
|
1588
1673
|
}
|
|
@@ -1692,6 +1777,8 @@ export function buildAdoptionPlanDocument(recommendation) {
|
|
|
1692
1777
|
label: recommendation.label,
|
|
1693
1778
|
preset: recommendation.preset,
|
|
1694
1779
|
confidence: recommendation.confidence,
|
|
1780
|
+
requiresConfirmation: recommendation.requiresConfirmation ?? false,
|
|
1781
|
+
confirmationReasons: recommendation.confirmationReasons ?? [],
|
|
1695
1782
|
phases: recommendation.phases,
|
|
1696
1783
|
adoptInOrder: recommendation.adoptInOrder,
|
|
1697
1784
|
matchedSignals: recommendation.matchedSignals ?? [],
|