@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.
@@ -128,9 +128,20 @@ export function buildModuleGraph(root, { ignore = [] } = {}) {
128
128
  } else if (node.type === 'ExportNamedDeclaration') {
129
129
  if (node.source) {
130
130
  for (const spec of node.specifiers) {
131
- const localName = spec.local.name;
132
131
  const exportedName = spec.exported.type === 'Identifier' ? spec.exported.name : spec.exported.value;
133
- reexports.set(exportedName, { source: node.source.value, imported: localName });
132
+ // `export * as ns from './m'` parses as an ExportNamedDeclaration
133
+ // whose specifier is an ExportNamespaceSpecifier — which carries
134
+ // only `exported`, no `local`. Reading spec.local.name threw
135
+ // TypeError here and, because buildModuleGraph is shared, took
136
+ // down every scan of the whole repo: one such line in
137
+ // openstatusHQ/openstatus (packages/db/src/index.ts:1) crashed
138
+ // `scan-foreign` across all 45 of its workspace packages.
139
+ if (spec.type === 'ExportNamespaceSpecifier') {
140
+ reexports.set(exportedName, { source: node.source.value, imported: '*' });
141
+ continue;
142
+ }
143
+ if (!spec.local) continue;
144
+ reexports.set(exportedName, { source: node.source.value, imported: spec.local.name });
134
145
  }
135
146
  } else if (node.specifiers?.length) {
136
147
  // export { X, Y as Z }; — forwarding a local binding. Only meaningful
@@ -155,6 +166,37 @@ export function buildModuleGraph(root, { ignore = [] } = {}) {
155
166
  return { root, files, graph };
156
167
  }
157
168
 
169
+ /**
170
+ * The package a bare specifier belongs to, with any subpath removed:
171
+ * `@starklab/stk-components/Button` and `@starklab/stk-components` both
172
+ * answer `@starklab/stk-components`, and `lodash/merge` answers `lodash`.
173
+ *
174
+ * Every resolver that asks "did this import come from the design system?"
175
+ * compares an origin's `pkg` against packageNameForPlatform()'s bare name,
176
+ * but `resolveOrigin` reports the specifier as written — so a subpath import
177
+ * matched nothing and the file was silently skipped. That is not a rare
178
+ * style: this repo writes 124 of them, and every one of apps/portfolio's
179
+ * imports is one, which is why a scan of it reported 0/69 components adopted
180
+ * for a site that renders Button on nearly every page.
181
+ *
182
+ * installedPackageScoringResolver.js already met this on the foreign side —
183
+ * see componentNameFromOrigin(), and the 0/149 coverage its header records —
184
+ * so this is that fix generalized, not a new idea. It deliberately only
185
+ * normalizes the package half: the imported *name* still has to be a real
186
+ * named export, because Stark's subpaths export named symbols and inferring
187
+ * a component from the subpath would be a guess where the repo has a fact.
188
+ *
189
+ * Relative specifiers are not package imports and answer null, so a caller
190
+ * can hand this any source string without pre-checking.
191
+ */
192
+ export function packageOfSpecifier(source) {
193
+ if (typeof source !== 'string' || !source || source.startsWith('.') || source.startsWith('/')) {
194
+ return null;
195
+ }
196
+ const parts = source.split('/');
197
+ return source.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
198
+ }
199
+
158
200
  /**
159
201
  * Resolves `name` back to its ultimate origin, following relative-import and
160
202
  * re-export hops within the target repo only (never into node_modules).
@@ -0,0 +1,274 @@
1
+ import { normalizeSeverity } from './adoptGate.js';
2
+ import { scrubForeignScanPaths } from './foreignScanReport.js';
3
+
4
+ /**
5
+ * The reporting half of `stark-cli adopt --pr-check`: turns a `runAdopt`
6
+ * result into the payload Dominion's POST /api/pr-check accepts, and sends it.
7
+ *
8
+ * This is the third sibling of adoptScanReport.js and foreignScanReport.js and
9
+ * inherits their first governing constraint verbatim — **it must never break
10
+ * the consumer's CI**: every failure path returns `{ reported: false, reason }`,
11
+ * nothing throws, and the caller never touches `process.exitCode`. The route
12
+ * on the other end decides whether the *pull request* goes red; a reporter
13
+ * fault must not.
14
+ *
15
+ * It inherits their second constraint too, and for a sharper reason than
16
+ * privacy. Those two scrub the absolute scan root out of a whole document
17
+ * before sending it; this one sends a list of `{ rule, location, severity }`
18
+ * rows rather than the document, but it scrubs the same way and with the same
19
+ * function, because `location` is half of the identity the route matches on.
20
+ *
21
+ * That was the bug this file shipped with. A resolver reports `file` as an
22
+ * absolute path — `/home/runner/work/Starklab/Starklab/packages/...` on CI,
23
+ * `/Users/<someone>/...` on a laptop — and the header here claimed it was
24
+ * already target-relative, so nothing scrubbed it. The full-scan half did
25
+ * scrub, storing `<root>/src/Avatar/Avatar.jsx`, so the two halves compared
26
+ * two spellings of the same file and agreed on nothing: on a 222-finding
27
+ * scan the check reported 222 new and 222 fixed, every push, forever. Equal
28
+ * counts are the signature — one set of findings counted twice under two
29
+ * identities.
30
+ *
31
+ * So the scrub is not tidiness here, it is the wire's correctness, and it is
32
+ * `scrubForeignScanPaths` rather than a local `startsWith(root)` for the same
33
+ * reason the fingerprint is `findingFingerprint`: the two halves agree because
34
+ * they run the same code, not because two implementations were written to
35
+ * match. `root` is therefore required by `reportPrCheck` — without it the
36
+ * locations are unscrubbed and the check is confidently wrong, which is worse
37
+ * than the honest `{ reported: false }` a missing field produces.
38
+ *
39
+ * ## Why this is a translation and not a forward
40
+ *
41
+ * The route wants one shape. `runAdopt` produces several, because each resolver
42
+ * grew its own:
43
+ *
44
+ * tokenAliases / tailwind / propApi / rnTokenAliases / rnTailwind
45
+ * { rule, severity, file, line, property | component + prop, … }
46
+ * — the common shape, and the only one Dominion stores
47
+ * usageRules
48
+ * { severity: 'Info', finding, violated, owner, fix, file }
49
+ * — no `rule`, no `line`, and a capitalized severity
50
+ * a11y
51
+ * { rule, file: <a page URL>, selector, severity }
52
+ * — `file` is a URL standing in for a path (a11yPass.js says so)
53
+ *
54
+ * Two of those cannot be forwarded as-is, and the failure would not be loud:
55
+ * the route builds a GitHub annotation by splitting `location` on `:` and
56
+ * prefixing the target dir, so an a11y finding would post an annotation on the
57
+ * path `<dir>/http` and a capitalized `Info` would miss the severity map. The
58
+ * second is harmless (it falls through to `notice`); the first is a wrong
59
+ * annotation on a file that does not exist, which is worse than no annotation.
60
+ *
61
+ * ## Why the subject fields travel, and why `violated` does not
62
+ *
63
+ * The route answers one question — is this finding *new on this commit* — by
64
+ * matching what we send against what the last full scan stored. A finding it
65
+ * cannot match reads as new every time, so agreeing with the store is not a
66
+ * detail here, it is the whole value of the check.
67
+ *
68
+ * Two consequences, and both are why this file changed after the wire first
69
+ * ran against production:
70
+ *
71
+ * The store's identity is `rule:file[:line]:subject`, where the subject is
72
+ * the custom property, or `Component.prop`, that separates two findings of
73
+ * one rule on one line. So `property`, `component` and `prop` are forwarded
74
+ * verbatim — not because the route reads them, but because it rebuilds that
75
+ * subject from them (apps/dominion/lib/prCheckDiff.ts).
76
+ *
77
+ * `usageRules` is not a section Dominion stores at all, so its findings can
78
+ * never match anything and would be reported as new on every pull request
79
+ * forever. They are counted as unsendable rather than sent — an honest gap
80
+ * beats permanent noise. Bringing them back is a change to what the scan
81
+ * ingests, not to what this reporter sends.
82
+ *
83
+ * ## Why the walk is generic
84
+ *
85
+ * `collectPrCheckFindings` recurses for any array under a `findings` key rather
86
+ * than reading a fixed list of resolver names. A hard-coded list is a thing to
87
+ * forget: the next resolver that reports findings would be silently absent from
88
+ * every pull request check, and nothing would say so. The generic walk means a
89
+ * new resolver is included the day it lands, and the shape rules below decide
90
+ * whether its findings can be placed in a file — the only question that matters
91
+ * here.
92
+ *
93
+ * ## What is dropped, and why nothing is dropped silently
94
+ *
95
+ * A finding reaches the route only if it can name a rule and a source location.
96
+ * Everything else is counted into `skipped` and reported to stderr by the CLI,
97
+ * because a finding that quietly does not travel is the exact failure this file
98
+ * exists to prevent:
99
+ *
100
+ * noRule — no `rule`. That is every `usageRules` finding (it names a
101
+ * `violated` pattern instead) and nothing else. The rule name
102
+ * is half the stored identity, so a finding without one could
103
+ * never be recognised again and never resolve as "fixed".
104
+ * noFile — the resolver reported a property or a component but no file
105
+ * (several token findings are like this). There is no line to
106
+ * annotate.
107
+ * notInFile — `file` contains a `:`. That is either a URL (every a11y
108
+ * finding) or an absolute Windows-style path, and both break
109
+ * the route's `location.split(':')`. One rule covers both; it
110
+ * is the reason a11y needs no special case. The test runs
111
+ * after the scrub, so a Windows root that became `<root>` is
112
+ * no longer caught by it — which is correct: it is a path the
113
+ * route can now split.
114
+ *
115
+ * Duplicates are folded before sending: two findings with the same rule and
116
+ * location are one fingerprint to the route, and sending both would post the
117
+ * same annotation twice.
118
+ */
119
+
120
+ /** A location the route can split back into a file and a line. */
121
+ function locationFor(finding) {
122
+ const file = typeof finding.file === 'string' ? finding.file.trim() : '';
123
+ if (!file) return { ok: false, why: 'noFile' };
124
+ if (file.includes(':')) return { ok: false, why: 'notInFile' };
125
+ const line = Number(finding.line);
126
+ return { ok: true, location: Number.isInteger(line) && line > 0 ? `${file}:${line}` : file };
127
+ }
128
+
129
+ /** The rule name the fingerprint is built on. */
130
+ function ruleFor(finding) {
131
+ return typeof finding.rule === 'string' && finding.rule.trim() ? finding.rule.trim() : null;
132
+ }
133
+
134
+ /**
135
+ * The fields the route needs to rebuild the stored subject, and nothing else —
136
+ * a payload key the route does not read is a key it could one day read wrong.
137
+ */
138
+ function subjectFor(finding) {
139
+ const subject = {};
140
+ for (const key of ['property', 'component', 'prop']) {
141
+ const value = finding[key];
142
+ if (typeof value === 'string' && value.trim()) subject[key] = value.trim();
143
+ }
144
+ return subject;
145
+ }
146
+
147
+ /**
148
+ * Walks a `runAdopt` result and returns every finding the route can place,
149
+ * plus a count of the ones it cannot.
150
+ *
151
+ * Exported for its own tests and because the CLI prints the skip counts.
152
+ */
153
+ export function collectPrCheckFindings(result) {
154
+ const findings = [];
155
+ const seen = new Set();
156
+ const skipped = { noRule: 0, noFile: 0, notInFile: 0 };
157
+
158
+ const visit = (value, key) => {
159
+ if (Array.isArray(value)) {
160
+ if (key === 'findings') {
161
+ for (const f of value) {
162
+ if (!f || typeof f !== 'object') continue;
163
+
164
+ const rule = ruleFor(f);
165
+ if (!rule) {
166
+ skipped.noRule += 1;
167
+ continue;
168
+ }
169
+
170
+ const where = locationFor(f);
171
+ if (!where.ok) {
172
+ skipped[where.why] += 1;
173
+ continue;
174
+ }
175
+
176
+ // The route's own severity map is lowercase; `Info` is a live
177
+ // spelling. An unrecognized one becomes `info` rather than being
178
+ // dropped — a finding with an odd severity is still a finding, and
179
+ // `info` is the bucket that cannot fail a check on its own.
180
+ const severity = normalizeSeverity(f.severity) ?? 'info';
181
+
182
+ const subject = subjectFor(f);
183
+
184
+ // The subject is part of what makes two findings different, so it is
185
+ // part of what makes two of them the same: without it, two hardcoded
186
+ // properties in one declaration block fold into one annotation.
187
+ const fingerprint = JSON.stringify([rule, where.location, subject]);
188
+ if (seen.has(fingerprint)) continue;
189
+ seen.add(fingerprint);
190
+
191
+ findings.push({ rule, location: where.location, severity, ...subject });
192
+ }
193
+ return;
194
+ }
195
+ for (const child of value) visit(child, key);
196
+ return;
197
+ }
198
+ if (value && typeof value === 'object') {
199
+ for (const [childKey, child] of Object.entries(value)) visit(child, childKey);
200
+ }
201
+ };
202
+
203
+ visit(result, null);
204
+ return { findings, skipped };
205
+ }
206
+
207
+ /**
208
+ * Builds the request body POST /api/pr-check expects.
209
+ *
210
+ * `root` is the directory the scan ran against, and the scrub that uses it is
211
+ * what makes a location here the same string the full scan stored — see this
212
+ * file's header for what happens when it is missing.
213
+ */
214
+ export function buildPrCheckReport(result, { targetDir, commitSha, root }) {
215
+ const { findings, skipped } = collectPrCheckFindings(scrubForeignScanPaths(result, root));
216
+ return { body: { targetDir, commitSha, findings }, skipped };
217
+ }
218
+
219
+ /**
220
+ * POSTs the findings and resolves to an outcome record. Never throws, never
221
+ * rejects — see this file's header.
222
+ *
223
+ * `fetchImpl` exists for tests; production always uses global fetch.
224
+ */
225
+ export async function reportPrCheck(result, {
226
+ url,
227
+ token,
228
+ root,
229
+ targetDir,
230
+ commitSha,
231
+ timeoutMs = 15000,
232
+ fetchImpl = globalThis.fetch,
233
+ } = {}) {
234
+ const missing = Object.entries({ url, token, root, targetDir, commitSha })
235
+ .filter(([, v]) => !v)
236
+ .map(([k]) => k);
237
+ if (missing.length > 0) {
238
+ return { reported: false, reason: `Missing required reporting field(s): ${missing.join(', ')}.` };
239
+ }
240
+
241
+ const { body, skipped } = buildPrCheckReport(result, { targetDir, commitSha, root });
242
+
243
+ let response;
244
+ try {
245
+ response = await fetchImpl(url, {
246
+ method: 'POST',
247
+ headers: {
248
+ 'content-type': 'application/json',
249
+ authorization: `Bearer ${token}`,
250
+ },
251
+ body: JSON.stringify(body),
252
+ signal: AbortSignal.timeout(timeoutMs),
253
+ });
254
+ } catch (err) {
255
+ // Network down, DNS failure, TLS error, timeout. The scan itself already
256
+ // succeeded and has already been printed.
257
+ return { reported: false, reason: `Could not reach ${url}: ${err.message}`, skipped };
258
+ }
259
+
260
+ const text = await response.text().catch(() => '');
261
+ let parsed = null;
262
+ try {
263
+ parsed = text ? JSON.parse(text) : null;
264
+ } catch {
265
+ // A proxy or error page rather than the API. Surfaced as-is below.
266
+ }
267
+
268
+ if (!response.ok) {
269
+ const detail = parsed?.error ?? (text ? text.slice(0, 200) : '(empty response)');
270
+ return { reported: false, status: response.status, reason: `${url} returned ${response.status}: ${detail}`, skipped };
271
+ }
272
+
273
+ return { reported: true, status: response.status, response: parsed, sent: body.findings.length, skipped };
274
+ }
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import traverseModule from '@babel/traverse';
4
4
 
5
5
  import { loadCatalog, packageNameForPlatform } from './catalog.js';
6
- import { buildModuleGraph, resolveOrigin } from './moduleGraph.js';
6
+ import { buildModuleGraph, packageOfSpecifier, resolveOrigin } from './moduleGraph.js';
7
7
  import { getComponentProps } from '../data.js';
8
8
 
9
9
  const traverse = traverseModule.default ?? traverseModule;
@@ -228,7 +228,7 @@ export function resolvePropApi(root, { platform = 'web', ignore = [] } = {}) {
228
228
  const localToComponent = new Map();
229
229
  for (const localName of entry.imports.keys()) {
230
230
  const origin = resolveOrigin(moduleGraph, file, localName);
231
- if (origin?.pkg === pkgName && byName.has(origin.name)) {
231
+ if (packageOfSpecifier(origin?.pkg) === pkgName && byName.has(origin.name)) {
232
232
  localToComponent.set(localName, origin.name);
233
233
  }
234
234
  }
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import traverseModule from '@babel/traverse';
4
4
 
5
5
  import { loadCatalog, packageNameForPlatform } from './catalog.js';
6
- import { buildModuleGraph, resolveOrigin } from './moduleGraph.js';
6
+ import { buildModuleGraph, packageOfSpecifier, resolveOrigin } from './moduleGraph.js';
7
7
 
8
8
  const traverse = traverseModule.default ?? traverseModule;
9
9
 
@@ -106,7 +106,7 @@ export function resolveReferences(root, { platform = 'web', ignore = [] } = {})
106
106
  const localToComponent = new Map();
107
107
  for (const localName of entry.imports.keys()) {
108
108
  const origin = resolveOrigin(moduleGraph, file, localName);
109
- if (origin?.pkg === pkgName && byName.has(origin.name)) {
109
+ if (packageOfSpecifier(origin?.pkg) === pkgName && byName.has(origin.name)) {
110
110
  localToComponent.set(localName, origin.name);
111
111
  }
112
112
  }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The rollup: bounded, deterministic per-`(component, prop, value)` and
3
+ * per-`(component, file)` counts, folded out of the enumerations that
4
+ * adoptScanReport.js's `DROPPED_KEYS` throws away before reporting.
5
+ *
6
+ * ## Why this exists
7
+ *
8
+ * `DROPPED_KEYS` is right about the enumerations and wrong about the
9
+ * aggregate. `checks` (one row per validated JSX attribute) and `sites` (one
10
+ * row per component reference) genuinely are most of the payload on a real
11
+ * repo, and they genuinely are re-derivable by re-scanning — so dropping them
12
+ * is correct. But the consequence was that nothing below the three-number
13
+ * `report` summaries ever reached Dominion, and a question like "which
14
+ * `variant` values does this org actually use, across repos?" had no data to
15
+ * answer from even though the scanner had computed it and thrown it away.
16
+ *
17
+ * The asymmetry that makes this worth doing before customers rather than
18
+ * after: a new *query* over grain that is already stored is free and works on
19
+ * the whole history; a new *rollup* costs a migration but is recovered by
20
+ * re-scanning; teaching the scanner to compute something it never computed is
21
+ * **not retroactive at all** — there is no backfill of a repo's past, and a
22
+ * six-month trend cannot be fabricated later.
23
+ *
24
+ * ## What it is not
25
+ *
26
+ * It is derived, never authoritative. Every number here is a fold of rows the
27
+ * same scan produced, so it can always be rebuilt by re-scanning the same
28
+ * commit, and it never carries a fact no resolver established. It is computed
29
+ * in `runAdopt` — i.e. *before* `stripAdoptDetail` runs — precisely because
30
+ * its inputs do not survive that strip.
31
+ *
32
+ * ## Limits, which are the resolvers' limits and must not be overstated
33
+ *
34
+ * `props` is not a census of prop usage. `evaluateCallSite` emits a check row
35
+ * only for `enum` props and for `required` props, so a string or boolean the
36
+ * mapping file does not track leaves no trace here either; and `required` is
37
+ * not authored into any real mapping file yet, so that rule contributes
38
+ * nothing against real data today. "Prop usage" currently means *enum-value
39
+ * usage*, which is less than the phrase suggests.
40
+ *
41
+ * `props` is null — not empty — on the native platform, because
42
+ * `resolvePropApi` does not run there at all (`prop-mapping/` has no `rn/`
43
+ * subdirectory). Null means "not measured"; an empty row list means "measured,
44
+ * found nothing". Collapsing the two is the failure mode the resolvers'
45
+ * `unresolvedReason` machinery exists to prevent, so it is not done here.
46
+ */
47
+
48
+ export const ROLLUP_SCHEMA_VERSION = 1;
49
+
50
+ /**
51
+ * Per-section row cap. The rollup exists to be bounded — an unbounded fold is
52
+ * the enumeration again with extra steps — so it truncates rather than growing
53
+ * with the repo, and says so when it does.
54
+ *
55
+ * Cardinality in practice is lopsided: `props` is bounded by the catalog's own
56
+ * enum surface (components × enum props × declared values) plus whatever
57
+ * invalid values a repo invents, so it lands in the hundreds. `files` is
58
+ * bounded by files × components-per-file, and is the one that can actually
59
+ * reach the cap on a large monorepo.
60
+ */
61
+ export const MAX_ROLLUP_ROWS = 5000;
62
+
63
+ /** Invalid enum values come from the consumer's source and are arbitrary text. */
64
+ const MAX_VALUE_LENGTH = 80;
65
+
66
+ /**
67
+ * Deliberately not a printable character. Component names, prop names, file
68
+ * paths and arbitrary invalid enum values all share this key space; any
69
+ * printable separator that one of them could itself contain would let two
70
+ * different tuples collapse into the same group.
71
+ */
72
+ const KEY_SEP = '\u0000';
73
+
74
+ const KINDS = ['jsx', 'createElement', 'hoc', 'indirect', 'reexport'];
75
+
76
+ /**
77
+ * `reexport` is deliberately excluded, matching KIND_CONFIDENCE's
78
+ * 'not-a-usage' and lib/adoptionScan.ts's `usageCount`: forwarding a binding
79
+ * through `export { Button }` is not a render.
80
+ */
81
+ const USAGE_KINDS = new Set(['jsx', 'createElement', 'hoc', 'indirect']);
82
+
83
+ function normalizeValue(value) {
84
+ if (value === undefined || value === null) return null;
85
+ const text = String(value);
86
+ return text.length > MAX_VALUE_LENGTH ? `${text.slice(0, MAX_VALUE_LENGTH)}…` : text;
87
+ }
88
+
89
+ /**
90
+ * Sorts by weight descending, then by the row's own key ascending, and caps.
91
+ *
92
+ * The tie-break on the key is what makes this reproducible: two scans of the
93
+ * same commit must produce identical rollups, and Map iteration order follows
94
+ * insertion, which follows file-walk order. `distinct` is reported before the
95
+ * cap, so a truncated rollup reads as truncated instead of as a small repo.
96
+ */
97
+ function finalize(rows, weightOf, keyOf, sources) {
98
+ const distinct = rows.length;
99
+ rows.sort((a, b) => {
100
+ const byWeight = weightOf(b) - weightOf(a);
101
+ if (byWeight !== 0) return byWeight;
102
+ const ka = keyOf(a);
103
+ const kb = keyOf(b);
104
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
105
+ });
106
+ return {
107
+ rows: distinct > MAX_ROLLUP_ROWS ? rows.slice(0, MAX_ROLLUP_ROWS) : rows,
108
+ distinct,
109
+ truncated: distinct > MAX_ROLLUP_ROWS,
110
+ sources,
111
+ };
112
+ }
113
+
114
+ /** Folds propApiResolver's `checks` into (component, prop, rule, classification, value) counts. */
115
+ export function rollupProps(propApi) {
116
+ if (!propApi || !Array.isArray(propApi.checks)) return null;
117
+
118
+ const keyFor = (row) =>
119
+ [row.component, row.prop, row.rule, row.classification, row.value ?? ''].join(KEY_SEP);
120
+
121
+ const groups = new Map();
122
+ for (const check of propApi.checks) {
123
+ const row = {
124
+ component: check.component,
125
+ prop: check.prop,
126
+ rule: check.rule,
127
+ classification: check.classification,
128
+ value: normalizeValue(check.value),
129
+ count: 1,
130
+ };
131
+ const key = keyFor(row);
132
+ const existing = groups.get(key);
133
+ if (existing) existing.count += 1;
134
+ else groups.set(key, row);
135
+ }
136
+
137
+ return finalize([...groups.values()], (r) => r.count, keyFor, propApi.checks.length);
138
+ }
139
+
140
+ /**
141
+ * Folds referenceResolver's per-component `sites` into (component, file)
142
+ * counts, keeping the five reference kinds apart.
143
+ *
144
+ * The kind split is carried rather than summed because the kinds are not
145
+ * interchangeable evidence: a `jsx` reference is `certain`, an `indirect` one
146
+ * is `needs-review` (KIND_CONFIDENCE). Folding them into one number would make
147
+ * a file of ambiguous references indistinguishable from a file of confirmed
148
+ * renders — the same conflation `zeroUsage` avoids upstream. `refs` is the
149
+ * usage subtotal, and exists so the common question does not have to re-derive
150
+ * which kinds count.
151
+ */
152
+ export function rollupFiles(components) {
153
+ if (!Array.isArray(components)) return null;
154
+
155
+ const keyFor = (row) => [row.component, row.file].join(KEY_SEP);
156
+
157
+ const groups = new Map();
158
+ let sources = 0;
159
+ for (const component of components) {
160
+ if (!Array.isArray(component?.sites)) continue;
161
+ for (const site of component.sites) {
162
+ sources += 1;
163
+ const row = { component: component.name, file: site.file, refs: 0 };
164
+ const key = keyFor(row);
165
+ let group = groups.get(key);
166
+ if (!group) {
167
+ for (const kind of KINDS) row[kind] = 0;
168
+ group = row;
169
+ groups.set(key, group);
170
+ }
171
+ if (KINDS.includes(site.kind)) group[site.kind] += 1;
172
+ if (USAGE_KINDS.has(site.kind)) group.refs += 1;
173
+ }
174
+ }
175
+
176
+ return finalize([...groups.values()], (r) => r.refs, keyFor, sources);
177
+ }
178
+
179
+ /**
180
+ * Builds the whole rollup from a composed `runAdopt` result.
181
+ *
182
+ * Takes the composed result rather than the individual resolvers so there is
183
+ * exactly one place that knows which resolver each section folds, and so a
184
+ * resolver that did not run stays null instead of silently becoming zero.
185
+ */
186
+ export function buildScanRollup(result) {
187
+ return {
188
+ schema: ROLLUP_SCHEMA_VERSION,
189
+ props: rollupProps(result?.propApi),
190
+ files: rollupFiles(result?.components),
191
+ };
192
+ }
@@ -313,6 +313,9 @@ function classifyThemeProperties(propGraph, themeEntryNames, inventory, layers)
313
313
  if (state === 'broken') {
314
314
  findings.push({ rule: 'broken-alias', severity: 'critical', property: name, reason: terminal.kind });
315
315
  }
316
+ if (state === 'unresolved') {
317
+ findings.push({ rule: 'unresolved-alias', severity: 'info', property: name, reason: 'terminal-not-in-scanned-css' });
318
+ }
316
319
  if (terminal.rawFallbackPresent) {
317
320
  findings.push({ rule: 'raw-fallback', severity: 'info', property: name });
318
321
  }
@@ -373,7 +376,9 @@ function classifyClassNameUsage(cls, propGraph, themeEntryNames, inventory, laye
373
376
  : { classification: 'broken', reason: 'undefined-stk-token' };
374
377
  }
375
378
  const bySelector = propGraph.get(name);
376
- if (!bySelector) return { classification: 'broken', reason: 'undefined-property' };
379
+ // 'unresolved', not 'broken' the definition may be @import'ed from a
380
+ // sibling package or injected at runtime; see classifyTerminalState.
381
+ if (!bySelector) return { classification: 'unresolved', reason: 'undefined-property' };
377
382
  const bound = bySelector.has(':root') ? ':root' : [...bySelector.keys()][0];
378
383
  const terminal = resolveTerminal(propGraph, name, bound, inventory, layers, new Set(), 0);
379
384
  const state = classifyTerminalState(terminal.kind);
@@ -6,6 +6,7 @@ import fg from 'fast-glob';
6
6
  const STK_PREFIX = '@starklab/stk';
7
7
  const UI_FRAMEWORK_DEPS = ['react', 'react-dom', 'react-native', 'expo', 'preact', 'vue', 'svelte'];
8
8
  const DEFAULT_IGNORE = ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/coverage/**'];
9
+ const UI_SOURCE_GLOBS = ['**/*.tsx', '**/*.jsx', '**/*.vue', '**/*.svelte'];
9
10
 
10
11
  function readJson(file) {
11
12
  try {
@@ -120,6 +121,32 @@ function hasUiFrameworkDep(pkg) {
120
121
  return UI_FRAMEWORK_DEPS.some((d) => deps.has(d));
121
122
  }
122
123
 
124
+ /**
125
+ * Second half of the `rendersUi` signal, and the reason it isn't a
126
+ * package.json read alone: a declared React dependency is evidence of
127
+ * *linking against* a UI framework, not of *containing* UI. Measured live
128
+ * against openstatusHQ/openstatus (45 workspace targets, none Stark-
129
+ * dependent): the dependency check alone produced 9 opportunity rows, of
130
+ * which 2 held no markup at all — `packages/api` (71 source files, zero
131
+ * JSX; it depends on react only to render @openstatus/emails templates
132
+ * server-side) and `packages/notifications/email` (5 files, zero JSX,
133
+ * react-dom for the same reason). Both are backend packages, i.e. exactly
134
+ * the rows an opportunity list must not carry, since its stated job is
135
+ * naming what to bring into scope next. A markup-file glob removes both
136
+ * and keeps all 7 true positives; no target in that repo has JSX without
137
+ * also declaring the framework, so requiring both signals cost no recall.
138
+ *
139
+ * A file listing, not a parse — the AST oracle used to establish those
140
+ * numbers agrees with the extension for every one of the 45 targets, and
141
+ * TypeScript will not compile JSX out of a .ts file anyway.
142
+ */
143
+ function hasUiSourceFiles(dir) {
144
+ return (
145
+ fg.sync(UI_SOURCE_GLOBS, { cwd: dir, ignore: DEFAULT_IGNORE, onlyFiles: true, suppressErrors: true })
146
+ .length > 0
147
+ );
148
+ }
149
+
123
150
  /**
124
151
  * Discovers every workspace target in a repo and classifies each in/out of
125
152
  * scope by direct-or-transitive dependency on @starklab/stk*
@@ -134,8 +161,9 @@ function hasUiFrameworkDep(pkg) {
134
161
  * can't be a scan target of its own anyway.
135
162
  *
136
163
  * A second, independent signal — `rendersUi` — flags whether an excluded
137
- * target still has a UI framework dependency (React/RN/etc.) despite having
138
- * no stk* dependency. These are the "opportunity list": excluded rows that
164
+ * target still contains UI code (a React/RN/etc. dependency *and* at least
165
+ * one markup source file see hasUiSourceFiles) despite having no stk*
166
+ * dependency. These are the "opportunity list": excluded rows that
139
167
  * must never read as zeros inside a score, because they're the most
140
168
  * valuable rows to bring into scope next.
141
169
  */
@@ -176,7 +204,7 @@ export function discoverTargets(root) {
176
204
  const isForceIncluded = forceInclude.includes(relPath);
177
205
  const forceExcludeReason = forceExclude[relPath];
178
206
  const dependsOnStk = transitivelyDependsOnStk(t, new Set());
179
- const rendersUi = t.pkg ? hasUiFrameworkDep(t.pkg) : false;
207
+ const rendersUi = t.pkg ? hasUiFrameworkDep(t.pkg) && hasUiSourceFiles(t.dir) : false;
180
208
 
181
209
  let inScope;
182
210
  let scopeReason;