@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
package/src/adopt/moduleGraph.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
138
|
-
*
|
|
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;
|