build: use native circular dependency checker
This commit is contained in:
parent
422541305b
commit
ead081bfde
5 changed files with 257 additions and 1435 deletions
|
|
@ -80,8 +80,11 @@ jobs:
|
|||
run: |
|
||||
export DOCKER_BUILDKIT=1
|
||||
export BUILDKIT_PROGRESS=plain
|
||||
cache_ref="${SF_IMAGE_REPOSITORY:-${SF_REGISTRY:-registry.infra.centralcloud.com}/singularity/sf-server}:buildcache"
|
||||
docker build \
|
||||
-f docker/Dockerfile.sf-server \
|
||||
--cache-from "type=registry,ref=${cache_ref}" \
|
||||
--cache-to "type=registry,ref=${cache_ref},mode=max" \
|
||||
--build-arg "SF_GIT_SHA=${GITHUB_SHA:-$(git rev-parse HEAD)}" \
|
||||
--build-arg "SF_GIT_REF=${GITHUB_REF_NAME:-$(git rev-parse --abbrev-ref HEAD)}" \
|
||||
--build-arg "SF_RELEASE_IMAGE=${{ steps.image.outputs.image }}" \
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ RUN --mount=type=cache,id=sf-server-root-tsbuild,target=/src/dist/.tsbuildinfo,s
|
|||
RUN --mount=type=cache,id=sf-server-next-cache,target=/src/web/.next/cache,sharing=locked \
|
||||
npm run build:web-host
|
||||
RUN npm run release:manifest -- --out dist/sf-release-manifest.json
|
||||
RUN npm prune --omit=dev --ignore-scripts --legacy-peer-deps
|
||||
RUN rm -rf \
|
||||
rust-engine/target \
|
||||
web/.next/cache \
|
||||
|
|
@ -74,7 +75,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /src /opt/sf
|
||||
COPY --from=build /src/package.json /src/package-lock.json /src/README.md /opt/sf/
|
||||
COPY --from=build /src/node_modules /opt/sf/node_modules
|
||||
COPY --from=build /src/packages /opt/sf/packages
|
||||
COPY --from=build /src/dist /opt/sf/dist
|
||||
COPY --from=build /src/pkg /opt/sf/pkg
|
||||
COPY --from=build /src/src/resources /opt/sf/src/resources
|
||||
COPY --from=build /src/scripts/postinstall.js /src/scripts/link-workspace-packages.cjs /src/scripts/ensure-workspace-builds.cjs /opt/sf/scripts/
|
||||
COPY --from=build /src/rust-engine/addon /opt/sf/rust-engine/addon
|
||||
COPY --from=build /src/rust-engine/npm /opt/sf/rust-engine/npm
|
||||
COPY --from=build /src/web/.next/standalone /opt/sf/web/.next/standalone
|
||||
|
||||
WORKDIR /workspace
|
||||
EXPOSE 4000
|
||||
|
|
|
|||
1411
package-lock.json
generated
1411
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -183,7 +183,6 @@
|
|||
"esbuild": "^0.28.0",
|
||||
"jiti": "^2.7.0",
|
||||
"jscpd": "^4.2.2",
|
||||
"madge": "^8.0.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-language-server": "^5.2.0",
|
||||
"vitest": "^4.1.6"
|
||||
|
|
|
|||
|
|
@ -3,17 +3,29 @@
|
|||
/**
|
||||
* check-circular-deps.mjs — detect circular imports across the SF codebase.
|
||||
*
|
||||
* Uses the workspace's installed TypeScript compiler API to parse JS/TS/MJS
|
||||
* files, build a directed import graph, then find cycles via Tarjan's
|
||||
* strongly-connected-components algorithm. Single-node SCCs that have a
|
||||
* self-loop are also reported.
|
||||
*
|
||||
* Replaces madge for SF — madge declares `peerDeps.typescript: "^5.4.4"`
|
||||
* and bundles its own `typescript@5.9.3` via `dependency-tree`, which
|
||||
* conflicted with the repo's `typescript@6.0.3` and cluttered the docker
|
||||
* build install. This walker uses the exact TS the rest of the repo uses,
|
||||
* so there is zero version drift.
|
||||
*
|
||||
* Usage:
|
||||
* npm run check:circular # scan src/ + packages/
|
||||
* npm run check:circular -- --ext # scan extension source only
|
||||
* npm run check:circular # scan src/ + packages/
|
||||
* npm run check:circular -- --ext # extension source only
|
||||
* node scripts/check-circular-deps.mjs [--ext] [--json]
|
||||
*
|
||||
* Exit 0 = no cycles found. Exit 1 = cycles detected (or scan error).
|
||||
*/
|
||||
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { dirname, extname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import madge from "madge";
|
||||
import ts from "typescript";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, "..");
|
||||
|
|
@ -26,35 +38,240 @@ const entries = extOnly
|
|||
? [resolve(root, "src/resources/extensions/sf")]
|
||||
: [resolve(root, "src"), resolve(root, "packages")];
|
||||
|
||||
const SOURCE_EXTS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"]);
|
||||
const SKIP_DIR = new Set(["node_modules", "dist", "build", "coverage", ".next"]);
|
||||
const SKIP_FILE = (name) =>
|
||||
name.endsWith(".test.js") ||
|
||||
name.endsWith(".test.mjs") ||
|
||||
name.endsWith(".test.ts") ||
|
||||
name.endsWith(".test.tsx") ||
|
||||
name.endsWith(".d.ts") ||
|
||||
name.endsWith(".d.ts.map");
|
||||
|
||||
console.error(
|
||||
`Scanning: ${entries.map((e) => e.replace(root + "/", "")).join(", ")}`,
|
||||
`Scanning: ${entries.map((e) => relative(root, e)).join(", ")}`,
|
||||
);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await madge(entries, {
|
||||
fileExtensions: ["js", "mjs", "ts"],
|
||||
excludeRegExp: [
|
||||
/node_modules/,
|
||||
/\.test\.(js|mjs|ts)$/,
|
||||
/\/dist\//,
|
||||
/\/tests?\//,
|
||||
],
|
||||
detectiveOptions: {
|
||||
es6: { mixedImports: true, skipAsyncImports: true },
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Scan failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
/** Collect source files under each entry, skipping tests / d.ts / dist / etc. */
|
||||
function collectSourceFiles(entry) {
|
||||
const out = [];
|
||||
const stack = [entry];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
let st;
|
||||
try {
|
||||
st = statSync(current);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (st.isDirectory()) {
|
||||
let names;
|
||||
try {
|
||||
names = readdirSync(current);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const name of names) {
|
||||
if (SKIP_DIR.has(name)) continue;
|
||||
if (name.startsWith(".")) continue; // skip dotted dirs/files
|
||||
stack.push(join(current, name));
|
||||
}
|
||||
} else if (st.isFile()) {
|
||||
if (!SOURCE_EXTS.has(extname(current))) continue;
|
||||
const base = current.split("/").pop() ?? "";
|
||||
if (SKIP_FILE(base)) continue;
|
||||
if (current.includes("/tests/") || current.includes("/test/")) continue;
|
||||
out.push(current);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const cycles = result.circular();
|
||||
const allFiles = entries.flatMap(collectSourceFiles);
|
||||
const fileSet = new Set(allFiles);
|
||||
|
||||
/**
|
||||
* Try to resolve a relative import specifier from `fromFile` to an actual
|
||||
* file on disk. Returns the resolved absolute path, or null if it doesn't
|
||||
* resolve into our scanned set (which is what we want — only intra-graph
|
||||
* edges count for cycle detection).
|
||||
*/
|
||||
function resolveImport(fromFile, spec) {
|
||||
if (!spec.startsWith(".") && !spec.startsWith("/")) return null; // ignore bare/npm
|
||||
const fromDir = dirname(fromFile);
|
||||
const baseAbs = resolve(fromDir, spec);
|
||||
|
||||
// Try exact, then ext substitution, then index files.
|
||||
const candidates = [
|
||||
baseAbs,
|
||||
`${baseAbs}.ts`,
|
||||
`${baseAbs}.tsx`,
|
||||
`${baseAbs}.mts`,
|
||||
`${baseAbs}.cts`,
|
||||
`${baseAbs}.js`,
|
||||
`${baseAbs}.mjs`,
|
||||
`${baseAbs}.cjs`,
|
||||
join(baseAbs, "index.ts"),
|
||||
join(baseAbs, "index.tsx"),
|
||||
join(baseAbs, "index.js"),
|
||||
join(baseAbs, "index.mjs"),
|
||||
];
|
||||
|
||||
// Also handle TS's .js → .ts substitution (common in ESM TS code that imports
|
||||
// "./foo.js" while the source file is foo.ts).
|
||||
if (spec.endsWith(".js")) {
|
||||
candidates.push(`${baseAbs.replace(/\.js$/, "")}.ts`);
|
||||
candidates.push(`${baseAbs.replace(/\.js$/, "")}.tsx`);
|
||||
candidates.push(`${baseAbs.replace(/\.js$/, "")}.mts`);
|
||||
}
|
||||
|
||||
for (const c of candidates) {
|
||||
if (fileSet.has(c)) return c;
|
||||
if (existsSync(c)) {
|
||||
try {
|
||||
if (statSync(c).isFile() && SOURCE_EXTS.has(extname(c))) return c;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract import specifiers from a source file via the TS compiler. */
|
||||
function extractImports(filePath) {
|
||||
let source;
|
||||
try {
|
||||
source = readFileSync(filePath, "utf-8");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const scriptKind =
|
||||
filePath.endsWith(".tsx") || filePath.endsWith(".jsx")
|
||||
? ts.ScriptKind.TSX
|
||||
: undefined;
|
||||
const sf = ts.createSourceFile(
|
||||
filePath,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
/* setParentNodes */ false,
|
||||
scriptKind,
|
||||
);
|
||||
const specs = [];
|
||||
|
||||
const visit = (node) => {
|
||||
// import X from "..." | import "..." | import { X } from "..."
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
specs.push(node.moduleSpecifier.text);
|
||||
}
|
||||
// export { X } from "..." | export * from "..."
|
||||
if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
specs.push(node.moduleSpecifier.text);
|
||||
}
|
||||
// dynamic import("...") | await import("...")
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
||||
node.arguments.length > 0 &&
|
||||
ts.isStringLiteral(node.arguments[0])
|
||||
) {
|
||||
specs.push(node.arguments[0].text);
|
||||
}
|
||||
// CommonJS require("...")
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
node.expression.text === "require" &&
|
||||
node.arguments.length > 0 &&
|
||||
ts.isStringLiteral(node.arguments[0])
|
||||
) {
|
||||
specs.push(node.arguments[0].text);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
|
||||
return specs;
|
||||
}
|
||||
|
||||
/** Build the import graph: file → Set of imported files within the scanned set. */
|
||||
const graph = new Map();
|
||||
for (const file of allFiles) {
|
||||
const edges = new Set();
|
||||
for (const spec of extractImports(file)) {
|
||||
const resolved = resolveImport(file, spec);
|
||||
if (resolved && fileSet.has(resolved) && resolved !== file) {
|
||||
edges.add(resolved);
|
||||
}
|
||||
}
|
||||
graph.set(file, edges);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tarjan's strongly-connected-components algorithm. Any SCC of size > 1
|
||||
* is a cycle; size-1 SCCs with a self-edge are also cycles.
|
||||
*/
|
||||
function tarjanSCC(g) {
|
||||
let index = 0;
|
||||
const indices = new Map();
|
||||
const lowlinks = new Map();
|
||||
const onStack = new Set();
|
||||
const stack = [];
|
||||
const sccs = [];
|
||||
|
||||
const strongConnect = (v) => {
|
||||
indices.set(v, index);
|
||||
lowlinks.set(v, index);
|
||||
index += 1;
|
||||
stack.push(v);
|
||||
onStack.add(v);
|
||||
|
||||
for (const w of g.get(v) ?? []) {
|
||||
if (!indices.has(w)) {
|
||||
strongConnect(w);
|
||||
lowlinks.set(v, Math.min(lowlinks.get(v), lowlinks.get(w)));
|
||||
} else if (onStack.has(w)) {
|
||||
lowlinks.set(v, Math.min(lowlinks.get(v), indices.get(w)));
|
||||
}
|
||||
}
|
||||
|
||||
if (lowlinks.get(v) === indices.get(v)) {
|
||||
const scc = [];
|
||||
let w;
|
||||
do {
|
||||
w = stack.pop();
|
||||
onStack.delete(w);
|
||||
scc.push(w);
|
||||
} while (w !== v);
|
||||
sccs.push(scc);
|
||||
}
|
||||
};
|
||||
|
||||
for (const v of g.keys()) {
|
||||
if (!indices.has(v)) strongConnect(v);
|
||||
}
|
||||
return sccs;
|
||||
}
|
||||
|
||||
const sccs = tarjanSCC(graph);
|
||||
const cycles = [];
|
||||
for (const scc of sccs) {
|
||||
if (scc.length > 1) {
|
||||
cycles.push(scc.map((p) => relative(root, p)));
|
||||
} else if (scc.length === 1) {
|
||||
const v = scc[0];
|
||||
if (graph.get(v)?.has(v)) cycles.push([relative(root, v)]);
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonOut) {
|
||||
console.log(JSON.stringify({ cycles, count: cycles.length }, null, 2));
|
||||
} else if (cycles.length === 0) {
|
||||
console.log("✅ No circular dependencies found.");
|
||||
console.log(`✅ No circular dependencies found. (scanned ${allFiles.length} files)`);
|
||||
} else {
|
||||
console.log(`❌ ${cycles.length} circular dependency chain(s) found:\n`);
|
||||
for (const [i, chain] of cycles.entries()) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue