@crewhaus/specialization-registry 0.1.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/package.json +41 -0
- package/src/index.test.ts +121 -0
- package/src/index.ts +287 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/specialization-registry",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Track G / §58 — file-backed registry of domain specializations (payments, booking, auth) that inject invariants into contract compilation. Source: Meta-Engineering Harnesses (arxiv 2605.25665, §4.4).",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/errors": "0.0.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Max Meier",
|
|
20
|
+
"email": "max@studiomax.io",
|
|
21
|
+
"url": "https://studiomax.io"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
26
|
+
"directory": "packages/specialization-registry"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/specialization-registry#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "restricted"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"NOTICE"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { BUILTIN_SPECIALIZATIONS, SpecializationRegistryError, loadRegistry, match } from "./index";
|
|
6
|
+
|
|
7
|
+
describe("BUILTIN_SPECIALIZATIONS", () => {
|
|
8
|
+
test("includes payments, auth, booking", () => {
|
|
9
|
+
const names = BUILTIN_SPECIALIZATIONS.map((s) => s.name);
|
|
10
|
+
expect(names).toContain("payments");
|
|
11
|
+
expect(names).toContain("auth");
|
|
12
|
+
expect(names).toContain("booking");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("payments specialization names the four key invariants", () => {
|
|
16
|
+
const p = BUILTIN_SPECIALIZATIONS.find((s) => s.name === "payments");
|
|
17
|
+
const ids = p?.invariants.map((i) => i.id);
|
|
18
|
+
expect(ids).toContain("idempotency-key");
|
|
19
|
+
expect(ids).toContain("state-transitions");
|
|
20
|
+
expect(ids).toContain("trust-boundary-client-status");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("match (auto mode)", () => {
|
|
25
|
+
test("matches payments when text mentions Stripe + paymentintent + refund", () => {
|
|
26
|
+
const result = match("Implement Stripe paymentIntent flow with refund support and idempotency");
|
|
27
|
+
expect(result?.specialization.name).toBe("payments");
|
|
28
|
+
expect(result?.confidence).toBeGreaterThanOrEqual(0.3);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("matches auth when text mentions jwt + login + token", () => {
|
|
32
|
+
const result = match("We need to add JWT-based login with refresh-token and revocation");
|
|
33
|
+
expect(result?.specialization.name).toBe("auth");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("matches booking on appointment + slot + calendar", () => {
|
|
37
|
+
const result = match("Online booking with appointment slot calendar availability");
|
|
38
|
+
expect(result?.specialization.name).toBe("booking");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns undefined when no specialization is confident enough", () => {
|
|
42
|
+
expect(match("Add a CSS spinner to the homepage")).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("returns the highest-confidence specialization when multiple match", () => {
|
|
46
|
+
// Both auth and payments keywords; auth has more keyword density.
|
|
47
|
+
const result = match("session token jwt oauth refresh-token login logout stripe");
|
|
48
|
+
expect(result?.specialization.name).toBe("auth");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("match (strict mode)", () => {
|
|
53
|
+
test("returns named specialization with confidence 1 when forceMatch is set", () => {
|
|
54
|
+
const result = match("anything at all", { mode: "strict", forceMatch: "payments" });
|
|
55
|
+
expect(result?.specialization.name).toBe("payments");
|
|
56
|
+
expect(result?.confidence).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("returns undefined when forceMatch is omitted in strict mode", () => {
|
|
60
|
+
expect(match("stripe refund paymentintent", { mode: "strict" })).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("throws when forceMatch names an unknown specialization", () => {
|
|
64
|
+
expect(() => match("x", { mode: "strict", forceMatch: "nonexistent" })).toThrow(
|
|
65
|
+
SpecializationRegistryError,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("loadRegistry", () => {
|
|
71
|
+
let dir: string;
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
dir = mkdtempSync(join(tmpdir(), "spec-registry-test-"));
|
|
74
|
+
});
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
rmSync(dir, { recursive: true, force: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("returns built-ins when directory is empty", () => {
|
|
80
|
+
const r = loadRegistry(dir);
|
|
81
|
+
expect(r.length).toBe(BUILTIN_SPECIALIZATIONS.length);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("returns built-ins when directory is missing", () => {
|
|
85
|
+
const r = loadRegistry(join(dir, "missing"));
|
|
86
|
+
expect(r.length).toBe(BUILTIN_SPECIALIZATIONS.length);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("project-local files override built-ins by name", () => {
|
|
90
|
+
const override = {
|
|
91
|
+
name: "payments",
|
|
92
|
+
description: "custom payments",
|
|
93
|
+
keywords: ["stripe"],
|
|
94
|
+
invariants: [{ id: "x", description: "x", required: true }],
|
|
95
|
+
confidenceThreshold: 0.1,
|
|
96
|
+
};
|
|
97
|
+
writeFileSync(join(dir, "payments.json"), JSON.stringify(override));
|
|
98
|
+
const r = loadRegistry(dir);
|
|
99
|
+
const p = r.find((s) => s.name === "payments");
|
|
100
|
+
expect(p?.description).toBe("custom payments");
|
|
101
|
+
expect(p?.invariants.length).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("project-local file with a new name is appended", () => {
|
|
105
|
+
const fresh = {
|
|
106
|
+
name: "ecommerce-checkout",
|
|
107
|
+
description: "new spec",
|
|
108
|
+
keywords: ["checkout", "cart"],
|
|
109
|
+
invariants: [{ id: "i", description: "i", required: true }],
|
|
110
|
+
confidenceThreshold: 0.5,
|
|
111
|
+
};
|
|
112
|
+
writeFileSync(join(dir, "ecommerce.json"), JSON.stringify(fresh));
|
|
113
|
+
const r = loadRegistry(dir);
|
|
114
|
+
expect(r.some((s) => s.name === "ecommerce-checkout")).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("throws on malformed JSON", () => {
|
|
118
|
+
writeFileSync(join(dir, "bad.json"), "{this is not json}");
|
|
119
|
+
expect(() => loadRegistry(dir)).toThrow(SpecializationRegistryError);
|
|
120
|
+
});
|
|
121
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track G (§58) — `specialization-registry`. File-backed registry of
|
|
3
|
+
* domain specializations.
|
|
4
|
+
*
|
|
5
|
+
* Source: Meta-Engineering Harnesses (Sengupta et al., HireNimbus,
|
|
6
|
+
* arxiv 2605.25665, §4.4). A specialization is a named collection of
|
|
7
|
+
* domain-specific invariants — for "payments" the invariants are
|
|
8
|
+
* idempotency keys, explicit state transitions, trust-boundary checks;
|
|
9
|
+
* for "auth" they're token expiry, scope checks, etc. The contract
|
|
10
|
+
* compiler (Track G in the same plan) calls into this registry to
|
|
11
|
+
* inject these invariants into the compiled contract.
|
|
12
|
+
*
|
|
13
|
+
* The paper's key safety rule: specializations apply only when
|
|
14
|
+
* **confidence crosses a threshold**. Below that, the pipeline
|
|
15
|
+
* proceeds without specialization to avoid corrupting the contract
|
|
16
|
+
* with wrong domain assumptions. The registry stores
|
|
17
|
+
* `(name, invariants, confidenceThreshold)` triples and exposes a
|
|
18
|
+
* `match(text, opts) → SpecializationMatch | undefined` function.
|
|
19
|
+
*
|
|
20
|
+
* v0 ships:
|
|
21
|
+
* - In-memory registry seeded with three reference specializations
|
|
22
|
+
* (payments, auth, booking).
|
|
23
|
+
* - File-backed registry that reads from
|
|
24
|
+
* `.crewhaus/specializations/<name>.json` (overrides built-ins).
|
|
25
|
+
* - `match()` — keyword-based confidence scoring; the contract
|
|
26
|
+
* compiler can pass `mode: "strict"` to require explicit opt-in
|
|
27
|
+
* and skip auto-detection entirely.
|
|
28
|
+
*
|
|
29
|
+
* Cited paper: Meta-Engineering Harnesses (arxiv 2605.25665, §4.4).
|
|
30
|
+
*/
|
|
31
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
32
|
+
import { join, resolve } from "node:path";
|
|
33
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
34
|
+
|
|
35
|
+
export class SpecializationRegistryError extends CrewhausError {
|
|
36
|
+
override readonly name = "SpecializationRegistryError";
|
|
37
|
+
constructor(message: string, cause?: unknown) {
|
|
38
|
+
super("config", message, cause);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One named invariant a specialization injects into a contract. The
|
|
44
|
+
* contract compiler maps each to a clause in the compiled YAML.
|
|
45
|
+
*/
|
|
46
|
+
export type Invariant = {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly description: string;
|
|
49
|
+
/** Severity for the contract reviewer: must-have vs nice-to-have. */
|
|
50
|
+
readonly required: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A specialization record. `keywords` are used by the default
|
|
55
|
+
* confidence-scoring heuristic; the score is the fraction of keywords
|
|
56
|
+
* present in the input text (case-insensitive substring match).
|
|
57
|
+
*/
|
|
58
|
+
export type Specialization = {
|
|
59
|
+
readonly name: string;
|
|
60
|
+
readonly description: string;
|
|
61
|
+
readonly keywords: ReadonlyArray<string>;
|
|
62
|
+
readonly invariants: ReadonlyArray<Invariant>;
|
|
63
|
+
/** Apply only when confidence >= this. Default 0.5. */
|
|
64
|
+
readonly confidenceThreshold: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type SpecializationMatch = {
|
|
68
|
+
readonly specialization: Specialization;
|
|
69
|
+
readonly confidence: number;
|
|
70
|
+
/** Which keywords were hit; for audit + debugging. */
|
|
71
|
+
readonly matchedKeywords: ReadonlyArray<string>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Built-in reference specializations. These are the ones the
|
|
76
|
+
* Meta-Engineering Harnesses paper specifically calls out as
|
|
77
|
+
* "recurring task domains" in their CTO-as-a-service deployment.
|
|
78
|
+
*/
|
|
79
|
+
export const BUILTIN_SPECIALIZATIONS: ReadonlyArray<Specialization> = Object.freeze([
|
|
80
|
+
Object.freeze({
|
|
81
|
+
name: "payments",
|
|
82
|
+
description: "Stripe / payment intent flows. Idempotency, state transitions, trust boundaries.",
|
|
83
|
+
keywords: Object.freeze([
|
|
84
|
+
"stripe",
|
|
85
|
+
"payment",
|
|
86
|
+
"paymentintent",
|
|
87
|
+
"charge",
|
|
88
|
+
"refund",
|
|
89
|
+
"invoice",
|
|
90
|
+
"checkout",
|
|
91
|
+
"billing",
|
|
92
|
+
]),
|
|
93
|
+
invariants: Object.freeze([
|
|
94
|
+
{
|
|
95
|
+
id: "idempotency-key",
|
|
96
|
+
description:
|
|
97
|
+
"Every state-mutating request must carry an idempotency key; the handler must short-circuit on duplicate keys.",
|
|
98
|
+
required: true,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "state-transitions",
|
|
102
|
+
description:
|
|
103
|
+
"Document the allowed state transitions explicitly (pending → succeeded, pending → failed, etc.) and reject any other transition.",
|
|
104
|
+
required: true,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "trust-boundary-client-status",
|
|
108
|
+
description:
|
|
109
|
+
"Never trust client-reported payment status; always verify against the upstream provider.",
|
|
110
|
+
required: true,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "discount-deduction-source",
|
|
114
|
+
description:
|
|
115
|
+
"Final-invoice calculations must read deposit/discount amounts from a single source of truth, not recompute from disparate fields.",
|
|
116
|
+
required: false,
|
|
117
|
+
},
|
|
118
|
+
]),
|
|
119
|
+
confidenceThreshold: 0.3,
|
|
120
|
+
}),
|
|
121
|
+
Object.freeze({
|
|
122
|
+
name: "auth",
|
|
123
|
+
description:
|
|
124
|
+
"Authentication and session-token handling. Token expiry, scope checks, revocation.",
|
|
125
|
+
keywords: Object.freeze([
|
|
126
|
+
"jwt",
|
|
127
|
+
"session",
|
|
128
|
+
"token",
|
|
129
|
+
"oauth",
|
|
130
|
+
"login",
|
|
131
|
+
"logout",
|
|
132
|
+
"refresh-token",
|
|
133
|
+
"auth",
|
|
134
|
+
]),
|
|
135
|
+
invariants: Object.freeze([
|
|
136
|
+
{
|
|
137
|
+
id: "token-expiry-check",
|
|
138
|
+
description:
|
|
139
|
+
"Every token verification must check expiry (exp claim) and reject expired tokens.",
|
|
140
|
+
required: true,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "scope-check",
|
|
144
|
+
description:
|
|
145
|
+
"Every protected endpoint must verify the token's scope grants the requested action.",
|
|
146
|
+
required: true,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: "revocation-list",
|
|
150
|
+
description:
|
|
151
|
+
"Maintain a revocation list (or short token lifetimes + refresh tokens) so compromised tokens can be invalidated.",
|
|
152
|
+
required: true,
|
|
153
|
+
},
|
|
154
|
+
]),
|
|
155
|
+
confidenceThreshold: 0.3,
|
|
156
|
+
}),
|
|
157
|
+
Object.freeze({
|
|
158
|
+
name: "booking",
|
|
159
|
+
description:
|
|
160
|
+
"Scheduling / appointment flows. Conflict detection, time-zone handling, cancellation windows.",
|
|
161
|
+
keywords: Object.freeze([
|
|
162
|
+
"booking",
|
|
163
|
+
"appointment",
|
|
164
|
+
"schedule",
|
|
165
|
+
"calendar",
|
|
166
|
+
"reservation",
|
|
167
|
+
"slot",
|
|
168
|
+
"availability",
|
|
169
|
+
]),
|
|
170
|
+
invariants: Object.freeze([
|
|
171
|
+
{
|
|
172
|
+
id: "conflict-detection",
|
|
173
|
+
description:
|
|
174
|
+
"Reserving a slot must use a transaction that rejects double-bookings (FOR UPDATE / SELECT FOR SHARE / optimistic-lock).",
|
|
175
|
+
required: true,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "timezone-explicit",
|
|
179
|
+
description:
|
|
180
|
+
"Every time field must carry its time zone (or be normalised to UTC) — never assume the server's local time.",
|
|
181
|
+
required: true,
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: "cancellation-window",
|
|
185
|
+
description:
|
|
186
|
+
"Cancellations must respect a documented cancellation window; the handler enforces it server-side regardless of UI affordance.",
|
|
187
|
+
required: false,
|
|
188
|
+
},
|
|
189
|
+
]),
|
|
190
|
+
confidenceThreshold: 0.3,
|
|
191
|
+
}),
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Match a free-text issue/contract draft against the registered
|
|
196
|
+
* specializations. Returns the highest-confidence match whose score
|
|
197
|
+
* is >= its threshold, or undefined when no specialization matches.
|
|
198
|
+
*
|
|
199
|
+
* `mode: "strict"` skips auto-detection and only returns a match if
|
|
200
|
+
* the caller explicitly named one in `forceMatch`.
|
|
201
|
+
*/
|
|
202
|
+
export type MatchOptions = {
|
|
203
|
+
readonly mode?: "auto" | "strict";
|
|
204
|
+
readonly forceMatch?: string;
|
|
205
|
+
readonly registry?: ReadonlyArray<Specialization>;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export function match(text: string, opts: MatchOptions = {}): SpecializationMatch | undefined {
|
|
209
|
+
const registry = opts.registry ?? BUILTIN_SPECIALIZATIONS;
|
|
210
|
+
if (opts.mode === "strict") {
|
|
211
|
+
if (opts.forceMatch === undefined) return undefined;
|
|
212
|
+
const spec = registry.find((s) => s.name === opts.forceMatch);
|
|
213
|
+
if (spec === undefined) {
|
|
214
|
+
throw new SpecializationRegistryError(
|
|
215
|
+
`forceMatch "${opts.forceMatch}" not found in registry (known: ${registry
|
|
216
|
+
.map((s) => s.name)
|
|
217
|
+
.join(", ")})`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
specialization: spec,
|
|
222
|
+
confidence: 1,
|
|
223
|
+
matchedKeywords: spec.keywords,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const lower = text.toLowerCase();
|
|
227
|
+
let best: SpecializationMatch | undefined;
|
|
228
|
+
for (const spec of registry) {
|
|
229
|
+
const matched: string[] = [];
|
|
230
|
+
for (const kw of spec.keywords) {
|
|
231
|
+
if (lower.includes(kw.toLowerCase())) matched.push(kw);
|
|
232
|
+
}
|
|
233
|
+
const confidence = spec.keywords.length === 0 ? 0 : matched.length / spec.keywords.length;
|
|
234
|
+
if (confidence >= spec.confidenceThreshold) {
|
|
235
|
+
if (best === undefined || confidence > best.confidence) {
|
|
236
|
+
best = { specialization: spec, confidence, matchedKeywords: matched };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return best;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Read JSON-encoded specialization records from a directory, merge
|
|
245
|
+
* them with the built-ins (overriding by `name`), and return the
|
|
246
|
+
* combined registry. Used by the contract compiler to pick up
|
|
247
|
+
* project-specific specializations from `.crewhaus/specializations/`.
|
|
248
|
+
*/
|
|
249
|
+
export function loadRegistry(dir: string): ReadonlyArray<Specialization> {
|
|
250
|
+
const abs = resolve(dir);
|
|
251
|
+
let entries: string[];
|
|
252
|
+
try {
|
|
253
|
+
entries = readdirSync(abs);
|
|
254
|
+
} catch {
|
|
255
|
+
return BUILTIN_SPECIALIZATIONS;
|
|
256
|
+
}
|
|
257
|
+
const overrides: Record<string, Specialization> = {};
|
|
258
|
+
for (const name of entries) {
|
|
259
|
+
if (!name.endsWith(".json")) continue;
|
|
260
|
+
const path = join(abs, name);
|
|
261
|
+
let parsed: unknown;
|
|
262
|
+
try {
|
|
263
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
264
|
+
} catch (err) {
|
|
265
|
+
throw new SpecializationRegistryError(`failed to parse ${path}: ${(err as Error).message}`);
|
|
266
|
+
}
|
|
267
|
+
const spec = parsed as Specialization;
|
|
268
|
+
if (typeof spec.name !== "string" || spec.name.length === 0) {
|
|
269
|
+
throw new SpecializationRegistryError(`${path}: missing required "name" field`);
|
|
270
|
+
}
|
|
271
|
+
overrides[spec.name] = spec;
|
|
272
|
+
}
|
|
273
|
+
const merged: Specialization[] = [];
|
|
274
|
+
const seen = new Set<string>();
|
|
275
|
+
for (const builtin of BUILTIN_SPECIALIZATIONS) {
|
|
276
|
+
if (overrides[builtin.name] !== undefined) {
|
|
277
|
+
merged.push(overrides[builtin.name] as Specialization);
|
|
278
|
+
seen.add(builtin.name);
|
|
279
|
+
} else {
|
|
280
|
+
merged.push(builtin);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
for (const [name, spec] of Object.entries(overrides)) {
|
|
284
|
+
if (!seen.has(name)) merged.push(spec);
|
|
285
|
+
}
|
|
286
|
+
return merged;
|
|
287
|
+
}
|