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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure layer-glob matching for ark.config.json.
|
|
3
|
+
* Single source of truth for CLI (ark-shared / ark-check) and ESLint (bundled via import).
|
|
4
|
+
* No Node I/O beyond path.sep normalization — pure string/path math only.
|
|
5
|
+
*/
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const _regexpCache = new Map();
|
|
9
|
+
|
|
10
|
+
function escapeLiteral(ch) {
|
|
11
|
+
return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** True only when every `{` has a matching `}` (ignoring backslash-escaped braces). */
|
|
15
|
+
function bracesBalanced(glob) {
|
|
16
|
+
let depth = 0;
|
|
17
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
18
|
+
const c = glob[i];
|
|
19
|
+
if (c === '\\') {
|
|
20
|
+
i += 1; // skip the escaped character
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (c === '{') depth += 1;
|
|
24
|
+
else if (c === '}') {
|
|
25
|
+
depth -= 1;
|
|
26
|
+
if (depth < 0) return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return depth === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Convert an ark.config.json layer glob pattern to an anchored RegExp (compiled once per
|
|
34
|
+
* pattern, then cached).
|
|
35
|
+
*
|
|
36
|
+
* IMPORTANT: the double-star is expanded in a SINGLE pass. A chained two-step replace
|
|
37
|
+
* (double-star to dot-star, then single-star to a no-slash class) corrupts the double-star,
|
|
38
|
+
* because the second step re-matches the star inside the substitution the first step just
|
|
39
|
+
* inserted. That made "src/kernel/**" stop matching nested paths, silently unclassifying
|
|
40
|
+
* every file in a subdirectory. Scanning one character at a time also lets us support
|
|
41
|
+
* brace alternation ("*.{ts,tsx}") and backslash escapes ("\\{" → literal brace).
|
|
42
|
+
*
|
|
43
|
+
* Brace alternation is only enabled when braces are balanced; an unbalanced brace (a config
|
|
44
|
+
* typo) is treated as a literal so the gate never crashes on `new RegExp`.
|
|
45
|
+
*/
|
|
46
|
+
export function globToRegExp(pattern) {
|
|
47
|
+
const cached = _regexpCache.get(pattern);
|
|
48
|
+
if (cached) return cached;
|
|
49
|
+
|
|
50
|
+
const glob = pattern.split(path.sep).join('/');
|
|
51
|
+
const useBraces = bracesBalanced(glob);
|
|
52
|
+
let out = '';
|
|
53
|
+
let braceDepth = 0;
|
|
54
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
55
|
+
const c = glob[i];
|
|
56
|
+
if (c === '\\' && i + 1 < glob.length) {
|
|
57
|
+
out += escapeLiteral(glob[i + 1]); // backslash escapes the next char to a literal
|
|
58
|
+
i += 1;
|
|
59
|
+
} else if (c === '*') {
|
|
60
|
+
if (glob[i + 1] === '*') {
|
|
61
|
+
if (glob[i + 2] === '/') {
|
|
62
|
+
out += '(?:.*/)?'; // `**/` matches zero or more path segments
|
|
63
|
+
i += 2;
|
|
64
|
+
} else {
|
|
65
|
+
out += '.*'; // `**` matches across `/`
|
|
66
|
+
i += 1;
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
out += '[^/]*'; // `*` matches within a single segment
|
|
70
|
+
}
|
|
71
|
+
} else if (c === '?') {
|
|
72
|
+
out += '[^/]';
|
|
73
|
+
} else if (c === '{' && useBraces) {
|
|
74
|
+
out += '(?:';
|
|
75
|
+
braceDepth += 1;
|
|
76
|
+
} else if (c === '}' && useBraces && braceDepth > 0) {
|
|
77
|
+
out += ')';
|
|
78
|
+
braceDepth -= 1;
|
|
79
|
+
} else if (c === ',' && useBraces && braceDepth > 0) {
|
|
80
|
+
out += '|';
|
|
81
|
+
} else {
|
|
82
|
+
out += escapeLiteral(c);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const re = new RegExp(`^${out}$`);
|
|
86
|
+
_regexpCache.set(pattern, re);
|
|
87
|
+
return re;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Specificity score for a layer glob: more literal path segments before the first wildcard
|
|
91
|
+
// wins, then longer literal text. So `src/kernel/app/**` (3 literal segments) beats
|
|
92
|
+
// `src/kernel/**` (2), and an exact file like `src/kernel/events.ts` beats both. This is what
|
|
93
|
+
// makes a facade split (a KernelApi surface layer overlapping a KernelInternal catch-all)
|
|
94
|
+
// resolve to the surface REGARDLESS of layer declaration order — the intuitive result.
|
|
95
|
+
export function patternSpecificity(pattern) {
|
|
96
|
+
const glob = String(pattern).split(path.sep).join('/');
|
|
97
|
+
const beforeWildcard = glob.split('*')[0];
|
|
98
|
+
const literalSegments = beforeWildcard.split('/').filter(Boolean).length;
|
|
99
|
+
const literalLength = glob.replace(/\*/g, '').length;
|
|
100
|
+
return literalSegments * 10000 + literalLength;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve a file's architecture layer from ark.config.json layer glob patterns. When more
|
|
105
|
+
* than one layer matches (overlapping globs, e.g. a facade split), the MOST SPECIFIC pattern
|
|
106
|
+
* wins; ties break by declaration order (first wins). Order-independent for non-ambiguous
|
|
107
|
+
* overlaps, so a config author can't silently break a facade by listing the catch-all first.
|
|
108
|
+
*
|
|
109
|
+
* A layer may also declare `exclude` globs. A file matching ANY exclude glob is NOT a
|
|
110
|
+
* candidate for that layer even if a `patterns` glob matches — this lets a broad pattern
|
|
111
|
+
* (e.g. `src/**/domain/**`) carve out subtrees it should not govern (framework internals
|
|
112
|
+
* like `**/kernel/**`) without enumerating every include. Excluding a file from its layer
|
|
113
|
+
* also removes it from that layer's rule and `forbiddenGlobals` enforcement, since both key
|
|
114
|
+
* off this classification — which is exactly how a broad domain glob stops mis-flagging
|
|
115
|
+
* `src/kernel/domain` as impure domain code. This is the single file→layer matcher shared by
|
|
116
|
+
* the ark-check CI gate and the ark-mcp write gate, so `exclude` behaves identically in both.
|
|
117
|
+
*/
|
|
118
|
+
export function layerForFile(root, file, layers) {
|
|
119
|
+
const abs = path.isAbsolute(file) ? file : path.resolve(root, file);
|
|
120
|
+
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
121
|
+
let bestName;
|
|
122
|
+
let bestScore = -1;
|
|
123
|
+
for (const layer of layers ?? []) {
|
|
124
|
+
if ((layer.exclude ?? []).some((pattern) => globToRegExp(pattern).test(rel))) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
for (const pattern of layer.patterns ?? []) {
|
|
128
|
+
if (globToRegExp(pattern).test(rel)) {
|
|
129
|
+
const score = patternSpecificity(pattern);
|
|
130
|
+
if (score > bestScore) {
|
|
131
|
+
bestScore = score;
|
|
132
|
+
bestName = layer.name;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return bestName;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
/** Classify a project-relative path (posix) without needing an absolute root. */
|
|
142
|
+
export function layerForRelativePath(relPath, layers) {
|
|
143
|
+
const rel = String(relPath).split(path.sep).join('/');
|
|
144
|
+
let bestName;
|
|
145
|
+
let bestScore = -1;
|
|
146
|
+
for (const layer of layers ?? []) {
|
|
147
|
+
if ((layer.exclude ?? []).some((pattern) => globToRegExp(pattern).test(rel))) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
for (const pattern of layer.patterns ?? []) {
|
|
151
|
+
if (globToRegExp(pattern).test(rel)) {
|
|
152
|
+
const score = patternSpecificity(pattern);
|
|
153
|
+
if (score > bestScore) {
|
|
154
|
+
bestScore = score;
|
|
155
|
+
bestName = layer.name;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return bestName;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** True when rules[] explicitly deny from→to. Missing rule = allowed (implicit). */
|
|
164
|
+
export function isEdgeDenied(rules, from, to) {
|
|
165
|
+
if (from === to) return false;
|
|
166
|
+
const hit = (rules ?? []).find((r) => r.from === from && r.to === to);
|
|
167
|
+
return hit?.allowed === false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Codegen / generated source globs skipped by the default scan.
|
|
172
|
+
* Universal (TanStack Router routeTree.gen, many `*.generated.ts` tools, etc.).
|
|
173
|
+
* Opt out with `excludeGenerated: false` in ark.config.json; add more via top-level `exclude`.
|
|
174
|
+
*/
|
|
175
|
+
export const DEFAULT_GENERATED_FILE_GLOBS = [
|
|
176
|
+
'**/*.gen.ts',
|
|
177
|
+
'**/*.gen.tsx',
|
|
178
|
+
'**/*.generated.ts',
|
|
179
|
+
'**/*.generated.tsx',
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Globs that remove files from ark-check scan (cycles, layers, coverage).
|
|
184
|
+
* @param {{ exclude?: string[], excludeGenerated?: boolean } | null | undefined} config
|
|
185
|
+
*/
|
|
186
|
+
export function scanExcludePatterns(config) {
|
|
187
|
+
const custom = Array.isArray(config?.exclude) ? config.exclude.filter((p) => typeof p === 'string') : [];
|
|
188
|
+
const generated =
|
|
189
|
+
config?.excludeGenerated === false ? [] : DEFAULT_GENERATED_FILE_GLOBS;
|
|
190
|
+
return [...generated, ...custom];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Relative path (posix) matches any scan-exclude glob. */
|
|
194
|
+
export function isScanExcludedRelative(relPath, config) {
|
|
195
|
+
const rel = String(relPath).split(path.sep).join('/');
|
|
196
|
+
return scanExcludePatterns(config).some((pattern) => globToRegExp(pattern).test(rel));
|
|
197
|
+
}
|
package/bin/ark-mcp.mjs
CHANGED
|
@@ -41,6 +41,9 @@ import {
|
|
|
41
41
|
arkCommand,
|
|
42
42
|
layerForFile,
|
|
43
43
|
shouldShowNewHereNudge,
|
|
44
|
+
detectWorkspaces,
|
|
45
|
+
detectTsPackageRoots,
|
|
46
|
+
resolveIncludeRoots,
|
|
44
47
|
} from './ark-shared.mjs';
|
|
45
48
|
|
|
46
49
|
const arkCheckBin = fileURLToPath(new URL('./ark-check.mjs', import.meta.url));
|
|
@@ -576,9 +579,9 @@ async function main() {
|
|
|
576
579
|
{
|
|
577
580
|
name: 'ark_place',
|
|
578
581
|
description:
|
|
579
|
-
'
|
|
580
|
-
'
|
|
581
|
-
'
|
|
582
|
+
'Place a file in the architecture: pass filePath (preferred) and/or description. ' +
|
|
583
|
+
'Returns layer, mayImport / mustNotImport, forbiddenGlobals. Call BEFORE writing a new file. ' +
|
|
584
|
+
'If only description is given, returns a conventional path proposal under a governed layer.',
|
|
582
585
|
inputSchema: {
|
|
583
586
|
type: 'object',
|
|
584
587
|
properties: {
|
|
@@ -586,8 +589,12 @@ async function main() {
|
|
|
586
589
|
type: 'string',
|
|
587
590
|
description: 'Path (relative to project root or absolute) of the file to place.',
|
|
588
591
|
},
|
|
592
|
+
description: {
|
|
593
|
+
type: 'string',
|
|
594
|
+
description:
|
|
595
|
+
'What you are building (e.g. "Remotion caption overlay"). Used when filePath is omitted to propose a path.',
|
|
596
|
+
},
|
|
589
597
|
},
|
|
590
|
-
required: ['filePath'],
|
|
591
598
|
},
|
|
592
599
|
},
|
|
593
600
|
{
|
|
@@ -599,6 +606,14 @@ async function main() {
|
|
|
599
606
|
'Call BEFORE generating project structure on greenfield or early-adoption repos.',
|
|
600
607
|
inputSchema: { type: 'object', properties: {} },
|
|
601
608
|
},
|
|
609
|
+
{
|
|
610
|
+
name: 'ark_suggest_include',
|
|
611
|
+
description:
|
|
612
|
+
'Propose ark.config.json include roots from workspaces and nested TypeScript packages ' +
|
|
613
|
+
'(polyglot-safe). Same idea as ark-check --suggest-include. Use when coverage is empty ' +
|
|
614
|
+
'or the contract misses package roots.',
|
|
615
|
+
inputSchema: { type: 'object', properties: {} },
|
|
616
|
+
},
|
|
602
617
|
];
|
|
603
618
|
|
|
604
619
|
const RESOURCES = [
|
|
@@ -741,8 +756,52 @@ async function main() {
|
|
|
741
756
|
// `allowed:false` denies) — which layers it may and must not import.
|
|
742
757
|
function runPlace(params) {
|
|
743
758
|
const filePath = params?.arguments?.filePath;
|
|
759
|
+
const description = params?.arguments?.description;
|
|
760
|
+
if ((typeof filePath !== 'string' || !filePath) && typeof description === 'string' && description.trim()) {
|
|
761
|
+
// Description-only: propose a governed path under PresentationAdapters (UI default).
|
|
762
|
+
const slug = description
|
|
763
|
+
.trim()
|
|
764
|
+
.toLowerCase()
|
|
765
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
766
|
+
.replace(/^-|-$/g, '')
|
|
767
|
+
.slice(0, 48) || 'component';
|
|
768
|
+
const proposedPath = `src/components/${slug}.tsx`;
|
|
769
|
+
const layerName = inferLayer(proposedPath, config, args.root) || 'PresentationAdapters';
|
|
770
|
+
return {
|
|
771
|
+
content: [
|
|
772
|
+
{
|
|
773
|
+
type: 'text',
|
|
774
|
+
text: JSON.stringify(
|
|
775
|
+
{
|
|
776
|
+
filePath: proposedPath,
|
|
777
|
+
proposed: true,
|
|
778
|
+
description: description.trim(),
|
|
779
|
+
layer: layerName,
|
|
780
|
+
governed: Boolean(inferLayer(proposedPath, config, args.root)),
|
|
781
|
+
note:
|
|
782
|
+
'filePath was omitted — proposed a conventional path from description. ' +
|
|
783
|
+
'Pass filePath explicitly for authoritative placement. Then validate_code the snippet.',
|
|
784
|
+
},
|
|
785
|
+
null,
|
|
786
|
+
2
|
|
787
|
+
),
|
|
788
|
+
},
|
|
789
|
+
],
|
|
790
|
+
isError: false,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
744
793
|
if (typeof filePath !== 'string' || !filePath) {
|
|
745
|
-
return {
|
|
794
|
+
return {
|
|
795
|
+
content: [
|
|
796
|
+
{
|
|
797
|
+
type: 'text',
|
|
798
|
+
text:
|
|
799
|
+
'ark_place needs filePath and/or description. ' +
|
|
800
|
+
'Example: { "filePath": "src/components/Foo.tsx" } or { "description": "caption overlay UI component" }.',
|
|
801
|
+
},
|
|
802
|
+
],
|
|
803
|
+
isError: true,
|
|
804
|
+
};
|
|
746
805
|
}
|
|
747
806
|
const layerName = inferLayer(filePath, config, args.root);
|
|
748
807
|
if (!layerName) {
|
|
@@ -814,12 +873,50 @@ async function main() {
|
|
|
814
873
|
};
|
|
815
874
|
}
|
|
816
875
|
|
|
876
|
+
function runSuggestIncludeTool() {
|
|
877
|
+
try {
|
|
878
|
+
const workspaces = detectWorkspaces(args.root);
|
|
879
|
+
const tsPackages = detectTsPackageRoots(args.root);
|
|
880
|
+
const suggestedInclude = resolveIncludeRoots(args.root);
|
|
881
|
+
return {
|
|
882
|
+
content: [
|
|
883
|
+
{
|
|
884
|
+
type: 'text',
|
|
885
|
+
text: JSON.stringify(
|
|
886
|
+
{
|
|
887
|
+
ok: true,
|
|
888
|
+
workspaces,
|
|
889
|
+
tsPackages,
|
|
890
|
+
suggestedInclude:
|
|
891
|
+
suggestedInclude.length > 0
|
|
892
|
+
? suggestedInclude
|
|
893
|
+
: tsPackages.length > 0
|
|
894
|
+
? tsPackages
|
|
895
|
+
: ['src'],
|
|
896
|
+
next: 'npx ark-check --adopt-contract --write',
|
|
897
|
+
},
|
|
898
|
+
null,
|
|
899
|
+
2
|
|
900
|
+
),
|
|
901
|
+
},
|
|
902
|
+
],
|
|
903
|
+
isError: false,
|
|
904
|
+
};
|
|
905
|
+
} catch (error) {
|
|
906
|
+
return {
|
|
907
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
908
|
+
isError: true,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
817
913
|
const TOOL_HANDLERS = {
|
|
818
914
|
validate_code: runValidate,
|
|
819
915
|
ark_check: runCheckTool,
|
|
820
916
|
ark_coverage: runCoverageTool,
|
|
821
917
|
ark_place: runPlace,
|
|
822
918
|
ark_recommend: runRecommendTool,
|
|
919
|
+
ark_suggest_include: runSuggestIncludeTool,
|
|
823
920
|
};
|
|
824
921
|
|
|
825
922
|
const send = (msg) => process.stdout.write(`${JSON.stringify(msg)}\n`);
|