arkgate 2.5.0 → 2.6.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 +26 -0
- package/bin/ark-check.mjs +194 -3867
- package/bin/ark-layer-match.mjs +168 -0
- package/bin/ark-shared.mjs +8 -131
- package/bin/lib/agent-gates.mjs +1550 -0
- package/bin/lib/doctor-plan.mjs +503 -0
- package/bin/lib/html-report.mjs +1301 -0
- package/bin/lib/presets.mjs +244 -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/package.json +2 -1
- package/server.json +2 -2
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
}
|
package/bin/ark-shared.mjs
CHANGED
|
@@ -362,137 +362,14 @@ export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
|
|
|
362
362
|
return uses;
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
let depth = 0;
|
|
374
|
-
for (let i = 0; i < glob.length; i += 1) {
|
|
375
|
-
const c = glob[i];
|
|
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
|
-
}
|
|
365
|
+
// Layer glob matching — single source of truth in ark-layer-match.mjs (also used by ESLint).
|
|
366
|
+
export {
|
|
367
|
+
globToRegExp,
|
|
368
|
+
patternSpecificity,
|
|
369
|
+
layerForFile,
|
|
370
|
+
layerForRelativePath,
|
|
371
|
+
isEdgeDenied,
|
|
372
|
+
} from './ark-layer-match.mjs';
|
|
496
373
|
|
|
497
374
|
function normalizePrefix(prefix) {
|
|
498
375
|
return prefix.endsWith('.') ? prefix : `${prefix}.`;
|