@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.
Files changed (107) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +147 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze.md +2 -1
  5. package/dist/agent-src/commands/council/debate.md +13 -4
  6. package/dist/agent-src/commands/council/default.md +26 -11
  7. package/dist/agent-src/commands/council.md +1 -1
  8. package/dist/agent-src/commands/design-system/capture.md +44 -0
  9. package/dist/agent-src/commands/design-system/generate.md +75 -0
  10. package/dist/agent-src/commands/design-system/import.md +63 -0
  11. package/dist/agent-src/commands/design-system.md +66 -0
  12. package/dist/agent-src/commands/pr/create.md +51 -26
  13. package/dist/agent-src/commands/worktree/create.md +5 -3
  14. package/dist/agent-src/rules/settings-ask-protocol.md +14 -9
  15. package/dist/agent-src/skills/ai-council/SKILL.md +24 -6
  16. package/dist/agent-src/skills/corpus-grounding/SKILL.md +12 -1
  17. package/dist/agent-src/skills/corpus-grounding/scripts/decision_engine.ts +215 -7
  18. package/dist/agent-src/skills/corpus-grounding/scripts/ground.ts +39 -1
  19. package/dist/agent-src/skills/design-intelligence/ATTRIBUTION.md +19 -2
  20. package/dist/agent-src/skills/design-intelligence/data/manifest.json +36 -1
  21. package/dist/agent-src/skills/design-intelligence/data/motion.csv +17 -0
  22. package/dist/agent-src/skills/design-system-capture/SKILL.md +9 -0
  23. package/dist/agent-src/skills/design-system-capture/references/design-system-json.md +70 -0
  24. package/dist/agent-src/skills/git-workflow/SKILL.md +9 -3
  25. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +9 -6
  26. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +25 -21
  27. package/dist/agent-src/templates/agent-settings.md +0 -1
  28. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  29. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
  30. package/dist/cli-delegate/{chunk-BV3WR67D.js → chunk-55WZN5VF.js} +3 -6
  31. package/dist/cli-delegate/{chunk-X3VOARZD.js → chunk-L565YFXH.js} +3 -3
  32. package/dist/cli-delegate/{chunk-JMQZ7MM5.js → chunk-MNWRDMR5.js} +1 -1
  33. package/dist/cli-delegate/{chunk-IR3IVRVC.js → chunk-NLP54U3J.js} +2 -1
  34. package/dist/cli-delegate/{chunk-FV34EGEX.js → chunk-NN4KW4HZ.js} +1 -1
  35. package/dist/cli-delegate/{chunk-6ENSXSBW.js → chunk-PPQO4NGR.js} +1 -1
  36. package/dist/cli-delegate/chunk-RLN73PR5.js +157 -0
  37. package/dist/cli-delegate/{chunk-6LOPHJLD.js → chunk-RYNADUFG.js} +1 -1
  38. package/dist/cli-delegate/{chunk-I5EAFRII.js → chunk-ZPSXJTLI.js} +2 -154
  39. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  40. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  41. package/dist/cli-delegate/cmd_conformance.js +673 -215
  42. package/dist/cli-delegate/cmd_doctor.js +2 -2
  43. package/dist/cli-delegate/cmd_explain.js +2 -2
  44. package/dist/cli-delegate/cmd_export.js +4 -4
  45. package/dist/cli-delegate/cmd_handoff.js +10 -8
  46. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  47. package/dist/cli-delegate/cmd_migrate.js +2 -2
  48. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  49. package/dist/cli-delegate/cmd_prune.js +4 -4
  50. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  51. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  52. package/dist/cli-delegate/cmd_session_recycle.js +6 -4
  53. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  54. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  55. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  56. package/dist/cli-delegate/cmd_sync.js +4 -4
  57. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  58. package/dist/cli-delegate/cmd_update.js +1 -1
  59. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  60. package/dist/cli-delegate/cmd_validate.js +4 -4
  61. package/dist/cli-delegate/cmd_versions.js +1 -1
  62. package/dist/discovery/deprecation-report.md +1 -1
  63. package/dist/discovery/discovery-manifest.json +127 -22
  64. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  65. package/dist/discovery/discovery-manifest.summary.md +3 -3
  66. package/dist/discovery/dormancy-report.md +1 -1
  67. package/dist/discovery/orphan-report.md +1 -1
  68. package/dist/discovery/packs.json +9 -5
  69. package/dist/discovery/trust-report.md +2 -2
  70. package/dist/discovery/workspaces.json +8 -4
  71. package/dist/hooks/dispatch.js +994 -399
  72. package/dist/install/install.mjs +5 -7
  73. package/dist/mcp/registry-manifest.json +2 -2
  74. package/dist/mcp/server.json +2 -2
  75. package/dist/mcp/server.mjs +1104 -593
  76. package/dist/scripts/_lib/agent_settings.js +8 -0
  77. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  78. package/dist/server/schemas/settings.js +3 -4
  79. package/dist/server/schemas/settings.js.map +1 -1
  80. package/dist/ui/assets/{index-5fXOHoVh.js → index-B0aMJnuz.js} +2 -2
  81. package/dist/ui/assets/{index-5fXOHoVh.js.map → index-B0aMJnuz.js.map} +1 -1
  82. package/dist/ui/index.html +1 -1
  83. package/docs/contracts/settings-classes.md +12 -9
  84. package/package.json +1 -1
  85. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
  86. package/src/config/agent-settings.template.yml +15 -18
  87. package/src/config/gate-coverage.yml +33 -2
  88. package/src/config/gate-violation-baselines.json +2 -2
  89. package/src/config/pack-size-budget.json +3 -2
  90. package/src/scripts/_lib/agent_settings.ts +8 -0
  91. package/src/scripts/_lib/design_system_import.ts +987 -0
  92. package/src/scripts/check_branch_freshness.ts +238 -6
  93. package/src/scripts/design_system_import.ts +264 -0
  94. package/src/scripts/external_sources_denylist.json +7 -2
  95. package/src/scripts/hook_manifest.yaml +71 -2
  96. package/src/scripts/hooks/concern_registry.ts +4 -0
  97. package/src/scripts/hooks/dispatch_hook.ts +10 -0
  98. package/src/scripts/hooks/host_semantics.ts +2 -0
  99. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +212 -0
  100. package/src/scripts/hooks/subagent_ledger_hook.ts +723 -0
  101. package/src/scripts/hooks/turn_end_gate_hook.ts +39 -2
  102. package/src/scripts/lint_hook_manifest.ts +2 -0
  103. package/src/scripts/lint_rule_skill_pack_reach.ts +194 -13
  104. package/src/scripts/session_register_hook.ts +120 -10
  105. package/src/server/schemas/settings.ts +3 -6
  106. package/tests/fixtures/hooks/subagent_start.json +7 -0
  107. 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
+ }