arkgate 2.13.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/README.md +53 -36
- package/bin/ark-check.mjs +72 -6
- package/bin/ark-mcp.mjs +108 -1
- package/bin/ark-shared.mjs +204 -149
- package/bin/ark.mjs +90 -25
- package/bin/lib/adapter-contract.mjs +93 -0
- package/bin/lib/agent-gates.mjs +1 -0
- package/bin/lib/analysis-engine.mjs +1171 -0
- package/bin/lib/architecture-scan.mjs +84 -135
- package/bin/lib/ci-and-commands.mjs +51 -15
- package/bin/lib/config-warnings.mjs +7 -205
- package/bin/lib/design-smells.mjs +434 -0
- package/bin/lib/doctor-plan.mjs +149 -16
- 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 +7 -1
- package/bin/lib/install-migrate.mjs +54 -16
- package/bin/lib/presets.mjs +42 -2
- package/bin/lib/safety-diagnostics.mjs +18 -17
- package/bin/lib/scan-files.mjs +12 -1
- package/bin/lib/skill-install.mjs +8 -1
- package/bin/lib/source-policy.mjs +36 -0
- package/bin/lib/start-preview.mjs +271 -0
- package/bin/lib/ts-resolve.mjs +11 -2
- package/bin/lib/write-path-capabilities.mjs +4 -0
- 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 +125 -48
- package/dist/eslint/index.d.cts +7 -1
- package/dist/eslint/index.d.ts +7 -1
- package/dist/eslint/index.js +125 -48
- package/dist/index.cjs +1248 -3302
- package/dist/index.d.cts +359 -483
- package/dist/index.d.ts +359 -483
- package/dist/index.js +1231 -3248
- package/docs/agent-guide.md +34 -16
- package/docs/ai-gates.md +30 -7
- package/docs/brownfield-adoption.md +52 -1
- package/docs/migrate-from-ark-runtime-kernel.md +2 -3
- package/docs/package-surface.md +10 -13
- package/docs/production-hardening.md +17 -4
- package/docs/typescript-support.md +27 -0
- package/package.json +33 -11
- package/schemas/ark.analysis-result.schema.json +91 -0
- package/server.json +2 -2
- package/templates/skills/ark-adopt.md +26 -3
- package/templates/skills/ark-architect.md +10 -2
- package/templates/skills/ark-autopilot.md +37 -20
- package/templates/skills/ark-contract.md +7 -0
- package/templates/skills/ark-coverage.md +44 -45
- package/templates/skills/ark-explain.md +8 -0
- package/templates/skills/ark-explore.md +117 -47
- package/templates/skills/ark-fix.md +22 -0
- package/templates/skills/ark-loop.md +15 -1
- package/templates/skills/ark-place.md +7 -0
- package/templates/skills/ark-think.md +24 -20
- package/dist/configContract-iBLxx5Tz.d.cts +0 -53
- package/dist/configContract-iBLxx5Tz.d.ts +0 -53
- 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 -2606
- package/dist/nestjs/index.cjs.map +0 -1
- package/dist/nestjs/index.d.cts +0 -23
- package/dist/nestjs/index.d.ts +0 -23
- package/dist/nestjs/index.js +0 -2582
- package/dist/nestjs/index.js.map +0 -1
- package/dist/runtime/index.cjs +0 -4014
- package/dist/runtime/index.cjs.map +0 -1
- package/dist/runtime/index.d.cts +0 -3
- package/dist/runtime/index.d.ts +0 -3
- package/dist/runtime/index.js +0 -3925
- package/dist/runtime/index.js.map +0 -1
- package/dist/types-BxBwnBpC.d.cts +0 -1041
- package/dist/types-Wcs_l1_J.d.ts +0 -1041
package/bin/ark-shared.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
DEFAULT_ARK_CONFIG_RULES,
|
|
6
6
|
withArkConfigMetadata,
|
|
7
7
|
} from './lib/config-contract.mjs';
|
|
8
|
+
import { collectForbiddenCapabilityUses } from './lib/analysis-engine.mjs';
|
|
9
|
+
import { looksLikeArkIntent } from './lib/source-policy.mjs';
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Default intent-prefix map shared by both CLIs and the ark-mcp write-path gate. The rule
|
|
@@ -130,6 +132,40 @@ export function applyFrameworkLayoutOverlays(config, root) {
|
|
|
130
132
|
}
|
|
131
133
|
};
|
|
132
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
|
+
|
|
133
169
|
if (signals.nestFramework) {
|
|
134
170
|
ensureInclude('src');
|
|
135
171
|
// Nest flat + modular conventions (controllers/services next to modules).
|
|
@@ -312,7 +348,26 @@ export function applyFrameworkLayoutOverlays(config, root) {
|
|
|
312
348
|
if (signals.libraryOnly && !signals.nestFramework && !signals.nextFramework) {
|
|
313
349
|
ensureInclude('src');
|
|
314
350
|
ensureInclude('lib');
|
|
315
|
-
|
|
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
|
+
]);
|
|
316
371
|
// Prefer domain over application for a single-folder lib: only domain if no split.
|
|
317
372
|
next.frameworkOverlay = next.frameworkOverlay
|
|
318
373
|
? `${next.frameworkOverlay}+library`
|
|
@@ -373,102 +428,8 @@ export function resolveOperatingMode({
|
|
|
373
428
|
return 'suggest';
|
|
374
429
|
}
|
|
375
430
|
|
|
376
|
-
function singleFileTypeChecker(ts, sourceFile) {
|
|
377
|
-
const options = {
|
|
378
|
-
noLib: true,
|
|
379
|
-
noResolve: true,
|
|
380
|
-
target: ts.ScriptTarget.Latest,
|
|
381
|
-
};
|
|
382
|
-
const host = ts.createCompilerHost(options, true);
|
|
383
|
-
host.getSourceFile = (fileName) =>
|
|
384
|
-
fileName === sourceFile.fileName ? sourceFile : undefined;
|
|
385
|
-
host.fileExists = (fileName) => fileName === sourceFile.fileName;
|
|
386
|
-
host.readFile = (fileName) =>
|
|
387
|
-
fileName === sourceFile.fileName ? sourceFile.text : undefined;
|
|
388
|
-
return ts.createProgram([sourceFile.fileName], options, host).getTypeChecker();
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
function propertyAccessPath(ts, node) {
|
|
392
|
-
const segments = [];
|
|
393
|
-
let current = node;
|
|
394
|
-
while (ts.isPropertyAccessExpression(current)) {
|
|
395
|
-
segments.unshift(current.name.text);
|
|
396
|
-
current = current.expression;
|
|
397
|
-
}
|
|
398
|
-
if (!ts.isIdentifier(current)) return undefined;
|
|
399
|
-
segments.unshift(current.text);
|
|
400
|
-
return { root: current, segments };
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
function isRuntimeIdentifierReference(ts, node) {
|
|
404
|
-
if (ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) return false;
|
|
405
|
-
return (
|
|
406
|
-
(ts.isExpressionNode(node) && !ts.isInTypeQuery(node)) ||
|
|
407
|
-
(ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node)
|
|
408
|
-
);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
function hasLocalDeclaration(ts, checker, sourceFile, node) {
|
|
412
|
-
const shorthand =
|
|
413
|
-
ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node;
|
|
414
|
-
const symbol = shorthand
|
|
415
|
-
? checker.getShorthandAssignmentValueSymbol(node.parent)
|
|
416
|
-
: checker.getSymbolAtLocation(node);
|
|
417
|
-
return Boolean(
|
|
418
|
-
symbol?.declarations?.some((declaration) => declaration.getSourceFile() === sourceFile)
|
|
419
|
-
);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Find uses of forbidden ambient globals in a TypeScript source file.
|
|
424
|
-
*
|
|
425
|
-
* A no-lib, no-resolution TypeScript program binds declarations in this file only. An
|
|
426
|
-
* identifier with a symbol is therefore local (parameter, variable, import, etc.); an
|
|
427
|
-
* unbound runtime identifier is ambient. Dotted entries use AST property chains and
|
|
428
|
-
* explicit `globalThis` access is normalized to the configured global name.
|
|
429
|
-
*
|
|
430
|
-
* Kept in sync with `analyzeForbiddenGlobals` in
|
|
431
|
-
* src/kernel/ai-gate/AICodeGate.ts — the standalone CLIs must not import from dist.
|
|
432
|
-
* Returns [{ name, node }] where `name` is the matched forbidden entry.
|
|
433
|
-
*/
|
|
434
431
|
export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
|
|
435
|
-
|
|
436
|
-
if (entries.size === 0) return [];
|
|
437
|
-
const checker = singleFileTypeChecker(ts, sourceFile);
|
|
438
|
-
const uses = [];
|
|
439
|
-
|
|
440
|
-
const visit = (node) => {
|
|
441
|
-
const nestedPropertyAccess =
|
|
442
|
-
ts.isPropertyAccessExpression(node) &&
|
|
443
|
-
ts.isPropertyAccessExpression(node.parent) &&
|
|
444
|
-
node.parent.expression === node;
|
|
445
|
-
if (ts.isPropertyAccessExpression(node) && !nestedPropertyAccess) {
|
|
446
|
-
const path = propertyAccessPath(ts, node);
|
|
447
|
-
if (path && !hasLocalDeclaration(ts, checker, sourceFile, path.root)) {
|
|
448
|
-
const explicitGlobalThis = path.segments[0] === 'globalThis';
|
|
449
|
-
const normalized = explicitGlobalThis ? path.segments.slice(1) : path.segments;
|
|
450
|
-
let match;
|
|
451
|
-
for (let length = normalized.length; length >= (explicitGlobalThis ? 1 : 2); length -= 1) {
|
|
452
|
-
const candidate = normalized.slice(0, length).join('.');
|
|
453
|
-
if (entries.has(candidate)) {
|
|
454
|
-
match = candidate;
|
|
455
|
-
break;
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
if (match) uses.push({ name: match, node });
|
|
459
|
-
}
|
|
460
|
-
} else if (
|
|
461
|
-
ts.isIdentifier(node) &&
|
|
462
|
-
entries.has(node.text) &&
|
|
463
|
-
isRuntimeIdentifierReference(ts, node) &&
|
|
464
|
-
!hasLocalDeclaration(ts, checker, sourceFile, node)
|
|
465
|
-
) {
|
|
466
|
-
uses.push({ name: node.text, node });
|
|
467
|
-
}
|
|
468
|
-
ts.forEachChild(node, visit);
|
|
469
|
-
};
|
|
470
|
-
visit(sourceFile);
|
|
471
|
-
return uses;
|
|
432
|
+
return collectForbiddenCapabilityUses(ts, sourceFile, forbidden ?? []);
|
|
472
433
|
}
|
|
473
434
|
|
|
474
435
|
// Layer glob matching — generated from canonical src/domain/layerMatch.ts (see generate:layer-match).
|
|
@@ -511,17 +472,8 @@ export function resolveIntentLayer(intent, layers) {
|
|
|
511
472
|
return sorted.find((layer) => layer.prefixes.some((prefix) => intent.startsWith(prefix)))?.name;
|
|
512
473
|
}
|
|
513
474
|
|
|
514
|
-
/**
|
|
515
|
-
* Intent-name recognizer. Kept deliberately in sync with `looksLikeIntentName` in
|
|
516
|
-
* src/kernel/ai-gate/AICodeGate.ts: the two live in separate layers on purpose — the
|
|
517
|
-
* CLIs run standalone (with only `typescript` present, no build), so they must not
|
|
518
|
-
* import from the compiled library. Update both if the layer prefixes change.
|
|
519
|
-
*/
|
|
520
|
-
const INTENT_NAME =
|
|
521
|
-
/^(Domain|Application|Adapter|Workflow|Job|Presentation|Reporting|Metadata|Security|Audit|Observability|Kernel)\.[A-Za-z0-9_.]+$/;
|
|
522
|
-
|
|
523
475
|
export function looksLikeIntent(value) {
|
|
524
|
-
return
|
|
476
|
+
return looksLikeArkIntent(value);
|
|
525
477
|
}
|
|
526
478
|
|
|
527
479
|
/**
|
|
@@ -970,6 +922,85 @@ function isSourceFile(name) {
|
|
|
970
922
|
return /\.(tsx?|jsx?|mjsx?|cjsx?|mts|cts)$/i.test(name);
|
|
971
923
|
}
|
|
972
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
|
+
|
|
973
1004
|
function walkSourceFiles(dir, files = [], depth = 0) {
|
|
974
1005
|
if (depth > 12) return files;
|
|
975
1006
|
const stat = fs.statSync(dir, { throwIfNoEntry: false });
|
|
@@ -1048,36 +1079,9 @@ function countTsxFiles(files) {
|
|
|
1048
1079
|
*/
|
|
1049
1080
|
export function collectAggregatedDeps(root) {
|
|
1050
1081
|
const deps = {};
|
|
1051
|
-
const
|
|
1052
|
-
if (
|
|
1053
|
-
|
|
1054
|
-
const block = pkg[key];
|
|
1055
|
-
if (block && typeof block === 'object') Object.assign(deps, block);
|
|
1056
|
-
}
|
|
1057
|
-
};
|
|
1058
|
-
mergePkg(readPackageJson(root));
|
|
1059
|
-
let entries = [];
|
|
1060
|
-
try {
|
|
1061
|
-
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
1062
|
-
} catch {
|
|
1063
|
-
return deps;
|
|
1064
|
-
}
|
|
1065
|
-
for (const entry of entries) {
|
|
1066
|
-
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
1067
|
-
const childRoot = path.join(root, entry.name);
|
|
1068
|
-
mergePkg(readPackageJson(childRoot));
|
|
1069
|
-
// One level under conventional multi-package roots
|
|
1070
|
-
if (['packages', 'apps', 'services', 'plugins', 'packages-internal'].includes(entry.name)) {
|
|
1071
|
-
try {
|
|
1072
|
-
for (const sub of fs.readdirSync(childRoot, { withFileTypes: true })) {
|
|
1073
|
-
if (sub.isDirectory() && !sub.name.startsWith('.')) {
|
|
1074
|
-
mergePkg(readPackageJson(path.join(childRoot, sub.name)));
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
|
-
} catch {
|
|
1078
|
-
/* ignore */
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1082
|
+
for (const unit of discoverRepoUnits(root)) {
|
|
1083
|
+
if (['docs', 'example', 'test'].includes(unit.role)) continue;
|
|
1084
|
+
Object.assign(deps, unit.productionDeps);
|
|
1081
1085
|
}
|
|
1082
1086
|
return deps;
|
|
1083
1087
|
}
|
|
@@ -1088,27 +1092,32 @@ export function collectAggregatedDeps(root) {
|
|
|
1088
1092
|
*/
|
|
1089
1093
|
export function collectRepoShapeSignals(root) {
|
|
1090
1094
|
const pkg = readPackageJson(root);
|
|
1095
|
+
const repoUnits = discoverRepoUnits(root);
|
|
1091
1096
|
const workspaceDirs = detectWorkspaces(root);
|
|
1092
1097
|
const workspaces = workspaceDirs.length > 0;
|
|
1093
1098
|
// Include frontend/web/client — common Next monorepo app folders.
|
|
1094
|
-
const candidateScanDirs =
|
|
1095
|
-
'
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
'apps',
|
|
1100
|
-
'frontend',
|
|
1101
|
-
'web',
|
|
1102
|
-
'client',
|
|
1103
|
-
'app',
|
|
1104
|
-
...workspaceDirs,
|
|
1105
|
-
];
|
|
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
|
+
));
|
|
1106
1104
|
const srcDirs = [...new Set(candidateScanDirs)].filter((d) =>
|
|
1107
1105
|
fs.existsSync(path.join(root, d))
|
|
1108
1106
|
);
|
|
1109
1107
|
const scanRoots = srcDirs.length > 0 ? srcDirs.map((d) => path.join(root, d)) : [root];
|
|
1110
|
-
const sourceFiles = scanRoots.flatMap((dir) => walkSourceFiles(dir));
|
|
1108
|
+
const sourceFiles = [...new Set(scanRoots.flatMap((dir) => walkSourceFiles(dir)))];
|
|
1111
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);
|
|
1112
1121
|
const tinyTree = sourceFileCount < 3;
|
|
1113
1122
|
|
|
1114
1123
|
// Nested package.json deps (not root-only) — critical for monorepo Next under frontend/
|
|
@@ -1246,6 +1255,8 @@ export function collectRepoShapeSignals(root) {
|
|
|
1246
1255
|
}
|
|
1247
1256
|
|
|
1248
1257
|
return {
|
|
1258
|
+
repoUnits,
|
|
1259
|
+
discoveredRoots: srcDirs,
|
|
1249
1260
|
workspaces,
|
|
1250
1261
|
workspaceDirs,
|
|
1251
1262
|
ui,
|
|
@@ -1263,6 +1274,7 @@ export function collectRepoShapeSignals(root) {
|
|
|
1263
1274
|
libraryOnly,
|
|
1264
1275
|
tinyTree,
|
|
1265
1276
|
sourceFileCount,
|
|
1277
|
+
projectedGovernedCoverage,
|
|
1266
1278
|
domain,
|
|
1267
1279
|
application,
|
|
1268
1280
|
domainHeavy,
|
|
@@ -1349,6 +1361,16 @@ export function whyFromMatchedSignals(signals, matched) {
|
|
|
1349
1361
|
return why;
|
|
1350
1362
|
}
|
|
1351
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
|
+
|
|
1352
1374
|
export function defaultPlaybookPath() {
|
|
1353
1375
|
return path.join(
|
|
1354
1376
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -1433,6 +1455,11 @@ export function scoreArchetypes(signals, playbook) {
|
|
|
1433
1455
|
label: fallback.label,
|
|
1434
1456
|
preset: fallback.preset,
|
|
1435
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
|
+
],
|
|
1436
1463
|
phases: fallback.phases,
|
|
1437
1464
|
analogy: fallback.analogy,
|
|
1438
1465
|
antiPatterns: fallback.antiPatterns,
|
|
@@ -1454,6 +1481,10 @@ export function scoreArchetypes(signals, playbook) {
|
|
|
1454
1481
|
if (thinTs) {
|
|
1455
1482
|
confidence = Math.min(confidence, 0.28);
|
|
1456
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);
|
|
1457
1488
|
|
|
1458
1489
|
return {
|
|
1459
1490
|
ranked: scored,
|
|
@@ -1468,6 +1499,12 @@ export function scoreArchetypes(signals, playbook) {
|
|
|
1468
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.',
|
|
1469
1500
|
}
|
|
1470
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
|
+
],
|
|
1471
1508
|
phases: top.phases,
|
|
1472
1509
|
analogy: top.analogy,
|
|
1473
1510
|
antiPatterns: top.antiPatterns,
|
|
@@ -1505,6 +1542,8 @@ export function buildArchitectureRecommendation(root, options = {}) {
|
|
|
1505
1542
|
label: result.label,
|
|
1506
1543
|
preset: result.preset,
|
|
1507
1544
|
confidence: result.confidence,
|
|
1545
|
+
requiresConfirmation: result.requiresConfirmation ?? false,
|
|
1546
|
+
confirmationReasons: result.confirmationReasons ?? [],
|
|
1508
1547
|
...(result.thinTsSurface ? { thinTsSurface: true, caution: result.caution } : {}),
|
|
1509
1548
|
phases: result.phases,
|
|
1510
1549
|
adoptInOrder,
|
|
@@ -1512,6 +1551,7 @@ export function buildArchitectureRecommendation(root, options = {}) {
|
|
|
1512
1551
|
antiPatterns: result.antiPatterns,
|
|
1513
1552
|
books: result.books,
|
|
1514
1553
|
why: whyFromMatchedSignals(signals, result.matched),
|
|
1554
|
+
evidence: evidenceFromMatchedSignals(signals, result.matched),
|
|
1515
1555
|
matchedSignals: result.matched,
|
|
1516
1556
|
runnerUp: result.runnerUp,
|
|
1517
1557
|
toolHints: signals.toolHints,
|
|
@@ -1519,6 +1559,15 @@ export function buildArchitectureRecommendation(root, options = {}) {
|
|
|
1519
1559
|
policyPack: policyPackId,
|
|
1520
1560
|
signals: {
|
|
1521
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
|
+
})),
|
|
1522
1571
|
workspaces: signals.workspaces,
|
|
1523
1572
|
ui: signals.ui,
|
|
1524
1573
|
apiSurface: signals.apiSurface,
|
|
@@ -1615,6 +1664,10 @@ export function formatArchitectureRecommendationHuman(recommendation) {
|
|
|
1615
1664
|
lines.push('');
|
|
1616
1665
|
lines.push(`Archetype: ${recommendation.archetype} — ${recommendation.label}`);
|
|
1617
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
|
+
}
|
|
1618
1671
|
if (recommendation.galleryStarter) {
|
|
1619
1672
|
lines.push(`Gallery starter: ${recommendation.galleryStarter}`);
|
|
1620
1673
|
}
|
|
@@ -1724,6 +1777,8 @@ export function buildAdoptionPlanDocument(recommendation) {
|
|
|
1724
1777
|
label: recommendation.label,
|
|
1725
1778
|
preset: recommendation.preset,
|
|
1726
1779
|
confidence: recommendation.confidence,
|
|
1780
|
+
requiresConfirmation: recommendation.requiresConfirmation ?? false,
|
|
1781
|
+
confirmationReasons: recommendation.confirmationReasons ?? [],
|
|
1727
1782
|
phases: recommendation.phases,
|
|
1728
1783
|
adoptInOrder: recommendation.adoptInOrder,
|
|
1729
1784
|
matchedSignals: recommendation.matchedSignals ?? [],
|