@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,43 @@
1
+ /** Shared diagnostics collector (docs/specs/validation-and-coverage.md). */
2
+
3
+ /**
4
+ * AL5: two behaviors that exist because of what the tool actually did when a
5
+ * user got something wrong, not because of a design idea.
6
+ *
7
+ * 1. `hint` — a separate, optional "here is what to do" line. Keeping it out of
8
+ * `message` is what makes actionability structural: the message says what is
9
+ * wrong, the hint says what to change, and the CLI renders them distinctly
10
+ * (report.json keeps both fields). Before this, advice that the docs page
11
+ * already gave was simply absent at the point of failure.
12
+ *
13
+ * 2. De-duplication on (severity, code, message). DERIVE and NORMALIZE both run
14
+ * once per mode combo, so a single authoring mistake was reported once per
15
+ * combo — a 2-token alias cycle printed twelve lines. Identical text repeated
16
+ * N times carries no information beyond the first. Anything genuinely
17
+ * per-mode already says so in its message (contrast warnings name the mode),
18
+ * so those still come through separately.
19
+ */
20
+ export class Diagnostics {
21
+ constructor() {
22
+ this.items = [];
23
+ this._seen = new Set();
24
+ }
25
+ #push(severity, code, message, context) {
26
+ const key = `${severity} ${code} ${message}`;
27
+ if (this._seen.has(key)) return;
28
+ this._seen.add(key);
29
+ this.items.push({ severity, code, message, ...context });
30
+ }
31
+ error(code, message, context = {}) { this.#push('error', code, message, context); }
32
+ warn(code, message, context = {}) { this.#push('warning', code, message, context); }
33
+ info(code, message, context = {}) { this.#push('info', code, message, context); }
34
+ get errors() { return this.items.filter((i) => i.severity === 'error'); }
35
+ get warnings() { return this.items.filter((i) => i.severity === 'warning'); }
36
+ /** Did this code already fire? Used to suppress consequences of a root cause. */
37
+ has(code) { return this.items.some((i) => i.code === code); }
38
+ shouldFail(failOn = 'error') {
39
+ if (failOn === 'error') return this.errors.length > 0;
40
+ if (failOn === 'warning') return this.errors.length > 0 || this.warnings.length > 0;
41
+ return this.errors.length > 0;
42
+ }
43
+ }
package/src/diff.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Semantic diff of two resolved IRs (docs/specs/diff.md, ROADMAP P6). Pure and
3
+ * deterministic: no I/O, no git — the CLI resolves "before" (a git ref) and
4
+ * "after" (the working tree) via compile() and hands both here.
5
+ *
6
+ * The unit of a design-system change is a **semantic slot value in a mode**, not
7
+ * a token-file line: renaming an option token or restructuring layers that leaves
8
+ * every resolved value identical is correctly reported as no change, because the
9
+ * compiled themes are identical. That is the whole point of diffing the resolved
10
+ * graph rather than the source.
11
+ */
12
+
13
+ import { CONTRAST_PAIRS, contrastThreshold, pairRatio } from './checks.js';
14
+
15
+ /** Canonical, stable string form of a resolved value, for equality only. */
16
+ function canon(value) {
17
+ if (value === null || value === undefined) return String(value);
18
+ if (typeof value !== 'object') return String(value);
19
+ if (Array.isArray(value)) return `[${value.map(canon).join(',')}]`;
20
+ // plain data objects (color components, typography/shadow composites) — sort
21
+ // keys so equality is independent of construction order.
22
+ return `{${Object.keys(value).sort().map((k) => `${k}:${canon(value[k])}`).join(',')}}`;
23
+ }
24
+
25
+ const provKind = (entry) => entry?.provenance?.kind ?? null;
26
+
27
+ /**
28
+ * @returns {{ modes: Array<{ mode, added: string[], removed: string[],
29
+ * changed: Array<{ slot, before, after, valueChanged, provChanged }> }>,
30
+ * changedSlots: Set<string>, hasChanges: boolean }}
31
+ */
32
+ export function diffResolved(before, after) {
33
+ const modeNames = [...new Set([...Object.keys(before.modes), ...Object.keys(after.modes)])].sort();
34
+ const modes = [];
35
+ const changedSlots = new Set();
36
+
37
+ for (const mode of modeNames) {
38
+ const b = before.modes[mode] ?? new Map();
39
+ const a = after.modes[mode] ?? new Map();
40
+ const slots = [...new Set([...b.keys(), ...a.keys()])].sort();
41
+ const added = [], removed = [], changed = [];
42
+
43
+ for (const slot of slots) {
44
+ const be = b.get(slot);
45
+ const ae = a.get(slot);
46
+ if (!be && ae) { added.push(slot); changedSlots.add(slot); continue; }
47
+ if (be && !ae) { removed.push(slot); changedSlots.add(slot); continue; }
48
+ const valueChanged = canon(be.value) !== canon(ae.value);
49
+ const provChanged = provKind(be) !== provKind(ae);
50
+ if (valueChanged || provChanged) {
51
+ changed.push({ slot, before: be, after: ae, valueChanged, provChanged });
52
+ changedSlots.add(slot);
53
+ }
54
+ }
55
+ modes.push({ mode, added, removed, changed });
56
+ }
57
+
58
+ const hasChanges = modes.some((m) => m.added.length || m.removed.length || m.changed.length);
59
+ return { modes, changedSlots, hasChanges };
60
+ }
61
+
62
+ /**
63
+ * Accessibility regressions introduced by the change (docs/specs/diff.md).
64
+ *
65
+ * `check` tells you the contrast is bad *now*; this tells you **this change made
66
+ * it bad** — which is the question a reviewer has, and the one a passing-CI
67
+ * baseline can regress on silently. Uses the same pairs and threshold as
68
+ * `runChecks`, so the two can never disagree about what "passing" means.
69
+ *
70
+ * Severities, worst first:
71
+ * `regressed` — passed the standard before, fails now (the headline case)
72
+ * `worsened` — already failing, and the ratio dropped further
73
+ *
74
+ * A pair that improves, or that fails identically on both sides, is not reported.
75
+ *
76
+ * @returns {Array<{ mode, fg, bg, before: number, after: number, status, threshold }>}
77
+ */
78
+ export function contrastRegressions(before, after, config) {
79
+ const threshold = contrastThreshold(config);
80
+ const out = [];
81
+ for (const mode of Object.keys(after.modes)) {
82
+ const bMap = before.modes[mode];
83
+ const aMap = after.modes[mode];
84
+ if (!bMap || !aMap) continue; // mode added or removed — not a regression
85
+ for (const [fg, bg] of CONTRAST_PAIRS) {
86
+ const b = pairRatio(bMap, fg, bg);
87
+ const a = pairRatio(aMap, fg, bg);
88
+ if (b === null || a === null) continue;
89
+ if (b >= threshold && a < threshold) {
90
+ out.push({ mode, fg, bg, before: b, after: a, status: 'regressed', threshold });
91
+ } else if (b < threshold && a < threshold && a < b - 0.05) {
92
+ out.push({ mode, fg, bg, before: b, after: a, status: 'worsened', threshold });
93
+ }
94
+ }
95
+ }
96
+ return out;
97
+ }
package/src/index.js ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * @transtyle/core — public programmatic API (docs/architecture/overview.md:
3
+ * "core is a library first, CLI second").
4
+ */
5
+
6
+ import path from 'node:path';
7
+ import { mkdir, writeFile } from 'node:fs/promises';
8
+ import { loadConfig, loadTokenTrees } from './load.js';
9
+ import { validate } from './schema/validate.js';
10
+ import { configSchema } from './schema/config.schema.js';
11
+ import { normalize, resolveDeferredAliases, reportModeCarryOver } from './normalize.js';
12
+ import { derive } from './derive.js';
13
+ import { runChecks } from './checks.js';
14
+ import { Diagnostics } from './diagnostics.js';
15
+ import { nearestName } from './nearest.js';
16
+ import { formatColor, formatHslTriplet, formatHex, contrastRatio, mix } from './color.js';
17
+
18
+ export { formatColor, formatHslTriplet, formatHex, contrastRatio, mix } from './color.js';
19
+ export { Diagnostics } from './diagnostics.js';
20
+ export { diffResolved, contrastRegressions } from './diff.js';
21
+
22
+ /**
23
+ * Run the pipeline. `emit: false` = `transtyle check` (pipeline minus EMIT —
24
+ * same code path by design, docs/architecture/pipeline.md).
25
+ */
26
+ /**
27
+ * `knownExporters` (AL5) is the caller's list of exporter names it can resolve —
28
+ * the CLI's OFFICIAL_EXPORTERS keys. Core stays exporter-agnostic (it never
29
+ * imports one), but TST1301 can then tell "you typo'd" apart from "that
30
+ * exporter exists, you just haven't configured it", which are opposite fixes.
31
+ */
32
+ export async function compile({ cwd, targets, emit = true, loadExporter, knownExporters = [] }) {
33
+ const diagnostics = new Diagnostics();
34
+ const { config } = await loadConfig(cwd);
35
+
36
+ // Config schema validation (audit A8): a typo'd or mis-typed config key is an
37
+ // error, not a silently-ignored field. Fail before touching tokens — a broken
38
+ // config shape would only produce misleading downstream diagnostics.
39
+ for (const { path: p, message } of validate(config, configSchema)) {
40
+ diagnostics.error('TST1010', `transtyle.config.json: ${p === '(root)' ? '' : p + ' '}${message}`);
41
+ }
42
+ if (diagnostics.errors.length > 0) {
43
+ return { config, diagnostics, results: [], normalized: null };
44
+ }
45
+
46
+ // LOAD + NORMALIZE + DERIVE (shared across targets)
47
+ const trees = await loadTokenTrees(cwd, config.tokens, diagnostics);
48
+ const normalized = normalize(trees, config, diagnostics);
49
+ derive(normalized, config, diagnostics);
50
+ // Authored aliases pointing at slots DERIVE materializes (e.g. a component
51
+ // token aliasing `{semantic.radius.full}`) resolve here — see normalize.js.
52
+ resolveDeferredAliases(normalized, diagnostics);
53
+
54
+ // TST1204 (cross-mode carry-over) can only be judged once every alias has a
55
+ // value: the slot's own text is identical in both modes when the per-mode
56
+ // value lives on the alias target, which is exactly how the binding-layer
57
+ // adoption pattern works. See reportModeCarryOver.
58
+ reportModeCarryOver(normalized, config, diagnostics);
59
+
60
+ // The engine's one non-negotiable input (AL5 — see derive.js for why it moved
61
+ // here). Checked after every alias has had its chance to resolve, and only
62
+ // when nothing upstream already explains the absence: a dangling alias or an
63
+ // unparseable color makes this token missing as a *consequence*, and reporting
64
+ // both sends the user to fix the symptom.
65
+ const primaryMissing = Object.values(normalized.modes).some(
66
+ (m) => m.get('semantic.color.primary.solid')?.value === undefined,
67
+ );
68
+ const upstream = ['TST1002', 'TST1104', 'TST1105', 'TST1106'].some((c) => diagnostics.has(c));
69
+ if (primaryMissing && !upstream) {
70
+ diagnostics.error(
71
+ 'TST1201',
72
+ 'semantic.color.primary.solid is not authored — it is the one token the derivation engine cannot invent.',
73
+ {
74
+ // The old text blamed `config derivation.require`, which most configs
75
+ // (including `transtyle init`'s own scaffold) never set. It is an engine
76
+ // invariant, not a consequence of configuration.
77
+ hint: 'Author it as `semantic.color.primary.solid` (your brand color). A bare `semantic.color.primary` is a different path — the role grid anchors on the `.solid` cell.',
78
+ },
79
+ );
80
+ }
81
+
82
+ runChecks(normalized, config, diagnostics);
83
+
84
+ // derivation.require: listed slots must be authored, not derived. Color
85
+ // roles require their `.solid` anchor cell (the role grid's authored anchor,
86
+ // was `.base` pre-revision); other requires (e.g. radius.md) are bare paths.
87
+ for (const req of config.derivation?.require ?? []) {
88
+ const kind = normalized.modes[normalized.defaultMode].get(`${req}.solid`)?.provenance.kind
89
+ ?? normalized.modes[normalized.defaultMode].get(req)?.provenance.kind;
90
+ if (kind === 'derived' || kind === undefined) {
91
+ diagnostics.error('TST1202', `Required token is not authored: ${req}`);
92
+ }
93
+ }
94
+
95
+ const targetNames = targets?.length ? targets : Object.keys(config.targets ?? {});
96
+ const results = [];
97
+
98
+ for (const name of targetNames) {
99
+ const targetConfig = config.targets?.[name];
100
+ if (!targetConfig) {
101
+ // AL5: two different mistakes reached the same dead-end message. A typo
102
+ // needs the near name; a correctly-spelled exporter that simply isn't in
103
+ // this config needs to be told to add it. Neither is "check instance
104
+ // names" with the names withheld.
105
+ const configured = Object.keys(config.targets ?? {});
106
+ const near = nearestName(name, configured);
107
+ const known = knownExporters.includes(name);
108
+ diagnostics.error(
109
+ 'TST1301',
110
+ `Target "${name}" is not configured in transtyle.config.json`,
111
+ {
112
+ hint: near
113
+ ? `Did you mean "${near}"? Configured targets: ${configured.join(', ') || '(none)'}`
114
+ : known
115
+ ? `"${name}" is a known exporter but this config doesn't use it — add it under "targets" with an "output" directory.`
116
+ : `Configured targets: ${configured.join(', ') || '(none)'}`,
117
+ },
118
+ );
119
+ continue;
120
+ }
121
+ if (diagnostics.errors.length > 0) break; // never emit with errors present
122
+
123
+ // Target instances: the config key is the instance name; `exporter` selects
124
+ // the plugin (defaults to the key), so one exporter can be configured twice
125
+ // with different options (docs/specs/configuration.md#target-instances).
126
+ const exporter = await loadExporter(targetConfig.exporter ?? name);
127
+
128
+ // Validate this instance's options against the exporter's own schema (audit
129
+ // A8): unknown or mis-typed options are errors. Exporters without options
130
+ // reject any options object; exporters with options declare `optionsSchema`.
131
+ if (targetConfig.options !== undefined) {
132
+ const schema = exporter.optionsSchema ?? { type: 'object', additionalProperties: false };
133
+ for (const { path: p, message } of validate(targetConfig.options, schema)) {
134
+ diagnostics.error('TST1011', `target "${name}" options: ${p === '(root)' ? '' : p + ' '}${message}`);
135
+ }
136
+ }
137
+ if (diagnostics.errors.length > 0) break; // don't emit with invalid options
138
+
139
+ // RESOLVE + EMIT: exporter returns file descriptions; only core touches the filesystem.
140
+ const ctx = {
141
+ config, targetConfig, formatColor, formatHslTriplet, formatHex, contrastRatio, mix,
142
+ projectName: config.name ?? 'design-system',
143
+ // Sibling-target manifest (docs/specs/exporters/storybook.md#composition):
144
+ // name, exporter, and output dir of every configured target — never their
145
+ // resolutions. Lets composition-capable exporters reference sibling
146
+ // ARTIFACT PATHS, keeping the no-cross-target-coupling invariant.
147
+ siblings: Object.entries(config.targets ?? {}).map(([n, t]) => ({
148
+ name: n, exporter: t.exporter ?? n, output: t.output ?? `dist/${n}`,
149
+ })),
150
+ };
151
+ const { files, coverage } = exporter.emit(normalized, ctx);
152
+
153
+ const outDir = path.resolve(cwd, targetConfig.output ?? `dist/${name}`);
154
+ const written = [];
155
+ if (emit) {
156
+ await mkdir(outDir, { recursive: true });
157
+ for (const f of files) {
158
+ await writeFile(path.join(outDir, f.path), f.contents, 'utf8');
159
+ written.push(path.relative(cwd, path.join(outDir, f.path)));
160
+ }
161
+ // Build manifest + machine-readable report (docs/specs/validation-and-coverage.md)
162
+ const report = buildReport(name, targetConfig, coverage, diagnostics, written);
163
+ await writeFile(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2) + '\n', 'utf8');
164
+ written.push(path.relative(cwd, path.join(outDir, 'report.json')));
165
+ }
166
+ // `emitted` carries the file *specs* (path + contents) even when emit is
167
+ // off — `transtyle diff` re-emits both sides in-memory to compute per-target
168
+ // impact without writing anything. `files` stays the written paths.
169
+ results.push({ target: name, files: written, coverage, emitted: files });
170
+ }
171
+
172
+ return { config, diagnostics, results, normalized };
173
+ }
174
+
175
+ function buildReport(target, targetConfig, coverage, diagnostics, files) {
176
+ const counts = {};
177
+ for (const item of coverage) counts[item.class] = (counts[item.class] ?? 0) + 1;
178
+ return {
179
+ $schema: 'https://transtyle.dev/schemas/report/v0.json',
180
+ target,
181
+ options: targetConfig.options ?? {},
182
+ generatedBy: 'transtyle 0.1.0 (walking skeleton)',
183
+ coverage: { counts, items: coverage },
184
+ diagnostics: diagnostics.items,
185
+ files,
186
+ };
187
+ }
package/src/load.js ADDED
@@ -0,0 +1,136 @@
1
+ /** LOAD stage: config discovery + token file reading (docs/architecture/pipeline.md#1-load). */
2
+
3
+ import { readFile, readdir } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ export async function loadConfig(cwd) {
7
+ const file = path.join(cwd, 'transtyle.config.json');
8
+ let raw;
9
+ try {
10
+ raw = await readFile(file, 'utf8');
11
+ } catch {
12
+ throw new Error(`No transtyle.config.json found in ${cwd}`);
13
+ }
14
+ const config = JSON.parse(raw);
15
+ if (!config.tokens?.length) throw new Error('Config error: "tokens" must list at least one glob.');
16
+ return { config, configPath: file };
17
+ }
18
+
19
+ /** Minimal glob: supports literal paths and single-`*` segments (e.g. "tokens/*.tokens.json"). */
20
+ async function expandGlob(cwd, pattern) {
21
+ if (pattern.includes('**')) throw new Error(`Skeleton glob does not support "**": ${pattern}`);
22
+ const segs = pattern.split('/');
23
+ let paths = [cwd];
24
+ for (const seg of segs) {
25
+ const next = [];
26
+ for (const p of paths) {
27
+ if (seg.includes('*')) {
28
+ const re = new RegExp('^' + seg.split('*').map(escapeRe).join('.*') + '$');
29
+ let entries = [];
30
+ try { entries = await readdir(p, { withFileTypes: true }); } catch { /* missing dir */ }
31
+ for (const e of entries) if (re.test(e.name)) next.push(path.join(p, e.name));
32
+ } else {
33
+ next.push(path.join(p, seg));
34
+ }
35
+ }
36
+ paths = next;
37
+ }
38
+ return paths.sort(); // deterministic order
39
+ }
40
+
41
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
42
+
43
+ /**
44
+ * Token entries are strings (globs) or objects `{ files, mode }` — the latter
45
+ * declares a mode-scoped layer: a pure DTCG file whose values apply to one
46
+ * mode of one dimension (docs/specs/configuration.md#token-layering).
47
+ */
48
+ export async function loadTokenTrees(cwd, entries, diagnostics) {
49
+ const trees = [];
50
+ const seenExtensionNamespaces = new Set(); // compile-wide, so TST1304 fires once per namespace, not once per file
51
+ for (const entry of entries) {
52
+ const globs = typeof entry === 'string' ? [entry] : [].concat(entry.files);
53
+ const modeScope = typeof entry === 'string' ? undefined : entry.mode;
54
+ for (const g of globs) {
55
+ const files = await expandGlob(cwd, g);
56
+ if (files.length === 0)
57
+ diagnostics.warn('TST1001', `Token glob matched no files: ${g}`, {
58
+ // AL5: this is usually the whole story behind every error that
59
+ // follows, so it should be the one that tells you where it looked.
60
+ hint: `Resolved relative to ${cwd}. Check the path in "tokens" in transtyle.config.json.`,
61
+ });
62
+ for (const f of files) {
63
+ try {
64
+ const tree = JSON.parse(await readFile(f, 'utf8'));
65
+ const rel = path.relative(cwd, f);
66
+ validateTokenTree(tree, rel, diagnostics, seenExtensionNamespaces);
67
+ trees.push({ file: rel, tree, modeScope });
68
+ } catch (e) {
69
+ // Relative path (AL5): an absolute one buries the filename that
70
+ // matters at the end of a long, uninformative prefix.
71
+ diagnostics.error('TST1002', `Failed to parse ${path.relative(cwd, f)}: ${e.message}`);
72
+ }
73
+ }
74
+ }
75
+ }
76
+ return trees;
77
+ }
78
+
79
+ // ---------- structural DTCG validation (T10, docs/specs/validation-and-coverage.md) ----------
80
+
81
+ /** The DTCG $type set this IR understands (docs/architecture/ir.md#foundation-dtcg-superset). */
82
+ const DTCG_TYPES = new Set([
83
+ 'color', 'dimension', 'fontFamily', 'fontWeight', 'duration', 'cubicBezier', 'number',
84
+ 'typography', 'shadow', 'border', 'gradient', 'transition', 'strokeStyle',
85
+ ]);
86
+ /** The three-tier token model (docs/architecture/ir.md#the-three-tier-token-model). */
87
+ const TIERS = new Set(['option', 'semantic', 'component']);
88
+ /** Transtyle's own reserved `$extensions` namespaces (proposal 0001 §4.4) — anything else is foreign. */
89
+ const KNOWN_EXTENSION_NAMESPACES = new Set(['transtyle.modes', 'transtyle.role', 'transtyle.state-mechanism']);
90
+
91
+ /**
92
+ * Catches authoring mistakes `collectTokens()`'s permissive walk would
93
+ * otherwise silently swallow: a top-level group outside the three tiers, a
94
+ * node that clearly meant to be a token but has no `$value`, an unrecognized
95
+ * `$type` (still carried, just opaque to derivation), and foreign
96
+ * `$extensions` namespaces (carried through untouched, surfaced once).
97
+ * Runs per loaded file, before merging — `seenNamespaces` is shared across
98
+ * the whole `loadTokenTrees()` call so TST1304 fires once per compile.
99
+ */
100
+ export function validateTokenTree(tree, file, diagnostics, seenNamespaces = new Set()) {
101
+ for (const key of Object.keys(tree)) {
102
+ if (key.startsWith('$')) continue;
103
+ if (!TIERS.has(key)) {
104
+ diagnostics.warn('TST1305', `${file}: top-level group "${key}" is not option/semantic/component`);
105
+ }
106
+ }
107
+ const walk = (node, path_) => {
108
+ if (node === null || typeof node !== 'object' || Array.isArray(node)) return;
109
+ const localType = node.$type;
110
+ if (node.$extensions && typeof node.$extensions === 'object') {
111
+ for (const ns of Object.keys(node.$extensions)) {
112
+ if (!KNOWN_EXTENSION_NAMESPACES.has(ns) && !seenNamespaces.has(ns)) {
113
+ seenNamespaces.add(ns);
114
+ diagnostics.info('TST1304', `${file}: foreign $extensions namespace "${ns}" carried through untouched (not a transtyle namespace)`);
115
+ }
116
+ }
117
+ }
118
+ const hasValue = '$value' in node;
119
+ const childKeys = Object.keys(node).filter((k) => !k.startsWith('$'));
120
+ if (!hasValue && childKeys.length === 0 && localType !== undefined) {
121
+ diagnostics.error('TST1302', `${path_.join('.')}: declares $type "${localType}" but has neither $value nor child tokens`);
122
+ return;
123
+ }
124
+ if (hasValue) {
125
+ if (localType !== undefined && !DTCG_TYPES.has(localType)) {
126
+ diagnostics.warn('TST1306', `${path_.join('.')}: unknown $type "${localType}" — carried through opaque (no type-specific parsing or derivation)`);
127
+ }
128
+ return;
129
+ }
130
+ for (const [key, child] of Object.entries(node)) {
131
+ if (key.startsWith('$')) continue;
132
+ walk(child, [...path_, key]);
133
+ }
134
+ };
135
+ walk(tree, []);
136
+ }
package/src/nearest.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * "Did you mean …?" — shared by config validation (unknown key), target
3
+ * resolution (unknown target instance), and the CLI's `explain` (unknown slot).
4
+ *
5
+ * AL5 rationale: every one of those three errors used to end at "X is not
6
+ * valid" while the code was holding the list of things that ARE valid. A typo
7
+ * is the most common way each is reached, so the suggestion is not a nicety —
8
+ * it is usually the entire fix.
9
+ */
10
+
11
+ /** Classic Levenshtein edit distance. */
12
+ export function levenshtein(a, b) {
13
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
14
+ for (let j = 0; j <= b.length; j++) dp[0][j] = j;
15
+ for (let i = 1; i <= a.length; i++) {
16
+ for (let j = 1; j <= b.length; j++) {
17
+ dp[i][j] = a[i - 1] === b[j - 1]
18
+ ? dp[i - 1][j - 1]
19
+ : 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
20
+ }
21
+ }
22
+ return dp[a.length][b.length];
23
+ }
24
+
25
+ /**
26
+ * The closest candidate to `name`, or null when nothing is close enough.
27
+ * The threshold scales with the name's length (a third of it, at least 1, at
28
+ * most 3) so short names don't match everything and long ones still tolerate a
29
+ * couple of slips — guessing wildly is worse than not guessing.
30
+ */
31
+ export function nearestName(name, candidates) {
32
+ const limit = Math.max(1, Math.min(3, Math.floor(name.length / 3)));
33
+ let best = null;
34
+ for (const c of candidates) {
35
+ const d = levenshtein(name, c);
36
+ if (d <= limit && (!best || d < best.d)) best = { c, d };
37
+ }
38
+ return best?.c ?? null;
39
+ }