arkgate 2.6.0 → 2.7.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 (57) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +98 -70
  3. package/bin/ark-check.mjs +240 -1001
  4. package/bin/ark-layer-match.mjs +153 -147
  5. package/bin/ark-mcp.mjs +102 -5
  6. package/bin/ark-shared.mjs +304 -165
  7. package/bin/ark.mjs +44 -34
  8. package/bin/lib/agent-gates.mjs +448 -15
  9. package/bin/lib/architecture-scan.mjs +279 -0
  10. package/bin/lib/ast-scan.mjs +199 -0
  11. package/bin/lib/baseline-key.mjs +23 -0
  12. package/bin/lib/config-warnings.mjs +228 -0
  13. package/bin/lib/doctor-plan.mjs +11 -4
  14. package/bin/lib/graph-cycles.mjs +56 -0
  15. package/bin/lib/presets.mjs +75 -4
  16. package/bin/lib/remediation.mjs +150 -0
  17. package/bin/lib/scan-files.mjs +69 -0
  18. package/bin/lib/ts-resolve.mjs +215 -0
  19. package/bin/lib/violations.mjs +3 -9
  20. package/dist/eslint/index.cjs +21 -3
  21. package/dist/eslint/index.cjs.map +1 -1
  22. package/dist/eslint/index.d.cts +5 -3
  23. package/dist/eslint/index.d.ts +5 -3
  24. package/dist/eslint/index.js +21 -3
  25. package/dist/eslint/index.js.map +1 -1
  26. package/dist/index.cjs +1 -1
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +3 -3
  29. package/dist/index.d.ts +3 -3
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/nestjs/index.cjs +1 -1
  33. package/dist/nestjs/index.cjs.map +1 -1
  34. package/dist/nestjs/index.d.cts +1 -1
  35. package/dist/nestjs/index.d.ts +1 -1
  36. package/dist/nestjs/index.js +1 -1
  37. package/dist/nestjs/index.js.map +1 -1
  38. package/dist/runtime/index.cjs +3080 -0
  39. package/dist/runtime/index.cjs.map +1 -0
  40. package/dist/runtime/index.d.cts +2 -0
  41. package/dist/runtime/index.d.ts +2 -0
  42. package/dist/runtime/index.js +2998 -0
  43. package/dist/runtime/index.js.map +1 -0
  44. package/dist/{types-DpdVN7Lm.d.cts → types-CP3KkwZt.d.cts} +1 -1
  45. package/dist/{types-DpdVN7Lm.d.ts → types-CP3KkwZt.d.ts} +1 -1
  46. package/docs/agent-guide.md +67 -1
  47. package/docs/migrate-from-ark-runtime-kernel.md +4 -2
  48. package/docs/package-surface.md +72 -0
  49. package/docs/production-hardening.md +3 -0
  50. package/package.json +11 -1
  51. package/server.json +2 -2
  52. package/templates/skills/ark-adopt.md +43 -87
  53. package/templates/skills/ark-autopilot.md +39 -77
  54. package/templates/skills/ark-contract.md +43 -84
  55. package/templates/skills/ark-coverage.md +62 -83
  56. package/templates/skills/ark-fix.md +45 -90
  57. package/templates/skills/ark-loop.md +44 -66
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Architecture check pipeline: content scan → import graph → layer edges → cycles.
3
+ * Extracted from ark-check entry (R3). Entry remains orchestration + presentation.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import {
8
+ collectForbiddenGlobalUses,
9
+ layerForFile,
10
+ looksLikeIntent,
11
+ } from '../ark-shared.mjs';
12
+ import {
13
+ isTypeOnlyModuleReference,
14
+ isArkPublishCandidate,
15
+ isPublishCall,
16
+ lineOf,
17
+ moduleSpecifierFromCall,
18
+ objectHasProperty,
19
+ publishHasSource,
20
+ publishSourceLiteral,
21
+ sourceFileExportsOnlyTypes,
22
+ stringLiteralText,
23
+ textOfModuleSpecifier,
24
+ } from './ast-scan.mjs';
25
+ import {
26
+ intentLayersFromManifest,
27
+ layerForIntent,
28
+ isBlocked,
29
+ collectConfigWarnings,
30
+ } from './config-warnings.mjs';
31
+ import { detectCycles } from './graph-cycles.mjs';
32
+ import { normalize } from './scan-files.mjs';
33
+ import {
34
+ createCompilerOptionsLookup,
35
+ createModuleResolutionHost,
36
+ loadScanCache,
37
+ resolveImport,
38
+ saveScanCache,
39
+ scanCacheKey,
40
+ } from './ts-resolve.mjs';
41
+
42
+ /**
43
+ * Parse one governed source file into content violations + module edges.
44
+ */
45
+ export function scanSourceFile(ts, root, config, rules, manifestIntentLayers, file, sourceLayer) {
46
+ const source = fs.readFileSync(file, 'utf8');
47
+ const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
48
+ const violations = [];
49
+ const edges = [];
50
+
51
+ const layerConfig = config.layers.find((layer) => layer.name === sourceLayer);
52
+ const forbiddenGlobals = Array.isArray(layerConfig?.forbiddenGlobals)
53
+ ? layerConfig.forbiddenGlobals.filter((entry) => typeof entry === 'string')
54
+ : [];
55
+ for (const use of collectForbiddenGlobalUses(ts, sourceFile, forbiddenGlobals)) {
56
+ violations.push({
57
+ ruleId: 'FORBIDDEN_GLOBAL',
58
+ file: normalize(path.relative(root, file)),
59
+ line: lineOf(sourceFile, use.node.getStart(sourceFile)),
60
+ fromLayer: sourceLayer,
61
+ target: use.name,
62
+ message: `${sourceLayer} must not use the ambient global "${use.name}".`,
63
+ });
64
+ }
65
+
66
+ const checkModuleEdge = (specifier, node, kind, typeOnly = false) => {
67
+ edges.push({
68
+ specifier,
69
+ line: lineOf(sourceFile, node.getStart(sourceFile)),
70
+ kind,
71
+ typeOnly,
72
+ });
73
+ };
74
+
75
+ const visit = (node) => {
76
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
77
+ const specifier = textOfModuleSpecifier(node);
78
+ if (specifier) {
79
+ checkModuleEdge(
80
+ specifier,
81
+ node,
82
+ ts.isImportDeclaration(node) ? 'import' : 'export',
83
+ isTypeOnlyModuleReference(ts, node)
84
+ );
85
+ }
86
+ }
87
+
88
+ if (ts.isCallExpression(node)) {
89
+ const moduleCall = moduleSpecifierFromCall(ts, node);
90
+ if (moduleCall) {
91
+ checkModuleEdge(moduleCall.value, node, moduleCall.kind);
92
+ }
93
+
94
+ if (isPublishCall(ts, node)) {
95
+ const firstArg = node.arguments[0];
96
+ const rawIntent = stringLiteralText(ts, firstArg);
97
+ if (
98
+ (rawIntent && looksLikeIntent(rawIntent)) ||
99
+ objectHasProperty(ts, firstArg, 'intent')
100
+ ) {
101
+ violations.push({
102
+ ruleId: 'RAW_EVENT_PUBLISH',
103
+ file: normalize(path.relative(root, file)),
104
+ line: lineOf(sourceFile, node.getStart(sourceFile)),
105
+ message:
106
+ 'Publish through a registered intent creator; raw event objects or intent strings bypass Ark contracts and tooling.',
107
+ });
108
+ }
109
+
110
+ if (isArkPublishCandidate(ts, node) && !publishHasSource(ts, node)) {
111
+ violations.push({
112
+ ruleId: 'PUBLISH_MISSING_SOURCE',
113
+ file: normalize(path.relative(root, file)),
114
+ line: lineOf(sourceFile, node.getStart(sourceFile)),
115
+ fromLayer: sourceLayer,
116
+ message: 'Strict Ark publish calls must include metadata.source.',
117
+ });
118
+ }
119
+
120
+ const sourceIntent = publishSourceLiteral(ts, node);
121
+ if (sourceIntent && looksLikeIntent(sourceIntent)) {
122
+ const sourceIntentLayer = layerForIntent(
123
+ sourceIntent,
124
+ config.layers,
125
+ manifestIntentLayers
126
+ );
127
+ if (sourceIntentLayer && sourceIntentLayer !== sourceLayer) {
128
+ violations.push({
129
+ ruleId: 'PUBLISH_SOURCE_LAYER_MISMATCH',
130
+ file: normalize(path.relative(root, file)),
131
+ line: lineOf(sourceFile, node.getStart(sourceFile)),
132
+ fromLayer: sourceLayer,
133
+ toLayer: sourceIntentLayer,
134
+ target: sourceIntent,
135
+ message:
136
+ `Publish source "${sourceIntent}" resolves to ${sourceIntentLayer}, but the publishing file is classified as ${sourceLayer}.`,
137
+ });
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ if (ts.isStringLiteralLike(node) && looksLikeIntent(node.text)) {
144
+ const targetLayer = layerForIntent(node.text, config.layers, manifestIntentLayers);
145
+ const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
146
+ if (rule) {
147
+ violations.push({
148
+ ruleId: 'LAYER_INTENT_REFERENCE_VIOLATION',
149
+ file: normalize(path.relative(root, file)),
150
+ line: lineOf(sourceFile, node.getStart(sourceFile)),
151
+ fromLayer: sourceLayer,
152
+ toLayer: targetLayer,
153
+ target: node.text,
154
+ message:
155
+ rule.message ??
156
+ `${sourceLayer} must not reference ${targetLayer} intent ${node.text}.`,
157
+ });
158
+ }
159
+ }
160
+
161
+ ts.forEachChild(node, visit);
162
+ };
163
+ visit(sourceFile);
164
+ return {
165
+ contentViolations: violations,
166
+ edges,
167
+ exportsOnlyTypes: sourceFileExportsOnlyTypes(ts, sourceFile),
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Full architecture scan for governed files.
173
+ * @returns {{ violations: object[], warnings: object[] }}
174
+ */
175
+ export function runArchitectureScan({ root, config, manifest, rules, files, ts, args }) {
176
+ const manifestIntentLayers = intentLayersFromManifest(manifest);
177
+ const compilerOptionsFor = createCompilerOptionsLookup(ts, root, args.tsconfig);
178
+ const moduleHost = createModuleResolutionHost(ts);
179
+
180
+ const violations = [];
181
+ const warnings = collectConfigWarnings(root, config, files, rules, manifest);
182
+ const cacheKey = args.noCache ? undefined : scanCacheKey(root, args);
183
+ const cachedFiles = cacheKey ? loadScanCache(root, cacheKey) : undefined;
184
+ const nextCacheFiles = {};
185
+
186
+ const importGraph = new Map();
187
+ const scanned = [];
188
+ for (const file of files) {
189
+ const sourceLayer = layerForFile(root, file, config.layers);
190
+ if (!sourceLayer) continue;
191
+ const relFile = normalize(path.relative(root, file));
192
+ if (!importGraph.has(relFile)) importGraph.set(relFile, new Set());
193
+ const stat = fs.statSync(file);
194
+ const fileKey = `${stat.mtimeMs}:${stat.size}`;
195
+ const cached = cachedFiles?.[relFile];
196
+ const entry =
197
+ cached && cached.fileKey === fileKey
198
+ ? cached
199
+ : {
200
+ fileKey,
201
+ ...scanSourceFile(
202
+ ts,
203
+ root,
204
+ config,
205
+ rules,
206
+ manifestIntentLayers,
207
+ file,
208
+ sourceLayer
209
+ ),
210
+ };
211
+ nextCacheFiles[relFile] = entry;
212
+ scanned.push({ file, sourceLayer, relFile, entry });
213
+ }
214
+
215
+ for (const { file, sourceLayer, relFile, entry } of scanned) {
216
+ violations.push(...entry.contentViolations);
217
+ for (const edge of entry.edges) {
218
+ const target = resolveImport(
219
+ ts,
220
+ edge.specifier,
221
+ file,
222
+ compilerOptionsFor(file),
223
+ moduleHost,
224
+ root
225
+ );
226
+ const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
227
+ if (target && targetLayer) {
228
+ const relTarget = normalize(path.relative(root, target));
229
+ if (relTarget !== relFile && !edge.typeOnly) {
230
+ importGraph.get(relFile).add(relTarget);
231
+ }
232
+ }
233
+ const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
234
+ if (rule) {
235
+ const relTarget = normalize(path.relative(root, target));
236
+ const targetCached = nextCacheFiles[relTarget];
237
+ const staticEdge = edge.kind === 'import' || edge.kind === 'export';
238
+ const targetTypeOnlyExports =
239
+ staticEdge && Boolean(targetCached?.exportsOnlyTypes) && !edge.typeOnly;
240
+ const sourcePureTypeModule = Boolean(entry.exportsOnlyTypes);
241
+ violations.push({
242
+ ruleId: 'LAYER_IMPORT_VIOLATION',
243
+ file: relFile,
244
+ line: edge.line,
245
+ fromLayer: sourceLayer,
246
+ toLayer: targetLayer,
247
+ target: relTarget,
248
+ ...(edge.typeOnly ? { typeOnly: true } : {}),
249
+ ...(targetTypeOnlyExports ? { targetTypeOnlyExports: true } : {}),
250
+ ...(sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
251
+ ...(edge.kind ? { edgeKind: edge.kind } : {}),
252
+ message: rule.message ?? `${sourceLayer} must not ${edge.kind} ${targetLayer}.`,
253
+ });
254
+ }
255
+ }
256
+ }
257
+
258
+ if (cacheKey) saveScanCache(root, cacheKey, nextCacheFiles);
259
+
260
+ const cyclePolicy = String(config.cyclePolicy || 'strict').toLowerCase();
261
+ if (cyclePolicy !== 'off') {
262
+ const cycles = detectCycles(importGraph);
263
+ if (cyclePolicy === 'soft' || cyclePolicy === 'framework-soft') {
264
+ for (const c of cycles) {
265
+ warnings.push({
266
+ ruleId: 'CIRCULAR_DEPENDENCY',
267
+ message: `${c.message} (soft cycle policy — advisory only; set cyclePolicy: "strict" to fail the check)`,
268
+ file: c.file,
269
+ target: c.target,
270
+ failsStrict: false,
271
+ });
272
+ }
273
+ } else {
274
+ violations.push(...cycles);
275
+ }
276
+ }
277
+
278
+ return { violations, warnings };
279
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * AST helpers for publish checks, type-only edges, and module specifiers.
3
+ * Extracted from ark-check entry (R3).
4
+ */
5
+ import { looksLikeIntent } from '../ark-shared.mjs';
6
+
7
+ export function lineOf(sourceFile, pos) {
8
+ return sourceFile.getLineAndCharacterOfPosition(pos).line + 1;
9
+ }
10
+
11
+ export function textOfModuleSpecifier(node) {
12
+ return node.moduleSpecifier && typeof node.moduleSpecifier.text === 'string'
13
+ ? node.moduleSpecifier.text
14
+ : undefined;
15
+ }
16
+
17
+ // True when an import/export edge carries ONLY types (`import type …`, or a named import
18
+ // where every binding is `type`-qualified). Type-only edges are erased at compile time —
19
+ // they create no runtime coupling, only a design/type-placement dependency — so callers can
20
+ // rank them below real value imports in a burn-down. A side-effect import (`import "x"`) or
21
+ // any default/namespace/value binding is NOT type-only.
22
+ export function isTypeOnlyModuleReference(ts, node) {
23
+ if (ts.isImportDeclaration(node)) {
24
+ const clause = node.importClause;
25
+ if (!clause) return false; // side-effect import — runtime edge
26
+ if (clause.isTypeOnly) return true; // `import type …`
27
+ const named = clause.namedBindings;
28
+ if (named && ts.isNamedImports(named) && named.elements.length > 0) {
29
+ return named.elements.every((element) => element.isTypeOnly);
30
+ }
31
+ return false; // default or namespace binding of a value
32
+ }
33
+ if (ts.isExportDeclaration(node)) {
34
+ if (node.isTypeOnly) return true;
35
+ const clause = node.exportClause;
36
+ if (clause && ts.isNamedExports(clause) && clause.elements.length > 0) {
37
+ return clause.elements.every((element) => element.isTypeOnly);
38
+ }
39
+ return false;
40
+ }
41
+ return false;
42
+ }
43
+
44
+ /**
45
+ * True when a module is a pure type-surface file: only type/interface exports and
46
+ * type-only imports. Conservative false (→ judgment) when:
47
+ * - any top-level runtime statement (value decls, expression stmts, side-effect imports)
48
+ * - ambiguous `export { X }` without type keyword, export *, default/export=
49
+ * Used so static value-syntax `import { T }` of a pure-type module can be mechanical-safe
50
+ * (convert to `import type`). Never trust this for require()/import() edges.
51
+ */
52
+ export function sourceFileExportsOnlyTypes(ts, sourceFile) {
53
+ let sawTypeExport = false;
54
+ const hasExportModifier = (node) =>
55
+ Array.isArray(node.modifiers) &&
56
+ node.modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
57
+
58
+ for (const stmt of sourceFile.statements) {
59
+ // Type-only imports OK; value or side-effect imports mean runtime load of deps.
60
+ if (ts.isImportDeclaration(stmt)) {
61
+ if (!isTypeOnlyModuleReference(ts, stmt)) return false;
62
+ continue;
63
+ }
64
+ if (typeof ts.isImportEqualsDeclaration === 'function' && ts.isImportEqualsDeclaration(stmt)) {
65
+ return false;
66
+ }
67
+ if (ts.isExportDeclaration(stmt)) {
68
+ if (stmt.isTypeOnly) {
69
+ sawTypeExport = true;
70
+ continue;
71
+ }
72
+ // export * from '…' can re-export values — not provably type-only.
73
+ if (!stmt.exportClause) return false;
74
+ if (ts.isNamespaceExport(stmt.exportClause)) return false;
75
+ if (ts.isNamedExports(stmt.exportClause)) {
76
+ if (stmt.exportClause.elements.length === 0) return false;
77
+ for (const el of stmt.exportClause.elements) {
78
+ if (!el.isTypeOnly) return false; // bare `export { X }` — ambiguous without checker
79
+ }
80
+ sawTypeExport = true;
81
+ continue;
82
+ }
83
+ return false;
84
+ }
85
+ if (ts.isExportAssignment(stmt)) return false; // export = / export default expr
86
+ if (ts.isTypeAliasDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)) {
87
+ if (hasExportModifier(stmt)) sawTypeExport = true;
88
+ continue;
89
+ }
90
+ // Any other top-level statement (const/fn/class/enum, console.log, if, …) is runtime.
91
+ return false;
92
+ }
93
+ return sawTypeExport;
94
+ }
95
+
96
+ export function propertyName(ts, node) {
97
+ if (!node) return undefined;
98
+ if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) return node.text;
99
+ return undefined;
100
+ }
101
+
102
+ export function objectProperty(ts, node, name) {
103
+ if (!node || !ts.isObjectLiteralExpression(node)) return undefined;
104
+ return node.properties.find((property) => {
105
+ if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) {
106
+ return false;
107
+ }
108
+ return propertyName(ts, property.name) === name;
109
+ });
110
+ }
111
+
112
+ export function objectHasProperty(ts, node, name) {
113
+ return objectProperty(ts, node, name) !== undefined;
114
+ }
115
+
116
+ export function objectPropertyValue(ts, node, name) {
117
+ const property = objectProperty(ts, node, name);
118
+ return property && ts.isPropertyAssignment(property)
119
+ ? property.initializer
120
+ : undefined;
121
+ }
122
+
123
+ export function objectHasMetadataSource(ts, node) {
124
+ const metadata = objectPropertyValue(ts, node, 'metadata');
125
+ return objectHasProperty(ts, metadata, 'source');
126
+ }
127
+
128
+ export function stringLiteralText(ts, node) {
129
+ return node && ts.isStringLiteralLike(node) ? node.text : undefined;
130
+ }
131
+
132
+ export function isPublishCall(ts, node) {
133
+ if (!ts.isCallExpression(node)) return false;
134
+ const expression = node.expression;
135
+ if (ts.isPropertyAccessExpression(expression)) {
136
+ return expression.name.text === 'publish';
137
+ }
138
+ return ts.isIdentifier(expression) && expression.text === 'publish';
139
+ }
140
+
141
+ export function looksLikeIntentCreatorExpression(ts, node) {
142
+ if (!node) return false;
143
+ if (ts.isIdentifier(node)) {
144
+ return /^[A-Z]/.test(node.text);
145
+ }
146
+ if (ts.isPropertyAccessExpression(node)) {
147
+ return looksLikeIntentCreatorExpression(ts, node.name);
148
+ }
149
+ return false;
150
+ }
151
+
152
+ export function isArkPublishCandidate(ts, node) {
153
+ if (!ts.isCallExpression(node)) return false;
154
+ const firstArg = node.arguments[0];
155
+ const rawIntent = stringLiteralText(ts, firstArg);
156
+ return (
157
+ (rawIntent !== undefined && looksLikeIntent(rawIntent)) ||
158
+ objectHasProperty(ts, firstArg, 'intent') ||
159
+ looksLikeIntentCreatorExpression(ts, firstArg)
160
+ );
161
+ }
162
+
163
+ export function publishSourceLiteral(ts, node) {
164
+ if (!ts.isCallExpression(node)) return undefined;
165
+ const [firstArg, secondArg, thirdArg] = node.arguments;
166
+ const rawMetadata = objectPropertyValue(ts, firstArg, 'metadata');
167
+ return (
168
+ stringLiteralText(ts, objectPropertyValue(ts, rawMetadata, 'source')) ??
169
+ stringLiteralText(ts, objectPropertyValue(ts, secondArg, 'source')) ??
170
+ stringLiteralText(ts, objectPropertyValue(ts, thirdArg, 'source'))
171
+ );
172
+ }
173
+
174
+ export function publishHasSource(ts, node) {
175
+ if (!ts.isCallExpression(node)) return false;
176
+ const [firstArg, secondArg, thirdArg] = node.arguments;
177
+ return (
178
+ objectHasMetadataSource(ts, firstArg) ||
179
+ objectHasProperty(ts, secondArg, 'source') ||
180
+ objectHasProperty(ts, thirdArg, 'source')
181
+ );
182
+ }
183
+ export function moduleSpecifierFromCall(ts, node) {
184
+ if (!ts.isCallExpression(node)) return undefined;
185
+
186
+ if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
187
+ const first = node.arguments[0];
188
+ const value = stringLiteralText(ts, first);
189
+ return value ? { value, kind: 'dynamic-import' } : undefined;
190
+ }
191
+
192
+ if (ts.isIdentifier(node.expression) && node.expression.text === 'require') {
193
+ const first = node.arguments[0];
194
+ const value = stringLiteralText(ts, first);
195
+ return value ? { value, kind: 'require' } : undefined;
196
+ }
197
+
198
+ return undefined;
199
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/baselineKey.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/baseline-key.mjs). Zero Node I/O.
9
+ */
10
+
11
+ /**
12
+ * Stable key used by `--baseline` / `--update-baseline` to match frozen debt.
13
+ * Field order and empty-string fallbacks are part of the CLI contract.
14
+ */
15
+ export function baselineKey(violation) {
16
+ return [
17
+ violation.ruleId,
18
+ violation.file,
19
+ violation.fromLayer ?? '',
20
+ violation.toLayer ?? '',
21
+ violation.target ?? '',
22
+ ].join('|');
23
+ }