@event4u/agent-config 10.3.0 → 11.0.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +147 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze.md +2 -1
- package/dist/agent-src/commands/council/debate.md +13 -4
- package/dist/agent-src/commands/council/default.md +26 -11
- package/dist/agent-src/commands/council.md +1 -1
- package/dist/agent-src/commands/design-system/capture.md +44 -0
- package/dist/agent-src/commands/design-system/generate.md +75 -0
- package/dist/agent-src/commands/design-system/import.md +63 -0
- package/dist/agent-src/commands/design-system.md +66 -0
- package/dist/agent-src/commands/pr/create.md +51 -26
- package/dist/agent-src/commands/worktree/create.md +5 -3
- package/dist/agent-src/rules/settings-ask-protocol.md +14 -9
- package/dist/agent-src/skills/ai-council/SKILL.md +24 -6
- package/dist/agent-src/skills/corpus-grounding/SKILL.md +12 -1
- package/dist/agent-src/skills/corpus-grounding/scripts/decision_engine.ts +215 -7
- package/dist/agent-src/skills/corpus-grounding/scripts/ground.ts +39 -1
- package/dist/agent-src/skills/design-intelligence/ATTRIBUTION.md +19 -2
- package/dist/agent-src/skills/design-intelligence/data/manifest.json +36 -1
- package/dist/agent-src/skills/design-intelligence/data/motion.csv +17 -0
- package/dist/agent-src/skills/design-system-capture/SKILL.md +9 -0
- package/dist/agent-src/skills/design-system-capture/references/design-system-json.md +70 -0
- package/dist/agent-src/skills/git-workflow/SKILL.md +9 -3
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +9 -6
- package/dist/agent-src/skills/using-git-worktrees/SKILL.md +25 -21
- package/dist/agent-src/templates/agent-settings.md +0 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
- package/dist/cli-delegate/{chunk-BV3WR67D.js → chunk-55WZN5VF.js} +3 -6
- package/dist/cli-delegate/{chunk-X3VOARZD.js → chunk-L565YFXH.js} +3 -3
- package/dist/cli-delegate/{chunk-JMQZ7MM5.js → chunk-MNWRDMR5.js} +1 -1
- package/dist/cli-delegate/{chunk-IR3IVRVC.js → chunk-NLP54U3J.js} +2 -1
- package/dist/cli-delegate/{chunk-FV34EGEX.js → chunk-NN4KW4HZ.js} +1 -1
- package/dist/cli-delegate/{chunk-6ENSXSBW.js → chunk-PPQO4NGR.js} +1 -1
- package/dist/cli-delegate/chunk-RLN73PR5.js +157 -0
- package/dist/cli-delegate/{chunk-6LOPHJLD.js → chunk-RYNADUFG.js} +1 -1
- package/dist/cli-delegate/{chunk-I5EAFRII.js → chunk-ZPSXJTLI.js} +2 -154
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +673 -215
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +4 -4
- package/dist/cli-delegate/cmd_handoff.js +10 -8
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +4 -4
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +6 -4
- package/dist/cli-delegate/cmd_settings_get.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +4 -4
- package/dist/cli-delegate/cmd_uninstall.js +4 -4
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +4 -4
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +127 -22
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +9 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +8 -4
- package/dist/hooks/dispatch.js +994 -399
- package/dist/install/install.mjs +5 -7
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1104 -593
- package/dist/scripts/_lib/agent_settings.js +8 -0
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/dist/server/schemas/settings.js +3 -4
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/ui/assets/{index-5fXOHoVh.js → index-B0aMJnuz.js} +2 -2
- package/dist/ui/assets/{index-5fXOHoVh.js.map → index-B0aMJnuz.js.map} +1 -1
- package/dist/ui/index.html +1 -1
- package/docs/contracts/settings-classes.md +12 -9
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
- package/src/config/agent-settings.template.yml +15 -18
- package/src/config/gate-coverage.yml +33 -2
- package/src/config/gate-violation-baselines.json +2 -2
- package/src/config/pack-size-budget.json +3 -2
- package/src/scripts/_lib/agent_settings.ts +8 -0
- package/src/scripts/_lib/design_system_import.ts +987 -0
- package/src/scripts/check_branch_freshness.ts +238 -6
- package/src/scripts/design_system_import.ts +264 -0
- package/src/scripts/external_sources_denylist.json +7 -2
- package/src/scripts/hook_manifest.yaml +71 -2
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/dispatch_hook.ts +10 -0
- package/src/scripts/hooks/host_semantics.ts +2 -0
- package/src/scripts/hooks/spawn_guard_shadow_hook.ts +212 -0
- package/src/scripts/hooks/subagent_ledger_hook.ts +723 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +39 -2
- package/src/scripts/lint_hook_manifest.ts +2 -0
- package/src/scripts/lint_rule_skill_pack_reach.ts +194 -13
- package/src/scripts/session_register_hook.ts +120 -10
- package/src/server/schemas/settings.ts +3 -6
- package/tests/fixtures/hooks/subagent_start.json +7 -0
- package/tests/fixtures/hooks/subagent_stop.json +8 -0
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import adapter for the `design-system.json` consumer contract — three lanes,
|
|
3
|
+
* one shape (road-to-design-system-onramp Phase 1).
|
|
4
|
+
*
|
|
5
|
+
* The contract in `design-system-capture/references/design-system-json.md` was
|
|
6
|
+
* written for an ecosystem of external extraction tools and, until this module,
|
|
7
|
+
* nothing in the tree ever fed it: no script under `src/scripts/` read, parsed,
|
|
8
|
+
* or validated a `design-system.json`. The work-engine only ever asked whether
|
|
9
|
+
* the key was *present*. So this is the missing producer-side half, and it is
|
|
10
|
+
* deliberately the smallest thing that can be one: a **pure, no-network file
|
|
11
|
+
* transform**. It never fetches, never crawls, never launches a browser — the
|
|
12
|
+
* 2026-06-28 lock says the package owns the contract and not the crawler, and a
|
|
13
|
+
* transform that reached the network would quietly become the crawler.
|
|
14
|
+
*
|
|
15
|
+
* Three input lanes:
|
|
16
|
+
*
|
|
17
|
+
* - `native` — an existing `design-system.json`: validated and passed
|
|
18
|
+
* through, so a hand-authored or previously-exported file
|
|
19
|
+
* travels the same code path as an extracted one.
|
|
20
|
+
* - `dtcg` — a W3C Design Tokens file (`{$value, $type}` leaves). Mapped
|
|
21
|
+
* by `$type`, never by path: the type IS the semantic in
|
|
22
|
+
* DTCG, and a foreign file will not use this repo's own
|
|
23
|
+
* `primitive`/`semantic`/`component` layer names.
|
|
24
|
+
* - `dembrandt` — the rich extraction lane, mapped by DOCUMENTED top-level
|
|
25
|
+
* key name with tolerant inner shapes. See the honesty note
|
|
26
|
+
* below; this is the lane most likely to drift.
|
|
27
|
+
*
|
|
28
|
+
* **Why the dembrandt lane is shape-tolerant rather than schema-pinned.** The
|
|
29
|
+
* tool documents its output *categories* (colors, typography, spacing, borders,
|
|
30
|
+
* shadows, motion, components, breakpoints) and names exactly two top-level
|
|
31
|
+
* JSON keys explicitly — `motion` and `wcag`. It publishes no schema for the
|
|
32
|
+
* nested values. Pinning invented field names and coding against them is the
|
|
33
|
+
* precise failure `source-discovery-gate` exists to stop, so this lane matches
|
|
34
|
+
* on the documented key names and then accepts a small set of *shapes* per
|
|
35
|
+
* bucket, routing everything it does not recognise to `_meta.unmapped` with a
|
|
36
|
+
* note. That is the roadmap's own falsifier honoured in code: a value that
|
|
37
|
+
* cannot be mapped without inventing it ships as observation, never as a token.
|
|
38
|
+
*
|
|
39
|
+
* **Provenance is mandatory on every lane.** The contract rejects an artifact
|
|
40
|
+
* with no `source` — you cannot confirm what you cannot trace. A DTCG file
|
|
41
|
+
* never carries provenance by construction, so the caller may supply it; when
|
|
42
|
+
* it does, `source._meta.provenance_origin` records `caller` rather than
|
|
43
|
+
* `input`, so a reader can always tell an extracted provenance from an asserted
|
|
44
|
+
* one. No provenance from either side is a rejection, not a default.
|
|
45
|
+
*
|
|
46
|
+
* Everything this module emits is **observed, not authoritative** (the
|
|
47
|
+
* contract's trust posture). It produces a proposal for per-field human
|
|
48
|
+
* confirmation; it never writes `DESIGN.md` and never resolves a brand conflict.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/** The three documented input lanes. */
|
|
52
|
+
export type Lane = 'native' | 'dtcg' | 'dembrandt';
|
|
53
|
+
|
|
54
|
+
/** Provenance block — mandatory, per the contract's `source` rule. */
|
|
55
|
+
export interface SourceBlock {
|
|
56
|
+
kind: 'url' | 'repo' | 'dir';
|
|
57
|
+
ref: string;
|
|
58
|
+
captured_at: string;
|
|
59
|
+
_meta?: { provenance_origin: 'input' | 'caller' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface FontFamily {
|
|
63
|
+
role: string;
|
|
64
|
+
name: string;
|
|
65
|
+
bundled_local: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ScaleStep {
|
|
69
|
+
step: string;
|
|
70
|
+
size: string;
|
|
71
|
+
lineHeight?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ComponentObservation {
|
|
75
|
+
name: string;
|
|
76
|
+
observed: { classes?: string[]; props?: string[] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The contract shape. Only `source` is mandatory; every other key is optional. */
|
|
80
|
+
export interface DesignSystem {
|
|
81
|
+
source: SourceBlock;
|
|
82
|
+
colors?: { light?: Record<string, string>; dark?: Record<string, string> };
|
|
83
|
+
typography?: { families?: FontFamily[]; scale?: ScaleStep[] };
|
|
84
|
+
spacing?: { base?: string; scale?: string[] };
|
|
85
|
+
radius?: Record<string, string>;
|
|
86
|
+
shadow?: Record<string, string>;
|
|
87
|
+
motion?: {
|
|
88
|
+
durations?: Record<string, string>;
|
|
89
|
+
easings?: Record<string, string>;
|
|
90
|
+
_meta?: Record<string, unknown>;
|
|
91
|
+
};
|
|
92
|
+
components?: ComponentObservation[];
|
|
93
|
+
_meta?: Record<string, unknown>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type ImportOutcome =
|
|
97
|
+
| { ok: true; lane: Lane; design_system: DesignSystem; notes: string[] }
|
|
98
|
+
| { ok: false; lane: Lane | null; reason: string };
|
|
99
|
+
|
|
100
|
+
/** Caller-supplied provenance, used only when the input carries none. */
|
|
101
|
+
export interface ProvenanceOverride {
|
|
102
|
+
kind: 'url' | 'repo' | 'dir';
|
|
103
|
+
ref: string;
|
|
104
|
+
captured_at?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const SOURCE_KINDS = new Set(['url', 'repo', 'dir']);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Top-level keys the extraction tool documents. Matching is by NAME only —
|
|
111
|
+
* the nested shapes are undocumented and are handled tolerantly below.
|
|
112
|
+
*/
|
|
113
|
+
const DEMBRANDT_KEYS = new Set([
|
|
114
|
+
'colors',
|
|
115
|
+
'typography',
|
|
116
|
+
'spacing',
|
|
117
|
+
'borders',
|
|
118
|
+
'shadows',
|
|
119
|
+
'motion',
|
|
120
|
+
'components',
|
|
121
|
+
'breakpoints',
|
|
122
|
+
'wcag',
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
126
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A DTCG leaf is `{$value, …}`; the draft-era `{value, type}` spelling counts too. */
|
|
130
|
+
function isTokenLeaf(v: unknown): v is Record<string, unknown> {
|
|
131
|
+
if (!isPlainObject(v)) return false;
|
|
132
|
+
return '$value' in v || ('value' in v && 'type' in v);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function leafValue(node: Record<string, unknown>): string | null {
|
|
136
|
+
const raw = '$value' in node ? node['$value'] : node['value'];
|
|
137
|
+
if (typeof raw === 'string') return raw;
|
|
138
|
+
if (typeof raw === 'number') return String(raw);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function leafType(node: Record<string, unknown>): string | null {
|
|
143
|
+
const raw = '$type' in node ? node['$type'] : node['type'];
|
|
144
|
+
return typeof raw === 'string' ? raw : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Decide the lane from the input's own shape.
|
|
149
|
+
*
|
|
150
|
+
* Order matters: `native` is checked first because an exported artifact may
|
|
151
|
+
* legitimately contain DTCG-looking sub-objects, and a native file must never
|
|
152
|
+
* be re-mapped through the lossy DTCG path.
|
|
153
|
+
*/
|
|
154
|
+
export function detectLane(input: unknown): Lane | null {
|
|
155
|
+
if (!isPlainObject(input)) return null;
|
|
156
|
+
// The PRESENCE of a `source` block is the native marker, not its validity.
|
|
157
|
+
// Requiring a well-formed one here would route a native artifact with
|
|
158
|
+
// broken provenance into a token lane, which then rejects it with "this
|
|
159
|
+
// format does not carry provenance" — true of that lane and false of the
|
|
160
|
+
// file the user actually has. A malformed `source` must fail as a malformed
|
|
161
|
+
// `source`.
|
|
162
|
+
if (isPlainObject(input['source'])) return 'native';
|
|
163
|
+
if (hasTokenLeaf(input, 0)) return 'dtcg';
|
|
164
|
+
for (const key of Object.keys(input)) {
|
|
165
|
+
if (DEMBRANDT_KEYS.has(key)) return 'dembrandt';
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Depth-bounded search for a DTCG leaf — a token file always has one near the top. */
|
|
171
|
+
function hasTokenLeaf(node: unknown, depth: number): boolean {
|
|
172
|
+
if (depth > 6 || !isPlainObject(node)) return false;
|
|
173
|
+
for (const value of Object.values(node)) {
|
|
174
|
+
if (isTokenLeaf(value)) return true;
|
|
175
|
+
if (hasTokenLeaf(value, depth + 1)) return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Read the `source` block off a native artifact, or explain why it is unusable. */
|
|
181
|
+
function readSource(raw: unknown): { source: SourceBlock } | { error: string } {
|
|
182
|
+
if (!isPlainObject(raw)) return { error: 'source is missing' };
|
|
183
|
+
const kind = raw['kind'];
|
|
184
|
+
const ref = raw['ref'];
|
|
185
|
+
const capturedAt = raw['captured_at'];
|
|
186
|
+
if (typeof kind !== 'string' || !SOURCE_KINDS.has(kind)) {
|
|
187
|
+
return { error: `source.kind must be one of url|repo|dir (got ${JSON.stringify(kind)})` };
|
|
188
|
+
}
|
|
189
|
+
if (typeof ref !== 'string' || ref.trim() === '') {
|
|
190
|
+
return { error: 'source.ref is missing or empty' };
|
|
191
|
+
}
|
|
192
|
+
if (typeof capturedAt !== 'string' || capturedAt.trim() === '') {
|
|
193
|
+
return { error: 'source.captured_at is missing or empty' };
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
source: {
|
|
197
|
+
kind: kind as SourceBlock['kind'],
|
|
198
|
+
ref,
|
|
199
|
+
captured_at: capturedAt,
|
|
200
|
+
_meta: { provenance_origin: 'input' },
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function sourceFromOverride(p: ProvenanceOverride): SourceBlock {
|
|
206
|
+
return {
|
|
207
|
+
kind: p.kind,
|
|
208
|
+
ref: p.ref,
|
|
209
|
+
// A capture time the caller did not state is not invented: the field is
|
|
210
|
+
// mandatory, so an absent one is recorded as unknown rather than as now.
|
|
211
|
+
captured_at: p.captured_at ?? 'unknown',
|
|
212
|
+
_meta: { provenance_origin: 'caller' },
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* String→string maps survive; anything else is reported rather than coerced.
|
|
218
|
+
*
|
|
219
|
+
* `dropped` collects the keys this helper could not take. Without it the helper
|
|
220
|
+
* returned a PARTIAL map and looked like a success: because the result was
|
|
221
|
+
* non-null, the caller's `else` branch — the one that routes an unrecognised
|
|
222
|
+
* value into `_meta` — never ran, so a nested subtree vanished with an empty
|
|
223
|
+
* note list. Reporting the loss is what lets every caller keep the module's own
|
|
224
|
+
* promise that nothing is dropped in silence.
|
|
225
|
+
*/
|
|
226
|
+
function stringMap(value: unknown, dropped?: string[]): Record<string, string> | null {
|
|
227
|
+
if (!isPlainObject(value)) return null;
|
|
228
|
+
const out: Record<string, string> = {};
|
|
229
|
+
for (const [k, v] of Object.entries(value)) {
|
|
230
|
+
if (typeof v === 'string') out[k] = v;
|
|
231
|
+
else if (typeof v === 'number') out[k] = String(v);
|
|
232
|
+
else dropped?.push(k);
|
|
233
|
+
}
|
|
234
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** As `stringMap`, for arrays; `dropped` collects the indices that did not fit. */
|
|
238
|
+
function stringList(value: unknown, dropped?: string[]): string[] | null {
|
|
239
|
+
if (!Array.isArray(value)) return null;
|
|
240
|
+
const out: string[] = [];
|
|
241
|
+
value.forEach((v, i) => {
|
|
242
|
+
if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
|
|
243
|
+
else dropped?.push(String(i));
|
|
244
|
+
});
|
|
245
|
+
return out.length > 0 ? out : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- lane: native ------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Structural expectation per contract key — `object` covers both the role-maps
|
|
252
|
+
* (`radius`, `shadow`) and the nested blocks (`colors`, `typography`, …).
|
|
253
|
+
*/
|
|
254
|
+
const NATIVE_KEY_SHAPE: Record<string, 'object' | 'array'> = {
|
|
255
|
+
colors: 'object',
|
|
256
|
+
typography: 'object',
|
|
257
|
+
spacing: 'object',
|
|
258
|
+
radius: 'object',
|
|
259
|
+
shadow: 'object',
|
|
260
|
+
motion: 'object',
|
|
261
|
+
components: 'array',
|
|
262
|
+
_meta: 'object',
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Validate against the contract's field rules, then pass through.
|
|
267
|
+
*
|
|
268
|
+
* The lane deliberately does NOT rewrite values: a native artifact has already
|
|
269
|
+
* been through somebody's per-field confirmation, and silently normalising it
|
|
270
|
+
* here would make two runs over the same file disagree.
|
|
271
|
+
*
|
|
272
|
+
* Validation is therefore **report-only** past the mandatory `source`. A key
|
|
273
|
+
* whose shape contradicts the contract is kept and flagged, not dropped: the
|
|
274
|
+
* import is a proposal a human reads, and a value they can see and reject is
|
|
275
|
+
* strictly better than one this module deleted on their behalf. Only missing
|
|
276
|
+
* provenance is fatal, because that is the one rule the contract itself states
|
|
277
|
+
* as a rejection.
|
|
278
|
+
*/
|
|
279
|
+
function mapNative(input: Record<string, unknown>): ImportOutcome {
|
|
280
|
+
const src = readSource(input['source']);
|
|
281
|
+
if ('error' in src) {
|
|
282
|
+
return { ok: false, lane: 'native', reason: `native lane rejected — ${src.error}` };
|
|
283
|
+
}
|
|
284
|
+
const notes: string[] = [];
|
|
285
|
+
const carried: Record<string, unknown> = {};
|
|
286
|
+
const unmapped: Record<string, unknown> = {};
|
|
287
|
+
|
|
288
|
+
for (const [key, value] of Object.entries(input)) {
|
|
289
|
+
if (key === 'source') continue;
|
|
290
|
+
const expected = NATIVE_KEY_SHAPE[key];
|
|
291
|
+
if (expected === undefined) {
|
|
292
|
+
notes.push(`unknown top-level key "${key}" is not in the contract — kept under _meta.unmapped`);
|
|
293
|
+
unmapped[key] = value;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const shapeOk = expected === 'array' ? Array.isArray(value) : isPlainObject(value);
|
|
297
|
+
if (!shapeOk) {
|
|
298
|
+
notes.push(`"${key}" should be ${expected === 'array' ? 'an array' : 'an object'} — kept as-is and flagged for the human`);
|
|
299
|
+
}
|
|
300
|
+
carried[key] = value;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// `bundled_local` is a flag the extractor sets, never an instruction to
|
|
304
|
+
// this package — a non-boolean there means the producer misread the
|
|
305
|
+
// contract, and the human should see that rather than a coerced `false`.
|
|
306
|
+
const typography = carried['typography'];
|
|
307
|
+
if (isPlainObject(typography) && Array.isArray(typography['families'])) {
|
|
308
|
+
for (const fam of typography['families']) {
|
|
309
|
+
if (isPlainObject(fam) && 'bundled_local' in fam && typeof fam['bundled_local'] !== 'boolean') {
|
|
310
|
+
notes.push('typography.families[].bundled_local is not a boolean — the contract makes it a flag, not a value');
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (Object.keys(unmapped).length > 0) {
|
|
317
|
+
const existingMeta = isPlainObject(carried['_meta']) ? carried['_meta'] : {};
|
|
318
|
+
// Merge INTO any existing `_meta.unmapped` rather than replacing it.
|
|
319
|
+
// Overwriting the key loses entries a previous pass recorded — reachable
|
|
320
|
+
// by re-importing this adapter's own output — and losing carried values
|
|
321
|
+
// is precisely what the native lane exists not to do.
|
|
322
|
+
const existingUnmapped = isPlainObject(existingMeta['unmapped']) ? existingMeta['unmapped'] : {};
|
|
323
|
+
carried['_meta'] = { ...existingMeta, unmapped: { ...existingUnmapped, ...unmapped } };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// The cast is the lane's contract with itself: `source` is validated above,
|
|
327
|
+
// every other key is carried verbatim and its shape reported rather than
|
|
328
|
+
// enforced, so the type is an assertion about provenance only.
|
|
329
|
+
const out = { source: src.source, ...carried } as DesignSystem;
|
|
330
|
+
return { ok: true, lane: 'native', design_system: out, notes };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// --- lane: dtcg --------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Bucket a DTCG leaf by its `$type`.
|
|
337
|
+
*
|
|
338
|
+
* `dimension` is genuinely ambiguous — DTCG uses it for spacing, radius and
|
|
339
|
+
* font size alike — so the PATH breaks the tie, and only for that one type.
|
|
340
|
+
* Everywhere else the type decides, which is what keeps this lane independent
|
|
341
|
+
* of any particular authoring tool's group names.
|
|
342
|
+
*/
|
|
343
|
+
function dtcgBucket(type: string | null, path: string[]): string | null {
|
|
344
|
+
const joined = path.join('.').toLowerCase();
|
|
345
|
+
switch (type) {
|
|
346
|
+
case 'color':
|
|
347
|
+
return 'color';
|
|
348
|
+
case 'fontFamily':
|
|
349
|
+
return 'fontFamily';
|
|
350
|
+
case 'shadow':
|
|
351
|
+
return 'shadow';
|
|
352
|
+
case 'duration':
|
|
353
|
+
return 'duration';
|
|
354
|
+
case 'cubicBezier':
|
|
355
|
+
return 'easing';
|
|
356
|
+
case 'dimension':
|
|
357
|
+
case 'number':
|
|
358
|
+
if (joined.includes('radius') || joined.includes('rounded')) return 'radius';
|
|
359
|
+
if (joined.includes('fontsize') || joined.includes('font-size')) return 'fontSize';
|
|
360
|
+
if (joined.includes('spacing') || joined.includes('space')) return 'spacing';
|
|
361
|
+
return null;
|
|
362
|
+
default:
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Segments that are never a role on their own — they name the bucket, not the value. */
|
|
368
|
+
const NON_ROLE_SEGMENTS = new Set([
|
|
369
|
+
'color',
|
|
370
|
+
'colors',
|
|
371
|
+
'spacing',
|
|
372
|
+
'radius',
|
|
373
|
+
'shadow',
|
|
374
|
+
'duration',
|
|
375
|
+
'easing',
|
|
376
|
+
'fontsize',
|
|
377
|
+
'fontfamily',
|
|
378
|
+
'default',
|
|
379
|
+
]);
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Derive a role name from a token's path.
|
|
383
|
+
*
|
|
384
|
+
* The last segment alone is wrong for the two commonest DTCG layouts, and both
|
|
385
|
+
* failures are silent overwrites rather than errors: a palette (`color.gray.50`
|
|
386
|
+
* and `color.blue.50` both reduce to `50`) and a bucket-named leaf
|
|
387
|
+
* (`button.radius` and `card.radius` both reduce to `radius`). So a numeric or
|
|
388
|
+
* bucket-naming final segment takes its parent with it — `gray-50`,
|
|
389
|
+
* `button-radius` — and every other leaf keeps its own name.
|
|
390
|
+
*/
|
|
391
|
+
export function roleName(path: string[]): string {
|
|
392
|
+
// A `default` leaf names its parent, so it is dropped BEFORE anything is
|
|
393
|
+
// joined. Joining first and stripping the suffix afterwards takes the
|
|
394
|
+
// *bucket* as the parent on a three-segment path, so `button.radius.default`
|
|
395
|
+
// came out as `radius` — a role named after its bucket, which then collided
|
|
396
|
+
// with every other component's radius.
|
|
397
|
+
const withoutDefault =
|
|
398
|
+
path.length > 1 && /^default$/i.test(path[path.length - 1] as string)
|
|
399
|
+
? path.slice(0, -1)
|
|
400
|
+
: path;
|
|
401
|
+
const last = withoutDefault[withoutDefault.length - 1] ?? 'value';
|
|
402
|
+
if (withoutDefault.length < 2) return last;
|
|
403
|
+
const parent = withoutDefault[withoutDefault.length - 2] as string;
|
|
404
|
+
if (/^\d+$/.test(last) || NON_ROLE_SEGMENTS.has(last.toLowerCase())) {
|
|
405
|
+
return `${parent}-${last}`;
|
|
406
|
+
}
|
|
407
|
+
return last;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Write a token into a bucket without ever overwriting a different value.
|
|
412
|
+
*
|
|
413
|
+
* `roleName` shortens a path to something a human can read, and any shortening
|
|
414
|
+
* can collide: `semantic.color.background` and `component.card.background` both
|
|
415
|
+
* reduce to `background`. Letting the second write win loses the first token
|
|
416
|
+
* while the import still reports success with an empty note list — the exact
|
|
417
|
+
* "quietly loses a block, looks like a successful run" failure this adapter's
|
|
418
|
+
* own risk register names.
|
|
419
|
+
*
|
|
420
|
+
* So a collision widens the name with more of the token's own path until it is
|
|
421
|
+
* free, and says so. Re-stating the SAME value under a shortened name is not a
|
|
422
|
+
* collision and stays silent — that is one decision expressed twice, not two
|
|
423
|
+
* decisions competing.
|
|
424
|
+
*/
|
|
425
|
+
function assignRole(
|
|
426
|
+
target: Record<string, string>,
|
|
427
|
+
role: string,
|
|
428
|
+
value: string,
|
|
429
|
+
path: string[],
|
|
430
|
+
notes: string[],
|
|
431
|
+
): void {
|
|
432
|
+
if (target[role] === undefined) {
|
|
433
|
+
target[role] = value;
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (target[role] === value) return;
|
|
437
|
+
for (let take = 2; take <= path.length; take++) {
|
|
438
|
+
const candidate = path.slice(path.length - take).join('-');
|
|
439
|
+
if (target[candidate] === value) return;
|
|
440
|
+
if (target[candidate] === undefined) {
|
|
441
|
+
target[candidate] = value;
|
|
442
|
+
notes.push(
|
|
443
|
+
`role "${role}" was already held by a different value — ${path.join('.')} is filed as "${candidate}" instead of overwriting it`,
|
|
444
|
+
);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// Unreachable for well-formed JSON: the full path is unique per leaf.
|
|
449
|
+
notes.push(`${path.join('.')} could not be given a free role name and was dropped`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Resolve a DTCG alias — `{primitive.color.gray.50}` — to the value it points at.
|
|
454
|
+
*
|
|
455
|
+
* Without this the contract receives a literal brace string where a colour
|
|
456
|
+
* belongs, and every consumer downstream (`DESIGN.md`, CSS vars, Tailwind)
|
|
457
|
+
* inherits an unusable token that still *looks* like a successful import. That
|
|
458
|
+
* is the quiet failure the fixtures exist to catch, so it is resolved here
|
|
459
|
+
* rather than left to the reader.
|
|
460
|
+
*
|
|
461
|
+
* Chains are followed, with a depth bound: a token file may legitimately alias
|
|
462
|
+
* an alias, and may also — through an authoring mistake — alias itself. An
|
|
463
|
+
* unresolvable reference is returned unchanged so the human sees the reference
|
|
464
|
+
* that broke, never a silently emptied field.
|
|
465
|
+
*/
|
|
466
|
+
export function resolveAlias(raw: string, root: Record<string, unknown>, depth = 0): string {
|
|
467
|
+
const match = /^\{([^}]+)\}$/.exec(raw.trim());
|
|
468
|
+
if (match === null) return raw;
|
|
469
|
+
if (depth > 8) return raw;
|
|
470
|
+
let node: unknown = root;
|
|
471
|
+
for (const segment of (match[1] as string).split('.')) {
|
|
472
|
+
if (!isPlainObject(node)) return raw;
|
|
473
|
+
node = node[segment];
|
|
474
|
+
}
|
|
475
|
+
if (!isTokenLeaf(node)) return raw;
|
|
476
|
+
const value = leafValue(node);
|
|
477
|
+
if (value === null) return raw;
|
|
478
|
+
return resolveAlias(value, root, depth + 1);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function mapDtcg(input: Record<string, unknown>, source: SourceBlock): ImportOutcome {
|
|
482
|
+
const notes: string[] = [];
|
|
483
|
+
let unresolvedAliases = 0;
|
|
484
|
+
const colorsLight: Record<string, string> = {};
|
|
485
|
+
const colorsDark: Record<string, string> = {};
|
|
486
|
+
const radius: Record<string, string> = {};
|
|
487
|
+
const shadow: Record<string, string> = {};
|
|
488
|
+
const durations: Record<string, string> = {};
|
|
489
|
+
const easings: Record<string, string> = {};
|
|
490
|
+
const spacingScale: string[] = [];
|
|
491
|
+
const families: FontFamily[] = [];
|
|
492
|
+
const fontSizes: ScaleStep[] = [];
|
|
493
|
+
const unmapped: Record<string, string> = {};
|
|
494
|
+
let darkByPath = 0;
|
|
495
|
+
|
|
496
|
+
const walk = (node: unknown, path: string[]): void => {
|
|
497
|
+
if (path.length > 8 || !isPlainObject(node)) return;
|
|
498
|
+
for (const [key, value] of Object.entries(node)) {
|
|
499
|
+
if (key.startsWith('$')) continue; // group metadata, never a token
|
|
500
|
+
const next = [...path, key];
|
|
501
|
+
if (isTokenLeaf(value)) {
|
|
502
|
+
const literal = leafValue(value);
|
|
503
|
+
if (literal === null) {
|
|
504
|
+
unmapped[next.join('.')] = '<non-scalar $value>';
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const raw = resolveAlias(literal, input);
|
|
508
|
+
if (raw === literal && /^\{[^}]+\}$/.test(literal.trim())) {
|
|
509
|
+
unresolvedAliases += 1;
|
|
510
|
+
}
|
|
511
|
+
const bucket = dtcgBucket(leafType(value), next);
|
|
512
|
+
const role = roleName(next);
|
|
513
|
+
// A path segment named `dark` is read as the dark THEME. That
|
|
514
|
+
// is an inference, and a fallible one — `text.dark` and
|
|
515
|
+
// `neutral.dark` are shades, not themes — so it is counted and
|
|
516
|
+
// surfaced rather than applied invisibly.
|
|
517
|
+
const isDark = next.some((p) => p.toLowerCase() === 'dark');
|
|
518
|
+
switch (bucket) {
|
|
519
|
+
case 'color':
|
|
520
|
+
if (isDark) {
|
|
521
|
+
darkByPath += 1;
|
|
522
|
+
assignRole(colorsDark, role, raw, next, notes);
|
|
523
|
+
} else {
|
|
524
|
+
assignRole(colorsLight, role, raw, next, notes);
|
|
525
|
+
}
|
|
526
|
+
break;
|
|
527
|
+
case 'radius':
|
|
528
|
+
assignRole(radius, role, raw, next, notes);
|
|
529
|
+
break;
|
|
530
|
+
case 'shadow':
|
|
531
|
+
assignRole(shadow, role, raw, next, notes);
|
|
532
|
+
break;
|
|
533
|
+
case 'duration':
|
|
534
|
+
assignRole(durations, role, raw, next, notes);
|
|
535
|
+
break;
|
|
536
|
+
case 'easing':
|
|
537
|
+
assignRole(easings, role, raw, next, notes);
|
|
538
|
+
break;
|
|
539
|
+
case 'spacing':
|
|
540
|
+
spacingScale.push(raw);
|
|
541
|
+
break;
|
|
542
|
+
case 'fontSize':
|
|
543
|
+
fontSizes.push({ step: role, size: raw });
|
|
544
|
+
break;
|
|
545
|
+
case 'fontFamily':
|
|
546
|
+
families.push({ role, name: raw, bundled_local: false });
|
|
547
|
+
break;
|
|
548
|
+
default:
|
|
549
|
+
unmapped[next.join('.')] = raw;
|
|
550
|
+
}
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
walk(value, next);
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
walk(input, []);
|
|
557
|
+
|
|
558
|
+
const out: DesignSystem = { source };
|
|
559
|
+
if (Object.keys(colorsLight).length > 0 || Object.keys(colorsDark).length > 0) {
|
|
560
|
+
out.colors = {};
|
|
561
|
+
if (Object.keys(colorsLight).length > 0) out.colors.light = colorsLight;
|
|
562
|
+
if (Object.keys(colorsDark).length > 0) out.colors.dark = colorsDark;
|
|
563
|
+
}
|
|
564
|
+
if (families.length > 0 || fontSizes.length > 0) {
|
|
565
|
+
out.typography = {};
|
|
566
|
+
if (families.length > 0) out.typography.families = families;
|
|
567
|
+
if (fontSizes.length > 0) out.typography.scale = fontSizes;
|
|
568
|
+
}
|
|
569
|
+
if (spacingScale.length > 0) out.spacing = { scale: spacingScale };
|
|
570
|
+
if (Object.keys(radius).length > 0) out.radius = radius;
|
|
571
|
+
if (Object.keys(shadow).length > 0) out.shadow = shadow;
|
|
572
|
+
if (Object.keys(durations).length > 0 || Object.keys(easings).length > 0) {
|
|
573
|
+
out.motion = {};
|
|
574
|
+
if (Object.keys(durations).length > 0) out.motion.durations = durations;
|
|
575
|
+
if (Object.keys(easings).length > 0) out.motion.easings = easings;
|
|
576
|
+
}
|
|
577
|
+
if (Object.keys(unmapped).length > 0) {
|
|
578
|
+
out._meta = { unmapped };
|
|
579
|
+
notes.push(
|
|
580
|
+
`${Object.keys(unmapped).length} DTCG token(s) had no contract bucket — kept as observation under _meta.unmapped`,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
if (darkByPath > 0) {
|
|
584
|
+
notes.push(
|
|
585
|
+
`${darkByPath} colour(s) were filed under the dark theme because a path segment is named "dark" — the format states no colour scheme, so verify these are theme values and not shades named dark`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
if (unresolvedAliases > 0) {
|
|
589
|
+
notes.push(
|
|
590
|
+
`${unresolvedAliases} DTCG alias reference(s) point at a token this file does not define — the reference is kept verbatim so the broken pointer is visible, not blanked`,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
// `bundled_local: false` is a reading, not a claim about the source site:
|
|
594
|
+
// DTCG carries no font-hosting information at all, so the flag records
|
|
595
|
+
// "not stated" and the note says so rather than letting a default pass
|
|
596
|
+
// for an observation.
|
|
597
|
+
if (families.length > 0) {
|
|
598
|
+
notes.push(
|
|
599
|
+
'DTCG carries no font-hosting information — typography.families[].bundled_local is false because the format cannot state it, not because the source self-hosts nothing',
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
return { ok: true, lane: 'dtcg', design_system: out, notes };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// --- lane: dembrandt ---------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Map the rich extraction lane by documented key name, tolerantly per bucket.
|
|
609
|
+
*
|
|
610
|
+
* Every branch has the same contract with the reader: recognise a shape and map
|
|
611
|
+
* it, or record it under `_meta` and say so in a note. Nothing is coerced into
|
|
612
|
+
* a token, and nothing is dropped in silence.
|
|
613
|
+
*/
|
|
614
|
+
function mapDembrandt(input: Record<string, unknown>, source: SourceBlock): ImportOutcome {
|
|
615
|
+
const notes: string[] = [];
|
|
616
|
+
const meta: Record<string, unknown> = {};
|
|
617
|
+
const out: DesignSystem = { source };
|
|
618
|
+
|
|
619
|
+
/** Route entries a helper could not take into `_meta`, and say so. */
|
|
620
|
+
const reportDropped = (bucket: string, dropped: string[], source_: unknown): void => {
|
|
621
|
+
if (dropped.length === 0) return;
|
|
622
|
+
meta[bucket] = source_;
|
|
623
|
+
notes.push(
|
|
624
|
+
`${bucket}: ${dropped.length} entry/entries (${dropped.slice(0, 5).join(', ')}${dropped.length > 5 ? ', …' : ''}) are not scalar values — the whole block is kept as observation under _meta.${bucket} so nothing is lost`,
|
|
625
|
+
);
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
const colors = input['colors'];
|
|
629
|
+
if (colors !== undefined) {
|
|
630
|
+
const nested = isPlainObject(colors) ? colors : undefined;
|
|
631
|
+
// Each candidate gets its OWN dropped list, and only the one actually
|
|
632
|
+
// used is reported. Sharing a list across the probes would count a
|
|
633
|
+
// legitimate nested grouping (`colors.semantic` + `colors.dark`) as a
|
|
634
|
+
// loss, because the flat-map probe necessarily rejects both.
|
|
635
|
+
const flatDropped: string[] = [];
|
|
636
|
+
const lightDropped: string[] = [];
|
|
637
|
+
const semanticDropped: string[] = [];
|
|
638
|
+
const darkDropped: string[] = [];
|
|
639
|
+
const flat = stringMap(colors, flatDropped);
|
|
640
|
+
const explicitLight = flat ?? stringMap(nested?.['light'], lightDropped);
|
|
641
|
+
const semantic =
|
|
642
|
+
explicitLight === null ? stringMap(nested?.['semantic'], semanticDropped) : null;
|
|
643
|
+
const light = explicitLight ?? semantic;
|
|
644
|
+
const dark = stringMap(nested?.['dark'], darkDropped);
|
|
645
|
+
const usedDropped = flat ? flatDropped : semantic ? semanticDropped : lightDropped;
|
|
646
|
+
reportDropped('colors', [...usedDropped, ...darkDropped], colors);
|
|
647
|
+
if (light || dark) {
|
|
648
|
+
out.colors = {};
|
|
649
|
+
if (light) out.colors.light = light;
|
|
650
|
+
if (dark) out.colors.dark = dark;
|
|
651
|
+
} else {
|
|
652
|
+
meta['colors'] = colors;
|
|
653
|
+
notes.push('colors present but not a role→value map — kept as observation under _meta.colors');
|
|
654
|
+
}
|
|
655
|
+
if (semantic !== null) {
|
|
656
|
+
// Naming the semantic role map "light" is an INFERENCE, not a
|
|
657
|
+
// reading: the extractor groups by semantic role and says nothing
|
|
658
|
+
// about colour scheme. It is the right default when a separate
|
|
659
|
+
// dark map exists, and it stays a stated assumption either way —
|
|
660
|
+
// the human confirms these per field.
|
|
661
|
+
notes.push(
|
|
662
|
+
'colors.light was taken from the extractor\'s semantic role map — the source states roles, not colour scheme, so verify this is the light theme',
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const typography = input['typography'];
|
|
668
|
+
if (isPlainObject(typography)) {
|
|
669
|
+
const fams = typography['families'] ?? typography['fonts'];
|
|
670
|
+
const built: FontFamily[] = [];
|
|
671
|
+
if (Array.isArray(fams)) {
|
|
672
|
+
for (const entry of fams) {
|
|
673
|
+
if (typeof entry === 'string') {
|
|
674
|
+
built.push({ role: 'body', name: entry, bundled_local: false });
|
|
675
|
+
} else if (isPlainObject(entry)) {
|
|
676
|
+
const name = entry['name'] ?? entry['family'];
|
|
677
|
+
if (typeof name === 'string') {
|
|
678
|
+
const role = typeof entry['role'] === 'string' ? entry['role'] : 'body';
|
|
679
|
+
built.push({ role, name, bundled_local: entry['bundled_local'] === true });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (built.length > 0) out.typography = { families: built };
|
|
685
|
+
const scale = typography['scale'] ?? typography['sizes'];
|
|
686
|
+
if (Array.isArray(scale)) {
|
|
687
|
+
const steps: ScaleStep[] = [];
|
|
688
|
+
for (const entry of scale) {
|
|
689
|
+
if (isPlainObject(entry) && typeof entry['step'] === 'string') {
|
|
690
|
+
const size = entry['size'];
|
|
691
|
+
if (typeof size === 'string' || typeof size === 'number') {
|
|
692
|
+
const step: ScaleStep = { step: entry['step'], size: String(size) };
|
|
693
|
+
const lh = entry['lineHeight'];
|
|
694
|
+
if (typeof lh === 'string' || typeof lh === 'number') step.lineHeight = String(lh);
|
|
695
|
+
steps.push(step);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (steps.length > 0) {
|
|
700
|
+
out.typography = out.typography ?? {};
|
|
701
|
+
out.typography.scale = steps;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (out.typography === undefined) {
|
|
705
|
+
meta['typography'] = typography;
|
|
706
|
+
notes.push('typography present in an unrecognised shape — kept as observation under _meta.typography');
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const spacing = input['spacing'];
|
|
711
|
+
if (spacing !== undefined) {
|
|
712
|
+
const spacingDropped: string[] = [];
|
|
713
|
+
const scale =
|
|
714
|
+
stringList(spacing, spacingDropped) ??
|
|
715
|
+
stringList(isPlainObject(spacing) ? spacing['scale'] : undefined, spacingDropped);
|
|
716
|
+
reportDropped('spacing', spacingDropped, spacing);
|
|
717
|
+
const base = isPlainObject(spacing) ? spacing['base'] : undefined;
|
|
718
|
+
if (scale || typeof base === 'string') {
|
|
719
|
+
out.spacing = {};
|
|
720
|
+
if (typeof base === 'string') out.spacing.base = base;
|
|
721
|
+
if (scale) out.spacing.scale = scale;
|
|
722
|
+
} else {
|
|
723
|
+
meta['spacing'] = spacing;
|
|
724
|
+
notes.push('spacing present but neither a scale list nor a base — kept as observation under _meta.spacing');
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// The tool documents "Borders (radius, widths, styles, colors)"; the
|
|
729
|
+
// contract has a `radius` key and no home for the other three, so only the
|
|
730
|
+
// radius half maps and the rest is stated as observation.
|
|
731
|
+
const borders = input['borders'];
|
|
732
|
+
if (borders !== undefined) {
|
|
733
|
+
const bordersObj = isPlainObject(borders) ? borders : undefined;
|
|
734
|
+
const hasRadiusKey = bordersObj !== undefined && 'radius' in bordersObj;
|
|
735
|
+
const borderDropped: string[] = [];
|
|
736
|
+
// The whole-object fallback is reachable ONLY when there is no `radius`
|
|
737
|
+
// key at all. Previously it also fired when `borders.radius` was present
|
|
738
|
+
// but not a string map (a list, say), and then emitted border *widths
|
|
739
|
+
// and styles* as radius tokens while dropping the real radius data —
|
|
740
|
+
// coercion plus silent loss, the two things this lane promises not to do.
|
|
741
|
+
const radius = hasRadiusKey
|
|
742
|
+
? stringMap(bordersObj['radius'], borderDropped)
|
|
743
|
+
: stringMap(borders, borderDropped);
|
|
744
|
+
if (radius) out.radius = radius;
|
|
745
|
+
else if (hasRadiusKey) {
|
|
746
|
+
meta['borders'] = borders;
|
|
747
|
+
notes.push(
|
|
748
|
+
'borders.radius is present but not a role→value map — the whole borders block is kept as observation under _meta.borders rather than mapping the other border fields as radius',
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
if (radius && borderDropped.length > 0) {
|
|
752
|
+
notes.push(
|
|
753
|
+
`borders.radius: ${borderDropped.length} non-scalar entry/entries were not mapped — see _meta.borders`,
|
|
754
|
+
);
|
|
755
|
+
meta['borders'] = borders;
|
|
756
|
+
}
|
|
757
|
+
if (isPlainObject(borders) && meta['borders'] === undefined) {
|
|
758
|
+
const rest: Record<string, unknown> = {};
|
|
759
|
+
for (const [k, v] of Object.entries(borders)) {
|
|
760
|
+
if (k !== 'radius') rest[k] = v;
|
|
761
|
+
}
|
|
762
|
+
if (Object.keys(rest).length > 0) {
|
|
763
|
+
meta['borders'] = rest;
|
|
764
|
+
notes.push(
|
|
765
|
+
'border widths/styles/colors have no key in the contract — kept as observation under _meta.borders',
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const shadows = input['shadows'];
|
|
772
|
+
if (shadows !== undefined) {
|
|
773
|
+
const shadowDropped: string[] = [];
|
|
774
|
+
const mapped = stringMap(shadows, shadowDropped);
|
|
775
|
+
if (mapped) {
|
|
776
|
+
out.shadow = mapped;
|
|
777
|
+
reportDropped('shadows', shadowDropped, shadows);
|
|
778
|
+
} else {
|
|
779
|
+
const list = stringList(shadows, shadowDropped);
|
|
780
|
+
if (list) {
|
|
781
|
+
const asMap: Record<string, string> = {};
|
|
782
|
+
list.forEach((v, i) => {
|
|
783
|
+
asMap[`shadow-${i + 1}`] = v;
|
|
784
|
+
});
|
|
785
|
+
out.shadow = asMap;
|
|
786
|
+
notes.push('shadows arrived as an unnamed list — roles are positional (shadow-1, shadow-2, …)');
|
|
787
|
+
} else {
|
|
788
|
+
meta['shadows'] = shadows;
|
|
789
|
+
notes.push('shadows present in an unrecognised shape — kept as observation under _meta.shadows');
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const motion = input['motion'];
|
|
795
|
+
if (isPlainObject(motion)) {
|
|
796
|
+
const durations =
|
|
797
|
+
stringMap(motion['durations']) ??
|
|
798
|
+
(() => {
|
|
799
|
+
const list = stringList(motion['durations'] ?? motion['durationScale']);
|
|
800
|
+
if (!list) return null;
|
|
801
|
+
const asMap: Record<string, string> = {};
|
|
802
|
+
list.forEach((v, i) => {
|
|
803
|
+
asMap[`step-${i + 1}`] = v;
|
|
804
|
+
});
|
|
805
|
+
return asMap;
|
|
806
|
+
})();
|
|
807
|
+
const easings =
|
|
808
|
+
stringMap(motion['easings']) ??
|
|
809
|
+
(() => {
|
|
810
|
+
const raw = motion['easings'] ?? motion['easing'];
|
|
811
|
+
if (!Array.isArray(raw)) return null;
|
|
812
|
+
const asMap: Record<string, string> = {};
|
|
813
|
+
for (const entry of raw) {
|
|
814
|
+
if (isPlainObject(entry)) {
|
|
815
|
+
const name = entry['name'] ?? entry['type'];
|
|
816
|
+
const value = entry['value'] ?? entry['curve'];
|
|
817
|
+
if (typeof name === 'string' && typeof value === 'string') asMap[name] = value;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return Object.keys(asMap).length > 0 ? asMap : null;
|
|
821
|
+
})();
|
|
822
|
+
if (durations || easings) {
|
|
823
|
+
out.motion = {};
|
|
824
|
+
if (durations) out.motion.durations = durations;
|
|
825
|
+
if (easings) out.motion.easings = easings;
|
|
826
|
+
}
|
|
827
|
+
// Per-context profiles and hover deltas are behaviour observations, not
|
|
828
|
+
// token decisions — the contract's own `motion._meta` is where they go.
|
|
829
|
+
const observed: Record<string, unknown> = {};
|
|
830
|
+
for (const key of ['contexts', 'profiles', 'hover', 'hoverDeltas', 'detected_libs']) {
|
|
831
|
+
if (motion[key] !== undefined) observed[key] = motion[key];
|
|
832
|
+
}
|
|
833
|
+
// Anything else under `motion` is residue: neither a token this lane
|
|
834
|
+
// maps nor a documented observation. It used to vanish, and the
|
|
835
|
+
// unrecognised-shape guard could not catch it because `out.motion` was
|
|
836
|
+
// already defined by whatever DID map. Keep it beside the observations.
|
|
837
|
+
const OBSERVED_KEYS = new Set(['contexts', 'profiles', 'hover', 'hoverDeltas', 'detected_libs']);
|
|
838
|
+
// A key counts as consumed only when ITS OWN bucket mapped. Testing
|
|
839
|
+
// "durations || easings" lets an unreadable `easings` shape disappear
|
|
840
|
+
// whenever `durations` happened to succeed — the same silent loss one
|
|
841
|
+
// level down.
|
|
842
|
+
const consumed = new Set<string>();
|
|
843
|
+
if (durations) {
|
|
844
|
+
consumed.add('durations');
|
|
845
|
+
consumed.add('durationScale');
|
|
846
|
+
}
|
|
847
|
+
if (easings) {
|
|
848
|
+
consumed.add('easings');
|
|
849
|
+
consumed.add('easing');
|
|
850
|
+
}
|
|
851
|
+
const residue: Record<string, unknown> = {};
|
|
852
|
+
for (const [key, value] of Object.entries(motion)) {
|
|
853
|
+
if (OBSERVED_KEYS.has(key) || consumed.has(key)) continue;
|
|
854
|
+
residue[key] = value;
|
|
855
|
+
}
|
|
856
|
+
if (Object.keys(observed).length > 0 || Object.keys(residue).length > 0) {
|
|
857
|
+
out.motion = out.motion ?? {};
|
|
858
|
+
out.motion._meta = { ...observed, ...(Object.keys(residue).length > 0 ? { unmapped: residue } : {}) };
|
|
859
|
+
}
|
|
860
|
+
if (Object.keys(residue).length > 0) {
|
|
861
|
+
notes.push(
|
|
862
|
+
`motion: ${Object.keys(residue).join(', ')} matched no token or observation shape — kept under motion._meta.unmapped`,
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
if (out.motion === undefined) {
|
|
866
|
+
meta['motion'] = motion;
|
|
867
|
+
notes.push('motion present in an unrecognised shape — kept as observation under _meta.motion');
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const components = input['components'];
|
|
872
|
+
if (Array.isArray(components)) {
|
|
873
|
+
const built: ComponentObservation[] = [];
|
|
874
|
+
for (const entry of components) {
|
|
875
|
+
if (!isPlainObject(entry)) continue;
|
|
876
|
+
const name = entry['name'] ?? entry['type'];
|
|
877
|
+
if (typeof name !== 'string') continue;
|
|
878
|
+
const observed: ComponentObservation['observed'] = {};
|
|
879
|
+
const classes = stringList(entry['classes'] ?? entry['selectors']);
|
|
880
|
+
const props = stringList(entry['props'] ?? entry['properties']);
|
|
881
|
+
if (classes) observed.classes = classes;
|
|
882
|
+
if (props) observed.props = props;
|
|
883
|
+
built.push({ name, observed });
|
|
884
|
+
}
|
|
885
|
+
if (built.length > 0) out.components = built;
|
|
886
|
+
} else if (isPlainObject(components)) {
|
|
887
|
+
// Mirror the array branch: omit an absent key rather than emitting an
|
|
888
|
+
// empty array, and never discard a scalar observation just because it
|
|
889
|
+
// is not a list.
|
|
890
|
+
const built: ComponentObservation[] = [];
|
|
891
|
+
const componentResidue: Record<string, unknown> = {};
|
|
892
|
+
for (const [name, value] of Object.entries(components)) {
|
|
893
|
+
const observed: ComponentObservation['observed'] = {};
|
|
894
|
+
const props = stringList(value) ?? (isPlainObject(value) ? Object.keys(value) : null);
|
|
895
|
+
if (props && props.length > 0) observed.props = props;
|
|
896
|
+
else if (typeof value === 'string' || typeof value === 'number') {
|
|
897
|
+
componentResidue[name] = value;
|
|
898
|
+
}
|
|
899
|
+
built.push({ name, observed });
|
|
900
|
+
}
|
|
901
|
+
if (built.length > 0) out.components = built;
|
|
902
|
+
if (Object.keys(componentResidue).length > 0) {
|
|
903
|
+
meta['components'] = componentResidue;
|
|
904
|
+
notes.push(
|
|
905
|
+
`components: ${Object.keys(componentResidue).join(', ')} carried a scalar observation rather than a class/prop list — kept under _meta.components`,
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// WCAG results and breakpoints are documented output with no contract key.
|
|
911
|
+
// They are the clearest case of "observation, never a token".
|
|
912
|
+
for (const key of ['wcag', 'breakpoints']) {
|
|
913
|
+
if (input[key] !== undefined) meta[key] = input[key];
|
|
914
|
+
}
|
|
915
|
+
if (input['wcag'] !== undefined) {
|
|
916
|
+
notes.push('wcag results are observation, not tokens — kept under _meta.wcag');
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (Object.keys(meta).length > 0) out._meta = { ...(out._meta ?? {}), ...meta };
|
|
920
|
+
|
|
921
|
+
const mappedKeys = Object.keys(out).filter((k) => k !== 'source' && k !== '_meta');
|
|
922
|
+
if (mappedKeys.length === 0) {
|
|
923
|
+
notes.push(
|
|
924
|
+
'no bucket could be mapped without inventing values — this import is observation only, per the lane falsifier',
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
return { ok: true, lane: 'dembrandt', design_system: out, notes };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// --- entry point -------------------------------------------------------------
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Transform any of the three lanes into the contract shape.
|
|
934
|
+
*
|
|
935
|
+
* Never throws on input shape: an unusable input is an `ok: false` outcome
|
|
936
|
+
* carrying the reason, because the caller (a CLI, a skill) has to be able to
|
|
937
|
+
* show the human WHY an artifact was refused. A thrown stack answers "it
|
|
938
|
+
* failed" and not "your file has no `source.ref`".
|
|
939
|
+
*/
|
|
940
|
+
export function importDesignSystem(
|
|
941
|
+
input: unknown,
|
|
942
|
+
provenance?: ProvenanceOverride,
|
|
943
|
+
forceLane?: Lane,
|
|
944
|
+
): ImportOutcome {
|
|
945
|
+
if (!isPlainObject(input)) {
|
|
946
|
+
return { ok: false, lane: null, reason: 'input is not a JSON object' };
|
|
947
|
+
}
|
|
948
|
+
// A forced lane only overrides which mapper runs. The input is never
|
|
949
|
+
// reshaped to fit it, so a file forced to a lane it does not match maps to
|
|
950
|
+
// nothing — the honest answer — instead of being bent into a false success.
|
|
951
|
+
const lane = forceLane ?? detectLane(input);
|
|
952
|
+
if (lane === null) {
|
|
953
|
+
return {
|
|
954
|
+
ok: false,
|
|
955
|
+
lane: null,
|
|
956
|
+
reason:
|
|
957
|
+
'no lane matched — expected a native artifact (with a `source` block), a DTCG token file ({$value,$type} leaves), or an extraction output carrying at least one documented key (colors, typography, spacing, borders, shadows, motion, components, breakpoints, wcag)',
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
if (lane === 'native') return mapNative(input);
|
|
961
|
+
|
|
962
|
+
const inputSource = isPlainObject(input['source']) ? readSource(input['source']) : null;
|
|
963
|
+
let source: SourceBlock;
|
|
964
|
+
if (inputSource !== null && !('error' in inputSource)) {
|
|
965
|
+
source = inputSource.source;
|
|
966
|
+
} else if (provenance !== undefined) {
|
|
967
|
+
source = sourceFromOverride(provenance);
|
|
968
|
+
} else if (inputSource !== null && 'error' in inputSource) {
|
|
969
|
+
// A present-but-unusable block is a different failure from an absent
|
|
970
|
+
// one, and only reachable through a forced lane. Naming it precisely
|
|
971
|
+
// keeps the message about the user's file rather than about the format.
|
|
972
|
+
return {
|
|
973
|
+
ok: false,
|
|
974
|
+
lane,
|
|
975
|
+
reason: `${lane} lane rejected — the file carries a source block, but ${inputSource.error}`,
|
|
976
|
+
};
|
|
977
|
+
} else {
|
|
978
|
+
return {
|
|
979
|
+
ok: false,
|
|
980
|
+
lane,
|
|
981
|
+
reason:
|
|
982
|
+
`${lane} lane rejected — no provenance. The contract makes \`source\` mandatory (kind + ref + captured_at); ` +
|
|
983
|
+
'this format does not carry it, so supply it explicitly.',
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
return lane === 'dtcg' ? mapDtcg(input, source) : mapDembrandt(input, source);
|
|
987
|
+
}
|