arkgate 2.5.0 → 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/README.md +90 -67
- package/bin/ark-check.mjs +458 -3918
- package/bin/ark-layer-match.mjs +197 -0
- package/bin/ark-mcp.mjs +102 -5
- package/bin/ark-shared.mjs +303 -137
- package/bin/ark.mjs +44 -34
- package/bin/lib/agent-gates.mjs +1983 -0
- package/bin/lib/doctor-plan.mjs +510 -0
- package/bin/lib/html-report.mjs +1301 -0
- package/bin/lib/presets.mjs +315 -0
- package/bin/lib/suggestions.mjs +109 -0
- package/bin/lib/violations.mjs +170 -0
- package/dist/eslint/index.cjs +32 -27
- package/dist/eslint/index.cjs.map +1 -1
- package/dist/eslint/index.d.cts +22 -6
- package/dist/eslint/index.d.ts +22 -6
- package/dist/eslint/index.js +32 -27
- package/dist/eslint/index.js.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +1 -1
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.js +1 -1
- package/dist/nestjs/index.js.map +1 -1
- package/docs/agent-guide.md +63 -0
- package/package.json +2 -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-shared.mjs
CHANGED
|
@@ -224,6 +224,10 @@ export function applyFrameworkLayoutOverlays(config, root) {
|
|
|
224
224
|
'src/services/**',
|
|
225
225
|
'src/use-cases/**',
|
|
226
226
|
'src/lib/**',
|
|
227
|
+
// Common Next "app core" bags (API clients, hooks, auth, stores) — not UI routes.
|
|
228
|
+
// Without these, monorepos like */src/core/** stay ungoverned and produce false greens.
|
|
229
|
+
'src/core/**',
|
|
230
|
+
'**/core/**',
|
|
227
231
|
'src/actions/**',
|
|
228
232
|
'src/**/actions.ts',
|
|
229
233
|
'src/**/actions.tsx',
|
|
@@ -244,6 +248,20 @@ export function applyFrameworkLayoutOverlays(config, root) {
|
|
|
244
248
|
'src/lib/prisma/**',
|
|
245
249
|
'src/server/db/**',
|
|
246
250
|
]);
|
|
251
|
+
// Demo assets, generated public output, and tool configs are not architecture surface.
|
|
252
|
+
const nextExcludes = [
|
|
253
|
+
'**/public/**',
|
|
254
|
+
'**/*.config.js',
|
|
255
|
+
'**/*.config.ts',
|
|
256
|
+
'**/*.config.mjs',
|
|
257
|
+
'**/playwright*.ts',
|
|
258
|
+
'**/eslint.config.*',
|
|
259
|
+
'**/postcss.config.*',
|
|
260
|
+
'**/prettier.config.*',
|
|
261
|
+
'**/rstest.config.*',
|
|
262
|
+
'**/scripts/**',
|
|
263
|
+
];
|
|
264
|
+
next.exclude = [...new Set([...(next.exclude ?? []), ...nextExcludes])];
|
|
247
265
|
next.frameworkOverlay = next.frameworkOverlay
|
|
248
266
|
? `${next.frameworkOverlay}+next`
|
|
249
267
|
: 'next';
|
|
@@ -362,137 +380,17 @@ export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
|
|
|
362
380
|
return uses;
|
|
363
381
|
}
|
|
364
382
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if (c === '\\') {
|
|
377
|
-
i += 1; // skip the escaped character
|
|
378
|
-
continue;
|
|
379
|
-
}
|
|
380
|
-
if (c === '{') depth += 1;
|
|
381
|
-
else if (c === '}') {
|
|
382
|
-
depth -= 1;
|
|
383
|
-
if (depth < 0) return false;
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
return depth === 0;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* Convert an ark.config.json layer glob pattern to an anchored RegExp (compiled once per
|
|
391
|
-
* pattern, then cached).
|
|
392
|
-
*
|
|
393
|
-
* IMPORTANT: the double-star is expanded in a SINGLE pass. A chained two-step replace
|
|
394
|
-
* (double-star to dot-star, then single-star to a no-slash class) corrupts the double-star,
|
|
395
|
-
* because the second step re-matches the star inside the substitution the first step just
|
|
396
|
-
* inserted. That made "src/kernel/**" stop matching nested paths, silently unclassifying
|
|
397
|
-
* every file in a subdirectory. Scanning one character at a time also lets us support
|
|
398
|
-
* brace alternation ("*.{ts,tsx}") and backslash escapes ("\\{" → literal brace).
|
|
399
|
-
*
|
|
400
|
-
* Brace alternation is only enabled when braces are balanced; an unbalanced brace (a config
|
|
401
|
-
* typo) is treated as a literal so the gate never crashes on `new RegExp`.
|
|
402
|
-
*/
|
|
403
|
-
export function globToRegExp(pattern) {
|
|
404
|
-
const cached = _regexpCache.get(pattern);
|
|
405
|
-
if (cached) return cached;
|
|
406
|
-
|
|
407
|
-
const glob = pattern.split(path.sep).join('/');
|
|
408
|
-
const useBraces = bracesBalanced(glob);
|
|
409
|
-
let out = '';
|
|
410
|
-
let braceDepth = 0;
|
|
411
|
-
for (let i = 0; i < glob.length; i += 1) {
|
|
412
|
-
const c = glob[i];
|
|
413
|
-
if (c === '\\' && i + 1 < glob.length) {
|
|
414
|
-
out += escapeLiteral(glob[i + 1]); // backslash escapes the next char to a literal
|
|
415
|
-
i += 1;
|
|
416
|
-
} else if (c === '*') {
|
|
417
|
-
if (glob[i + 1] === '*') {
|
|
418
|
-
if (glob[i + 2] === '/') {
|
|
419
|
-
out += '(?:.*/)?'; // `**/` matches zero or more path segments
|
|
420
|
-
i += 2;
|
|
421
|
-
} else {
|
|
422
|
-
out += '.*'; // `**` matches across `/`
|
|
423
|
-
i += 1;
|
|
424
|
-
}
|
|
425
|
-
} else {
|
|
426
|
-
out += '[^/]*'; // `*` matches within a single segment
|
|
427
|
-
}
|
|
428
|
-
} else if (c === '?') {
|
|
429
|
-
out += '[^/]';
|
|
430
|
-
} else if (c === '{' && useBraces) {
|
|
431
|
-
out += '(?:';
|
|
432
|
-
braceDepth += 1;
|
|
433
|
-
} else if (c === '}' && useBraces && braceDepth > 0) {
|
|
434
|
-
out += ')';
|
|
435
|
-
braceDepth -= 1;
|
|
436
|
-
} else if (c === ',' && useBraces && braceDepth > 0) {
|
|
437
|
-
out += '|';
|
|
438
|
-
} else {
|
|
439
|
-
out += escapeLiteral(c);
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
const re = new RegExp(`^${out}$`);
|
|
443
|
-
_regexpCache.set(pattern, re);
|
|
444
|
-
return re;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// Specificity score for a layer glob: more literal path segments before the first wildcard
|
|
448
|
-
// wins, then longer literal text. So `src/kernel/app/**` (3 literal segments) beats
|
|
449
|
-
// `src/kernel/**` (2), and an exact file like `src/kernel/events.ts` beats both. This is what
|
|
450
|
-
// makes a facade split (a KernelApi surface layer overlapping a KernelInternal catch-all)
|
|
451
|
-
// resolve to the surface REGARDLESS of layer declaration order — the intuitive result.
|
|
452
|
-
export function patternSpecificity(pattern) {
|
|
453
|
-
const glob = String(pattern).split(path.sep).join('/');
|
|
454
|
-
const beforeWildcard = glob.split('*')[0];
|
|
455
|
-
const literalSegments = beforeWildcard.split('/').filter(Boolean).length;
|
|
456
|
-
const literalLength = glob.replace(/\*/g, '').length;
|
|
457
|
-
return literalSegments * 10000 + literalLength;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/**
|
|
461
|
-
* Resolve a file's architecture layer from ark.config.json layer glob patterns. When more
|
|
462
|
-
* than one layer matches (overlapping globs, e.g. a facade split), the MOST SPECIFIC pattern
|
|
463
|
-
* wins; ties break by declaration order (first wins). Order-independent for non-ambiguous
|
|
464
|
-
* overlaps, so a config author can't silently break a facade by listing the catch-all first.
|
|
465
|
-
*
|
|
466
|
-
* A layer may also declare `exclude` globs. A file matching ANY exclude glob is NOT a
|
|
467
|
-
* candidate for that layer even if a `patterns` glob matches — this lets a broad pattern
|
|
468
|
-
* (e.g. `src/**/domain/**`) carve out subtrees it should not govern (framework internals
|
|
469
|
-
* like `**/kernel/**`) without enumerating every include. Excluding a file from its layer
|
|
470
|
-
* also removes it from that layer's rule and `forbiddenGlobals` enforcement, since both key
|
|
471
|
-
* off this classification — which is exactly how a broad domain glob stops mis-flagging
|
|
472
|
-
* `src/kernel/domain` as impure domain code. This is the single file→layer matcher shared by
|
|
473
|
-
* the ark-check CI gate and the ark-mcp write gate, so `exclude` behaves identically in both.
|
|
474
|
-
*/
|
|
475
|
-
export function layerForFile(root, file, layers) {
|
|
476
|
-
const abs = path.isAbsolute(file) ? file : path.resolve(root, file);
|
|
477
|
-
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
478
|
-
let bestName;
|
|
479
|
-
let bestScore = -1;
|
|
480
|
-
for (const layer of layers ?? []) {
|
|
481
|
-
if ((layer.exclude ?? []).some((pattern) => globToRegExp(pattern).test(rel))) {
|
|
482
|
-
continue;
|
|
483
|
-
}
|
|
484
|
-
for (const pattern of layer.patterns ?? []) {
|
|
485
|
-
if (globToRegExp(pattern).test(rel)) {
|
|
486
|
-
const score = patternSpecificity(pattern);
|
|
487
|
-
if (score > bestScore) {
|
|
488
|
-
bestScore = score;
|
|
489
|
-
bestName = layer.name;
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
return bestName;
|
|
495
|
-
}
|
|
383
|
+
// Layer glob matching — single source of truth in ark-layer-match.mjs (also used by ESLint).
|
|
384
|
+
export {
|
|
385
|
+
globToRegExp,
|
|
386
|
+
patternSpecificity,
|
|
387
|
+
layerForFile,
|
|
388
|
+
layerForRelativePath,
|
|
389
|
+
isEdgeDenied,
|
|
390
|
+
DEFAULT_GENERATED_FILE_GLOBS,
|
|
391
|
+
scanExcludePatterns,
|
|
392
|
+
isScanExcludedRelative,
|
|
393
|
+
} from './ark-layer-match.mjs';
|
|
496
394
|
|
|
497
395
|
function normalizePrefix(prefix) {
|
|
498
396
|
return prefix.endsWith('.') ? prefix : `${prefix}.`;
|
|
@@ -842,7 +740,15 @@ function readPackageJson(root) {
|
|
|
842
740
|
}
|
|
843
741
|
}
|
|
844
742
|
|
|
845
|
-
/**
|
|
743
|
+
/**
|
|
744
|
+
* Workspace / multi-package roots for monorepo include.
|
|
745
|
+
* Sources (universal — no project-specific names):
|
|
746
|
+
* 1. package.json workspaces + pnpm-workspace.yaml
|
|
747
|
+
* 2. rush.json projectFolder top segments (JSONC-tolerant)
|
|
748
|
+
* 3. lerna.json packages globs
|
|
749
|
+
* 4. If still empty: conventional multi-package top-level dirs that exist and
|
|
750
|
+
* contain at least one package.json (packages, apps, plugins, services, …)
|
|
751
|
+
*/
|
|
846
752
|
export function detectWorkspaces(root) {
|
|
847
753
|
const dirs = new Set();
|
|
848
754
|
const addGlob = (glob) => {
|
|
@@ -850,9 +756,16 @@ export function detectWorkspaces(root) {
|
|
|
850
756
|
const beforeStar = glob.split('*')[0].replace(/\/+$/, '');
|
|
851
757
|
if (beforeStar && beforeStar !== '.') dirs.add(normalizeRel(beforeStar));
|
|
852
758
|
};
|
|
759
|
+
const addTop = (rel) => {
|
|
760
|
+
if (typeof rel !== 'string') return;
|
|
761
|
+
const top = rel.split(/[/\\]/).filter(Boolean)[0];
|
|
762
|
+
if (top && top !== '.') dirs.add(normalizeRel(top));
|
|
763
|
+
};
|
|
764
|
+
|
|
853
765
|
const pkg = readPackageJson(root);
|
|
854
766
|
const ws = Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages;
|
|
855
767
|
if (Array.isArray(ws)) ws.forEach(addGlob);
|
|
768
|
+
|
|
856
769
|
const pnpmFile = path.join(root, 'pnpm-workspace.yaml');
|
|
857
770
|
if (fs.existsSync(pnpmFile)) {
|
|
858
771
|
let inPackages = false;
|
|
@@ -867,9 +780,183 @@ export function detectWorkspaces(root) {
|
|
|
867
780
|
if (item) addGlob(item[1].trim());
|
|
868
781
|
}
|
|
869
782
|
}
|
|
783
|
+
|
|
784
|
+
// Rush monorepos often have no root package.json workspaces — only rush.json.
|
|
785
|
+
const rushFile = path.join(root, 'rush.json');
|
|
786
|
+
if (fs.existsSync(rushFile)) {
|
|
787
|
+
try {
|
|
788
|
+
let text = fs.readFileSync(rushFile, 'utf8');
|
|
789
|
+
text = text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
|
790
|
+
const rush = JSON.parse(text);
|
|
791
|
+
for (const project of rush.projects || []) {
|
|
792
|
+
if (typeof project?.projectFolder === 'string') addTop(project.projectFolder);
|
|
793
|
+
}
|
|
794
|
+
} catch {
|
|
795
|
+
/* ignore malformed rush.json */
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Lerna packages globs.
|
|
800
|
+
const lernaFile = path.join(root, 'lerna.json');
|
|
801
|
+
if (fs.existsSync(lernaFile)) {
|
|
802
|
+
try {
|
|
803
|
+
const lerna = JSON.parse(fs.readFileSync(lernaFile, 'utf8'));
|
|
804
|
+
if (Array.isArray(lerna.packages)) lerna.packages.forEach(addGlob);
|
|
805
|
+
} catch {
|
|
806
|
+
/* ignore */
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Conventional multi-package roots when no explicit workspace manifest listed roots.
|
|
811
|
+
if (dirs.size === 0) {
|
|
812
|
+
const conventional = [
|
|
813
|
+
'packages',
|
|
814
|
+
'apps',
|
|
815
|
+
'plugins',
|
|
816
|
+
'services',
|
|
817
|
+
'server',
|
|
818
|
+
'servers',
|
|
819
|
+
'server-plugins',
|
|
820
|
+
'libs',
|
|
821
|
+
'lib',
|
|
822
|
+
'modules',
|
|
823
|
+
'foundations',
|
|
824
|
+
'pods',
|
|
825
|
+
'models',
|
|
826
|
+
'clients',
|
|
827
|
+
'sdks',
|
|
828
|
+
'tools',
|
|
829
|
+
'tooling',
|
|
830
|
+
'desktop',
|
|
831
|
+
'common',
|
|
832
|
+
];
|
|
833
|
+
for (const name of conventional) {
|
|
834
|
+
const abs = path.join(root, name);
|
|
835
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) continue;
|
|
836
|
+
if (dirContainsPackageJson(abs, 2)) dirs.add(name);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
870
840
|
return [...dirs];
|
|
871
841
|
}
|
|
872
842
|
|
|
843
|
+
/** True if dir (or a child within maxDepth) has a package.json — multi-package root signal. */
|
|
844
|
+
function dirContainsPackageJson(dir, maxDepth) {
|
|
845
|
+
try {
|
|
846
|
+
if (fs.existsSync(path.join(dir, 'package.json'))) return true;
|
|
847
|
+
if (maxDepth <= 0) return false;
|
|
848
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
849
|
+
if (!entry.isDirectory()) continue;
|
|
850
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
851
|
+
if (dirContainsPackageJson(path.join(dir, entry.name), maxDepth - 1)) return true;
|
|
852
|
+
}
|
|
853
|
+
} catch {
|
|
854
|
+
return false;
|
|
855
|
+
}
|
|
856
|
+
return false;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const SKIP_DIR_NAMES = new Set([
|
|
860
|
+
'node_modules',
|
|
861
|
+
'dist',
|
|
862
|
+
'build',
|
|
863
|
+
'coverage',
|
|
864
|
+
'.git',
|
|
865
|
+
'.next',
|
|
866
|
+
'.turbo',
|
|
867
|
+
'.cache',
|
|
868
|
+
'vendor',
|
|
869
|
+
'__pycache__',
|
|
870
|
+
]);
|
|
871
|
+
|
|
872
|
+
function dirHasTsSources(dir, maxDepth) {
|
|
873
|
+
try {
|
|
874
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
875
|
+
if (entry.isFile() && /\.(tsx?|jsx?|mts|cts)$/i.test(entry.name)) return true;
|
|
876
|
+
if (!entry.isDirectory()) continue;
|
|
877
|
+
if (SKIP_DIR_NAMES.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
878
|
+
if (maxDepth > 0 && dirHasTsSources(path.join(dir, entry.name), maxDepth - 1)) return true;
|
|
879
|
+
}
|
|
880
|
+
} catch {
|
|
881
|
+
return false;
|
|
882
|
+
}
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Discover package roots that contain TypeScript/JS sources (polyglot-safe).
|
|
888
|
+
* Returns relative paths (e.g. remotion-composer, packages/ui). Caps depth/count
|
|
889
|
+
* to avoid scanning huge trees. Universal — no project-specific names.
|
|
890
|
+
*/
|
|
891
|
+
export function detectTsPackageRoots(root, options = {}) {
|
|
892
|
+
const maxDepth = options.maxDepth ?? 3;
|
|
893
|
+
const maxRoots = options.maxRoots ?? 40;
|
|
894
|
+
const found = [];
|
|
895
|
+
|
|
896
|
+
const visit = (abs, rel, depth) => {
|
|
897
|
+
if (found.length >= maxRoots || depth > maxDepth) return;
|
|
898
|
+
let entries;
|
|
899
|
+
try {
|
|
900
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
901
|
+
} catch {
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
const hasPkg = entries.some((e) => e.isFile() && e.name === 'package.json');
|
|
905
|
+
if (hasPkg && rel && dirHasTsSources(abs, 3)) {
|
|
906
|
+
found.push(rel.split(path.sep).join('/'));
|
|
907
|
+
// Do not descend into nested packages under an already-selected package root
|
|
908
|
+
// unless maxDepth allows and we want monorepo packages/* children — still scan children
|
|
909
|
+
// for nested packages (packages/foo).
|
|
910
|
+
}
|
|
911
|
+
if (depth >= maxDepth) return;
|
|
912
|
+
for (const entry of entries) {
|
|
913
|
+
if (!entry.isDirectory()) continue;
|
|
914
|
+
if (SKIP_DIR_NAMES.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
915
|
+
// Skip agent skill asset trees (not app code).
|
|
916
|
+
if (entry.name === 'skills' || entry.name === 'templates' || entry.name === 'fixtures') continue;
|
|
917
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
918
|
+
visit(path.join(abs, entry.name), childRel, depth + 1);
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
visit(root, '', 0);
|
|
923
|
+
// Also treat root itself if it has package.json + TS
|
|
924
|
+
if (fs.existsSync(path.join(root, 'package.json')) && dirHasTsSources(root, 2)) {
|
|
925
|
+
if (!found.includes('.')) {
|
|
926
|
+
// Prefer explicit '.' only when no nested packages found
|
|
927
|
+
if (found.length === 0) found.push('.');
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return [...new Set(found)].sort();
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Merge workspace globs with TS package roots. If workspaces alone would miss
|
|
935
|
+
* all TS (or are empty), fill from detectTsPackageRoots.
|
|
936
|
+
*/
|
|
937
|
+
export function resolveIncludeRoots(root) {
|
|
938
|
+
const workspaces = detectWorkspaces(root);
|
|
939
|
+
const tsRoots = detectTsPackageRoots(root);
|
|
940
|
+
if (workspaces.length === 0) {
|
|
941
|
+
if (tsRoots.length > 0) return tsRoots.filter((r) => r !== '.');
|
|
942
|
+
return [];
|
|
943
|
+
}
|
|
944
|
+
// Workspaces present: keep them, add TS package roots not covered by a workspace prefix
|
|
945
|
+
const merged = new Set(workspaces);
|
|
946
|
+
for (const tr of tsRoots) {
|
|
947
|
+
if (tr === '.') continue;
|
|
948
|
+
const covered = workspaces.some(
|
|
949
|
+
(w) => tr === w || tr.startsWith(`${w}/`) || w.startsWith(`${tr}/`)
|
|
950
|
+
);
|
|
951
|
+
if (!covered) merged.add(tr.split('/')[0]); // top segment if nested
|
|
952
|
+
// Prefer full package path when it's a direct child
|
|
953
|
+
if (!tr.includes('/') || workspaces.includes(tr.split('/')[0])) {
|
|
954
|
+
if (fs.existsSync(path.join(root, tr, 'package.json'))) merged.add(tr);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return [...merged];
|
|
958
|
+
}
|
|
959
|
+
|
|
873
960
|
function isSourceFile(name) {
|
|
874
961
|
return /\.(tsx?|jsx?|mjsx?|cjsx?|mts|cts)$/i.test(name);
|
|
875
962
|
}
|
|
@@ -945,6 +1032,47 @@ function countTsxFiles(files) {
|
|
|
945
1032
|
return files.filter((f) => /\.(tsx|jsx)$/i.test(f)).length;
|
|
946
1033
|
}
|
|
947
1034
|
|
|
1035
|
+
/**
|
|
1036
|
+
* Merge root + nested package.json dependencies (frontend/, packages/*, apps/*, …).
|
|
1037
|
+
* Hosts that only put `arkgate` at the monorepo root and Next under frontend/ must
|
|
1038
|
+
* still detect nextFramework so layout overlays (src/core/**) apply on day one.
|
|
1039
|
+
*/
|
|
1040
|
+
export function collectAggregatedDeps(root) {
|
|
1041
|
+
const deps = {};
|
|
1042
|
+
const mergePkg = (pkg) => {
|
|
1043
|
+
if (!pkg || typeof pkg !== 'object') return;
|
|
1044
|
+
for (const key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
1045
|
+
const block = pkg[key];
|
|
1046
|
+
if (block && typeof block === 'object') Object.assign(deps, block);
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
mergePkg(readPackageJson(root));
|
|
1050
|
+
let entries = [];
|
|
1051
|
+
try {
|
|
1052
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
1053
|
+
} catch {
|
|
1054
|
+
return deps;
|
|
1055
|
+
}
|
|
1056
|
+
for (const entry of entries) {
|
|
1057
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
1058
|
+
const childRoot = path.join(root, entry.name);
|
|
1059
|
+
mergePkg(readPackageJson(childRoot));
|
|
1060
|
+
// One level under conventional multi-package roots
|
|
1061
|
+
if (['packages', 'apps', 'services', 'plugins', 'packages-internal'].includes(entry.name)) {
|
|
1062
|
+
try {
|
|
1063
|
+
for (const sub of fs.readdirSync(childRoot, { withFileTypes: true })) {
|
|
1064
|
+
if (sub.isDirectory() && !sub.name.startsWith('.')) {
|
|
1065
|
+
mergePkg(readPackageJson(path.join(childRoot, sub.name)));
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
} catch {
|
|
1069
|
+
/* ignore */
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
return deps;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
948
1076
|
/**
|
|
949
1077
|
* Collect deterministic repo shape signals for architecture archetype scoring.
|
|
950
1078
|
* Vendor packages may appear in toolHints only — never as the primary label.
|
|
@@ -953,7 +1081,20 @@ export function collectRepoShapeSignals(root) {
|
|
|
953
1081
|
const pkg = readPackageJson(root);
|
|
954
1082
|
const workspaceDirs = detectWorkspaces(root);
|
|
955
1083
|
const workspaces = workspaceDirs.length > 0;
|
|
956
|
-
|
|
1084
|
+
// Include frontend/web/client — common Next monorepo app folders (deer-flow-style).
|
|
1085
|
+
const candidateScanDirs = [
|
|
1086
|
+
'src',
|
|
1087
|
+
'lib',
|
|
1088
|
+
'api',
|
|
1089
|
+
'packages',
|
|
1090
|
+
'apps',
|
|
1091
|
+
'frontend',
|
|
1092
|
+
'web',
|
|
1093
|
+
'client',
|
|
1094
|
+
'app',
|
|
1095
|
+
...workspaceDirs,
|
|
1096
|
+
];
|
|
1097
|
+
const srcDirs = [...new Set(candidateScanDirs)].filter((d) =>
|
|
957
1098
|
fs.existsSync(path.join(root, d))
|
|
958
1099
|
);
|
|
959
1100
|
const scanRoots = srcDirs.length > 0 ? srcDirs.map((d) => path.join(root, d)) : [root];
|
|
@@ -961,13 +1102,14 @@ export function collectRepoShapeSignals(root) {
|
|
|
961
1102
|
const sourceFileCount = sourceFiles.length;
|
|
962
1103
|
const tinyTree = sourceFileCount < 3;
|
|
963
1104
|
|
|
964
|
-
|
|
1105
|
+
// Nested package.json deps (not root-only) — critical for monorepo Next under frontend/
|
|
1106
|
+
const deps = collectAggregatedDeps(root);
|
|
965
1107
|
const hasUiFramework = Object.keys(deps).some((name) =>
|
|
966
1108
|
/^(react|react-dom|vue|svelte|preact|solid-js)$/i.test(name.split('/')[0])
|
|
967
1109
|
);
|
|
968
1110
|
const srcUiFiles = sourceFiles.filter((file) => {
|
|
969
1111
|
const rel = path.relative(root, file).split(path.sep).join('/');
|
|
970
|
-
return rel.startsWith('src/') && /\.(tsx|jsx)$/i.test(file);
|
|
1112
|
+
return (rel.includes('/src/') || rel.startsWith('src/')) && /\.(tsx|jsx)$/i.test(file);
|
|
971
1113
|
});
|
|
972
1114
|
|
|
973
1115
|
const topNames = new Set(srcDirs.flatMap((d) => listTopLevelDirNames(root, d)));
|
|
@@ -983,7 +1125,8 @@ export function collectRepoShapeSignals(root) {
|
|
|
983
1125
|
const rel = path.relative(root, file).split(path.sep).join('/');
|
|
984
1126
|
return (
|
|
985
1127
|
/(^|\/)next\.config\./.test(rel) ||
|
|
986
|
-
/
|
|
1128
|
+
// app/page.tsx OR app/dashboard/page.tsx (middle segment optional)
|
|
1129
|
+
/(^|\/)app\/(?:.*\/)?page\.(t|j)sx?$/.test(rel) ||
|
|
987
1130
|
/(^|\/)pages\/.+\.(t|j)sx?$/.test(rel)
|
|
988
1131
|
);
|
|
989
1132
|
});
|
|
@@ -1250,7 +1393,15 @@ export function scoreArchetypes(signals, playbook) {
|
|
|
1250
1393
|
const rawConfidence = top.score / top.maxPositive;
|
|
1251
1394
|
const margin =
|
|
1252
1395
|
second && second.score > 0 ? (top.score - second.score) / Math.max(top.score, 1) : 0.25;
|
|
1253
|
-
|
|
1396
|
+
let confidence = Math.min(1, Math.max(0.1, rawConfidence * 0.7 + margin * 0.3));
|
|
1397
|
+
// Thin / zero TS surface: never present a high-confidence archetype as the sole answer.
|
|
1398
|
+
const thinTs =
|
|
1399
|
+
!signals.sourceFileCount ||
|
|
1400
|
+
signals.sourceFileCount < 8 ||
|
|
1401
|
+
signals.tinyTree;
|
|
1402
|
+
if (thinTs) {
|
|
1403
|
+
confidence = Math.min(confidence, 0.28);
|
|
1404
|
+
}
|
|
1254
1405
|
|
|
1255
1406
|
return {
|
|
1256
1407
|
ranked: scored,
|
|
@@ -1258,6 +1409,13 @@ export function scoreArchetypes(signals, playbook) {
|
|
|
1258
1409
|
label: top.label,
|
|
1259
1410
|
preset: top.preset,
|
|
1260
1411
|
confidence: Math.round(confidence * 1000) / 1000,
|
|
1412
|
+
...(thinTs
|
|
1413
|
+
? {
|
|
1414
|
+
thinTsSurface: true,
|
|
1415
|
+
caution:
|
|
1416
|
+
'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.',
|
|
1417
|
+
}
|
|
1418
|
+
: {}),
|
|
1261
1419
|
phases: top.phases,
|
|
1262
1420
|
analogy: top.analogy,
|
|
1263
1421
|
antiPatterns: top.antiPatterns,
|
|
@@ -1292,6 +1450,7 @@ export function buildArchitectureRecommendation(root, options = {}) {
|
|
|
1292
1450
|
label: result.label,
|
|
1293
1451
|
preset: result.preset,
|
|
1294
1452
|
confidence: result.confidence,
|
|
1453
|
+
...(result.thinTsSurface ? { thinTsSurface: true, caution: result.caution } : {}),
|
|
1295
1454
|
phases: result.phases,
|
|
1296
1455
|
adoptInOrder,
|
|
1297
1456
|
analogy: result.analogy,
|
|
@@ -1457,6 +1616,13 @@ export function formatArchitectureRecommendationHuman(recommendation) {
|
|
|
1457
1616
|
lines.push('');
|
|
1458
1617
|
lines.push(`Archetype: ${recommendation.archetype} — ${recommendation.label}`);
|
|
1459
1618
|
lines.push(`Preset: ${recommendation.preset} (confidence ${recommendation.confidence})`);
|
|
1619
|
+
if (recommendation.thinTsSurface) {
|
|
1620
|
+
lines.push('');
|
|
1621
|
+
lines.push(
|
|
1622
|
+
`⚠ Thin TypeScript surface (${recommendation.signals?.sourceFileCount ?? 0} files) — confidence is capped. Do not treat this as a firm shape.`
|
|
1623
|
+
);
|
|
1624
|
+
if (recommendation.caution) lines.push(recommendation.caution);
|
|
1625
|
+
}
|
|
1460
1626
|
if (recommendation.runnerUp?.id) {
|
|
1461
1627
|
lines.push(
|
|
1462
1628
|
`Runner-up: ${recommendation.runnerUp.id}${recommendation.runnerUp.label ? ` (${recommendation.runnerUp.label})` : ''}`
|
package/bin/ark.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
buildArchitectureRecommendation,
|
|
11
11
|
detectPackageManager,
|
|
12
12
|
detectWorkspaces,
|
|
13
|
+
resolveIncludeRoots,
|
|
14
|
+
detectTsPackageRoots,
|
|
13
15
|
INIT_WIZARD_CHOICES,
|
|
14
16
|
isValidArchetypeId,
|
|
15
17
|
mapWizardChoiceToArchetype,
|
|
@@ -345,16 +347,32 @@ async function start(args) {
|
|
|
345
347
|
if (!fs.existsSync(configPath)) {
|
|
346
348
|
const initArgs = ['--root', root, '--init'];
|
|
347
349
|
const preset = archetype ? resolveArchetypePreset(archetype).preset : undefined;
|
|
350
|
+
const includeRoots = resolveIncludeRoots(root);
|
|
351
|
+
const tsPackages = detectTsPackageRoots(root);
|
|
348
352
|
const workspaces = detectWorkspaces(root);
|
|
349
353
|
const looksLikeMonorepo =
|
|
354
|
+
includeRoots.length > 0 ||
|
|
355
|
+
tsPackages.length > 0 ||
|
|
350
356
|
workspaces.length > 0 ||
|
|
357
|
+
fs.existsSync(path.join(root, 'rush.json')) ||
|
|
358
|
+
fs.existsSync(path.join(root, 'pnpm-workspace.yaml')) ||
|
|
359
|
+
fs.existsSync(path.join(root, 'lerna.json')) ||
|
|
351
360
|
fs.existsSync(path.join(root, 'apps')) ||
|
|
352
361
|
fs.existsSync(path.join(root, 'packages'));
|
|
353
|
-
// Mature multi-package trees must NOT get a thin src/** starter (0 files
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
362
|
+
// Mature multi-package / nested-TS trees must NOT get a thin src/** starter (0 files).
|
|
363
|
+
if (looksLikeMonorepo && (rec?.mature || includeRoots.length > 0 || tsPackages.length > 0)) {
|
|
364
|
+
// UI-heavy TS packages (Remotion/Vite) prefer ui-surface patterns when recommend says so.
|
|
365
|
+
const useUi =
|
|
366
|
+
rec?.preset === 'feature-sliced' ||
|
|
367
|
+
rec?.archetype === 'frontend-surface' ||
|
|
368
|
+
(tsPackages.length > 0 && includeRoots.length === 0 && !rec?.mature);
|
|
369
|
+
initArgs.push('--preset', useUi && tsPackages.length <= 3 ? 'ui-surface' : 'monorepo');
|
|
370
|
+
const shown = includeRoots.length > 0 ? includeRoots : tsPackages;
|
|
371
|
+
console.log(
|
|
372
|
+
shown.length > 0
|
|
373
|
+
? ` Multi-package / TS package layout detected — profile include: ${shown.join(', ')}.`
|
|
374
|
+
: ' Multi-package layout detected — using monorepo profile.'
|
|
375
|
+
);
|
|
358
376
|
} else if (!rec?.mature && preset) {
|
|
359
377
|
initArgs.push('--preset', preset);
|
|
360
378
|
}
|
|
@@ -363,7 +381,12 @@ async function start(args) {
|
|
|
363
381
|
} else {
|
|
364
382
|
console.log(' Found an existing ark.config.json — keeping it.');
|
|
365
383
|
}
|
|
366
|
-
|
|
384
|
+
{
|
|
385
|
+
const gateArgs = ['--root', root, '--install-agent-gates'];
|
|
386
|
+
if (args.tools) gateArgs.push('--tools', args.tools);
|
|
387
|
+
if (args.force) gateArgs.push('--force');
|
|
388
|
+
runArkCheck(gateArgs, { cwd: root });
|
|
389
|
+
}
|
|
367
390
|
|
|
368
391
|
// 4) Show the plan: what's safe to auto-fix vs what needs a decision.
|
|
369
392
|
console.log('');
|
|
@@ -406,49 +429,36 @@ async function start(args) {
|
|
|
406
429
|
planOk = false;
|
|
407
430
|
}
|
|
408
431
|
|
|
409
|
-
// 5) Plain-language wrap-up —
|
|
410
|
-
//
|
|
411
|
-
// enforce = contract actually governs code and gates stay on.
|
|
432
|
+
// 5) Plain-language wrap-up — one next step, status light only.
|
|
433
|
+
// Modes are detected (Suggest/Adapt/Enforce), not user-picked settings.
|
|
412
434
|
console.log('');
|
|
413
435
|
if (mode === 'enforce' && planOk) {
|
|
414
|
-
console.log('Done —
|
|
436
|
+
console.log('Done — status: ENFORCE (gates can honestly protect you).');
|
|
415
437
|
console.log('What happens now:');
|
|
416
438
|
console.log(' • Every edit is checked (in CI and, if wired, at write time).');
|
|
417
|
-
console.log(' • Carry out remaining plan steps with /ark-autopilot (safe auto + your approvals).');
|
|
418
439
|
} else if (mode === 'suggest') {
|
|
419
|
-
console.log('Done —
|
|
440
|
+
console.log('Done — status: SUGGEST (starting shape installed; expand as you grow).');
|
|
420
441
|
console.log('What happens now:');
|
|
421
|
-
console.log(' • Gates are on for whatever the contract already matches.');
|
|
422
|
-
console.log(' • Expand coverage as you create real layer folders (see the plan above).');
|
|
423
442
|
if (governedPercent != null) {
|
|
424
|
-
console.log(` •
|
|
443
|
+
console.log(` • Ark governs ~${governedPercent}% of in-scope files — low is normal on a fresh scaffold.`);
|
|
425
444
|
}
|
|
426
|
-
console.log(' • When you want the agent to drive the plan: /ark-autopilot');
|
|
427
445
|
} else {
|
|
428
|
-
console.log('Done —
|
|
446
|
+
console.log('Done — status: ADAPT (contract still aligning with your real layout).');
|
|
429
447
|
console.log('What happens now:');
|
|
430
448
|
if (governedPercent != null) {
|
|
431
449
|
console.log(
|
|
432
|
-
` • Governed
|
|
450
|
+
` • Governed ~${governedPercent}% — a "clean" plan with low coverage checks almost nothing.`
|
|
433
451
|
);
|
|
434
452
|
}
|
|
435
|
-
console.log(` • See what is unmatched: ${arkCommand(root, 'ark-check', '--coverage')}`);
|
|
436
|
-
console.log(' • On a mature repo, prefer /ark-adopt over forcing a starter preset.');
|
|
437
|
-
console.log(' • Drive fixes with /ark-autopilot once the contract matches; until then ENFORCE is not honest.');
|
|
438
|
-
}
|
|
439
|
-
console.log(` • Re-run the plan anytime: ${arkCommand(root, 'ark-check', '--plan')}`);
|
|
440
|
-
console.log(` • Full project check: ${arkCommand(root, 'ark-check', '--root . --config ark.config.json --strict-config')}`);
|
|
441
|
-
console.log(` • Adoption health: ${arkCommand(root, 'ark-check', '--doctor')}`);
|
|
442
|
-
console.log(` • Update Ark later: ${arkCommand(root, 'ark', 'upgrade')}`);
|
|
443
|
-
if (fs.existsSync(path.join(root, '.ark-baseline.json'))) {
|
|
444
|
-
console.log(
|
|
445
|
-
' • Baseline file present — keep empty for ratchet-from-clean, or freeze debt with --update-baseline.'
|
|
446
|
-
);
|
|
447
|
-
} else {
|
|
448
|
-
console.log(
|
|
449
|
-
' • No baseline yet (fine on clean trees). Adopting dirty code? freeze with --update-baseline.'
|
|
450
|
-
);
|
|
451
453
|
}
|
|
454
|
+
console.log('');
|
|
455
|
+
console.log('Next (the only flow you need):');
|
|
456
|
+
console.log(' 1. In your agent: /ark-autopilot');
|
|
457
|
+
console.log(' → origin report, adoption, plan, safe fixes, leave gates on.');
|
|
458
|
+
console.log(` 2. Status anytime: ${arkCommand(root, 'ark-check', '--doctor')}`);
|
|
459
|
+
console.log(` 3. After edits: ${arkCommand(root, 'ark-check', '--root . --config ark.config.json --strict-config')}`);
|
|
460
|
+
console.log('');
|
|
461
|
+
console.log('Optional later: --plan · --coverage · /ark-fix · /ark-place · ark upgrade');
|
|
452
462
|
|
|
453
463
|
// 6) First architecture report — freezes an origin snapshot under .ark/reports/
|
|
454
464
|
// so later --report runs can show evolution. Idempotent: origin is written only once.
|