@starklab/stark-mcp 0.1.0 → 0.2.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/package.json +10 -4
- package/src/adopt/adoptScanReport.js +124 -0
- package/src/adopt/catalog.js +26 -6
- package/src/adopt/foreignDiscoveryResolver.js +276 -0
- package/src/adopt/foreignPropSchemaResolver.js +134 -0
- package/src/adopt/foreignScanReport.js +210 -0
- package/src/adopt/foreignScoringResolver.js +192 -0
- package/src/adopt/foreignSystemConfig.js +356 -0
- package/src/adopt/installedPackageDiscoveryResolver.js +602 -0
- package/src/adopt/installedPackagePropSchemaResolver.js +279 -0
- package/src/adopt/installedPackageScoringResolver.js +153 -0
- package/src/adopt/installedSystemAutoDetector.js +51 -0
- package/src/adopt/installedSystemScan.js +101 -0
- package/src/adopt/jsxOpportunityHelpers.js +99 -0
- package/src/adopt/moduleGraph.js +39 -8
- package/src/adopt/opportunityResolver.js +255 -0
- package/src/adopt/opportunitySignaturesNative.js +47 -0
- package/src/adopt/usageRulesResolver.js +298 -0
- package/src/adopt/vecnaMaterializer.js +165 -0
- package/src/adopt/vecnaVerifier.js +127 -0
- package/src/cli.js +407 -1
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +0 -21
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +0 -13
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +0 -11
- package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +0 -34
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +0 -8
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +0 -9
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +0 -9
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +0 -7
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +0 -12
- package/src/adopt/dominionFixture.test.js +0 -165
- package/src/adopt/propApiResolver.test.js +0 -229
- package/src/adopt/referenceResolver.test.js +0 -213
- package/src/adopt/rnTailwindResolver.test.js +0 -263
- package/src/adopt/rnTokenAliasResolver.test.js +0 -260
- package/src/adopt/tailwindResolver.test.js +0 -178
- package/src/adopt/targetDiscovery.test.js +0 -227
- package/src/adopt/tokenAliasResolver.test.js +0 -319
- package/src/adopt/wrapperResolver.test.js +0 -324
- package/src/data.test.js +0 -231
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The reporting half of `scan-foreign --report` (ADOPTION_APP_PLAN.md §10
|
|
6
|
+
* decision #25): turns a scan result into the payload Dominion's
|
|
7
|
+
* POST /api/foreign-scan accepts, and sends it.
|
|
8
|
+
*
|
|
9
|
+
* Two properties matter more than anything else here, and both are about
|
|
10
|
+
* what this must NOT do:
|
|
11
|
+
*
|
|
12
|
+
* 1. **It must never break the consumer's CI.** This runs inside somebody
|
|
13
|
+
* else's pipeline, appended to a command whose actual job is printing a
|
|
14
|
+
* scan to stdout. A tracker being down, a rotated token, a typo'd URL —
|
|
15
|
+
* none of that is a reason to fail their build. Every failure path here
|
|
16
|
+
* returns `{ reported: false, reason }`; nothing throws, and the caller
|
|
17
|
+
* never touches `process.exitCode`.
|
|
18
|
+
* 2. **It must not leak the customer's filesystem layout.** The scan is run
|
|
19
|
+
* against real absolute paths and several output fields embed them
|
|
20
|
+
* verbatim. Reporting is the moment those paths would leave the
|
|
21
|
+
* customer's machine and get persisted in someone's database, so the
|
|
22
|
+
* scrub belongs here rather than in the resolvers — stdout keeps the real
|
|
23
|
+
* paths, which is what a human debugging a local scan actually wants.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Fields confirmed to carry an absolute path, by inspecting real scan output
|
|
28
|
+
* from three live fixtures rather than reasoning about the code:
|
|
29
|
+
*
|
|
30
|
+
* discovery.root / propSchema.root / scoring.root (and per-package copies)
|
|
31
|
+
* root (multi-package top level)
|
|
32
|
+
* discovery.unresolvedReason — the path sits inside prose
|
|
33
|
+
* propSchema.sampled[].typeName — `import("/abs/path").Foo`
|
|
34
|
+
* propSchema.sampled[].props[].type — same
|
|
35
|
+
*
|
|
36
|
+
* Everything else — `typesEntryFile`, `evidence[].file`, `components[].file`,
|
|
37
|
+
* `duplicates[].file` — is already repo-relative.
|
|
38
|
+
*
|
|
39
|
+
* That spread is why this is a deep walk over every string rather than a
|
|
40
|
+
* list of known keys: two of the five are free prose and generated type
|
|
41
|
+
* strings, where a path can appear anywhere in the value and a key-by-key
|
|
42
|
+
* scrub would quietly miss it the first time a message is reworded.
|
|
43
|
+
*/
|
|
44
|
+
function replacements(root) {
|
|
45
|
+
const home = os.homedir();
|
|
46
|
+
let realRoot = null;
|
|
47
|
+
try {
|
|
48
|
+
realRoot = realpathSync(root);
|
|
49
|
+
} catch {
|
|
50
|
+
// A root that no longer resolves is not a reporting failure — the scan
|
|
51
|
+
// already ran against it. Fall through with the literal path only.
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Longest first: on macOS `realpathSync('/tmp/x')` is `/private/tmp/x`, so
|
|
55
|
+
// both spellings are live at once and replacing the shorter one first
|
|
56
|
+
// would leave the `/private` prefix stranded in the output.
|
|
57
|
+
return [
|
|
58
|
+
...new Set([realRoot, root].filter(Boolean)),
|
|
59
|
+
home,
|
|
60
|
+
]
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.sort((a, b) => b.length - a.length)
|
|
63
|
+
.map((from) => ({ from, to: from === home ? '<home>' : '<root>' }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Anything still absolute after the known prefixes are gone — a hoisted
|
|
67
|
+
// node_modules above the scan root in a monorepo, a global npm cache. Kept
|
|
68
|
+
// deliberately narrow (user-home-shaped paths only) so it can't chew through
|
|
69
|
+
// legitimate content like a URL or a package name.
|
|
70
|
+
const RESIDUAL_ABSOLUTE = /(?:\/(?:Users|home)\/[^/\s"')]+|[A-Za-z]:\\Users\\[^\\\s"')]+)(?:[/\\][^\s"')]*)?/g;
|
|
71
|
+
|
|
72
|
+
// Absorbs a path prefix left stranded immediately in front of a placeholder.
|
|
73
|
+
// This happens when the payload carries a spelling of the root that isn't in
|
|
74
|
+
// the substitution list — macOS resolves /tmp/x to /private/tmp/x, so if the
|
|
75
|
+
// root can no longer be realpath'd (it was deleted, or the scan is being
|
|
76
|
+
// re-reported later) only one spelling is replaced and "/private<root>" is
|
|
77
|
+
// what's left. A half-replaced path is worse than either whole outcome: it
|
|
78
|
+
// still leaks, and it no longer looks like a path to anything downstream.
|
|
79
|
+
const STRANDED_PREFIX = /(?:\/|[A-Za-z]:\\)[^\s"')]*?(?=<root>|<home>)/g;
|
|
80
|
+
|
|
81
|
+
function scrubString(value, subs) {
|
|
82
|
+
let out = value;
|
|
83
|
+
for (const { from, to } of subs) {
|
|
84
|
+
if (out.includes(from)) out = out.split(from).join(to);
|
|
85
|
+
}
|
|
86
|
+
return out.replace(STRANDED_PREFIX, '').replace(RESIDUAL_ABSOLUTE, '<path>');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Deep-copies `value`, rewriting every absolute path found in any string.
|
|
91
|
+
* Object keys are walked but never rewritten: no key in any scan output shape
|
|
92
|
+
* is a path (verified against all three shapes), and rewriting keys would
|
|
93
|
+
* risk collapsing two distinct entries into one.
|
|
94
|
+
*/
|
|
95
|
+
export function scrubForeignScanPaths(value, root) {
|
|
96
|
+
const subs = replacements(root);
|
|
97
|
+
|
|
98
|
+
const walk = (node) => {
|
|
99
|
+
if (typeof node === 'string') return scrubString(node, subs);
|
|
100
|
+
if (Array.isArray(node)) return node.map(walk);
|
|
101
|
+
if (node && typeof node === 'object') {
|
|
102
|
+
const out = {};
|
|
103
|
+
for (const [key, child] of Object.entries(node)) out[key] = walk(child);
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
return node;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return walk(value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Drops `propSchema` from what gets reported — top level for a single-package
|
|
114
|
+
* scan, per entry for a multi-package one.
|
|
115
|
+
*
|
|
116
|
+
* This is a deliberate reduction, not an oversight. Dominion's ingestion
|
|
117
|
+
* reads discovery, coverage and totals; it never reads a prop schema, and the
|
|
118
|
+
* catalog is re-derivable by re-scanning. Keeping it would ship a great deal
|
|
119
|
+
* of JSON nobody consumes: on a real @mui/material scan the reported payload
|
|
120
|
+
* goes from 2.18 MB to 10.6 KB. It also happens to be where most of the
|
|
121
|
+
* absolute paths live, so dropping it shrinks the leak surface at the same
|
|
122
|
+
* time — but the scrub above stands on its own and does not depend on this.
|
|
123
|
+
*
|
|
124
|
+
* stdout is untouched either way. `--report` changes what is sent, never what
|
|
125
|
+
* the command prints.
|
|
126
|
+
*/
|
|
127
|
+
export function stripPropSchema(result) {
|
|
128
|
+
const { propSchema, ...rest } = result;
|
|
129
|
+
if (Array.isArray(rest.packages)) {
|
|
130
|
+
rest.packages = rest.packages.map((entry) => {
|
|
131
|
+
const { propSchema: _dropped, ...keep } = entry;
|
|
132
|
+
return keep;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return rest;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Builds the request body POST /api/foreign-scan expects. `label` is passed
|
|
140
|
+
* separately because a single-package result has nowhere to carry it — only
|
|
141
|
+
* the caller knows which registry entry it resolved.
|
|
142
|
+
*/
|
|
143
|
+
export function buildForeignScanReport(result, { root, targetDir, commitSha, scannerVersion, label = null }) {
|
|
144
|
+
return {
|
|
145
|
+
targetDir,
|
|
146
|
+
commitSha,
|
|
147
|
+
scannerVersion,
|
|
148
|
+
...(label ? { label } : {}),
|
|
149
|
+
result: scrubForeignScanPaths(stripPropSchema(result), root),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* POSTs the scan and resolves to an outcome record. Never throws, never
|
|
155
|
+
* rejects — see property (1) in this file's header.
|
|
156
|
+
*
|
|
157
|
+
* `fetchImpl` exists for tests; production always uses global fetch.
|
|
158
|
+
*/
|
|
159
|
+
export async function reportForeignScan(result, {
|
|
160
|
+
url,
|
|
161
|
+
token,
|
|
162
|
+
root,
|
|
163
|
+
targetDir,
|
|
164
|
+
commitSha,
|
|
165
|
+
scannerVersion,
|
|
166
|
+
label = null,
|
|
167
|
+
timeoutMs = 15000,
|
|
168
|
+
fetchImpl = globalThis.fetch,
|
|
169
|
+
} = {}) {
|
|
170
|
+
const missing = Object.entries({ url, token, targetDir, commitSha, scannerVersion })
|
|
171
|
+
.filter(([, v]) => !v)
|
|
172
|
+
.map(([k]) => k);
|
|
173
|
+
if (missing.length > 0) {
|
|
174
|
+
return { reported: false, reason: `Missing required reporting field(s): ${missing.join(', ')}.` };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const body = buildForeignScanReport(result, { root, targetDir, commitSha, scannerVersion, label });
|
|
178
|
+
|
|
179
|
+
let response;
|
|
180
|
+
try {
|
|
181
|
+
response = await fetchImpl(url, {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers: {
|
|
184
|
+
'content-type': 'application/json',
|
|
185
|
+
authorization: `Bearer ${token}`,
|
|
186
|
+
},
|
|
187
|
+
body: JSON.stringify(body),
|
|
188
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
189
|
+
});
|
|
190
|
+
} catch (err) {
|
|
191
|
+
// Network down, DNS failure, TLS error, timeout. The scan itself already
|
|
192
|
+
// succeeded and has already been printed.
|
|
193
|
+
return { reported: false, reason: `Could not reach ${url}: ${err.message}` };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const text = await response.text().catch(() => '');
|
|
197
|
+
let parsed = null;
|
|
198
|
+
try {
|
|
199
|
+
parsed = text ? JSON.parse(text) : null;
|
|
200
|
+
} catch {
|
|
201
|
+
// A proxy or error page rather than the API. Surfaced as-is below.
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!response.ok) {
|
|
205
|
+
const detail = parsed?.error ?? (text ? text.slice(0, 200) : '(empty response)');
|
|
206
|
+
return { reported: false, status: response.status, reason: `${url} returned ${response.status}: ${detail}` };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { reported: true, status: response.status, response: parsed };
|
|
210
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { getForeignSystem } from './foreignSystemConfig.js';
|
|
5
|
+
import { buildModuleGraph, resolveCandidateFile, resolveRelative } from './moduleGraph.js';
|
|
6
|
+
import { resolveOpportunities } from './opportunityResolver.js';
|
|
7
|
+
|
|
8
|
+
// Strips // and block comments from JSONC source, string-literal-aware — a
|
|
9
|
+
// naive regex stripper is NOT safe here: a first attempt matched the block-
|
|
10
|
+
// comment opener inside the path pattern "@/*" itself and ate everything up
|
|
11
|
+
// to the next closer, which it found inside the unrelated glob for .ts
|
|
12
|
+
// files in tsconfig.json's own "include" array, corrupting the whole file.
|
|
13
|
+
// Caught live against shadcn-ui/taxonomy's real tsconfig.json, which
|
|
14
|
+
// contains both.
|
|
15
|
+
function stripJsonComments(src) {
|
|
16
|
+
let out = '';
|
|
17
|
+
let inString = false;
|
|
18
|
+
let stringChar = '';
|
|
19
|
+
for (let i = 0; i < src.length; i++) {
|
|
20
|
+
const c = src[i];
|
|
21
|
+
if (inString) {
|
|
22
|
+
out += c;
|
|
23
|
+
if (c === '\\') {
|
|
24
|
+
out += src[++i] ?? '';
|
|
25
|
+
} else if (c === stringChar) {
|
|
26
|
+
inString = false;
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (c === '"' || c === "'") {
|
|
31
|
+
inString = true;
|
|
32
|
+
stringChar = c;
|
|
33
|
+
out += c;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (c === '/' && src[i + 1] === '/') {
|
|
37
|
+
while (i < src.length && src[i] !== '\n') i++;
|
|
38
|
+
out += '\n';
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (c === '/' && src[i + 1] === '*') {
|
|
42
|
+
i += 2;
|
|
43
|
+
while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
|
|
44
|
+
i++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
out += c;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reads compilerOptions.paths/baseUrl from the target's own tsconfig.json
|
|
54
|
+
* (falling back to jsconfig.json). A live run against shadcn-ui/taxonomy
|
|
55
|
+
* caught the reason this exists: every real Next.js + shadcn consumer
|
|
56
|
+
* imports its own components via the `@/*` alias (`create-next-app`'s and
|
|
57
|
+
* shadcn's own CLI's default), never a relative path — without this,
|
|
58
|
+
* scoreCoverage() only ever sees the 0-import case. Trailing commas are
|
|
59
|
+
* also stripped tolerantly since real tsconfig.json is JSONC, not strict
|
|
60
|
+
* JSON; a config that still fails to parse just yields no aliases, same as
|
|
61
|
+
* a repo with no path aliases at all.
|
|
62
|
+
*/
|
|
63
|
+
function loadPathAliases(root) {
|
|
64
|
+
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
|
65
|
+
const file = path.join(root, name);
|
|
66
|
+
if (!existsSync(file)) continue;
|
|
67
|
+
try {
|
|
68
|
+
const raw = stripJsonComments(readFileSync(file, 'utf-8')).replace(/,(\s*[}\]])/g, '$1');
|
|
69
|
+
const json = JSON.parse(raw);
|
|
70
|
+
const paths = json.compilerOptions?.paths;
|
|
71
|
+
if (!paths) continue;
|
|
72
|
+
return { baseUrl: json.compilerOptions?.baseUrl || '.', paths };
|
|
73
|
+
} catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Resolves a bare specifier (e.g. "@/components/ui/button") against tsconfig paths, if any match. */
|
|
81
|
+
function resolveAliasedImport(root, aliases, specifier) {
|
|
82
|
+
if (!aliases) return null;
|
|
83
|
+
const base = path.resolve(root, aliases.baseUrl);
|
|
84
|
+
for (const [pattern, targets] of Object.entries(aliases.paths)) {
|
|
85
|
+
if (pattern.endsWith('/*')) {
|
|
86
|
+
const prefix = pattern.slice(0, -2);
|
|
87
|
+
if (specifier !== prefix && !specifier.startsWith(`${prefix}/`)) continue;
|
|
88
|
+
const suffix = specifier.slice(prefix.length).replace(/^\//, '');
|
|
89
|
+
for (const target of targets) {
|
|
90
|
+
const resolved = resolveCandidateFile(path.resolve(base, target.replace(/\*$/, ''), suffix));
|
|
91
|
+
if (resolved) return resolved;
|
|
92
|
+
}
|
|
93
|
+
} else if (pattern === specifier) {
|
|
94
|
+
for (const target of targets) {
|
|
95
|
+
const resolved = resolveCandidateFile(path.resolve(base, target));
|
|
96
|
+
if (resolved) return resolved;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Coverage — which of the components resolveForeignDiscovery found are
|
|
105
|
+
* actually imported anywhere else in the repo, vs. the full inventory the
|
|
106
|
+
* componentDir holds. shadcn components are local files, never bare
|
|
107
|
+
* package specifiers, so the origin?.pkg === pkgName filter every other
|
|
108
|
+
* resolver uses (usageRulesResolver.js, propApiResolver.js) doesn't apply
|
|
109
|
+
* here — instead this walks every file's relative imports through
|
|
110
|
+
* moduleGraph.js's own resolveRelative() and checks whether the resolved
|
|
111
|
+
* absolute path lands on a discovered component file. A local-file import
|
|
112
|
+
* edge stands in for "used" — no JSX-usage confirmation beyond that, which
|
|
113
|
+
* is exactly the "structural, not opinionated" scope Layer 3 commits to.
|
|
114
|
+
*/
|
|
115
|
+
function scoreCoverage(root, ignore, components) {
|
|
116
|
+
const moduleGraph = buildModuleGraph(root, { ignore });
|
|
117
|
+
const aliases = loadPathAliases(root);
|
|
118
|
+
const absComponentFiles = components.map((c) => path.resolve(root, c.file));
|
|
119
|
+
const absSet = new Set(absComponentFiles);
|
|
120
|
+
const usageCounts = new Map(absComponentFiles.map((f) => [f, 0]));
|
|
121
|
+
|
|
122
|
+
for (const file of moduleGraph.files) {
|
|
123
|
+
const entry = moduleGraph.graph.get(file);
|
|
124
|
+
if (!entry || entry.parseError) continue;
|
|
125
|
+
for (const { source } of entry.imports.values()) {
|
|
126
|
+
const resolved =
|
|
127
|
+
source.startsWith('.') || source.startsWith('/')
|
|
128
|
+
? resolveRelative(file, source)
|
|
129
|
+
: resolveAliasedImport(root, aliases, source);
|
|
130
|
+
if (resolved && resolved !== file && absSet.has(resolved)) {
|
|
131
|
+
usageCounts.set(resolved, usageCounts.get(resolved) + 1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const perComponent = components.map((c) => ({
|
|
137
|
+
component: c.name,
|
|
138
|
+
file: c.file,
|
|
139
|
+
usageCount: usageCounts.get(path.resolve(root, c.file)) ?? 0,
|
|
140
|
+
}));
|
|
141
|
+
const used = perComponent.filter((c) => c.usageCount > 0).length;
|
|
142
|
+
const available = perComponent.length;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
used,
|
|
146
|
+
available,
|
|
147
|
+
pct: available > 0 ? Math.round((used / available) * 1000) / 10 : null,
|
|
148
|
+
perComponent,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Duplication — reuses opportunityResolver.js's own resolveOpportunities()
|
|
154
|
+
* wholesale rather than hand-rolling a second near-identical matcher: a
|
|
155
|
+
* shadcn Button is itself a native <button> styled via a static className
|
|
156
|
+
* (Tailwind utility classes), the exact shape opportunityResolver.js's
|
|
157
|
+
* Button signature already detects. Findings inside the system's own
|
|
158
|
+
* componentDir are excluded — that's the catalog's own definition, not a
|
|
159
|
+
* duplicate of it. Phase 1 ships this one signature only, mirroring
|
|
160
|
+
* R3 dimension 1's "start narrow, expand only after a live run proves the
|
|
161
|
+
* false-positive rate low" precedent.
|
|
162
|
+
*/
|
|
163
|
+
function findDuplicates(root, ignore, system) {
|
|
164
|
+
const { opportunities } = resolveOpportunities(root, { platform: 'web', ignore });
|
|
165
|
+
const { parentName, dirName } = system.componentDir;
|
|
166
|
+
const uiDirFragment = `${parentName}${path.sep}${dirName}${path.sep}`;
|
|
167
|
+
return opportunities
|
|
168
|
+
.filter((o) => o.component === 'Button')
|
|
169
|
+
.filter((o) => !o.file.includes(uiDirFragment));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Layer 3 of "Version B" (ADOPTION_APP_PLAN.md §10 decision #25) —
|
|
174
|
+
* deliberately structural-only: catalog coverage and hand-rolled
|
|
175
|
+
* duplication, no opinionated conformance rules (Stark has no house
|
|
176
|
+
* opinion about a design system it doesn't own). No CI-gate severity for
|
|
177
|
+
* the same reason opportunityResolver.js's findings are always "Info" —
|
|
178
|
+
* this reports a fact, not a defect.
|
|
179
|
+
*/
|
|
180
|
+
export function scoreForeignAdoption(root, systemId, discovery, { ignore = [] } = {}) {
|
|
181
|
+
const system = getForeignSystem(systemId);
|
|
182
|
+
const coverage = scoreCoverage(root, ignore, discovery.components);
|
|
183
|
+
const duplicates = findDuplicates(root, ignore, system);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
system: systemId,
|
|
187
|
+
root,
|
|
188
|
+
coverage: { used: coverage.used, available: coverage.available, pct: coverage.pct },
|
|
189
|
+
coverageDetail: coverage.perComponent,
|
|
190
|
+
duplicates,
|
|
191
|
+
};
|
|
192
|
+
}
|