@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.
- package/README.md +42 -4
- package/package.json +12 -1
- package/src/adopt/a11yPass.js +397 -0
- package/src/adopt/adoptGate.js +471 -0
- package/src/adopt/adoptScanReport.js +9 -0
- package/src/adopt/componentPropApi.js +200 -0
- package/src/adopt/findingSnippet.js +386 -0
- package/src/adopt/foreignDiscoveryResolver.js +20 -3
- package/src/adopt/foreignScoringResolver.js +163 -22
- package/src/adopt/moduleGraph.js +44 -2
- package/src/adopt/prCheckReport.js +274 -0
- package/src/adopt/propApiResolver.js +2 -2
- package/src/adopt/referenceResolver.js +2 -2
- package/src/adopt/scanRollup.js +192 -0
- package/src/adopt/tailwindResolver.js +6 -1
- package/src/adopt/targetDiscovery.js +31 -3
- package/src/adopt/tokenAliasResolver.js +45 -2
- package/src/adopt/usageRulesResolver.js +299 -14
- package/src/adopt/vecnaMaterializer.js +45 -5
- package/src/adopt/vecnaVerifier.js +20 -9
- package/src/adopt/wrapperResolver.js +3 -3
- package/src/cli.js +343 -8
- package/src/data.js +105 -11
- package/src/server.js +69 -14
- package/src/whisperer.d.ts +106 -0
- package/src/whisperer.js +814 -0
|
@@ -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 };
|