arkgate 2.4.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.
@@ -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
+ }
@@ -362,137 +362,14 @@ export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
362
362
  return uses;
363
363
  }
364
364
 
365
- const _regexpCache = new Map();
366
-
367
- function escapeLiteral(ch) {
368
- return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
369
- }
370
-
371
- /** True only when every `{` has a matching `}` (ignoring backslash-escaped braces). */
372
- function bracesBalanced(glob) {
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}.`;