sparda-mcp 0.63.0 → 0.64.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 +1 -1
- package/src/commands/apocalypse.js +6 -2
- package/src/commands/badge.js +4 -1
- package/src/commands/dossier.js +4 -1
- package/src/commands/init.js +11 -0
- package/src/commands/prove.js +4 -1
- package/src/commands/review.js +6 -2
- package/src/server/stdio.js +4 -1
- package/src/ubg/apocalypse.js +13 -2
- package/src/ubg/extract.js +155 -1
- package/src/ubg/prisma.js +35 -1
package/package.json
CHANGED
|
@@ -45,8 +45,12 @@ export async function runApocalypse(opts) {
|
|
|
45
45
|
const findings = [...staticFindings, ...diffFindings];
|
|
46
46
|
// the honesty companion: where does the proof stop? (see `sparda blindspots`)
|
|
47
47
|
const blind = surveyBlindspots(canonical, report);
|
|
48
|
-
// coverage feeds the verdict: a clean app that resolved almost nothing is SURFACE, not PROVEN
|
|
49
|
-
|
|
48
|
+
// coverage feeds the verdict: a clean app that resolved almost nothing is SURFACE, not PROVEN;
|
|
49
|
+
// and any high-risk blind spot pulls a bare PROVEN down to PARTIAL (E-047, the giant-test rung)
|
|
50
|
+
const verdict = verdictOf(findings, canonical, {
|
|
51
|
+
coverage: blind.coverage.ratio,
|
|
52
|
+
blindHigh: blind.byRisk.critical + blind.byRisk.high,
|
|
53
|
+
});
|
|
50
54
|
|
|
51
55
|
if (opts.sarif) {
|
|
52
56
|
const sarifPath = path.join(opts.cwd, '.sparda', 'apocalypse.sarif');
|
package/src/commands/badge.js
CHANGED
|
@@ -16,7 +16,10 @@ export async function runBadge(opts) {
|
|
|
16
16
|
const canonical = canonicalizeGraph(graph);
|
|
17
17
|
const { findings } = checkGraph(canonical);
|
|
18
18
|
const blind = surveyBlindspots(canonical, report);
|
|
19
|
-
const verdict = verdictOf(findings, canonical, {
|
|
19
|
+
const verdict = verdictOf(findings, canonical, {
|
|
20
|
+
coverage: blind.coverage.ratio,
|
|
21
|
+
blindHigh: blind.byRisk.critical + blind.byRisk.high,
|
|
22
|
+
});
|
|
20
23
|
const cov = Math.round(blind.coverage.ratio * 100);
|
|
21
24
|
const { state, message, color } = badgeFor(verdict, { coverage: blind.coverage.ratio });
|
|
22
25
|
|
package/src/commands/dossier.js
CHANGED
|
@@ -21,7 +21,10 @@ export async function runDossier(opts) {
|
|
|
21
21
|
const { findings, polarity } = checkGraph(canonical);
|
|
22
22
|
const capsule = buildCapsule(canonical);
|
|
23
23
|
const blindspots = surveyBlindspots(canonical, compiled.report);
|
|
24
|
-
const verdict = verdictOf(findings, canonical, {
|
|
24
|
+
const verdict = verdictOf(findings, canonical, {
|
|
25
|
+
coverage: blindspots.coverage.ratio,
|
|
26
|
+
blindHigh: blindspots.byRisk.critical + blindspots.byRisk.high,
|
|
27
|
+
});
|
|
25
28
|
|
|
26
29
|
const data = {
|
|
27
30
|
app: path.basename(path.resolve(opts.cwd)) || 'app',
|
package/src/commands/init.js
CHANGED
|
@@ -48,6 +48,17 @@ export async function runInit(opts) {
|
|
|
48
48
|
s.stop(
|
|
49
49
|
`Stack detected: ${c.cyan('Next.js (App Router)')} — app dir: ${stack.entryFile}/, port: ${stack.port}`,
|
|
50
50
|
);
|
|
51
|
+
} else {
|
|
52
|
+
s.stop(
|
|
53
|
+
`Stack detected: ${c.cyan(stack.framework)} — but AST parsing is not supported yet.`,
|
|
54
|
+
);
|
|
55
|
+
throw Object.assign(
|
|
56
|
+
new Error(`AST extraction for ${stack.framework} is not yet supported.`),
|
|
57
|
+
{
|
|
58
|
+
code: 'USER',
|
|
59
|
+
hint: `SPARDA detected ${stack.framework}, but currently only supports Express, FastAPI, and Next.js for AST parsing. NestJS and others are on the roadmap!`,
|
|
60
|
+
},
|
|
61
|
+
);
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
// Opt-in runtime probe (Brief #3): static is the floor, probe only ADDS routes
|
package/src/commands/prove.js
CHANGED
|
@@ -19,7 +19,10 @@ export async function runProve(opts) {
|
|
|
19
19
|
|
|
20
20
|
const { findings, obligations } = checkGraph(canonical);
|
|
21
21
|
const blind = surveyBlindspots(canonical, report);
|
|
22
|
-
const verdict = verdictOf(findings, canonical, {
|
|
22
|
+
const verdict = verdictOf(findings, canonical, {
|
|
23
|
+
coverage: blind.coverage.ratio,
|
|
24
|
+
blindHigh: blind.byRisk.critical + blind.byRisk.high,
|
|
25
|
+
});
|
|
23
26
|
const capsule = buildCapsule(canonical);
|
|
24
27
|
const prints = fingerprintGraph(canonical);
|
|
25
28
|
// one app-level seal: a content address over every route's behavior hash — the same
|
package/src/commands/review.js
CHANGED
|
@@ -62,11 +62,15 @@ export function reviewGraphs(baseGraph, candidateGraph) {
|
|
|
62
62
|
// how much of the changed app SPARDA could actually see — and whether this PR moved
|
|
63
63
|
// that boundary. A green review over a graph the PR made 20% blinder is not the same
|
|
64
64
|
// as a green review at full sight; the reviewer must be able to tell them apart.
|
|
65
|
-
const
|
|
65
|
+
const candBlind = surveyBlindspots(candidateGraph);
|
|
66
|
+
const candCov = candBlind.coverage.ratio;
|
|
66
67
|
const baseCov = surveyBlindspots(baseGraph).coverage.ratio;
|
|
67
68
|
|
|
68
69
|
return {
|
|
69
|
-
verdict: verdictOf(findings, candidateGraph, {
|
|
70
|
+
verdict: verdictOf(findings, candidateGraph, {
|
|
71
|
+
coverage: candCov,
|
|
72
|
+
blindHigh: candBlind.byRisk.critical + candBlind.byRisk.high,
|
|
73
|
+
}),
|
|
70
74
|
obligations,
|
|
71
75
|
findings,
|
|
72
76
|
endpoints: { added, removed: removedEps },
|
package/src/server/stdio.js
CHANGED
|
@@ -1186,7 +1186,10 @@ export function proveApp(cwd, { route } = {}) {
|
|
|
1186
1186
|
const blind = surveyBlindspots(canonical, report);
|
|
1187
1187
|
// The verdict word always reflects the WHOLE app (a route filter narrows the finding list,
|
|
1188
1188
|
// never the safety claim — an AI must never read "PROVEN" because it hid the rest).
|
|
1189
|
-
const verdict = verdictOf(all, canonical, {
|
|
1189
|
+
const verdict = verdictOf(all, canonical, {
|
|
1190
|
+
coverage: blind.coverage.ratio,
|
|
1191
|
+
blindHigh: blind.byRisk.critical + blind.byRisk.high,
|
|
1192
|
+
});
|
|
1190
1193
|
return {
|
|
1191
1194
|
verdict: verdictState(verdict),
|
|
1192
1195
|
provable: verdict.provable,
|
package/src/ubg/apocalypse.js
CHANGED
|
@@ -488,7 +488,7 @@ const COVERAGE_FLOOR = 0.05;
|
|
|
488
488
|
// finding, never changes the CI gate; a PARTIAL app is still clean, just qualified.
|
|
489
489
|
const COVERAGE_COMPLETE = 0.6;
|
|
490
490
|
|
|
491
|
-
export function verdictOf(findings, graph, { coverage } = {}) {
|
|
491
|
+
export function verdictOf(findings, graph, { coverage, blindHigh = 0 } = {}) {
|
|
492
492
|
const counts = { critical: 0, high: 0, medium: 0, info: 0 };
|
|
493
493
|
for (const f of findings) counts[f.severity]++;
|
|
494
494
|
// Advisory findings (BOLA/IDOR, ADR-058) are absence-based and FP-prone: they point a
|
|
@@ -528,9 +528,20 @@ export function verdictOf(findings, graph, { coverage } = {}) {
|
|
|
528
528
|
// A clean whole-app proof below the completeness bar is PARTIAL — proved, but not over
|
|
529
529
|
// the whole surface. Only meaningful when coverage was measured (whole-app run); a partial
|
|
530
530
|
// graph (heal delta, coverage undefined) is never labelled partial.
|
|
531
|
+
//
|
|
532
|
+
// E-047 (the blind-spot rung, from the cal.com giant test): coverage is a RATIO, so on a
|
|
533
|
+
// huge app it can clear the bar (cal.com/api/v2: 71%) while the ABSOLUTE count of high-risk
|
|
534
|
+
// blind spots is large (46 guarded mutations whose writes never resolved). A bare "PROVEN"
|
|
535
|
+
// over 46 unproven high-risk mutations over-claims. So a clean app is also PARTIAL when any
|
|
536
|
+
// high-severity blind spot remains — the label carries the uncertainty (metrology's error
|
|
537
|
+
// bars), independent of the ratio. Sound: only ever SOFTENS PROVEN→PARTIAL, never masks a
|
|
538
|
+
// finding, never touches the CI gate (`safe`). blindHigh defaults to 0, so a caller that
|
|
539
|
+
// did not survey blind spots (heal delta) is unaffected.
|
|
531
540
|
const clean = provable && !surfaceOnly && hardCount === 0;
|
|
532
541
|
const partial =
|
|
533
|
-
clean &&
|
|
542
|
+
clean &&
|
|
543
|
+
entrypoints > 0 &&
|
|
544
|
+
((coverage != null && coverage < COVERAGE_COMPLETE) || blindHigh > 0);
|
|
534
545
|
// `safe` is the CI gate (block a risky deploy): a surface-only app has no
|
|
535
546
|
// critical/high findings and is NOT risky, so it does not fail the gate — it just
|
|
536
547
|
// isn't a positive proof. `clean` is the strong claim (PROVEN) and DOES require
|
package/src/ubg/extract.js
CHANGED
|
@@ -140,6 +140,8 @@ const moduleCache = new Map(); // absFile -> module facts (parse once per compil
|
|
|
140
140
|
export function clearModuleCache() {
|
|
141
141
|
moduleCache.clear();
|
|
142
142
|
tsconfigCache.clear();
|
|
143
|
+
workspaceCache.clear();
|
|
144
|
+
workspaceRootOf.clear();
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
// absFile → { ast, functions: Map<name,{node,line,exported}>, imports: Map<local,abs>, error }
|
|
@@ -357,7 +359,159 @@ export function resolveRelImport(fromFile, spec) {
|
|
|
357
359
|
// cross-module hop (controller → service → repository) dead-ends and effects behind
|
|
358
360
|
// DI are invisible. A bare npm package (`kysely`, `@nestjs/common`) simply resolves
|
|
359
361
|
// to nothing here (no matching file under the project), which is correct.
|
|
360
|
-
|
|
362
|
+
//
|
|
363
|
+
// The WORKSPACE fallback (the mycorrhizal network): a monorepo app's real mutation
|
|
364
|
+
// logic lives in shared workspace packages it imports by NAME, not by path — cal.com's
|
|
365
|
+
// `this.svc.updateEventType()` delegates to `@calcom/platform-libraries` → `@calcom/trpc`
|
|
366
|
+
// → `prisma.update()`, three packages away and entirely outside the analyzed app dir. A
|
|
367
|
+
// tsconfig alias can't reach them (they're resolved via the workspace, not `paths`). So
|
|
368
|
+
// when the alias miss, map the `@scope/pkg[/subpath]` specifier to the package's real
|
|
369
|
+
// directory under the workspace and resolve into it. Trees share nutrients across the
|
|
370
|
+
// fungal network, not just their own root ball — the schema/effect code is a shared
|
|
371
|
+
// nutrient drawn from the workspace, not the app's own folder.
|
|
372
|
+
return (
|
|
373
|
+
resolveAliasedImport(fromFile, clean(spec)) ??
|
|
374
|
+
resolveWorkspaceImport(fromFile, clean(spec))
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// name -> absolute package dir, for every package in the workspace that owns `fromFile`.
|
|
379
|
+
// Cached per workspace root (built once, ~100 package.json reads on a giant monorepo).
|
|
380
|
+
const workspaceCache = new Map(); // rootDir -> Map(name -> dir)
|
|
381
|
+
const workspaceRootOf = new Map(); // dir -> rootDir | null
|
|
382
|
+
|
|
383
|
+
// walk up to the monorepo root: the nearest ancestor whose package.json declares
|
|
384
|
+
// `workspaces`, or that carries a pnpm-workspace.yaml. `null` = not a workspace.
|
|
385
|
+
function findWorkspaceRoot(fromFile) {
|
|
386
|
+
let dir = path.dirname(fromFile);
|
|
387
|
+
const chain = [];
|
|
388
|
+
for (let i = 0; i < 40; i++) {
|
|
389
|
+
if (workspaceRootOf.has(dir)) {
|
|
390
|
+
const v = workspaceRootOf.get(dir);
|
|
391
|
+
for (const d of chain) workspaceRootOf.set(d, v);
|
|
392
|
+
return v;
|
|
393
|
+
}
|
|
394
|
+
chain.push(dir);
|
|
395
|
+
let root = null;
|
|
396
|
+
try {
|
|
397
|
+
const pj = path.join(dir, 'package.json');
|
|
398
|
+
if (fs.existsSync(pj)) {
|
|
399
|
+
const pkg = JSON.parse(fs.readFileSync(pj, 'utf8'));
|
|
400
|
+
if (pkg.workspaces) root = dir;
|
|
401
|
+
}
|
|
402
|
+
if (!root && fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) root = dir;
|
|
403
|
+
} catch {
|
|
404
|
+
// unreadable manifest — keep walking up
|
|
405
|
+
}
|
|
406
|
+
if (root) {
|
|
407
|
+
for (const d of chain) workspaceRootOf.set(d, root);
|
|
408
|
+
return root;
|
|
409
|
+
}
|
|
410
|
+
const parent = path.dirname(dir);
|
|
411
|
+
if (parent === dir) break;
|
|
412
|
+
dir = parent;
|
|
413
|
+
}
|
|
414
|
+
for (const d of chain) workspaceRootOf.set(d, null);
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// the workspace's package globs, from package.json `workspaces` (array or {packages})
|
|
419
|
+
// or a minimal pnpm-workspace.yaml parse (the `- '...'` list under `packages:`).
|
|
420
|
+
function workspaceGlobs(root) {
|
|
421
|
+
const globs = [];
|
|
422
|
+
try {
|
|
423
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
424
|
+
const ws = pkg.workspaces;
|
|
425
|
+
if (Array.isArray(ws)) globs.push(...ws);
|
|
426
|
+
else if (ws && Array.isArray(ws.packages)) globs.push(...ws.packages);
|
|
427
|
+
} catch {
|
|
428
|
+
// no/invalid root package.json — fall through to pnpm
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
const yml = fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8');
|
|
432
|
+
for (const m of yml.matchAll(/^\s*-\s*['"]?([^'"\n]+?)['"]?\s*$/gm)) globs.push(m[1]);
|
|
433
|
+
} catch {
|
|
434
|
+
// no pnpm-workspace.yaml
|
|
435
|
+
}
|
|
436
|
+
return globs;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// expand one workspace glob to concrete package dirs. Supports the two forms real
|
|
440
|
+
// workspaces use: an exact path (`packages/app-store`) and a trailing `/*` (one level of
|
|
441
|
+
// subdirs, e.g. `packages/*`, `packages/platform/*`). `**` is treated as a single level —
|
|
442
|
+
// good enough for every workspace layout in the wild, and never unbounded.
|
|
443
|
+
function expandGlob(root, glob) {
|
|
444
|
+
const g = glob.replace(/\/\*\*$/, '/*');
|
|
445
|
+
const star = g.indexOf('*');
|
|
446
|
+
if (star === -1) return [path.resolve(root, g)];
|
|
447
|
+
const baseRel = g.slice(0, star).replace(/\/$/, '');
|
|
448
|
+
const baseDir = path.resolve(root, baseRel);
|
|
449
|
+
try {
|
|
450
|
+
return fs
|
|
451
|
+
.readdirSync(baseDir, { withFileTypes: true })
|
|
452
|
+
.filter((e) => e.isDirectory())
|
|
453
|
+
.map((e) => path.join(baseDir, e.name));
|
|
454
|
+
} catch {
|
|
455
|
+
return [];
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function workspacePackages(fromFile) {
|
|
460
|
+
const root = findWorkspaceRoot(fromFile);
|
|
461
|
+
if (!root) return null;
|
|
462
|
+
if (workspaceCache.has(root)) return workspaceCache.get(root);
|
|
463
|
+
const map = new Map();
|
|
464
|
+
for (const glob of workspaceGlobs(root)) {
|
|
465
|
+
for (const dir of expandGlob(root, glob)) {
|
|
466
|
+
try {
|
|
467
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
468
|
+
if (typeof pkg.name === 'string' && !map.has(pkg.name)) map.set(pkg.name, dir);
|
|
469
|
+
} catch {
|
|
470
|
+
// not a package (no/invalid package.json) — skip
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
workspaceCache.set(root, map);
|
|
475
|
+
return map;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// the entry file of a package with no subpath import (`@calcom/trpc`): its declared
|
|
479
|
+
// source/main/module, else the conventional src/index or index.
|
|
480
|
+
function packageEntry(dir) {
|
|
481
|
+
const fields = [];
|
|
482
|
+
try {
|
|
483
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
484
|
+
for (const f of [pkg.source, pkg.module, pkg.main])
|
|
485
|
+
if (typeof f === 'string') fields.push(f);
|
|
486
|
+
} catch {
|
|
487
|
+
// no package.json fields — conventions below still apply
|
|
488
|
+
}
|
|
489
|
+
for (const f of [...fields, 'src/index', 'index']) {
|
|
490
|
+
const hit = firstModuleFile(path.resolve(dir, f.replace(/\.(m?[jt]s|cjs)$/, '')));
|
|
491
|
+
if (hit) return hit;
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// resolve `@scope/pkg/subpath` (or `pkg/subpath`) to a real source file under the
|
|
497
|
+
// workspace. Longest package-name match wins (`@calcom/platform-libraries` beats a
|
|
498
|
+
// hypothetical `@calcom/platform`), then the subpath resolves against the package dir.
|
|
499
|
+
function resolveWorkspaceImport(fromFile, spec) {
|
|
500
|
+
if (spec.startsWith('.') || spec.startsWith('/')) return null;
|
|
501
|
+
const map = workspacePackages(fromFile);
|
|
502
|
+
if (!map) return null;
|
|
503
|
+
let best = null;
|
|
504
|
+
for (const name of map.keys()) {
|
|
505
|
+
if (
|
|
506
|
+
(spec === name || spec.startsWith(name + '/')) &&
|
|
507
|
+
name.length > (best?.length ?? -1)
|
|
508
|
+
)
|
|
509
|
+
best = name;
|
|
510
|
+
}
|
|
511
|
+
if (!best) return null;
|
|
512
|
+
const dir = map.get(best);
|
|
513
|
+
const sub = spec.length > best.length ? spec.slice(best.length + 1) : '';
|
|
514
|
+
return sub ? firstModuleFile(path.resolve(dir, sub)) : packageEntry(dir);
|
|
361
515
|
}
|
|
362
516
|
|
|
363
517
|
// probe the standard TS/JS extensions + index files for a resolved base path.
|
package/src/ubg/prisma.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { cmp } from './schema.js';
|
|
13
|
+
import { workspacePackages } from './extract.js';
|
|
13
14
|
|
|
14
15
|
const SCHEMA_CANDIDATES = ['prisma/schema.prisma', 'schema.prisma', 'db/schema.prisma'];
|
|
15
16
|
// Prisma's `prismaSchemaFolder` layout (stable since 6.x): the schema is SPLIT across many
|
|
@@ -55,6 +56,35 @@ function collectSchemaFiles(cwd, fileCandidates, dirCandidates) {
|
|
|
55
56
|
return [];
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
// P4 (E-048, the mycorrhizal network for STATE): a monorepo app usually declares NO schema of
|
|
60
|
+
// its own — it depends on a shared `@scope/prisma`-style workspace package that owns the whole
|
|
61
|
+
// schema (cal.com: apps/web has 0 tables locally; `@calcom/prisma` has 100 models). Without
|
|
62
|
+
// this the app's entire state layer is invisible and every mutation reasons against nothing.
|
|
63
|
+
// When the app dir yields no schema, look through its workspace DEPENDENCIES for the package
|
|
64
|
+
// that owns one — the same name→dir map the module resolver already builds. Schema-ish deps are
|
|
65
|
+
// tried first (a `prisma`/`db` package is the DB package), then any remaining workspace dep.
|
|
66
|
+
function workspaceSchemaFiles(cwd, fileCandidates, dirCandidates) {
|
|
67
|
+
let deps;
|
|
68
|
+
try {
|
|
69
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
70
|
+
deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
71
|
+
} catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const map = workspacePackages(path.join(cwd, 'package.json'));
|
|
75
|
+
if (!map) return [];
|
|
76
|
+
const inWorkspace = Object.keys(deps).filter((n) => map.has(n));
|
|
77
|
+
const ordered = [
|
|
78
|
+
...inWorkspace.filter((n) => /prisma|schema|(^|[-/])db($|[-/])|database/i.test(n)),
|
|
79
|
+
...inWorkspace.filter((n) => !/prisma|schema|(^|[-/])db($|[-/])|database/i.test(n)),
|
|
80
|
+
];
|
|
81
|
+
for (const name of ordered) {
|
|
82
|
+
const files = collectSchemaFiles(map.get(name), fileCandidates, dirCandidates);
|
|
83
|
+
if (files.length) return files;
|
|
84
|
+
}
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
58
88
|
const TYPE_MAP = {
|
|
59
89
|
string: 'string',
|
|
60
90
|
int: 'number',
|
|
@@ -78,7 +108,11 @@ export function parsePrismaSchemas(cwd) {
|
|
|
78
108
|
} catch {
|
|
79
109
|
// no package.json / unparsable — the default candidates stand
|
|
80
110
|
}
|
|
81
|
-
|
|
111
|
+
// local first; then the workspace dependency that owns the shared schema (P4)
|
|
112
|
+
const local = collectSchemaFiles(cwd, candidates, SCHEMA_DIR_CANDIDATES);
|
|
113
|
+
const files = local.length
|
|
114
|
+
? local
|
|
115
|
+
: workspaceSchemaFiles(cwd, candidates, SCHEMA_DIR_CANDIDATES);
|
|
82
116
|
if (!files.length) return { tables: [], skipped, sourceFile: null };
|
|
83
117
|
|
|
84
118
|
// Read + strip comments once per file; keep the relative path for correct per-table locs.
|