@aestheticfunction/dspack-gen 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.
Files changed (79) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +202 -0
  3. package/README.md +147 -0
  4. package/dist/adapters/anthropic.d.ts +16 -0
  5. package/dist/adapters/anthropic.js +71 -0
  6. package/dist/adapters/fake.d.ts +21 -0
  7. package/dist/adapters/fake.js +32 -0
  8. package/dist/adapters/index.d.ts +6 -0
  9. package/dist/adapters/index.js +11 -0
  10. package/dist/adapters/ollama.d.ts +16 -0
  11. package/dist/adapters/ollama.js +91 -0
  12. package/dist/adapters/types.d.ts +66 -0
  13. package/dist/adapters/types.js +47 -0
  14. package/dist/audit/report.d.ts +84 -0
  15. package/dist/audit/report.js +62 -0
  16. package/dist/cli.d.ts +2 -0
  17. package/dist/cli.js +182 -0
  18. package/dist/core/compiler.d.ts +39 -0
  19. package/dist/core/compiler.js +142 -0
  20. package/dist/core/contract.d.ts +175 -0
  21. package/dist/core/contract.js +62 -0
  22. package/dist/core/generation-schema.d.ts +32 -0
  23. package/dist/core/generation-schema.js +85 -0
  24. package/dist/core/index.d.ts +12 -0
  25. package/dist/core/index.js +12 -0
  26. package/dist/core/lint/findings.d.ts +46 -0
  27. package/dist/core/lint/findings.js +28 -0
  28. package/dist/core/lint/index.d.ts +7 -0
  29. package/dist/core/lint/index.js +67 -0
  30. package/dist/core/lint/rules.d.ts +27 -0
  31. package/dist/core/lint/rules.js +212 -0
  32. package/dist/core/lint/vocabulary.d.ts +13 -0
  33. package/dist/core/lint/vocabulary.js +71 -0
  34. package/dist/core/lint/walk.d.ts +14 -0
  35. package/dist/core/lint/walk.js +30 -0
  36. package/dist/core/surface-schema.d.ts +78 -0
  37. package/dist/core/surface-schema.js +85 -0
  38. package/dist/eval/assert.d.ts +2 -0
  39. package/dist/eval/assert.js +50 -0
  40. package/dist/eval/run.d.ts +2 -0
  41. package/dist/eval/run.js +60 -0
  42. package/dist/eval/runner.d.ts +36 -0
  43. package/dist/eval/runner.js +310 -0
  44. package/dist/eval/types.d.ts +143 -0
  45. package/dist/eval/types.js +1 -0
  46. package/dist/index.d.ts +15 -0
  47. package/dist/index.js +14 -0
  48. package/dist/repair/render.d.ts +20 -0
  49. package/dist/repair/render.js +28 -0
  50. package/dist/run/orchestrator.d.ts +90 -0
  51. package/dist/run/orchestrator.js +191 -0
  52. package/dist/serve.d.ts +25 -0
  53. package/dist/serve.js +144 -0
  54. package/package.json +76 -0
  55. package/src/adapters/anthropic.ts +88 -0
  56. package/src/adapters/fake.ts +32 -0
  57. package/src/adapters/index.ts +13 -0
  58. package/src/adapters/ollama.ts +123 -0
  59. package/src/adapters/types.ts +91 -0
  60. package/src/audit/report.ts +139 -0
  61. package/src/cli.ts +191 -0
  62. package/src/core/compiler.ts +205 -0
  63. package/src/core/contract.ts +205 -0
  64. package/src/core/generation-schema.ts +99 -0
  65. package/src/core/index.ts +12 -0
  66. package/src/core/lint/findings.ts +80 -0
  67. package/src/core/lint/index.ts +80 -0
  68. package/src/core/lint/rules.ts +320 -0
  69. package/src/core/lint/vocabulary.ts +80 -0
  70. package/src/core/lint/walk.ts +44 -0
  71. package/src/core/surface-schema.ts +86 -0
  72. package/src/eval/assert.ts +55 -0
  73. package/src/eval/run.ts +62 -0
  74. package/src/eval/runner.ts +366 -0
  75. package/src/eval/types.ts +143 -0
  76. package/src/index.ts +15 -0
  77. package/src/repair/render.ts +68 -0
  78. package/src/run/orchestrator.ts +272 -0
  79. package/src/serve.ts +164 -0
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Gate S3 — the rule-type registry and evaluators (normative semantics:
3
+ * spec/dspack-v0.3.md §5.3 and spec/dspack-v0.4.md §4).
4
+ *
5
+ * The registry is the thesis-bearing seam: new rule types land additively in
6
+ * v0.4 by adding an entry — never by touching existing evaluators. v0.4 kept
7
+ * that promise for `required-props` (a new entry); `forbiddenCategories` is
8
+ * the spec'd exception — a new OPTIONAL field on forbidden-composition
9
+ * (v0.4 §4.2; existing fields' semantics frozen). A rule whose type has no
10
+ * registry entry is a HARD error (UnknownRuleTypeError → CLI exit 4):
11
+ * silently skipping would misreport a surface as governed.
12
+ *
13
+ * All three v0.3 evaluators are implemented. (The M1 plan deferred
14
+ * `forbidden-composition` to M2, but the v0.3 shadcn contract carries a
15
+ * UNIVERSAL forbidden-composition rule and spec §5.4 forbids skipping — a
16
+ * two-evaluator linter would hard-error on every lint of the real contract.
17
+ * Deviation flagged for maintainer review in the PR; the evaluator is ~40
18
+ * lines and fixture F5 activates with it.)
19
+ */
20
+ import type {
21
+ ComponentChoiceRule,
22
+ Contract,
23
+ ForbiddenCompositionRule,
24
+ RequiredCompositionRule,
25
+ RequiredPropsRule,
26
+ RuleEntry,
27
+ Surface,
28
+ } from "../contract.js";
29
+ import { categoryIndex } from "../contract.js";
30
+ import { LEVEL_OF, type Finding } from "./findings.js";
31
+ import { descendantsOf, walkSurface, type VisitedNode } from "./walk.js";
32
+
33
+ export class UnknownRuleTypeError extends Error {
34
+ constructor(readonly ruleId: string, readonly ruleType: string, detail: string) {
35
+ super(`rule '${ruleId}' has unknown or unimplemented type '${ruleType}': ${detail}`);
36
+ this.name = "UnknownRuleTypeError";
37
+ }
38
+ }
39
+
40
+ type Evaluator = (rule: RuleEntry, surface: Surface, contract: Contract) => Finding[];
41
+
42
+ /** The rule-type registry. Additive-only across spec versions. */
43
+ const REGISTRY: Record<string, Evaluator> = {
44
+ "component-choice": evaluateComponentChoice,
45
+ "required-composition": evaluateRequiredComposition,
46
+ "forbidden-composition": evaluateForbiddenComposition,
47
+ "required-props": evaluateRequiredProps,
48
+ };
49
+
50
+ export function evaluateRules(surface: Surface, contract: Contract): Finding[] {
51
+ const findings: Finding[] = [];
52
+ for (const rule of contract.rules ?? []) {
53
+ if (rule.appliesTo && !rule.appliesTo.intents.includes(surface.intent)) continue;
54
+ const evaluator = REGISTRY[rule.type];
55
+ if (evaluator === undefined) {
56
+ throw new UnknownRuleTypeError(rule.id, rule.type, "not a v0.3/v0.4 rule type");
57
+ }
58
+ findings.push(...evaluator(rule, surface, contract));
59
+ }
60
+ return findings;
61
+ }
62
+
63
+ function finding(rule: RuleEntry, message: string, location: Finding["location"]): Finding {
64
+ return {
65
+ ruleId: rule.id,
66
+ type: rule.type,
67
+ requirement: rule.severity,
68
+ level: LEVEL_OF[rule.severity],
69
+ message,
70
+ rationale: rule.rationale,
71
+ location,
72
+ exampleIds: rule.examples ?? [],
73
+ };
74
+ }
75
+
76
+ const SURFACE_LOCATION = { path: "$.root", component: "surface" } as const;
77
+
78
+ /** Human reference to a node for messages: path plus id when present. */
79
+ function describeNode(visited: VisitedNode): string {
80
+ return visited.node.id ? `at ${visited.path}, id "${visited.node.id}"` : `at ${visited.path}`;
81
+ }
82
+
83
+ function locationOf(visited: VisitedNode): Finding["location"] {
84
+ return { path: visited.path, component: visited.node.component, nodeId: visited.node.id };
85
+ }
86
+
87
+ /**
88
+ * component-choice: every id in `require` MUST appear ≥1 time (finding at the
89
+ * surface root per missing id); every id in `forbid` MUST appear 0 times
90
+ * (finding per matching node).
91
+ */
92
+ function evaluateComponentChoice(entry: RuleEntry, surface: Surface): Finding[] {
93
+ const rule = entry as ComponentChoiceRule;
94
+ const findings: Finding[] = [];
95
+ const nodes = walkSurface(surface);
96
+
97
+ for (const id of rule.forbid ?? []) {
98
+ for (const visited of nodes.filter((v) => v.node.component === id)) {
99
+ findings.push(
100
+ finding(rule, `Component '${id}' is forbidden for intent '${surface.intent}'.`, locationOf(visited)),
101
+ );
102
+ }
103
+ }
104
+ for (const id of rule.require ?? []) {
105
+ if (!nodes.some((v) => v.node.component === id)) {
106
+ findings.push(finding(rule, `Required component '${id}' does not appear in the surface.`, SURFACE_LOCATION));
107
+ }
108
+ }
109
+ return findings;
110
+ }
111
+
112
+ /**
113
+ * required-composition: for EVERY node matching `component`, each
114
+ * requiredSubComponents entry MUST have ≥ min matching descendants, and each
115
+ * requiredProps entry MUST hold (on the node itself, or on every descendant
116
+ * matching `on` — of which at least one must exist).
117
+ */
118
+ function evaluateRequiredComposition(entry: RuleEntry, surface: Surface): Finding[] {
119
+ const rule = entry as RequiredCompositionRule;
120
+ const findings: Finding[] = [];
121
+
122
+ for (const visited of walkSurface(surface).filter((v) => v.node.component === rule.component)) {
123
+ const descendants = descendantsOf(visited);
124
+
125
+ for (const requirement of rule.requiredSubComponents ?? []) {
126
+ const min = requirement.min ?? 1;
127
+ const found = descendants.filter((d) => d.node.component === requirement.id).length;
128
+ if (found < min) {
129
+ findings.push(
130
+ finding(
131
+ rule,
132
+ `Required sub-component '${requirement.id}' (min ${min}) not found among descendants (found ${found}).`,
133
+ locationOf(visited),
134
+ ),
135
+ );
136
+ }
137
+ }
138
+
139
+ for (const requirement of rule.requiredProps ?? []) {
140
+ const holds = (candidate: VisitedNode): boolean =>
141
+ requirement.oneOf.includes(candidate.node.props?.[requirement.prop] as never);
142
+ if (requirement.on === undefined) {
143
+ if (!holds(visited)) {
144
+ findings.push(
145
+ finding(
146
+ rule,
147
+ `Required prop '${requirement.prop}' must be one of ${requirement.oneOf.map((v) => JSON.stringify(v)).join(", ")}.`,
148
+ locationOf(visited),
149
+ ),
150
+ );
151
+ }
152
+ } else {
153
+ const targets = descendants.filter((d) => d.node.component === requirement.on);
154
+ if (targets.length === 0) {
155
+ findings.push(
156
+ finding(
157
+ rule,
158
+ `No descendant '${requirement.on}' found to satisfy required prop '${requirement.prop}'.`,
159
+ locationOf(visited),
160
+ ),
161
+ );
162
+ }
163
+ for (const target of targets.filter((t) => !holds(t))) {
164
+ findings.push(
165
+ finding(
166
+ rule,
167
+ `Required prop '${requirement.prop}' on '${requirement.on}' must be one of ${requirement.oneOf.map((v) => JSON.stringify(v)).join(", ")}.`,
168
+ locationOf(target),
169
+ ),
170
+ );
171
+ }
172
+ }
173
+ }
174
+ }
175
+ return findings;
176
+ }
177
+
178
+ /**
179
+ * forbidden-composition: for EVERY node matching `component`, no descendant
180
+ * may match any forbiddenDescendants id — per spec §5.3 the finding is
181
+ * LOCATED AT the offending descendant (the message names the matching origin
182
+ * node) — and no forbiddenProps entry may hold (on the node itself, or on
183
+ * descendants matching `on`, located at the checked node).
184
+ */
185
+ function evaluateForbiddenComposition(entry: RuleEntry, surface: Surface, contract: Contract): Finding[] {
186
+ const rule = entry as ForbiddenCompositionRule;
187
+ const findings: Finding[] = [];
188
+ const categories = rule.forbiddenCategories?.length ? categoryIndex(contract) : undefined;
189
+
190
+ for (const visited of walkSurface(surface).filter((v) => v.node.component === rule.component)) {
191
+ const descendants = descendantsOf(visited);
192
+
193
+ for (const id of rule.forbiddenDescendants ?? []) {
194
+ for (const offender of descendants.filter((d) => d.node.component === id)) {
195
+ findings.push(
196
+ finding(
197
+ rule,
198
+ `Forbidden descendant '${id}' inside '${rule.component}' (${describeNode(visited)}).`,
199
+ locationOf(offender),
200
+ ),
201
+ );
202
+ }
203
+ }
204
+
205
+ // v0.4 §4.2: membership resolved through the contract at lint time; the
206
+ // message names BOTH the concrete id and the matched category so repair
207
+ // feedback stays actionable without the contract in hand.
208
+ for (const category of rule.forbiddenCategories ?? []) {
209
+ for (const offender of descendants) {
210
+ if (categories?.get(offender.node.component)?.includes(category)) {
211
+ findings.push(
212
+ finding(
213
+ rule,
214
+ `Forbidden descendant '${offender.node.component}' (category '${category}') inside '${rule.component}' (${describeNode(visited)}).`,
215
+ locationOf(offender),
216
+ ),
217
+ );
218
+ }
219
+ }
220
+ }
221
+
222
+ for (const constraint of rule.forbiddenProps ?? []) {
223
+ const violates = (candidate: VisitedNode): boolean =>
224
+ constraint.values.includes(candidate.node.props?.[constraint.prop] as never);
225
+ const targets =
226
+ constraint.on === undefined ? [visited] : descendants.filter((d) => d.node.component === constraint.on);
227
+ for (const target of targets.filter(violates)) {
228
+ findings.push(
229
+ finding(
230
+ rule,
231
+ `Forbidden value ${JSON.stringify(target.node.props?.[constraint.prop])} for prop '${constraint.prop}'${constraint.on ? ` on '${constraint.on}'` : ""}.`,
232
+ locationOf(target),
233
+ ),
234
+ );
235
+ }
236
+ }
237
+ }
238
+ return findings;
239
+ }
240
+
241
+ /**
242
+ * required-props (v0.4 §4.1, as amended 2026-07-04): content every instance
243
+ * of `component` must carry. `requiredText` looks at the node's own `text`
244
+ * (textScope "self", the default) or anywhere in its subtree ("subtree" —
245
+ * for compound wrappers whose documented projections lift a label from
246
+ * within). Without `within`, EVERY matching node must satisfy. With
247
+ * `within`, each scope must contain at least one matching descendant, and
248
+ * AT LEAST ONE of them must satisfy (∃ — the amendment; the original ∀ form
249
+ * measurably rejected surfaces whose emission succeeds, dspack-gen#24).
250
+ */
251
+ function evaluateRequiredProps(entry: RuleEntry, surface: Surface): Finding[] {
252
+ const rule = entry as RequiredPropsRule;
253
+ const findings: Finding[] = [];
254
+
255
+ const hasText = (node: VisitedNode["node"]): boolean => typeof node.text === "string" && node.text.length > 0;
256
+ const subtreeHasText = (node: VisitedNode["node"]): boolean =>
257
+ hasText(node) ||
258
+ (node.children ?? []).some(subtreeHasText) ||
259
+ Object.values(node.slots ?? {}).some((slot) => slot.some(subtreeHasText));
260
+
261
+ /** Violation messages for a node; empty when it satisfies the rule. */
262
+ const violations = (target: VisitedNode): string[] => {
263
+ const messages: string[] = [];
264
+ if (rule.requiredText) {
265
+ if (rule.textScope === "subtree") {
266
+ if (!subtreeHasText(target.node)) {
267
+ messages.push(`'${rule.component}' must carry non-empty text somewhere in its subtree (none found).`);
268
+ }
269
+ } else if (!hasText(target.node)) {
270
+ messages.push(
271
+ `'${rule.component}' must carry non-empty direct text (its own \`text\` field — text in descendants does not count).`,
272
+ );
273
+ }
274
+ }
275
+ for (const requirement of rule.requiredProps ?? []) {
276
+ const value = target.node.props?.[requirement.prop];
277
+ if (value === undefined) {
278
+ messages.push(`Required prop '${requirement.prop}' is not present on '${rule.component}' itself.`);
279
+ } else if (requirement.oneOf && !requirement.oneOf.includes(value as never)) {
280
+ messages.push(
281
+ `Required prop '${requirement.prop}' must be one of ${requirement.oneOf.map((v) => JSON.stringify(v)).join(", ")} (got ${JSON.stringify(value)}).`,
282
+ );
283
+ }
284
+ }
285
+ return messages;
286
+ };
287
+
288
+ if (rule.within === undefined) {
289
+ for (const target of walkSurface(surface).filter((v) => v.node.component === rule.component)) {
290
+ for (const message of violations(target)) findings.push(finding(rule, message, locationOf(target)));
291
+ }
292
+ return findings;
293
+ }
294
+
295
+ for (const scope of walkSurface(surface).filter((v) => v.node.component === rule.within)) {
296
+ const targets = descendantsOf(scope).filter((d) => d.node.component === rule.component);
297
+ if (targets.length === 0) {
298
+ findings.push(
299
+ finding(
300
+ rule,
301
+ `'${rule.within}' contains no '${rule.component}' to carry the required content.`,
302
+ locationOf(scope),
303
+ ),
304
+ );
305
+ continue;
306
+ }
307
+ // ∃ (amended): at least one matching descendant satisfies; a finding at
308
+ // the scope only when all of them violate.
309
+ if (targets.every((target) => violations(target).length > 0)) {
310
+ findings.push(
311
+ finding(
312
+ rule,
313
+ `No '${rule.component}' inside '${rule.within}' satisfies the required content (${targets.length} checked).`,
314
+ locationOf(scope),
315
+ ),
316
+ );
317
+ }
318
+ }
319
+ return findings;
320
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Gate S2 — contract vocabulary. A check on ANY produced surface (model
3
+ * output, hand-authored, fixture), regardless of how generation was
4
+ * constrained: the S0 spike caught Ollama's mlx engine silently ignoring
5
+ * `format`, which is why S2 is never assumed from generation.
6
+ *
7
+ * Scope per spec §8: component/sub-component ids, prop names on components,
8
+ * enum prop values, declared slot names, plus surface-level consistency
9
+ * (registered intent, matching system name). Deliberately NOT checked:
10
+ * acceptsChildren semantics, non-enum prop types, ordering.
11
+ */
12
+ import { type Contract, type Surface, duplicateSubComponentIds, enumValues, subComponentIndex } from "../contract.js";
13
+ import { walkSurface } from "./walk.js";
14
+
15
+ export function checkVocabulary(surface: Surface, contract: Contract): string[] {
16
+ const errors: string[] = [];
17
+ const components = contract.components ?? {};
18
+ const subIndex = subComponentIndex(contract);
19
+
20
+ // Ambiguous vocabulary fails loudly before any id-dependent check — S2 and
21
+ // rule resolution must never depend on object iteration order (mirrors the
22
+ // dspack validate harness; spec v0.3 §5).
23
+ for (const [id, declaredBy] of duplicateSubComponentIds(contract)) {
24
+ errors.push(
25
+ `contract: sub-component id '${id}' is declared by multiple components (${declaredBy.join(", ")}); ` +
26
+ `sub-component ids must be unique document-wide for deterministic S2 and rule resolution`,
27
+ );
28
+ }
29
+
30
+ if (surface.system !== contract.name) {
31
+ errors.push(`$.system: surface.system '${surface.system}' does not match contract name '${contract.name}'`);
32
+ }
33
+ if (!(contract.intents ?? []).some((i) => i.id === surface.intent)) {
34
+ errors.push(`$.intent: intent '${surface.intent}' is not registered in the contract`);
35
+ }
36
+
37
+ for (const { node, path } of walkSurface(surface)) {
38
+ const isComponent = node.component in components;
39
+ const isSub = subIndex.has(node.component);
40
+ if (!isComponent && !isSub) {
41
+ errors.push(`${path}: component '${node.component}' is not a component or sub-component of the contract`);
42
+ continue;
43
+ }
44
+ if (node.props && Object.keys(node.props).length > 0) {
45
+ if (isSub) {
46
+ errors.push(`${path}: sub-component '${node.component}' does not declare props in this contract`);
47
+ } else {
48
+ const declared = components[node.component].props ?? {};
49
+ for (const [name, value] of Object.entries(node.props)) {
50
+ const descriptor = declared[name];
51
+ if (!descriptor) {
52
+ errors.push(`${path}: prop '${name}' is not declared on component '${node.component}'`);
53
+ continue;
54
+ }
55
+ const allowed = enumValues(descriptor);
56
+ if (allowed && !allowed.includes(value)) {
57
+ errors.push(
58
+ `${path}: prop '${name}' on '${node.component}' has value ${JSON.stringify(value)}; allowed: ${allowed
59
+ .map((v) => JSON.stringify(v))
60
+ .join(", ")}`,
61
+ );
62
+ }
63
+ }
64
+ }
65
+ }
66
+ if (node.slots && isComponent) {
67
+ const declaredSlots = new Set(
68
+ (components[node.component].composition?.subComponents ?? [])
69
+ .map((s) => s.slot)
70
+ .filter((s): s is string => Boolean(s)),
71
+ );
72
+ for (const slot of Object.keys(node.slots)) {
73
+ if (!declaredSlots.has(slot)) {
74
+ errors.push(`${path}: slot '${slot}' is not declared on component '${node.component}'`);
75
+ }
76
+ }
77
+ }
78
+ }
79
+ return errors;
80
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Surface-tree traversal shared by S2 and S3. Paths use the `$.root…` JSONPath
3
+ * style that findings, the emitter, and the spec's worked failures all share.
4
+ * "Descendants" (spec §5.3) = everything reachable through `children` and
5
+ * `slots`, at any depth.
6
+ */
7
+ import type { Surface, SurfaceNode } from "../contract.js";
8
+
9
+ export interface VisitedNode {
10
+ node: SurfaceNode;
11
+ path: string;
12
+ }
13
+
14
+ export function walkSurface(surface: Surface): VisitedNode[] {
15
+ const visited: VisitedNode[] = [];
16
+ const visit = (node: SurfaceNode, path: string): void => {
17
+ visited.push({ node, path });
18
+ (node.children ?? []).forEach((child, i) => visit(child, `${path}.children[${i}]`));
19
+ for (const slot of Object.keys(node.slots ?? {}).sort()) {
20
+ node.slots![slot].forEach((child, i) => visit(child, `${path}.slots.${slot}[${i}]`));
21
+ }
22
+ };
23
+ visit(surface.root, "$.root");
24
+ return visited;
25
+ }
26
+
27
+ /** Descendants of `origin` (excluding itself), with absolute paths. */
28
+ export function descendantsOf(origin: VisitedNode): VisitedNode[] {
29
+ const visited: VisitedNode[] = [];
30
+ const visit = (node: SurfaceNode, path: string): void => {
31
+ (node.children ?? []).forEach((child, i) => {
32
+ visited.push({ node: child, path: `${path}.children[${i}]` });
33
+ visit(child, `${path}.children[${i}]`);
34
+ });
35
+ for (const slot of Object.keys(node.slots ?? {}).sort()) {
36
+ node.slots![slot].forEach((child, i) => {
37
+ visited.push({ node: child, path: `${path}.slots.${slot}[${i}]` });
38
+ visit(child, `${path}.slots.${slot}[${i}]`);
39
+ });
40
+ }
41
+ };
42
+ visit(origin.node, origin.path);
43
+ return visited;
44
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Vendored copy of dspack.surface.v0_1.schema.json (dspack repo, schema/).
3
+ * Gate S1's ground truth. Re-vendor when the surface schema version bumps;
4
+ * the const below is asserted against surface documents at lint time.
5
+ */
6
+ export const SURFACE_SCHEMA_VERSION = "0.1";
7
+
8
+ export const surfaceSchemaV0_1 = {
9
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
10
+ "$id": "https://github.com/aestheticfunction/dspack/blob/main/schema/dspack.surface.v0_1.schema.json",
11
+ "title": "dspack surface v0.1",
12
+ "description": "Schema for a dspack surface document — a protocol-neutral, nested component tree expressed in a dspack contract's vocabulary (component IDs, sub-component IDs, props, slots, text leaves) plus a declared generation intent. A surface is the pipeline's intermediate representation: never rendered, never transported, always compiled to a protocol by an emitter. This generic schema is the portable validation floor (gate S1); checking the tree against a specific contract's vocabulary is a separate gate (S2), and governance rules are a third (S3). The schema answers whether the object can exist; the linter answers whether it is correct; a renderer answers whether its compiled form can render. These layers must never collapse.",
13
+ "type": "object",
14
+ "required": [
15
+ "dspackSurface",
16
+ "system",
17
+ "intent",
18
+ "root"
19
+ ],
20
+ "properties": {
21
+ "dspackSurface": {
22
+ "type": "string",
23
+ "const": "0.1",
24
+ "description": "Surface format version. MUST be \"0.1\" for documents conforming to this version."
25
+ },
26
+ "system": {
27
+ "type": "string",
28
+ "minLength": 1,
29
+ "description": "Name of the design system contract this surface is expressed against (the contract's top-level name)."
30
+ },
31
+ "intent": {
32
+ "type": "string",
33
+ "description": "The declared generation intent (an intent ID registered in the bound contract). Declared by the caller, carried in the artifact, and used by the linter to activate intent-scoped rules."
34
+ },
35
+ "root": {
36
+ "$ref": "#/$defs/node",
37
+ "description": "The root component node of the surface tree."
38
+ }
39
+ },
40
+ "additionalProperties": false,
41
+ "$defs": {
42
+ "node": {
43
+ "type": "object",
44
+ "description": "A component instance in the surface tree. 'component' is a component ID or sub-component ID from the bound contract's vocabulary.",
45
+ "required": [
46
+ "component"
47
+ ],
48
+ "properties": {
49
+ "component": {
50
+ "type": "string",
51
+ "description": "Component ID or sub-component ID from the bound contract."
52
+ },
53
+ "id": {
54
+ "type": "string",
55
+ "description": "Optional stable identifier for this node, used in lint finding locations and emitted artifacts."
56
+ },
57
+ "props": {
58
+ "type": "object",
59
+ "description": "Prop values for this node. Prop names and enum values are validated against the bound contract (gate S2), not by this schema."
60
+ },
61
+ "text": {
62
+ "type": "string",
63
+ "description": "Text content for leaf nodes (components or sub-components that accept text children)."
64
+ },
65
+ "children": {
66
+ "type": "array",
67
+ "description": "Ordered child nodes.",
68
+ "items": {
69
+ "$ref": "#/$defs/node"
70
+ }
71
+ },
72
+ "slots": {
73
+ "type": "object",
74
+ "description": "Named slot contents, keyed by slot name declared on the parent's sub-components in the bound contract.",
75
+ "additionalProperties": {
76
+ "type": "array",
77
+ "items": {
78
+ "$ref": "#/$defs/node"
79
+ }
80
+ }
81
+ }
82
+ },
83
+ "additionalProperties": false
84
+ }
85
+ }
86
+ } as const;
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ /**
3
+ * `npm run eval:assert -- --results <results.json> --model <ref> --min-repair-success 0.9`
4
+ *
5
+ * Threshold check over an eval `results.json`: exit 0 when the model's
6
+ * repair-success rate meets the minimum, exit 2 when it does not (or when
7
+ * the rate is undefined because no run violated — a threshold over zero
8
+ * observations is vacuously unmet, stated loudly rather than passed
9
+ * silently). Per the plan, the hosted-model threshold is the only hard eval
10
+ * gate in M2; local models are report-only.
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { resolve } from "node:path";
14
+ import type { EvalResults } from "./types.js";
15
+
16
+ function fail(message: string): never {
17
+ console.error(`error: ${message}`);
18
+ process.exit(1);
19
+ }
20
+
21
+ const flags = new Map<string, string>();
22
+ const argv = process.argv.slice(2);
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const token = argv[i];
25
+ if (!token.startsWith("--")) fail(`unexpected argument '${token}'`);
26
+ const eq = token.indexOf("=");
27
+ if (eq !== -1) flags.set(token.slice(2, eq), token.slice(eq + 1));
28
+ else {
29
+ const value = argv[++i];
30
+ if (value === undefined) fail(`flag '${token}' is missing a value`);
31
+ flags.set(token.slice(2), value);
32
+ }
33
+ }
34
+
35
+ const resultsPath = flags.get("results") ?? fail("--results <results.json> is required");
36
+ const model = flags.get("model") ?? fail("--model <ref> is required");
37
+ const minRaw = flags.get("min-repair-success") ?? fail("--min-repair-success <0..1> is required");
38
+ const min = Number(minRaw);
39
+ if (!(min >= 0 && min <= 1)) fail(`--min-repair-success must be in [0,1] (got '${minRaw}')`);
40
+
41
+ const results = JSON.parse(readFileSync(resolve(resultsPath), "utf8")) as EvalResults;
42
+ const metrics = results.byModel[model];
43
+ if (!metrics) fail(`no results for model '${model}' (models present: ${Object.keys(results.byModel).join(", ")})`);
44
+
45
+ if (metrics.repairSuccessRate === null) {
46
+ console.error(
47
+ `eval:assert FAIL — ${model}: repair-success rate is undefined (no first-attempt violations in ${metrics.runs} run(s)); a threshold cannot be met over zero observations`,
48
+ );
49
+ process.exit(2);
50
+ }
51
+ const pass = metrics.repairSuccessRate >= min;
52
+ console.error(
53
+ `eval:assert ${pass ? "PASS" : "FAIL"} — ${model}: repair-success ${metrics.repairSuccessRate} (min ${min}, over ${metrics.runs} run(s))`,
54
+ );
55
+ process.exit(pass ? 0 : 2);
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ /**
3
+ * `npm run eval -- --adapter fake --matrix eval/matrix.fake.json [--out out/eval/fake]`
4
+ * `npm run eval -- --adapter live --matrix eval/matrix.json [--out out/eval/<ts>]`
5
+ *
6
+ * Fake mode is deterministic (scripted fixtures, fixed clock) and is the CI
7
+ * gate; live mode talks to the configured providers and is documented, never
8
+ * CI-gating. Exit 0 on completion (results are data, not a pass/fail —
9
+ * thresholds are `eval:assert`'s job); exit 1 on usage/internal error.
10
+ */
11
+ import { runMatrix } from "./runner.js";
12
+
13
+ function fail(message: string): never {
14
+ console.error(`error: ${message}`);
15
+ process.exit(1);
16
+ }
17
+
18
+ const BOOLEAN_FLAGS = new Set(["resume"]);
19
+
20
+ function parseFlags(argv: string[]): Map<string, string> {
21
+ const flags = new Map<string, string>();
22
+ for (let i = 0; i < argv.length; i++) {
23
+ const token = argv[i];
24
+ if (!token.startsWith("--")) fail(`unexpected argument '${token}'`);
25
+ const eq = token.indexOf("=");
26
+ if (eq !== -1) flags.set(token.slice(2, eq), token.slice(eq + 1));
27
+ else if (BOOLEAN_FLAGS.has(token.slice(2))) flags.set(token.slice(2), "true");
28
+ else {
29
+ const value = argv[++i];
30
+ if (value === undefined) fail(`flag '${token}' is missing a value`);
31
+ flags.set(token.slice(2), value);
32
+ }
33
+ }
34
+ return flags;
35
+ }
36
+
37
+ async function main(): Promise<void> {
38
+ const flags = parseFlags(process.argv.slice(2));
39
+ const adapterMode = flags.get("adapter") ?? fail("--adapter fake|live is required");
40
+ if (adapterMode !== "fake" && adapterMode !== "live") fail("--adapter must be 'fake' or 'live'");
41
+ const matrixPath = flags.get("matrix") ?? fail("--matrix <matrix.json> is required");
42
+ const outDir =
43
+ flags.get("out") ??
44
+ (adapterMode === "fake" ? "out/eval/fake" : `out/eval/${new Date().toISOString().replace(/[:.]/g, "-")}`);
45
+
46
+ const results = await runMatrix({
47
+ matrixPath,
48
+ outDir,
49
+ adapterMode,
50
+ // --resume: skip runs whose audit report is already retained in outDir
51
+ // (contained-error records are retried) — an interrupted matrix picks up
52
+ // where it stopped instead of re-burning compute.
53
+ resume: flags.get("resume") === "true",
54
+ // Fixed clock in fake mode: retained reports and results.json are byte-stable.
55
+ now: adapterMode === "fake" ? () => new Date("2026-01-01T00:00:00.000Z") : undefined,
56
+ log: (line) => console.error(line),
57
+ });
58
+ const errorRuns = results.cells.reduce((k, c) => k + c.metrics.errorRuns, 0);
59
+ console.error(`eval: ${results.cells.length} cell(s)${errorRuns ? ` — ${errorRuns} CONTAINED ERROR RUN(S), see .error.json records` : ""} -> ${outDir}/results.json`);
60
+ }
61
+
62
+ void main();