@transtyle/core 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,367 @@
1
+ /**
2
+ * NORMALIZE stage: canonical per-mode IR with alias resolution and provenance
3
+ * (docs/architecture/pipeline.md#2-normalize).
4
+ */
5
+
6
+ import { collectTokens, collectRoleArchetypes, mergeTrees, aliasTarget, comboKey, expandModeMatrix, PROVENANCE, COLOR_ROLES } from '@transtyle/ir';
7
+ import { parseColor } from './color.js';
8
+
9
+ /** Matches `semantic.color.<role>.solid` — the anchor cell an entire role grid
10
+ * (hover/active/tint/outline/on-colors, ~16 slots) fans out from. */
11
+ const ROLE_SOLID = /^semantic\.color\.([\w-]+)\.solid$/;
12
+
13
+ /**
14
+ * @returns {{ modes: Record<string, Map<string, Entry>>, modeDimension: string }}
15
+ * Entry = { type, value, provenance }
16
+ * Color values are parsed to { l, c, h, alpha }; other types kept as authored.
17
+ *
18
+ * Multi-dimension modes (T8, docs/architecture/ir.md#modes): every configured
19
+ * dimension is resolved independently, then combos are the cross-product,
20
+ * keyed `dim1val+dim2val+...` (dimension-declaration order). Most exporters
21
+ * only know about the *first* declared dimension (conventionally
22
+ * `color-scheme`) — they've always read `normalized.modes.light`/`.dark`, so
23
+ * those single-dimension-value keys stay as aliases into the combo whose
24
+ * every OTHER dimension sits at its own default. This is the whole back-compat
25
+ * story: a single-dimension config (today's Acme/Cathode) degenerates to
26
+ * exactly the old behavior (combo keys equal old mode names 1:1).
27
+ */
28
+ export function normalize(tokenTrees, config, diagnostics) {
29
+ const dimEntries = Object.entries(config.modes ?? {});
30
+ if (dimEntries.length === 0) dimEntries.push(['color-scheme', { values: ['light'], default: 'light' }]);
31
+ const dimDefaults = new Map(dimEntries);
32
+ const primaryDimName = dimEntries[0][0];
33
+
34
+ // AL5 mode-shape sweep: the FIRST dimension is the polarity axis — derive.js
35
+ // reads dark/light off it (`isDark` keys on `modeDimension`), and exporters
36
+ // bind the `modes.light`/`modes.dark` aliases, which only exist for the
37
+ // primary dimension's values. So `color-scheme` declared anywhere but first
38
+ // silently drops dark mode: the authored dark values still land in their
39
+ // combos, but no exporter can reach them, and nothing warned. Found by
40
+ // compiling a density-first config against every exporter — all emitted a
41
+ // dark block filled with light values.
42
+ //
43
+ // This is an ERROR, not a warning: the output is guaranteed wrong (a dark
44
+ // block filled with light values), and a warning ships that under the default
45
+ // `failOn: error`. There is no coherent "make it work" fix — even a corrected
46
+ // alias would leave `isDark` false for a non-primary color-scheme, so derived
47
+ // dark values compute as light. Reordering `modes` is the only fix, so the
48
+ // build must stop. Gated on `color-scheme` carrying more than one value: with
49
+ // a single value there is no non-default scheme to drop, so nothing is wrong
50
+ // and erroring would be a false failure.
51
+ if (
52
+ dimEntries.length > 1 &&
53
+ dimDefaults.has('color-scheme') &&
54
+ dimDefaults.get('color-scheme').values.length > 1 &&
55
+ primaryDimName !== 'color-scheme'
56
+ ) {
57
+ diagnostics.error(
58
+ 'TST1112',
59
+ `"color-scheme" is declared but "${primaryDimName}" is the first mode dimension — light/dark is bound to the first dimension, so this design system's dark mode will not reach any exporter.`,
60
+ { hint: 'List "color-scheme" first in `modes`. Only the first dimension carries light/dark polarity; the others are extra axes exporters mostly drop.' },
61
+ );
62
+ }
63
+
64
+ // Base layers merge into the token forest; mode-scoped layers inject values
65
+ // into the same modeValues structure that inline $extensions produce — the
66
+ // two authoring forms are equivalent by construction (ADR-0009).
67
+ const merged = mergeTrees(
68
+ tokenTrees.filter((t) => !t.modeScope).map((t) => t.tree),
69
+ (p) => diagnostics.warn('TST1103', `Token defined more than once (last wins): ${p}`),
70
+ );
71
+ const raw = collectTokens(merged);
72
+ const roleArchetypes = collectRoleArchetypes(merged, diagnostics);
73
+
74
+ for (const layer of tokenTrees.filter((t) => t.modeScope)) {
75
+ const scopeEntries = Object.entries(layer.modeScope);
76
+ if (scopeEntries.length !== 1) {
77
+ diagnostics.error('TST1110', `${layer.file}: a mode-scoped layer must target exactly one dimension`);
78
+ continue;
79
+ }
80
+ const [scopeDim, scopeMode] = scopeEntries[0];
81
+ if (!config.modes?.[scopeDim]?.values.includes(scopeMode)) {
82
+ diagnostics.error('TST1109', `${layer.file}: unknown mode "${scopeDim}: ${scopeMode}" (not declared in config.modes)`);
83
+ continue;
84
+ }
85
+ for (const [tokenPath, tok] of collectTokens(layer.tree)) {
86
+ const base = raw.get(tokenPath);
87
+ if (!base) {
88
+ diagnostics.warn('TST1107', `${layer.file}: mode value for unknown token "${tokenPath}" (no default-mode value exists) — skipped`);
89
+ continue;
90
+ }
91
+ base.modeValues[scopeDim] ??= {};
92
+ if (base.modeValues[scopeDim][scopeMode] !== undefined) {
93
+ diagnostics.warn('TST1108', `${tokenPath}: ${scopeDim}=${scopeMode} value overridden by later layer ${layer.file}`);
94
+ }
95
+ base.modeValues[scopeDim][scopeMode] = tok.value;
96
+ }
97
+ }
98
+
99
+ // `autoDark` reclassifies a cross-mode carry-over's provenance (see
100
+ // reportModeCarryOver below for the diagnostic half, and the note there on
101
+ // why it can only be judged after aliases resolve).
102
+ const autoDark = Boolean(config?.derivation?.autoDark);
103
+
104
+ const combos = expandModeMatrix(dimEntries);
105
+ const modes = {};
106
+ const comboDims = {};
107
+ for (const { key, values } of combos) {
108
+ const map = new Map();
109
+ for (const [tokenPath, tok] of raw) {
110
+ // Per-dimension resolution, applied independently and left-to-right
111
+ // (docs/architecture/ir.md#modes "resolved per-dimension independently"):
112
+ // a token overriding on more than one non-default dimension at once is
113
+ // the rare pathological pair the spec defers; last dimension wins there.
114
+ let value = tok.value;
115
+ let overriddenMode = null;
116
+ let autoDarkCarried = false;
117
+ for (const [dimName] of dimEntries) {
118
+ const v = values[dimName];
119
+ if (v === dimDefaults.get(dimName).default) continue;
120
+ const override = tok.modeValues?.[dimName]?.[v];
121
+ if (override !== undefined) { value = override; overriddenMode = `${dimName}=${v}`; }
122
+ else if (dimName === 'color-scheme' && autoDark) {
123
+ const role = tokenPath.match(ROLE_SOLID)?.[1];
124
+ if (role && (COLOR_ROLES.includes(role) || roleArchetypes.has(role))) autoDarkCarried = true;
125
+ }
126
+ }
127
+ map.set(tokenPath, {
128
+ type: tok.type,
129
+ rawValue: value,
130
+ provenance: {
131
+ kind: autoDarkCarried ? PROVENANCE.DERIVED : PROVENANCE.AUTHORED,
132
+ mode: overriddenMode ?? key,
133
+ ...(autoDarkCarried ? { rule: 'auto-dark-carry(constant)@standard@1' } : {}),
134
+ },
135
+ });
136
+ }
137
+ // Resolve aliases with cycle detection, then parse values.
138
+ for (const tokenPath of map.keys()) resolveEntry(map, tokenPath, [], diagnostics);
139
+ modes[key] = map;
140
+ comboDims[key] = values;
141
+ }
142
+
143
+ // Back-compat aliases: `modes.light` / `modes.dark` (or whatever the first
144
+ // dimension's values are) point at the combo where every OTHER dimension
145
+ // sits at ITS OWN default — exactly what every pre-T8 exporter means.
146
+ const otherDefaults = Object.fromEntries(dimEntries.slice(1).map(([n, d]) => [n, d.default]));
147
+ const dimNames = dimEntries.map(([n]) => n);
148
+ for (const v of dimDefaults.get(primaryDimName).values) {
149
+ modes[v] = modes[comboKey(dimNames, { [primaryDimName]: v, ...otherDefaults })];
150
+ }
151
+
152
+ return {
153
+ modes,
154
+ modeDimension: primaryDimName,
155
+ defaultMode: dimDefaults.get(primaryDimName).default,
156
+ modeValues: dimDefaults.get(primaryDimName).values,
157
+ dimensions: Object.fromEntries(dimEntries),
158
+ dimensionNames: dimNames,
159
+ comboDims,
160
+ allCombos: combos.map((c) => c.key),
161
+ roleArchetypes,
162
+ };
163
+ }
164
+
165
+ /** Structural equality for resolved values (colors are `{l,c,h,alpha}`). */
166
+ const sameValue = (a, b) =>
167
+ a === b || (a !== null && b !== null && typeof a === 'object' && typeof b === 'object' && JSON.stringify(a) === JSON.stringify(b));
168
+
169
+ /**
170
+ * TST1204: a role's `.solid` anchor drives its whole grid (~16 derived slots —
171
+ * hover/active/tint/outline/on-colors), so when the default-mode color reaches
172
+ * a non-default `color-scheme` value unchanged, the ENTIRE grid is that scheme's
173
+ * theme. Not an absence (TST1201/1203 cover that): a value that's present, looks
174
+ * complete, and never changed. Documented, default behavior (`diagnostics.md`,
175
+ * "My brand color is identical in dark mode") — `info`, not a mistake — but
176
+ * nothing surfaced it except the docs page.
177
+ *
178
+ * **Runs after aliases resolve, and compares resolved values.** It used to run
179
+ * inside NORMALIZE's per-mode loop, testing only whether the catalog slot itself
180
+ * carried a mode override — which is false for every design system adopted the
181
+ * way this project recommends. Carbon binds `semantic.color.danger.solid` to
182
+ * `{semantic.color.carbon.support-error}`, and it is the *alias target* that
183
+ * carries the dark value: the alias string is identical in both modes, so the
184
+ * old check called it a silent carry-over while the emitted dark theme was in
185
+ * fact a different red. Four of Carbon's seven notes were wrong that way. The
186
+ * condition is now both halves — no per-mode value authored ON the slot (so an
187
+ * explicit, deliberately identical dark value stays silent) AND the resolved
188
+ * colors actually being equal.
189
+ *
190
+ * Reported once per (role, scheme value) regardless of how many OTHER dimensions
191
+ * multiply the combos sharing that fact (`dark+comfortable` and `dark+compact`
192
+ * are one carry-over, not two). Fires whether or not `autoDark` is on: autoDark
193
+ * does not compute a distinct color for this slot (docs/exercises/
194
+ * phase0-shadcn.md F7 — `darkBrandAdjust` is a still-open research question),
195
+ * so the color genuinely is the carried-over one either way. What autoDark
196
+ * changes is provenance: without it the carry-over is misclassified `authored`;
197
+ * with it it's `derived`, so `report.json` shows synthetic dark-theme coverage
198
+ * honestly. (That reclassification only reaches non-aliased slots — an aliased
199
+ * carry-over stays `aliased`, which is accurate about where the value came from
200
+ * but doesn't get autoDark's coverage honesty. Separate question, left alone.)
201
+ */
202
+ export function reportModeCarryOver(normalized, config, diagnostics) {
203
+ const DIM = 'color-scheme';
204
+ const dim = normalized.dimensions?.[DIM];
205
+ if (!dim) return;
206
+ const autoDark = Boolean(config?.derivation?.autoDark);
207
+ const reported = new Set();
208
+
209
+ for (const key of normalized.allCombos) {
210
+ const dims = normalized.comboDims[key];
211
+ const scheme = dims[DIM];
212
+ if (scheme === dim.default) continue;
213
+ const map = normalized.modes[key];
214
+ const base = normalized.modes[comboKey(normalized.dimensionNames, { ...dims, [DIM]: dim.default })];
215
+ if (!map || !base) continue;
216
+
217
+ for (const [tokenPath, entry] of map) {
218
+ const role = tokenPath.match(ROLE_SOLID)?.[1];
219
+ if (!role || !(COLOR_ROLES.includes(role) || normalized.roleArchetypes.has(role))) continue;
220
+ // Only roles the user actually supplied. Running after DERIVE means every
221
+ // derived role anchor (`accent.solid` aliasing primary, `danger.solid`
222
+ // hue-anchored from it) is in the map too, and each of them carries over
223
+ // for exactly one reason: the authored anchor did. Reporting them would
224
+ // print eight consequences of one cause — the noise AL5 removed
225
+ // everywhere else.
226
+ if (!['authored', 'aliased'].includes(entry.provenance?.kind)) continue;
227
+ // An explicit per-mode value on the slot itself is a decision, however it
228
+ // compares — never second-guessed here.
229
+ if (entry.provenance?.mode === `${DIM}=${scheme}`) continue;
230
+ const baseline = base.get(tokenPath);
231
+ if (!baseline || !sameValue(entry.value, baseline.value)) continue;
232
+
233
+ const dedupeKey = `${tokenPath}|${scheme}`;
234
+ if (reported.has(dedupeKey)) continue;
235
+ reported.add(dedupeKey);
236
+ diagnostics.info(
237
+ 'TST1204',
238
+ `${tokenPath} has no authored value for ${DIM}=${scheme} — the ${dim.default}-mode value carries over unchanged, and so does its whole derived grid`,
239
+ autoDark
240
+ ? { hint: `Author ${tokenPath} for ${DIM}=${scheme} if this role should differ in that mode. \`derivation.autoDark\` is on, so this carry-over is now classified "derived" in coverage — but it does not yet compute a distinct color (that transform is still an open research question; see the roadmap).` }
241
+ : { hint: `Author ${tokenPath} for ${DIM}=${scheme} if this role should differ in that mode. This is default behavior — nothing is broken.` },
242
+ );
243
+ }
244
+ }
245
+ }
246
+
247
+ /**
248
+ * An alias whose target isn't in the map *yet*. Catalog slots the DERIVE stage
249
+ * materializes (`radius.full`, the role grid, the elevation ladder) don't exist
250
+ * at NORMALIZE time, so authoring `{semantic.radius.full}` — the very style
251
+ * ir.md's component-layer sketch uses — must not be judged dangling here.
252
+ * These entries are re-resolved by resolveDeferredAliases() after DERIVE; only
253
+ * then, if the target still doesn't exist, is it a real dangling alias.
254
+ */
255
+ const DEFERRED = Symbol('deferred-alias');
256
+ /** Resolution failed because of an alias cycle, already reported as TST1104. */
257
+ const CYCLE = Symbol('alias-cycle');
258
+
259
+ /**
260
+ * Report one cycle once (AL5). The resolver reaches a two-token loop from both
261
+ * ends, so the same cycle was printed twice with the chain rotated — two
262
+ * different message strings describing one mistake, which de-duplication by
263
+ * message cannot catch. Keying on the sorted member set makes any rotation of
264
+ * the same loop a single report; the chain is still printed in traversal order,
265
+ * because that is what shows the user how the loop closes.
266
+ */
267
+ const reportedCycles = new WeakMap();
268
+ function reportCycle(diagnostics, chain) {
269
+ let seen = reportedCycles.get(diagnostics);
270
+ if (!seen) reportedCycles.set(diagnostics, (seen = new Set()));
271
+ const key = [...new Set(chain)].sort().join('|');
272
+ if (seen.has(key)) return;
273
+ seen.add(key);
274
+ diagnostics.error('TST1104', `Alias cycle: ${chain.join(' → ')}`, {
275
+ hint: 'Break the loop: one of these tokens has to hold a literal value.',
276
+ });
277
+ }
278
+
279
+ function resolveEntry(map, tokenPath, stack, diagnostics) {
280
+ const entry = map.get(tokenPath);
281
+ if (!entry) return undefined;
282
+ if (entry.value !== undefined) return entry;
283
+ if (entry.pendingAlias) return DEFERRED;
284
+ if (stack.includes(tokenPath)) {
285
+ reportCycle(diagnostics, [...stack, tokenPath]);
286
+ // AL5: a distinct sentinel, not `undefined`. Returning `undefined` made the
287
+ // caller report TST1105 "dangling alias" on top of the cycle — which is
288
+ // false (the target exists; it just loops) and doubled the output on the
289
+ // exact error where the chain is already printed in full.
290
+ return CYCLE;
291
+ }
292
+ let raw = entry.rawValue;
293
+ const target = aliasTarget(raw);
294
+ if (target) {
295
+ // Absent target: possibly derived later — defer rather than erroring.
296
+ // A target that IS present but failed to resolve (bad color syntax, cycle)
297
+ // is a genuine failure now, exactly as before.
298
+ if (!map.has(target)) {
299
+ entry.pendingAlias = target;
300
+ return DEFERRED;
301
+ }
302
+ const resolved = resolveEntry(map, target, [...stack, tokenPath], diagnostics);
303
+ if (resolved === DEFERRED) {
304
+ entry.pendingAlias = target;
305
+ return DEFERRED;
306
+ }
307
+ if (resolved === CYCLE) return CYCLE; // already reported as TST1104
308
+ if (!resolved) {
309
+ diagnostics.error('TST1105', `Dangling alias in ${tokenPath}: {${target}}`, {
310
+ hint: `Nothing resolves to "${target}". Check the tier prefix (option./semantic./component.) and the spelling.`,
311
+ });
312
+ return undefined;
313
+ }
314
+ entry.type = entry.type ?? resolved.type;
315
+ entry.value = resolved.value;
316
+ entry.provenance = { kind: 'aliased', target, mode: entry.provenance.mode };
317
+ return entry;
318
+ }
319
+ try {
320
+ entry.value = entry.type === 'color' ? parseColor(raw) : raw;
321
+ } catch (e) {
322
+ diagnostics.error('TST1106', `${tokenPath}: ${e.message}`);
323
+ return undefined;
324
+ }
325
+ return entry;
326
+ }
327
+
328
+ /**
329
+ * Post-DERIVE pass: resolve every alias deferred at NORMALIZE time, now that
330
+ * the derived catalog slots exist. Still-missing targets are the real dangling
331
+ * aliases and get TST1105 here — same code, same message, just diagnosed after
332
+ * the stage that could legitimately have supplied the target.
333
+ */
334
+ export function resolveDeferredAliases(normalized, diagnostics) {
335
+ const seen = new Set();
336
+ for (const map of Object.values(normalized.modes)) {
337
+ if (!map || seen.has(map)) continue; // modes.light/dark alias the combo maps
338
+ seen.add(map);
339
+ for (const tokenPath of [...map.keys()]) resolvePending(map, tokenPath, [], diagnostics);
340
+ }
341
+ }
342
+
343
+ function resolvePending(map, tokenPath, stack, diagnostics) {
344
+ const entry = map.get(tokenPath);
345
+ if (!entry || !entry.pendingAlias) return entry;
346
+ if (stack.includes(tokenPath)) {
347
+ reportCycle(diagnostics, [...stack, tokenPath]);
348
+ delete entry.pendingAlias;
349
+ return CYCLE;
350
+ }
351
+ const target = entry.pendingAlias;
352
+ const resolved = map.has(target)
353
+ ? resolvePending(map, target, [...stack, tokenPath], diagnostics)
354
+ : undefined;
355
+ delete entry.pendingAlias;
356
+ if (resolved === CYCLE) return CYCLE; // already reported as TST1104
357
+ if (!resolved || resolved.value === undefined) {
358
+ diagnostics.error('TST1105', `Dangling alias in ${tokenPath}: {${target}}`, {
359
+ hint: `Nothing resolves to "${target}" — not authored, and not produced by derivation. Check the tier prefix (option./semantic./component.) and the spelling.`,
360
+ });
361
+ return undefined;
362
+ }
363
+ entry.type = entry.type ?? resolved.type;
364
+ entry.value = resolved.value;
365
+ entry.provenance = { kind: 'aliased', target, mode: entry.provenance.mode };
366
+ return entry;
367
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The `transtyle.config.json` schema — source of truth for BOTH the runtime
3
+ * validator (validate.js, used in compile()) and the published editor schema
4
+ * (scripts/gen-schemas.mjs → website/public/schemas/config/v0.json). Written in
5
+ * the JSON Schema subset validate.js understands; scripts/check-schemas.mjs
6
+ * proves the published file and this object stay identical.
7
+ *
8
+ * Matches docs/specs/configuration.md. `additionalProperties: false` at the top
9
+ * level and inside each target is what makes a typo an error (audit A8) instead
10
+ * of a silently-ignored key.
11
+ *
12
+ * NOTE: exporter `options` are intentionally `additionalProperties: true` here —
13
+ * each exporter validates its own options against its own schema at load time
14
+ * (index.js + exporter `optionsSchema`), because the shape depends on which
15
+ * exporter the instance selects, which this static schema can't know.
16
+ */
17
+
18
+ const tokenLayer = {
19
+ anyOf: [
20
+ { type: 'string' },
21
+ {
22
+ type: 'object',
23
+ required: ['files', 'mode'],
24
+ additionalProperties: false,
25
+ properties: {
26
+ files: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
27
+ mode: { type: 'object', additionalProperties: { type: 'string' } },
28
+ },
29
+ },
30
+ ],
31
+ };
32
+
33
+ const modeDimension = {
34
+ type: 'object',
35
+ required: ['values'],
36
+ additionalProperties: false,
37
+ properties: {
38
+ values: { type: 'array', minItems: 1, items: { type: 'string' } },
39
+ default: { type: 'string' },
40
+ },
41
+ };
42
+
43
+ const target = {
44
+ type: 'object',
45
+ additionalProperties: false,
46
+ properties: {
47
+ output: { type: 'string' },
48
+ exporter: { type: 'string' },
49
+ options: { type: 'object' }, // validated per-exporter at load time; see note above
50
+ },
51
+ };
52
+
53
+ export const configSchema = {
54
+ type: 'object',
55
+ required: ['tokens'],
56
+ additionalProperties: false,
57
+ properties: {
58
+ $schema: { type: 'string' },
59
+ name: { type: 'string' },
60
+ tokens: { type: 'array', minItems: 1, items: tokenLayer },
61
+ modes: { type: 'object', additionalProperties: modeDimension },
62
+ derivation: {
63
+ type: 'object',
64
+ additionalProperties: false,
65
+ properties: {
66
+ rules: { type: 'string' },
67
+ autoDark: { type: 'boolean' },
68
+ require: { type: 'array', items: { type: 'string' } },
69
+ },
70
+ },
71
+ targets: { type: 'object', additionalProperties: target },
72
+ check: {
73
+ type: 'object',
74
+ additionalProperties: false,
75
+ properties: {
76
+ failOn: { type: 'string', enum: ['error', 'warning', 'approximation'] },
77
+ contrast: {
78
+ type: 'object',
79
+ additionalProperties: false,
80
+ properties: { standard: { type: 'string', enum: ['wcag21-aa', 'wcag21-aaa'] } },
81
+ },
82
+ },
83
+ },
84
+ },
85
+ };
86
+
87
+ /** Metadata added only to the *published* file (gen-schemas.mjs), not used at runtime. */
88
+ export const configSchemaMeta = {
89
+ $id: 'https://transtyle.dev/schemas/config/v0.json',
90
+ title: 'Transtyle config (transtyle.config.json)',
91
+ description: 'Schema for a Transtyle project configuration file. See https://transtyle.dev/docs/configuration/.',
92
+ };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The `report.json` schema — the machine-readable build report core emits per
3
+ * target (docs/specs/validation-and-coverage.md). Source of truth for the
4
+ * published file (scripts/gen-schemas.mjs → website/public/schemas/report/v0.json).
5
+ * Unlike the config schema this is not enforced on user input — core *produces*
6
+ * reports — but scripts/check-schemas.mjs validates every generated report
7
+ * against it, so the published schema can never drift from what we actually emit.
8
+ */
9
+
10
+ const coverageItem = {
11
+ type: 'object',
12
+ required: ['variable', 'slot', 'class'],
13
+ additionalProperties: false,
14
+ properties: {
15
+ variable: { type: 'string' },
16
+ slot: { type: 'string' },
17
+ class: { type: 'string', enum: ['native', 'derived', 'approximated', 'dropped', 'unsupported'] },
18
+ provenance: { type: 'string', enum: ['authored', 'aliased', 'derived', 'defaulted'] },
19
+ note: { type: 'string' },
20
+ },
21
+ };
22
+
23
+ const diagnostic = {
24
+ type: 'object',
25
+ required: ['severity', 'code', 'message'],
26
+ additionalProperties: true,
27
+ properties: {
28
+ severity: { type: 'string', enum: ['error', 'warning', 'info'] },
29
+ code: { type: 'string' },
30
+ message: { type: 'string' },
31
+ // AL5: optional, and deliberately separate from `message` — what is wrong
32
+ // and what to change are different sentences, and tools consuming the
33
+ // report (editors, CI annotations) want to place them differently.
34
+ hint: { type: 'string' },
35
+ },
36
+ };
37
+
38
+ export const reportSchema = {
39
+ type: 'object',
40
+ required: ['target', 'generatedBy', 'coverage', 'diagnostics', 'files'],
41
+ additionalProperties: false,
42
+ properties: {
43
+ $schema: { type: 'string' },
44
+ target: { type: 'string' },
45
+ options: { type: 'object' },
46
+ generatedBy: { type: 'string' },
47
+ coverage: {
48
+ type: 'object',
49
+ required: ['counts', 'items'],
50
+ additionalProperties: false,
51
+ properties: {
52
+ counts: { type: 'object', additionalProperties: { type: 'integer' } },
53
+ items: { type: 'array', items: coverageItem },
54
+ },
55
+ },
56
+ diagnostics: { type: 'array', items: diagnostic },
57
+ files: { type: 'array', items: { type: 'string' } },
58
+ },
59
+ };
60
+
61
+ export const reportSchemaMeta = {
62
+ $id: 'https://transtyle.dev/schemas/report/v0.json',
63
+ title: 'Transtyle build report (report.json)',
64
+ description: 'Schema for a Transtyle per-target build report. See https://transtyle.dev/docs/diagnostics/.',
65
+ };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * A tiny, zero-dependency validator for the JSON Schema *subset* Transtyle's
3
+ * own schemas use (docs/specs/configuration.md, audit A7/A8). We do NOT ship a
4
+ * full JSON Schema engine (ajv) — core has zero external dependencies by policy
5
+ * (VISION non-goal / package.json). Instead, the config and report schemas are
6
+ * written in that subset (see config.schema.js / report.schema.js), this walker
7
+ * validates against them, and `scripts/gen-schemas.mjs` publishes the very same
8
+ * objects as real draft-2020-12 files for editors and the website. One source of
9
+ * truth, two consumers — `scripts/check-schemas.mjs` asserts they never diverge.
10
+ *
11
+ * Supported keywords: type, enum, const, required, properties,
12
+ * additionalProperties (boolean | schema), items, minItems, anyOf. That is
13
+ * exactly what our schemas need and no more — extend deliberately.
14
+ */
15
+
16
+ import { nearestName } from '../nearest.js';
17
+
18
+ const typeOf = (v) =>
19
+ v === null ? 'null'
20
+ : Array.isArray(v) ? 'array'
21
+ : typeof v === 'object' ? 'object'
22
+ : typeof v === 'number' ? (Number.isInteger(v) ? 'integer' : 'number')
23
+ : typeof v; // 'string' | 'boolean'
24
+
25
+ /** integer also satisfies number; everything else is exact. */
26
+ function typeMatches(value, type) {
27
+ const actual = typeOf(value);
28
+ const types = Array.isArray(type) ? type : [type];
29
+ return types.some((t) => t === actual || (t === 'number' && actual === 'integer'));
30
+ }
31
+
32
+ /**
33
+ * Validate `value` against `schema`. Returns an array of { path, message }
34
+ * (empty = valid). `path` is a dotted JSON path from the document root.
35
+ */
36
+ export function validate(value, schema, path = '') {
37
+ const errors = [];
38
+ const push = (p, message) => errors.push({ path: p || '(root)', message });
39
+
40
+ if (schema.const !== undefined && value !== schema.const) {
41
+ push(path, `must equal ${JSON.stringify(schema.const)}`);
42
+ }
43
+ if (schema.enum && !schema.enum.some((e) => e === value)) {
44
+ push(path, `must be one of ${schema.enum.map((e) => JSON.stringify(e)).join(', ')}`);
45
+ return errors; // an out-of-enum value fails nothing else usefully
46
+ }
47
+ if (schema.type && !typeMatches(value, schema.type)) {
48
+ push(path, `must be ${Array.isArray(schema.type) ? schema.type.join(' or ') : schema.type}, got ${typeOf(value)}`);
49
+ return errors; // wrong type → downstream keyword checks are noise
50
+ }
51
+
52
+ if (schema.anyOf) {
53
+ const branchErrors = schema.anyOf.map((s) => validate(value, s, path));
54
+ if (!branchErrors.some((e) => e.length === 0)) {
55
+ push(path, `does not match any allowed shape`);
56
+ }
57
+ }
58
+
59
+ if (typeOf(value) === 'object' && (schema.properties || schema.required || 'additionalProperties' in schema)) {
60
+ for (const key of schema.required ?? []) {
61
+ if (!(key in value)) push(path, `missing required property "${key}"`);
62
+ }
63
+ const props = schema.properties ?? {};
64
+ const addl = schema.additionalProperties;
65
+ for (const [key, v] of Object.entries(value)) {
66
+ const childPath = path ? `${path}.${key}` : key;
67
+ if (props[key]) {
68
+ errors.push(...validate(v, props[key], childPath));
69
+ } else if (addl === false) {
70
+ // AL5: pushed at the PARENT path, not childPath — the message already
71
+ // names the key, and prefixing the child path rendered as
72
+ // `targts unknown property "targts"`. The near-miss suggestion is the
73
+ // whole point of rejecting unknown keys instead of ignoring them: a
74
+ // typo'd `targts` is otherwise a config that silently does nothing.
75
+ const near = nearestName(key, Object.keys(props));
76
+ push(path, `unknown property "${key}"${near ? ` — did you mean "${near}"?` : ''}`);
77
+ } else if (addl && typeof addl === 'object') {
78
+ errors.push(...validate(v, addl, childPath));
79
+ }
80
+ }
81
+ }
82
+
83
+ if (typeOf(value) === 'array') {
84
+ if (schema.minItems != null && value.length < schema.minItems) {
85
+ push(path, `must have at least ${schema.minItems} item${schema.minItems === 1 ? '' : 's'}`);
86
+ }
87
+ if (schema.items) {
88
+ value.forEach((item, i) => errors.push(...validate(item, schema.items, `${path}[${i}]`)));
89
+ }
90
+ }
91
+
92
+ return errors;
93
+ }