arkgate 2.6.1 → 2.8.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +8 -3
  3. package/bin/ark-check.mjs +19 -993
  4. package/bin/ark-layer-match.mjs +148 -171
  5. package/bin/ark-shared.mjs +9 -159
  6. package/bin/lib/agent-gates.mjs +48 -228
  7. package/bin/lib/architecture-scan.mjs +299 -0
  8. package/bin/lib/ast-scan.mjs +427 -0
  9. package/bin/lib/baseline-key.mjs +23 -0
  10. package/bin/lib/codex-home.mjs +320 -0
  11. package/bin/lib/config-warnings.mjs +228 -0
  12. package/bin/lib/doctor-plan.mjs +2 -0
  13. package/bin/lib/graph-cycles.mjs +56 -0
  14. package/bin/lib/remediation.mjs +182 -0
  15. package/bin/lib/scan-files.mjs +69 -0
  16. package/bin/lib/ts-resolve.mjs +216 -0
  17. package/bin/lib/violations.mjs +3 -9
  18. package/dist/eslint/index.cjs +21 -3
  19. package/dist/eslint/index.cjs.map +1 -1
  20. package/dist/eslint/index.d.cts +5 -3
  21. package/dist/eslint/index.d.ts +5 -3
  22. package/dist/eslint/index.js +21 -3
  23. package/dist/eslint/index.js.map +1 -1
  24. package/dist/index.cjs +1 -1
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +3 -3
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/nestjs/index.cjs +1 -1
  31. package/dist/nestjs/index.cjs.map +1 -1
  32. package/dist/nestjs/index.d.cts +1 -1
  33. package/dist/nestjs/index.d.ts +1 -1
  34. package/dist/nestjs/index.js +1 -1
  35. package/dist/nestjs/index.js.map +1 -1
  36. package/dist/runtime/index.cjs +3080 -0
  37. package/dist/runtime/index.cjs.map +1 -0
  38. package/dist/runtime/index.d.cts +2 -0
  39. package/dist/runtime/index.d.ts +2 -0
  40. package/dist/runtime/index.js +2998 -0
  41. package/dist/runtime/index.js.map +1 -0
  42. package/dist/{types-DpdVN7Lm.d.cts → types-CP3KkwZt.d.cts} +1 -1
  43. package/dist/{types-DpdVN7Lm.d.ts → types-CP3KkwZt.d.ts} +1 -1
  44. package/docs/agent-guide.md +5 -2
  45. package/docs/ai-gates.md +41 -7
  46. package/docs/brownfield-adoption.md +7 -0
  47. package/docs/demos/03-copilot-autopilot.md +3 -2
  48. package/docs/enthusiast/reference-commands.md +2 -2
  49. package/docs/migrate-from-ark-runtime-kernel.md +4 -2
  50. package/docs/package-surface.md +72 -0
  51. package/docs/production-hardening.md +3 -0
  52. package/package.json +12 -1
  53. package/server.json +2 -2
  54. package/templates/skills/ark-explain.md +3 -2
  55. package/templates/skills/ark-loop.md +2 -1
@@ -0,0 +1,182 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/remediation.ts
5
+ * Regenerate: node scripts/generate-cli-pure.mjs
6
+ * Drift check: node scripts/generate-cli-pure.mjs --check
7
+ *
8
+ * Pure CLI helper (bin/lib/remediation.mjs). Zero Node I/O.
9
+ */
10
+
11
+ export const REMEDIATION_CLASSES = [
12
+ 'mechanical-safe',
13
+ 'judgment',
14
+ 'deferred',
15
+ ];
16
+ /** All remediationKinds that may return class: mechanical-safe (ordered for docs/tests). */
17
+ export const MECHANICAL_SAFE_KINDS = [
18
+ 'pure-type-file-relocate',
19
+ 'type-only-import-move',
20
+ 'import-type-from-pure-type-module',
21
+ 'import-type-of-type-exports',
22
+ ];
23
+ /** fixClass values from enrichViolationWithFixClass (eval corpus / reports). */
24
+ export const KNOWN_FIX_CLASSES = [
25
+ 'file-move',
26
+ 'port-inversion',
27
+ 'inject-port',
28
+ 'registered-intent',
29
+ 'add-source-metadata',
30
+ 'fix-source-layer',
31
+ 'intent-relocation',
32
+ 'break-cycle',
33
+ 'review-contract',
34
+ ];
35
+ /**
36
+ * Co-pilot work classifier — the TRUST BOUNDARY for auto-apply.
37
+ * Biased toward 'judgment': false mechanical-safe is worse than an extra human approval.
38
+ */
39
+ export function classifyRemediation(violation) {
40
+ const ruleId = violation?.ruleId;
41
+ if (ruleId === 'LAYER_IMPORT_VIOLATION') {
42
+ // Single invariant: runtime module loads are never mechanical-safe.
43
+ const edgeKind = violation?.edgeKind;
44
+ if (edgeKind === 'require' || edgeKind === 'dynamic-import') {
45
+ return {
46
+ class: 'judgment',
47
+ confidence: 0.75,
48
+ rationale: 'Runtime module load (require/import()) still executes the target file — not auto-safe; rewrite to a static import type if appropriate.',
49
+ };
50
+ }
51
+ if (violation?.typeOnly && violation?.sourcePureTypeModule) {
52
+ return {
53
+ class: 'mechanical-safe',
54
+ confidence: 0.88,
55
+ remediationKind: 'pure-type-file-relocate',
56
+ rationale: 'Whole source file is type-only surface (no runtime statements) with a type-only cross-layer edge: relocate the file to the owning layer (or extract the type there). Behavior-preserving; gate verifies.',
57
+ };
58
+ }
59
+ if (violation?.typeOnly) {
60
+ return {
61
+ class: 'mechanical-safe',
62
+ confidence: 0.9,
63
+ remediationKind: 'type-only-import-move',
64
+ rationale: 'Type-only import (erased at runtime): move the type to the layer that owns it and re-export for back-compat. Behavior-preserving, and the gate verifies it.',
65
+ };
66
+ }
67
+ if (violation?.targetTypeOnlyExports) {
68
+ return {
69
+ class: 'mechanical-safe',
70
+ confidence: 0.85,
71
+ remediationKind: 'import-type-from-pure-type-module',
72
+ rationale: 'Static import targets a pure type-only module: convert to `import type` (erased at runtime) and place the type in a shared/owning layer. No runtime coupling; gate verifies.',
73
+ };
74
+ }
75
+ // R6: value-syntax named import/export of type-only exports from a mixed module.
76
+ // Only set when scan proves no dual-space value export and no top-level side effects.
77
+ if (violation?.namedBindingsTypeOnly) {
78
+ return {
79
+ class: 'mechanical-safe',
80
+ confidence: 0.86,
81
+ remediationKind: 'import-type-of-type-exports',
82
+ rationale: 'Named bindings are type-only exports of the target module (even if the file also exports values): convert to `import type` / `export type` (erased at runtime). Gate verifies.',
83
+ };
84
+ }
85
+ return {
86
+ class: 'judgment',
87
+ confidence: 0.7,
88
+ rationale: 'Value import — real runtime coupling. Relocating it (e.g. a route reaching the DB → a repository) is a refactor whose organization is a human choice.',
89
+ };
90
+ }
91
+ if (ruleId === 'FORBIDDEN_GLOBAL') {
92
+ return {
93
+ class: 'judgment',
94
+ confidence: 0.8,
95
+ rationale: 'Ambient global in a pure layer: inject the capability through a port (Clock, Config, Http). Introducing the port is a design decision.',
96
+ };
97
+ }
98
+ if (ruleId === 'CIRCULAR_DEPENDENCY') {
99
+ return {
100
+ class: 'judgment',
101
+ confidence: 0.7,
102
+ rationale: 'Dependency cycle: breaking it means deciding which side owns the shared abstraction.',
103
+ };
104
+ }
105
+ if (typeof ruleId === 'string' && ruleId.length > 0) {
106
+ return {
107
+ class: 'judgment',
108
+ confidence: 0.6,
109
+ rationale: 'Needs a human decision on how to satisfy the contract without weakening the gate.',
110
+ };
111
+ }
112
+ return {
113
+ class: 'deferred',
114
+ confidence: 0.3,
115
+ rationale: 'Unrecognized violation shape — a human should look before anything is changed.',
116
+ };
117
+ }
118
+ /**
119
+ * Deterministic fix-class labels for JSON output (English, shared with skills/reports).
120
+ */
121
+ export function enrichViolationWithFixClass(violation) {
122
+ const enriched = { ...violation };
123
+ switch (violation.ruleId) {
124
+ case 'LAYER_IMPORT_VIOLATION':
125
+ if (violation.typeOnly || violation.targetTypeOnlyExports || violation.namedBindingsTypeOnly) {
126
+ enriched.fixClass = 'file-move';
127
+ enriched.effort = 'small';
128
+ enriched.enthusiastHint = violation.namedBindingsTypeOnly
129
+ ? 'Those named imports are type-only exports of the target — use `import type { … }` (or `export type { … }`) so the edge is erased at runtime.'
130
+ : violation.targetTypeOnlyExports
131
+ ? 'The imported module only exports types — use `import type` and place the type in a layer both sides may share.'
132
+ : 'This is a type-only import — move the type to a layer both sides may share, or relocate the file to match its role.';
133
+ }
134
+ else {
135
+ enriched.fixClass = 'port-inversion';
136
+ enriched.effort = 'medium';
137
+ enriched.enthusiastHint = `${violation.fromLayer ?? 'This layer'} must not import ${violation.toLayer ?? 'that layer'} directly. Define an interface (port) where you need the capability and inject the implementation from the outer layer.`;
138
+ }
139
+ break;
140
+ case 'FORBIDDEN_GLOBAL':
141
+ enriched.fixClass = 'inject-port';
142
+ enriched.effort = 'small';
143
+ enriched.enthusiastHint = `Do not call "${violation.target ?? 'that global'}" here. Pass the capability in through a small interface (for example a Clock, HttpPort, or Config provider).`;
144
+ break;
145
+ case 'RAW_EVENT_PUBLISH':
146
+ enriched.fixClass = 'registered-intent';
147
+ enriched.effort = 'small';
148
+ enriched.enthusiastHint =
149
+ 'Register the event intent first, then publish through the creator returned by the registry — not a raw string or object.';
150
+ break;
151
+ case 'PUBLISH_MISSING_SOURCE':
152
+ enriched.fixClass = 'add-source-metadata';
153
+ enriched.effort = 'small';
154
+ enriched.enthusiastHint =
155
+ 'Add metadata.source to the publish call so Ark knows which layer is publishing the event.';
156
+ break;
157
+ case 'PUBLISH_SOURCE_LAYER_MISMATCH':
158
+ enriched.fixClass = 'fix-source-layer';
159
+ enriched.effort = 'small';
160
+ enriched.enthusiastHint =
161
+ 'Use a source intent that belongs to the same layer as this file, or move the publish call to the layer that owns the source.';
162
+ break;
163
+ case 'LAYER_INTENT_REFERENCE_VIOLATION':
164
+ enriched.fixClass = 'intent-relocation';
165
+ enriched.effort = 'small';
166
+ enriched.enthusiastHint =
167
+ 'Reference that intent from a layer allowed to know about it — usually an adapter or application layer, not the domain core.';
168
+ break;
169
+ case 'CIRCULAR_DEPENDENCY':
170
+ enriched.fixClass = 'break-cycle';
171
+ enriched.effort = 'medium';
172
+ enriched.enthusiastHint =
173
+ 'Two modules import each other in a loop. Extract shared code, invert one dependency behind a port, or merge them if they are really one unit.';
174
+ break;
175
+ default:
176
+ enriched.fixClass = 'review-contract';
177
+ enriched.effort = 'small';
178
+ enriched.enthusiastHint =
179
+ 'Read the violation message and the layer rules in ark.config.json, then adjust imports or move code to the correct layer.';
180
+ }
181
+ return enriched;
182
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Governed source file walk / collection for ark-check.
3
+ * Extracted from ark-check entry (R3).
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { isScanExcludedRelative } from '../ark-shared.mjs';
8
+
9
+ export const SOURCE_FILE_NAME = /\.[cm]?[tj]sx?$/;
10
+
11
+ /** Unit/e2e test files are not architecture surface — agents and Nest put them next
12
+ * to production code (*.spec.ts). Counting them as ungoverned forces false
13
+ * CONFIG_UNCLASSIFIED_FILES under --strict-config on every starter. */
14
+ export const TEST_FILE_NAME =
15
+ /\.(spec|test)\.(tsx?|jsx?|mts|cts)$/i;
16
+
17
+ export function isGovernableSourceFile(name) {
18
+ return SOURCE_FILE_NAME.test(name) && !name.endsWith('.d.ts') && !TEST_FILE_NAME.test(name);
19
+ }
20
+
21
+ export function isSkippedSourceDir(name) {
22
+ return (
23
+ name === 'node_modules' ||
24
+ name === 'dist' ||
25
+ name === 'coverage' ||
26
+ name === '__tests__' ||
27
+ name === '__mocks__' ||
28
+ name === 'e2e' ||
29
+ // Top-level style Nest/Jest folders (not "testing" helpers inside src)
30
+ name === 'test' ||
31
+ name === 'tests'
32
+ );
33
+ }
34
+
35
+ export function walk(dir, files = []) {
36
+ const stat = fs.statSync(dir, { throwIfNoEntry: false });
37
+ if (!stat) return files;
38
+ // An `include` entry may be a single file (e.g. a root-level "middleware.ts"),
39
+ // not just a directory — govern it directly instead of trying to scandir it
40
+ // (which threw ENOTDIR). The extension filter still applies.
41
+ if (stat.isFile()) {
42
+ if (isGovernableSourceFile(path.basename(dir))) files.push(dir);
43
+ return files;
44
+ }
45
+ if (!stat.isDirectory()) return files;
46
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
47
+ const full = path.join(dir, entry.name);
48
+ if (entry.isDirectory()) {
49
+ if (isSkippedSourceDir(entry.name)) continue;
50
+ walk(full, files);
51
+ } else if (isGovernableSourceFile(entry.name)) {
52
+ files.push(full);
53
+ }
54
+ }
55
+ return files;
56
+ }
57
+
58
+ /** Walk include roots then drop codegen / config.exclude (universal scan filter). */
59
+ export function collectGovernedFiles(root, config) {
60
+ const raw = (config.include ?? []).flatMap((entry) => walk(path.join(root, entry)));
61
+ return raw.filter((abs) => {
62
+ const rel = normalize(path.relative(root, abs));
63
+ return !isScanExcludedRelative(rel, config);
64
+ });
65
+ }
66
+
67
+ export function normalize(value) {
68
+ return value.split(path.sep).join('/');
69
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * TypeScript module resolution + per-file scan cache for ark-check.
3
+ * Extracted from ark-check entry (R3).
4
+ */
5
+ import crypto from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ export function createModuleResolutionHost(ts) {
10
+ const sys = ts?.sys;
11
+ const fileExists = (f) => {
12
+ if (sys?.fileExists) return sys.fileExists(f);
13
+ return fs.existsSync(f);
14
+ };
15
+ const readFile = (f) => {
16
+ if (sys?.readFile) return sys.readFile(f);
17
+ try {
18
+ return fs.readFileSync(f, 'utf8');
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ };
23
+ const directoryExists = (d) => {
24
+ if (sys?.directoryExists) return sys.directoryExists(d);
25
+ try {
26
+ return fs.statSync(d).isDirectory();
27
+ } catch {
28
+ return false;
29
+ }
30
+ };
31
+ return {
32
+ fileExists,
33
+ readFile,
34
+ directoryExists,
35
+ getCurrentDirectory: () =>
36
+ sys?.getCurrentDirectory ? sys.getCurrentDirectory() : process.cwd(),
37
+ getDirectories: (d) => {
38
+ if (sys?.getDirectories) return sys.getDirectories(d);
39
+ try {
40
+ return fs
41
+ .readdirSync(d, { withFileTypes: true })
42
+ .filter((e) => e.isDirectory())
43
+ .map((e) => e.name);
44
+ } catch {
45
+ return [];
46
+ }
47
+ },
48
+ realpath: sys?.realpath ? (p) => sys.realpath(p) : undefined,
49
+ useCaseSensitiveFileNames: sys?.useCaseSensitiveFileNames ?? true,
50
+ };
51
+ }
52
+
53
+ export function parseTsconfig(ts, configPath) {
54
+ const host = createModuleResolutionHost(ts);
55
+ const read = ts.readConfigFile(configPath, host.readFile);
56
+ if (read.error) return {};
57
+ // parseJsonConfigFileContent wants a ParseConfigHost-like object; our resolution host
58
+ // is enough for option extraction.
59
+ const parsed = ts.parseJsonConfigFileContent(
60
+ read.config,
61
+ {
62
+ useCaseSensitiveFileNames: host.useCaseSensitiveFileNames,
63
+ readDirectory: ts.sys?.readDirectory
64
+ ? (...args) => ts.sys.readDirectory(...args)
65
+ : () => [],
66
+ fileExists: host.fileExists,
67
+ readFile: host.readFile,
68
+ },
69
+ path.dirname(configPath)
70
+ );
71
+ return parsed.options;
72
+ }
73
+
74
+ /**
75
+ * Compiler options for a given source file. With --tsconfig every file uses that one
76
+ * config; otherwise each file uses the NEAREST tsconfig.json above it (like tsc does),
77
+ * so monorepo packages with per-package path aliases resolve correctly under one --root.
78
+ */
79
+ export function createCompilerOptionsLookup(ts, root, tsconfigArg) {
80
+ if (tsconfigArg) {
81
+ const configPath = path.isAbsolute(tsconfigArg) ? tsconfigArg : path.join(root, tsconfigArg);
82
+ const options = fs.existsSync(configPath) ? parseTsconfig(ts, configPath) : {};
83
+ return () => options;
84
+ }
85
+ const byDir = new Map();
86
+ const byConfig = new Map();
87
+ return (file) => {
88
+ const dir = path.dirname(file);
89
+ if (byDir.has(dir)) return byDir.get(dir);
90
+ const configPath = ts.findConfigFile(dir, ts.sys.fileExists, 'tsconfig.json');
91
+ let options = {};
92
+ if (configPath) {
93
+ if (!byConfig.has(configPath)) byConfig.set(configPath, parseTsconfig(ts, configPath));
94
+ options = byConfig.get(configPath);
95
+ }
96
+ byDir.set(dir, options);
97
+ return options;
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Per-file scan cache. A cache entry stores the parsed file's content-derived results:
103
+ * content violations (forbidden globals, publish checks, intent references) and the list
104
+ * of module-edge specifiers. Edges are NEVER cached as violations — they are re-resolved
105
+ * against the live filesystem every run, because resolution depends on files and tsconfigs
106
+ * outside the cached file. The whole cache is keyed by the config+manifest contents, so
107
+ * any rule change invalidates everything.
108
+ */
109
+ export function scanCachePath(root) {
110
+ return path.join(root, 'node_modules', '.cache', 'ark-check.json');
111
+ }
112
+
113
+ export function scanCacheKey(root, args) {
114
+ const read = (p) => {
115
+ try {
116
+ return fs.readFileSync(p, 'utf8');
117
+ } catch {
118
+ return '';
119
+ }
120
+ };
121
+ const configPath = path.isAbsolute(args.config) ? args.config : path.join(root, args.config);
122
+ const manifestPath = args.manifest
123
+ ? path.isAbsolute(args.manifest)
124
+ ? args.manifest
125
+ : path.join(root, args.manifest)
126
+ : undefined;
127
+ // Bump this schema tag whenever the cached scan shape or detection semantics change, so a
128
+ // warm cache from an older Ark can't feed stale entries to new logic. v2: typeOnly on edges.
129
+ // v3: per-file exportsOnlyTypes. v4: typeOnlyExportNames + namedBindings.
130
+ // v5: hasTopLevelSideEffects. v6: non-exported impure inits + non-export class statics.
131
+ return crypto
132
+ .createHash('sha1')
133
+ .update(`ark-check-cache-v6\0${read(configPath)}\0${manifestPath ? read(manifestPath) : ''}`)
134
+ .digest('hex');
135
+ }
136
+
137
+ export function loadScanCache(root, key) {
138
+ try {
139
+ const data = JSON.parse(fs.readFileSync(scanCachePath(root), 'utf8'));
140
+ return data.key === key && data.files && typeof data.files === 'object' ? data.files : undefined;
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ }
145
+
146
+ export function saveScanCache(root, key, files) {
147
+ try {
148
+ const target = scanCachePath(root);
149
+ fs.mkdirSync(path.dirname(target), { recursive: true });
150
+ fs.writeFileSync(target, JSON.stringify({ key, files }));
151
+ } catch {
152
+ // cache is best-effort: read-only filesystems just re-parse every run
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Fallback resolver for extensionless relative imports whose on-disk target uses an
158
+ * extension `ts.resolveModuleName` won't resolve without a matching tsconfig
159
+ * (notably `.mts`/`.cts`). Mirrors the classic candidate list.
160
+ */
161
+ export function isFile(candidate) {
162
+ try {
163
+ return fs.statSync(candidate).isFile();
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ export function resolveRelativeFallback(fromFile, specifier) {
170
+ const base = path.resolve(path.dirname(fromFile), specifier);
171
+ const candidates = [
172
+ base, // only used when the specifier already carries an extension (isFile filters dirs)
173
+ `${base}.ts`,
174
+ `${base}.tsx`,
175
+ `${base}.mts`,
176
+ `${base}.cts`,
177
+ `${base}.js`,
178
+ `${base}.jsx`,
179
+ `${base}.mjs`,
180
+ `${base}.cjs`,
181
+ path.join(base, 'index.ts'),
182
+ path.join(base, 'index.tsx'),
183
+ path.join(base, 'index.mts'),
184
+ path.join(base, 'index.cts'),
185
+ ];
186
+ // isFile (not existsSync) so a directory named like the specifier never shadows the
187
+ // real module file — e.g. `./foo` must not resolve to a `foo/` directory before `foo.mts`.
188
+ return candidates.find(isFile);
189
+ }
190
+
191
+ /**
192
+ * Resolve any import specifier (relative, tsconfig path-alias, or package) to a source
193
+ * file using TypeScript's module resolver, returning the resolved file (or undefined for
194
+ * unresolved / declaration-only targets).
195
+ *
196
+ * ark-check governs one project rooted at --root. A resolved target is skipped when its
197
+ * path RELATIVE TO ROOT either escapes the root (leading `..`) or contains a `node_modules`
198
+ * segment. Using the root-relative path (not an absolute substring) means a project that
199
+ * itself lives under a node_modules segment is still governed, while a broad catch-all
200
+ * pattern (`**`) can't false-flag vendored deps or files outside the project. Monorepos can
201
+ * run under a single --root (per-package tsconfigs are honored via the nearest-tsconfig
202
+ * lookup); edges that resolve outside the root are still skipped.
203
+ */
204
+ export function resolveImport(ts, specifier, containingFile, options, host, root) {
205
+ const res = ts.resolveModuleName(specifier, containingFile, options, host);
206
+ let file = res.resolvedModule?.resolvedFileName;
207
+ if (!file && specifier.startsWith('.')) {
208
+ file = resolveRelativeFallback(containingFile, specifier);
209
+ }
210
+ if (!file) return undefined;
211
+ if (file.endsWith('.d.ts')) return undefined;
212
+ const abs = path.resolve(file);
213
+ const segments = path.relative(root, abs).split(path.sep);
214
+ if (segments[0] === '..' || segments.includes('node_modules')) return undefined;
215
+ return abs;
216
+ }
@@ -10,15 +10,9 @@ const color = {
10
10
  bold: (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
11
11
  };
12
12
 
13
- export function baselineKey(violation) {
14
- return [
15
- violation.ruleId,
16
- violation.file,
17
- violation.fromLayer ?? '',
18
- violation.toLayer ?? '',
19
- violation.target ?? '',
20
- ].join('|');
21
- }
13
+ /** Canonical: src/domain/baselineKey.ts → bin/lib/baseline-key.mjs (R4). */
14
+ import { baselineKey } from './baseline-key.mjs';
15
+ export { baselineKey };
22
16
 
23
17
  export function readBaseline(root, baselinePath) {
24
18
  const fullPath = path.isAbsolute(baselinePath) ? baselinePath : path.join(root, baselinePath);
@@ -53,6 +53,24 @@ var regexpCache = /* @__PURE__ */ new Map();
53
53
  function escapeLiteral(ch) {
54
54
  return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
55
55
  }
56
+ function normalizeGlobSeparators(pattern) {
57
+ let out = "";
58
+ for (let i = 0; i < pattern.length; i += 1) {
59
+ const c = pattern[i];
60
+ if (c === "\\" && i + 1 < pattern.length) {
61
+ const next = pattern[i + 1];
62
+ if ("*?{}[],".includes(next) || next === "\\") {
63
+ out += "\\" + next;
64
+ i += 1;
65
+ continue;
66
+ }
67
+ out += "/";
68
+ continue;
69
+ }
70
+ out += c;
71
+ }
72
+ return out;
73
+ }
56
74
  function bracesBalanced(glob) {
57
75
  let depth = 0;
58
76
  for (let i = 0; i < glob.length; i += 1) {
@@ -72,7 +90,7 @@ function bracesBalanced(glob) {
72
90
  function globToRegExp(pattern) {
73
91
  const cached = regexpCache.get(pattern);
74
92
  if (cached) return cached;
75
- const glob = pattern.split("\\").join("/");
93
+ const glob = normalizeGlobSeparators(pattern);
76
94
  const useBraces = bracesBalanced(glob);
77
95
  let out = "";
78
96
  let braceDepth = 0;
@@ -112,14 +130,14 @@ function globToRegExp(pattern) {
112
130
  return re;
113
131
  }
114
132
  function patternSpecificity(pattern) {
115
- const glob = String(pattern).split("\\").join("/");
133
+ const glob = normalizeGlobSeparators(String(pattern));
116
134
  const beforeWildcard = glob.split("*")[0];
117
135
  const literalSegments = beforeWildcard.split("/").filter(Boolean).length;
118
136
  const literalLength = glob.replace(/\*/g, "").length;
119
137
  return literalSegments * 1e4 + literalLength;
120
138
  }
121
139
  function layerForRelativePath(relPath, layers) {
122
- const rel = String(relPath).split("\\").join("/");
140
+ const rel = String(relPath).split(/[/\\]/).join("/");
123
141
  let bestName;
124
142
  let bestScore = -1;
125
143
  for (const layer of layers ?? []) {