@starklab/stark-mcp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Turns a `runAdopt()` result over a customer repo into a PASS/FAIL verdict
3
+ * with concrete evidence — the missing half of "scan a client repo → gate →
4
+ * verdict" (ADOPTION_APP_PLAN.md §6).
5
+ *
6
+ * Before this, the two halves existed and never met. `stark-cli adopt` runs
7
+ * eight resolvers over real customer code and prints numbers; `verifyVecna-
8
+ * Layout` produces a judgment but only over one Vecna-generated LayoutConfig
9
+ * it materializes itself. Nothing ever judged a scanned repo, so a customer
10
+ * CI run could report 41 critical findings and still exit 0.
11
+ *
12
+ * Design rules, all inherited from the resolvers rather than invented here:
13
+ *
14
+ * 1. **Absence of evidence is never a violation** (§9). A resolver that did
15
+ * not run on this platform is `not-applicable`; a resolver that ran with
16
+ * nothing in its domain (no CSS files, no catalog JSX, Tailwind absent) is
17
+ * `no-evidence`. Neither can fail the gate, and both are reported rather
18
+ * than collapsed into a passing check — "PASS" over an empty denominator
19
+ * is a weaker claim than "PASS" over a full one, and the caller has to be
20
+ * able to tell them apart. Same denominator honesty as vecnaVerifier's
21
+ * `coverage.gaps`.
22
+ *
23
+ * 2. **The gate reads findings, it does not re-derive them.** Every verdict
24
+ * traces to a finding object a resolver already produced, carried through
25
+ * verbatim as evidence. No second, parallel implementation of any check.
26
+ * The one exception is the explicitly-labelled `policy: true` finding from
27
+ * `minComponentsUsed`, which is a caller's CI policy and not something a
28
+ * resolver could know.
29
+ *
30
+ * 3. **Low adoption is not a failure.** A repo that uses three catalog
31
+ * components correctly passes; that is a *score*, which Dominion computes
32
+ * from the same scan (apps/dominion/lib/adoptionScan.ts), not a
33
+ * conformance verdict. The reference check therefore has no failing state
34
+ * unless the caller opts in with `minComponentsUsed`.
35
+ *
36
+ * Two severity vocabularies reach this function and both are live: the adopt
37
+ * resolvers emit lowercase `critical|warning|info`, while `usageRules`
38
+ * forwards the conformance engine's own capitalized `Critical|Warning|Info`
39
+ * (packages/stk/conformance/index.js `SEVERITY`). Normalizing here rather
40
+ * than changing either producer keeps the gate the only place that has to
41
+ * know, and `unknownSeverities` surfaces any third spelling instead of
42
+ * silently bucketing it.
43
+ *
44
+ * Not gated: `opportunities` (hand-rolled markup a catalog component could
45
+ * replace) is a roadmap signal about code that never claimed to use Stark —
46
+ * failing CI on it would punish a repo for the parts it has not adopted yet,
47
+ * which is rule 3 again.
48
+ */
49
+
50
+ const SEVERITY_RANK = { info: 0, warning: 1, critical: 2 };
51
+
52
+ export const GATE_SEVERITIES = Object.keys(SEVERITY_RANK);
53
+
54
+ /** Lowercases the two live spellings into one. An unrecognized severity is
55
+ * counted as `info` — the non-blocking bucket — and recorded in the check's
56
+ * `unknownSeverities` so it shows up as a gap to close rather than as a
57
+ * finding that quietly stopped mattering. */
58
+ export function normalizeSeverity(value) {
59
+ const s = String(value ?? '').toLowerCase();
60
+ return s in SEVERITY_RANK ? s : null;
61
+ }
62
+
63
+ function blocks(severity, failOn) {
64
+ return SEVERITY_RANK[severity] >= SEVERITY_RANK[failOn];
65
+ }
66
+
67
+ /**
68
+ * Builds one check from a resolver's `findings` array.
69
+ *
70
+ * `evidence` is every blocking finding, capped at `maxEvidence` with
71
+ * `evidenceTotal` alongside so a truncated list can never be mistaken for the
72
+ * whole set. Findings are carried through verbatim (spread) — the gate adds a
73
+ * normalized `severity` and nothing else, so every field a resolver chose to
74
+ * report (rule, component, prop, value, allowed, selector, file, line) reaches
75
+ * the verdict without this module having to know the shape of any of them.
76
+ */
77
+ function fromFindings({ id, dimension, title, findings, measured, failOn, maxEvidence }) {
78
+ const counts = { critical: 0, warning: 0, info: 0 };
79
+ const unknownSeverities = [];
80
+ const blocking = [];
81
+
82
+ for (const f of findings) {
83
+ const severity = normalizeSeverity(f.severity);
84
+ if (severity === null) {
85
+ unknownSeverities.push(f.severity ?? null);
86
+ counts.info += 1;
87
+ continue;
88
+ }
89
+ counts[severity] += 1;
90
+ if (blocks(severity, failOn)) blocking.push({ ...f, severity });
91
+ }
92
+
93
+ const check = {
94
+ id,
95
+ dimension,
96
+ title,
97
+ status: blocking.length > 0 ? 'fail' : 'pass',
98
+ measured,
99
+ findings: counts,
100
+ evidenceTotal: blocking.length,
101
+ evidence: blocking.slice(0, maxEvidence),
102
+ };
103
+ if (unknownSeverities.length > 0) check.unknownSeverities = [...new Set(unknownSeverities)];
104
+ return check;
105
+ }
106
+
107
+ function skipped({ id, dimension, title, status, reason }) {
108
+ return {
109
+ id,
110
+ dimension,
111
+ title,
112
+ status,
113
+ reason,
114
+ measured: null,
115
+ findings: { critical: 0, warning: 0, info: 0 },
116
+ evidenceTotal: 0,
117
+ evidence: [],
118
+ };
119
+ }
120
+
121
+ /** A resolver whose headline `report` is the `{total, direct, aliased,
122
+ * unresolved, conformancePct}` shape — all four token resolvers share it
123
+ * exactly (tokenAliasResolver, tailwindResolver, rnTokenAliasResolver,
124
+ * rnTailwindResolver), which is why one builder covers them. */
125
+ function tokenCheck({ id, title, result, notApplicableReason, failOn, maxEvidence }) {
126
+ const shared = { id, dimension: 'tokens', title };
127
+ if (!result) return skipped({ ...shared, status: 'not-applicable', reason: notApplicableReason });
128
+ if (result.detected === false) {
129
+ return skipped({ ...shared, status: 'not-applicable', reason: result.reason ?? 'Not detected in this repo.' });
130
+ }
131
+ if (!result.report || result.report.total === 0) {
132
+ return skipped({
133
+ ...shared,
134
+ status: 'no-evidence',
135
+ reason: 'The resolver ran but found no token usage to classify — nothing to gate, not a clean result.',
136
+ });
137
+ }
138
+ // A repo that declares custom properties but resolves *none* of them to a
139
+ // Stark token is not a Stark consumer, and "not a consumer" is a scope
140
+ // question, not 400 conformance violations. The resolver's own numbers stay
141
+ // honest either way (408 total, 0 aliased is a true measurement); it is the
142
+ // verdict that would be wrong, and low adoption is a score, never a failure
143
+ // — ADOPTION_APP_PLAN.md §9, the same rule component-references already
144
+ // honours by reporting no-evidence when nothing in the catalog is imported.
145
+ //
146
+ // Found by gating a real external repo rather than a fixture
147
+ // (~/Projects/Admin.Users, a Vite + shadcn/Radix app with zero Stark): every
148
+ // custom property terminates in a raw literal by definition when there is no
149
+ // design system behind them, so `drift-behind-alias` fired 454 times — on
150
+ // Tailwind's own engine internals (`--tw-translate-x`) and on shadcn's theme
151
+ // tokens (`--color-background`). Neither is a Stark token that drifted.
152
+ //
153
+ // The boundary is strictly zero: one aliased token makes the repo a consumer,
154
+ // and its remaining drift is then a real finding worth failing on.
155
+ const starkTokens = (result.report.direct ?? 0) + (result.report.aliased ?? 0);
156
+ if (starkTokens === 0) {
157
+ return skipped({
158
+ ...shared,
159
+ status: 'no-evidence',
160
+ reason:
161
+ `Not one of the ${result.report.total} declared properties resolves to a Stark token — ` +
162
+ 'this repo does not consume the design system here, so there is nothing to conform.',
163
+ });
164
+ }
165
+ return fromFindings({
166
+ ...shared,
167
+ findings: result.findings ?? [],
168
+ measured: result.report,
169
+ failOn,
170
+ maxEvidence,
171
+ });
172
+ }
173
+
174
+ /**
175
+ * @param {object} adopt A `runAdopt()` result (the JSON `stark-cli adopt` prints).
176
+ * @param {object} [options]
177
+ * @param {'critical'|'warning'|'info'} [options.failOn='critical']
178
+ * Lowest severity that fails the gate. `critical` is the default
179
+ * because it is the only severity every resolver reserves for a real
180
+ * contract break; `warning` is a legitimate stricter setting, `info`
181
+ * would fail on findings whose own text says no action is needed.
182
+ * @param {number} [options.maxEvidence=5] Findings quoted per check.
183
+ * @param {number|null} [options.minComponentsUsed=null]
184
+ * Opt-in CI policy: fail when fewer than N catalog components are
185
+ * actually referenced. Off by default — see rule 3 above.
186
+ */
187
+ export function gateAdoptResult(adopt, { failOn = 'critical', maxEvidence = 5, minComponentsUsed = null } = {}) {
188
+ if (!adopt || typeof adopt !== 'object') {
189
+ throw new Error('gateAdoptResult expects a runAdopt() result object.');
190
+ }
191
+ if (!(failOn in SEVERITY_RANK)) {
192
+ throw new Error(`failOn must be one of: ${GATE_SEVERITIES.join(', ')} (got "${failOn}").`);
193
+ }
194
+
195
+ const platform = adopt.platform ?? 'web';
196
+ const checks = [];
197
+
198
+ // 1. References — the "which catalog components does this repo actually
199
+ // use" evidence. `components` always lists the whole catalog including
200
+ // zero-usage entries (referenceResolver's own contract), so the used
201
+ // count is the catalog minus zeroUsage, never `components.length`.
202
+ const components = adopt.components ?? [];
203
+ const used = components.filter((c) => !(adopt.zeroUsage ?? []).includes(c.name));
204
+ const usageCount = (c) => c.counts.jsx + c.counts.createElement + c.counts.hoc + c.counts.indirect;
205
+ const totalUsages = used.reduce((n, c) => n + usageCount(c), 0);
206
+ const referenceMeasured = {
207
+ catalogComponents: components.length,
208
+ componentsUsed: used.length,
209
+ usages: totalUsages,
210
+ scannedFiles: adopt.scannedFiles ?? 0,
211
+ unresolvedFiles: (adopt.unresolvedFiles ?? []).length,
212
+ };
213
+
214
+ if (used.length === 0) {
215
+ checks.push(
216
+ skipped({
217
+ id: 'component-references',
218
+ dimension: 'references',
219
+ title: 'Catalog component references',
220
+ status: 'no-evidence',
221
+ reason:
222
+ 'No catalog component is referenced anywhere in the scanned files — there is nothing to conform, so this is a scope question, not a violation.',
223
+ })
224
+ );
225
+ // A repo with zero adoption still fails an explicit floor, which is the
226
+ // one thing that floor exists to catch.
227
+ if (minComponentsUsed !== null && used.length < minComponentsUsed) {
228
+ checks[checks.length - 1] = policyFloor(referenceMeasured, minComponentsUsed, used.length, maxEvidence);
229
+ }
230
+ } else if (minComponentsUsed !== null && used.length < minComponentsUsed) {
231
+ checks.push(policyFloor(referenceMeasured, minComponentsUsed, used.length, maxEvidence));
232
+ } else {
233
+ checks.push({
234
+ id: 'component-references',
235
+ dimension: 'references',
236
+ title: 'Catalog component references',
237
+ status: 'pass',
238
+ measured: referenceMeasured,
239
+ findings: { critical: 0, warning: 0, info: 0 },
240
+ evidenceTotal: used.length,
241
+ // Evidence for a passing reference check is the usage itself: which
242
+ // components, how often, and one real call site each — so a PASS is
243
+ // readable as "here is what it found", not as an empty assertion.
244
+ evidence: [...used]
245
+ .sort((a, b) => usageCount(b) - usageCount(a) || a.name.localeCompare(b.name))
246
+ .slice(0, maxEvidence)
247
+ .map((c) => ({
248
+ component: c.name,
249
+ usages: usageCount(c),
250
+ counts: c.counts,
251
+ site: c.sites?.[0] ? { file: c.sites[0].file, line: c.sites[0].line, kind: c.sites[0].kind } : null,
252
+ })),
253
+ });
254
+ }
255
+
256
+ // 2. Prop API — the one dimension that is about how components are *called*.
257
+ if (!adopt.propApi) {
258
+ checks.push(
259
+ skipped({
260
+ id: 'prop-api',
261
+ dimension: 'prop-api',
262
+ title: 'Component prop API',
263
+ status: 'not-applicable',
264
+ reason:
265
+ 'Web-only: prop-mapping/ has no rn/ subdirectory, so there is no artifact to validate a React Native prop against.',
266
+ })
267
+ );
268
+ } else if ((adopt.propApi.report?.total ?? 0) === 0) {
269
+ checks.push(
270
+ skipped({
271
+ id: 'prop-api',
272
+ dimension: 'prop-api',
273
+ title: 'Component prop API',
274
+ status: 'no-evidence',
275
+ reason: 'No catalog call site carried a prop this resolver tracks — no props were checked.',
276
+ })
277
+ );
278
+ } else {
279
+ checks.push(
280
+ fromFindings({
281
+ id: 'prop-api',
282
+ dimension: 'prop-api',
283
+ title: 'Component prop API',
284
+ findings: adopt.propApi.findings ?? [],
285
+ measured: adopt.propApi.report,
286
+ failOn,
287
+ maxEvidence,
288
+ })
289
+ );
290
+ }
291
+
292
+ // 3–4. Tokens, web.
293
+ checks.push(
294
+ tokenCheck({
295
+ id: 'token-aliases',
296
+ title: 'CSS custom-property token aliases',
297
+ result: adopt.tokenAliases,
298
+ notApplicableReason: 'Web-only: CSS custom properties have no React Native equivalent.',
299
+ failOn,
300
+ maxEvidence,
301
+ })
302
+ );
303
+ checks.push(
304
+ tokenCheck({
305
+ id: 'tailwind-tokens',
306
+ title: 'Tailwind theme tokens',
307
+ result: adopt.tailwind,
308
+ notApplicableReason: 'Web-only.',
309
+ failOn,
310
+ maxEvidence,
311
+ })
312
+ );
313
+
314
+ // 5–6. Tokens, React Native.
315
+ checks.push(
316
+ tokenCheck({
317
+ id: 'rn-token-aliases',
318
+ title: 'React Native token aliases',
319
+ result: adopt.rnTokenAliases,
320
+ notApplicableReason: 'React Native only.',
321
+ failOn,
322
+ maxEvidence,
323
+ })
324
+ );
325
+ checks.push(
326
+ tokenCheck({
327
+ id: 'rn-tailwind-tokens',
328
+ title: 'NativeWind theme tokens',
329
+ result: adopt.rnTailwind,
330
+ notApplicableReason: 'React Native only.',
331
+ failOn,
332
+ maxEvidence,
333
+ })
334
+ );
335
+
336
+ // 7. Usage rules — the conformance engine's three rule-bearing checks, run
337
+ // over the customer's own JSX via the R3 adapter. Capitalized severities.
338
+ if (!adopt.usageRules) {
339
+ checks.push(
340
+ skipped({
341
+ id: 'usage-rules',
342
+ dimension: 'usage-rules',
343
+ title: 'Composition usage rules',
344
+ status: 'not-applicable',
345
+ reason: 'Web-only.',
346
+ })
347
+ );
348
+ } else if ((adopt.usageRules.filesWithUsage ?? 0) === 0) {
349
+ checks.push(
350
+ skipped({
351
+ id: 'usage-rules',
352
+ dimension: 'usage-rules',
353
+ title: 'Composition usage rules',
354
+ status: 'no-evidence',
355
+ reason: 'No scanned file contained a statically-resolvable Toolbar/Button/DropdownMenu composition to check.',
356
+ })
357
+ );
358
+ } else {
359
+ checks.push(
360
+ fromFindings({
361
+ id: 'usage-rules',
362
+ dimension: 'usage-rules',
363
+ title: 'Composition usage rules',
364
+ findings: adopt.usageRules.findings ?? [],
365
+ measured: {
366
+ scannedFiles: adopt.usageRules.scannedFiles ?? 0,
367
+ filesWithUsage: adopt.usageRules.filesWithUsage ?? 0,
368
+ unresolvedFiles: (adopt.usageRules.unresolvedFiles ?? []).length,
369
+ },
370
+ failOn,
371
+ maxEvidence,
372
+ })
373
+ );
374
+ }
375
+
376
+ // 8. Wrappers. wrapperResolver reports no `findings` array — its two
377
+ // problem shapes are `contradictions` (a dominion.config.json declaration
378
+ // the auto-detected terminal disagrees with) and `unresolvedDeclarations`
379
+ // (a declaration naming something that isn't a catalog component). Both
380
+ // are config defects rather than code violations, so both are Warning at
381
+ // most: the customer's shipped UI is unaffected either way, only the
382
+ // attribution of it is. Given a repo with no config at all, this is
383
+ // no-evidence rather than a clean pass.
384
+ const wrapperFindings = [
385
+ ...(adopt.contradictions ?? []).map((c) => ({
386
+ rule: 'wrapper-declaration-contradicted',
387
+ severity: 'warning',
388
+ ...c,
389
+ })),
390
+ ...(adopt.unresolvedDeclarations ?? []).map((d) => ({
391
+ rule: 'wrapper-declaration-unresolved',
392
+ severity: 'warning',
393
+ ...d,
394
+ })),
395
+ ];
396
+ if (!adopt.dominionConfigFound && wrapperFindings.length === 0) {
397
+ checks.push(
398
+ skipped({
399
+ id: 'wrapper-declarations',
400
+ dimension: 'wrappers',
401
+ title: 'Declared wrapper components',
402
+ status: 'no-evidence',
403
+ reason: 'No dominion.config.json wrapper declarations in this repo — nothing was declared, so nothing can contradict.',
404
+ })
405
+ );
406
+ } else {
407
+ checks.push(
408
+ fromFindings({
409
+ id: 'wrapper-declarations',
410
+ dimension: 'wrappers',
411
+ title: 'Declared wrapper components',
412
+ findings: wrapperFindings,
413
+ measured: {
414
+ wrappers: (adopt.wrappers ?? []).length,
415
+ provenance: adopt.provenanceTriple ?? null,
416
+ dominionConfigFound: Boolean(adopt.dominionConfigFound),
417
+ },
418
+ failOn,
419
+ maxEvidence,
420
+ })
421
+ );
422
+ }
423
+
424
+ const summary = {
425
+ checks: checks.length,
426
+ passed: checks.filter((c) => c.status === 'pass').length,
427
+ failed: checks.filter((c) => c.status === 'fail').length,
428
+ notApplicable: checks.filter((c) => c.status === 'not-applicable').length,
429
+ noEvidence: checks.filter((c) => c.status === 'no-evidence').length,
430
+ findings: checks.reduce(
431
+ (acc, c) => ({
432
+ critical: acc.critical + c.findings.critical,
433
+ warning: acc.warning + c.findings.warning,
434
+ info: acc.info + c.findings.info,
435
+ }),
436
+ { critical: 0, warning: 0, info: 0 }
437
+ ),
438
+ };
439
+
440
+ return {
441
+ verdict: summary.failed > 0 ? 'FAIL' : 'PASS',
442
+ failOn,
443
+ platform,
444
+ package: adopt.package ?? null,
445
+ root: adopt.root ?? null,
446
+ summary,
447
+ checks,
448
+ };
449
+ }
450
+
451
+ /** The one finding this module authors rather than forwards — flagged
452
+ * `policy: true` so a reader can tell a CI floor from a resolver's own
453
+ * judgment about the code. */
454
+ function policyFloor(measured, minComponentsUsed, actual, maxEvidence) {
455
+ const finding = {
456
+ rule: 'below-minimum-component-usage',
457
+ severity: 'critical',
458
+ policy: true,
459
+ required: minComponentsUsed,
460
+ actual,
461
+ };
462
+ return fromFindings({
463
+ id: 'component-references',
464
+ dimension: 'references',
465
+ title: 'Catalog component references',
466
+ findings: [finding],
467
+ measured,
468
+ failOn: 'critical',
469
+ maxEvidence,
470
+ });
471
+ }
@@ -37,6 +37,15 @@ import { scrubForeignScanPaths } from './foreignScanReport.js';
37
37
  * `findings` is deliberately NOT in this list. A finding is a defect the
38
38
  * tracker exists to surface, and the counts alone can't reconstruct one.
39
39
  *
40
+ * Neither is `rollup`, and that is the point of it: scanRollup.js folds
41
+ * `checks` and `sites` into bounded per-(component, prop, value) and
42
+ * per-(component, file) counts *before* this strip runs, so the aggregate
43
+ * survives while the enumeration does not. Everything above stays true — the
44
+ * ingest still never reads the enumeration — but "re-derivable by re-scanning"
45
+ * is a weaker guarantee than it sounds for a customer's *past* commits, which
46
+ * nobody re-scans, so the shape that a question might later need is kept and
47
+ * the rows behind it are not.
48
+ *
40
49
  * stdout is untouched either way — `--report` changes what is sent, never what
41
50
  * the command prints.
42
51
  */
@@ -0,0 +1,200 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { parse } from '@babel/parser';
5
+
6
+ import { stkRoot } from '../data.js';
7
+
8
+ /**
9
+ * The component prop API — what props the real React components actually
10
+ * accept, read from their own source.
11
+ *
12
+ * Everything else in this repo that claims to describe a component's props is
13
+ * hand-written: prop-mapping's `propMap` describes the Figma sync contract,
14
+ * and `layoutSchema` describes the AI layout-JSON contract. Neither is the
15
+ * component's signature, and both were free to drift from it — silently. That
16
+ * drift is what made the Vecna Gate assert against a fiction: it materialized
17
+ * `<BadgeStatus variant="…">` from a layoutSchema whose `variant` the real
18
+ * component has never accepted (it is `color`), and the propApi resolver then
19
+ * found nothing wrong with it because `variant` matched no rule either.
20
+ *
21
+ * This module closes that by parsing the destructured parameter of each
22
+ * component's own declaration, which is the only place the truth lives.
23
+ *
24
+ * Two halves, mirroring catalog.js:
25
+ * - extractComponentProps() runs in the monorepo, where the component
26
+ * sources exist, and is what the build-time generator calls.
27
+ * - loadComponentPropApi() reads the generated artifact that ships inside
28
+ * @starklab/stk, which is all a published install has — stark-mcp
29
+ * deliberately does not depend on @starklab/stk-components (see
30
+ * catalog.js for the same reasoning).
31
+ */
32
+
33
+ const PACKAGE_NAME = {
34
+ web: '@starklab/stk-components',
35
+ native: '@starklab/stk-react-native',
36
+ };
37
+
38
+ const ARTIFACT = 'component-props.json';
39
+
40
+ /** Wrappers that keep the props object as their inner function's first param. */
41
+ const TRANSPARENT_CALLS = new Set(['forwardRef', 'memo']);
42
+
43
+ function resolveModule(srcDir, spec) {
44
+ const base = path.resolve(srcDir, spec);
45
+ const candidates = [base, `${base}.jsx`, `${base}.js`, path.join(base, 'index.jsx'), path.join(base, 'index.js')];
46
+ for (const c of candidates) {
47
+ if (existsSync(c) && statSync(c).isFile()) return c;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Reads the same flat re-export barrel catalog.js reads, but keeps the module
54
+ * specifier and the local name too — the local name is what the declaration is
55
+ * called inside its own file, which is not always the exported name
56
+ * (`export { Foo as Bar }`).
57
+ */
58
+ function readBarrelBindings(file) {
59
+ const src = readFileSync(file, 'utf-8');
60
+ const bindings = new Map();
61
+ for (const line of src.split('\n')) {
62
+ const bare = line.trim();
63
+ if (!bare || bare.startsWith('//') || bare.startsWith('*') || bare.startsWith('/*')) continue;
64
+ const m = bare.match(/^export\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"];?$/);
65
+ if (!m) continue;
66
+ for (const part of m[1].split(',')) {
67
+ const segs = part.trim().split(/\s+as\s+/);
68
+ const local = segs[0]?.trim();
69
+ const exported = (segs[1] ?? segs[0])?.trim();
70
+ if (exported && local) bindings.set(exported, { source: m[2], local });
71
+ }
72
+ }
73
+ return bindings;
74
+ }
75
+
76
+ /** Unwraps forwardRef(…)/memo(…) — including React.forwardRef(…) — down to the function. */
77
+ function unwrapComponent(node) {
78
+ let current = node;
79
+ while (current && current.type === 'CallExpression') {
80
+ const callee = current.callee;
81
+ const name =
82
+ callee.type === 'Identifier'
83
+ ? callee.name
84
+ : callee.type === 'MemberExpression' && callee.property.type === 'Identifier'
85
+ ? callee.property.name
86
+ : null;
87
+ if (!TRANSPARENT_CALLS.has(name)) return null;
88
+ current = current.arguments[0] ?? null;
89
+ }
90
+ if (!current) return null;
91
+ const fn = ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'];
92
+ return fn.includes(current.type) ? current : null;
93
+ }
94
+
95
+ function findDeclaration(ast, localName) {
96
+ for (const node of ast.program.body) {
97
+ const decl = node.type === 'ExportNamedDeclaration' ? node.declaration : node;
98
+ if (!decl) continue;
99
+ if (decl.type === 'FunctionDeclaration' && decl.id?.name === localName) return decl;
100
+ if (decl.type === 'VariableDeclaration') {
101
+ for (const d of decl.declarations) {
102
+ if (d.id.type === 'Identifier' && d.id.name === localName) return d.init;
103
+ }
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ function propsFromPattern(pattern, code) {
110
+ if (!pattern || pattern.type !== 'ObjectPattern') return null;
111
+ const props = [];
112
+ for (const p of pattern.properties) {
113
+ if (p.type === 'RestElement') {
114
+ props.push({ name: p.argument.type === 'Identifier' ? p.argument.name : 'rest', rest: true });
115
+ continue;
116
+ }
117
+ if (p.type !== 'ObjectProperty') continue;
118
+ const name = p.key.type === 'Identifier' ? p.key.name : String(p.key.value);
119
+ if (p.value.type === 'AssignmentPattern') {
120
+ props.push({ name, hasDefault: true, defaultSource: code.slice(p.value.right.start, p.value.right.end) });
121
+ } else {
122
+ props.push({ name, hasDefault: false });
123
+ }
124
+ }
125
+ return props;
126
+ }
127
+
128
+ /**
129
+ * Parses every component the barrel exports and returns its accepted props.
130
+ *
131
+ * Exports that are not prop-object components — hooks, primarily — come back
132
+ * under `skipped` rather than being silently dropped, so a component that
133
+ * stops parsing (a refactor to positional params, say) is visible instead of
134
+ * quietly vanishing from the validator's denominator.
135
+ */
136
+ export function extractComponentProps(srcDir) {
137
+ const barrel = path.join(srcDir, 'index.js');
138
+ const bindings = readBarrelBindings(barrel);
139
+ const components = {};
140
+ const skipped = [];
141
+
142
+ for (const [exported, { source, local }] of bindings) {
143
+ const file = resolveModule(srcDir, source);
144
+ if (!file) {
145
+ skipped.push({ name: exported, reason: `module not found: ${source}` });
146
+ continue;
147
+ }
148
+ const code = readFileSync(file, 'utf-8');
149
+ let ast;
150
+ try {
151
+ ast = parse(code, { sourceType: 'module', plugins: ['jsx'] });
152
+ } catch (e) {
153
+ skipped.push({ name: exported, reason: `parse error: ${e.message}` });
154
+ continue;
155
+ }
156
+ const decl = findDeclaration(ast, local);
157
+ if (!decl) {
158
+ skipped.push({ name: exported, reason: `no declaration for "${local}" in ${source}` });
159
+ continue;
160
+ }
161
+ const fn = unwrapComponent(decl);
162
+ if (!fn) {
163
+ skipped.push({ name: exported, reason: 'not a function component' });
164
+ continue;
165
+ }
166
+ const props = propsFromPattern(fn.params[0], code);
167
+ if (!props) {
168
+ skipped.push({ name: exported, reason: 'first parameter is not a destructured props object' });
169
+ continue;
170
+ }
171
+ components[exported] = { source, props };
172
+ }
173
+
174
+ return { components, skipped };
175
+ }
176
+
177
+ /**
178
+ * Reads the generated artifact for one platform. Returns null when it is
179
+ * absent — the resolvers treat a missing prop API as "no ground truth
180
+ * available" and fall back to their prior behaviour rather than failing, the
181
+ * same posture every other adopt resolver takes toward missing inputs.
182
+ */
183
+ export function loadComponentPropApi(platform = 'web', root = stkRoot()) {
184
+ if (!PACKAGE_NAME[platform]) {
185
+ throw new Error(
186
+ `Unsupported platform "${platform}". Available: ${Object.keys(PACKAGE_NAME).join(', ')}.`
187
+ );
188
+ }
189
+ const file = path.join(root, ARTIFACT);
190
+ if (!existsSync(file)) return null;
191
+ let parsed;
192
+ try {
193
+ parsed = JSON.parse(readFileSync(file, 'utf-8'));
194
+ } catch {
195
+ return null;
196
+ }
197
+ return parsed.platforms?.[platform] ?? null;
198
+ }
199
+
200
+ export { PACKAGE_NAME as PROP_API_PACKAGE_NAME, ARTIFACT as PROP_API_ARTIFACT };