@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.
Files changed (40) hide show
  1. package/package.json +10 -4
  2. package/src/adopt/adoptScanReport.js +124 -0
  3. package/src/adopt/catalog.js +26 -6
  4. package/src/adopt/foreignDiscoveryResolver.js +276 -0
  5. package/src/adopt/foreignPropSchemaResolver.js +134 -0
  6. package/src/adopt/foreignScanReport.js +210 -0
  7. package/src/adopt/foreignScoringResolver.js +192 -0
  8. package/src/adopt/foreignSystemConfig.js +356 -0
  9. package/src/adopt/installedPackageDiscoveryResolver.js +602 -0
  10. package/src/adopt/installedPackagePropSchemaResolver.js +279 -0
  11. package/src/adopt/installedPackageScoringResolver.js +153 -0
  12. package/src/adopt/installedSystemAutoDetector.js +51 -0
  13. package/src/adopt/installedSystemScan.js +101 -0
  14. package/src/adopt/jsxOpportunityHelpers.js +99 -0
  15. package/src/adopt/moduleGraph.js +39 -8
  16. package/src/adopt/opportunityResolver.js +255 -0
  17. package/src/adopt/opportunitySignaturesNative.js +47 -0
  18. package/src/adopt/usageRulesResolver.js +298 -0
  19. package/src/adopt/vecnaMaterializer.js +165 -0
  20. package/src/adopt/vecnaVerifier.js +127 -0
  21. package/src/cli.js +407 -1
  22. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +0 -21
  23. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +0 -13
  24. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +0 -11
  25. package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +0 -34
  26. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +0 -8
  27. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +0 -9
  28. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +0 -9
  29. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +0 -7
  30. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +0 -12
  31. package/src/adopt/dominionFixture.test.js +0 -165
  32. package/src/adopt/propApiResolver.test.js +0 -229
  33. package/src/adopt/referenceResolver.test.js +0 -213
  34. package/src/adopt/rnTailwindResolver.test.js +0 -263
  35. package/src/adopt/rnTokenAliasResolver.test.js +0 -260
  36. package/src/adopt/tailwindResolver.test.js +0 -178
  37. package/src/adopt/targetDiscovery.test.js +0 -227
  38. package/src/adopt/tokenAliasResolver.test.js +0 -319
  39. package/src/adopt/wrapperResolver.test.js +0 -324
  40. 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
+ }