From c7e160cb5ca0c5bd6e0ba9e2a258587c106fbab5 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Wed, 18 Mar 2026 20:14:56 +0100 Subject: [PATCH 1/3] feat: add CSS @import tracking and path alias resolution to dependency graph Add CSS @import extraction from Svelte/Vue +`; + const imports = extractImports(source, "svelte", ".svelte"); + const specs = imports.map((i) => i.moduleSpecifier); + + expect(specs).toContain("svelte"); + expect(specs).toContain("./variables.css"); + expect(specs).toContain("../mixins.scss"); + }); + + it("extracts @import url(...) variant", () => { + const source = ` + +`; + const imports = extractImports(source, "svelte", ".svelte"); + expect(imports.some((i) => i.moduleSpecifier === "./theme.css")).toBe(true); + }); + + it("skips external URLs", () => { + const source = ` + +`; + const imports = extractImports(source, "svelte", ".svelte"); + const specs = imports.map((i) => i.moduleSpecifier); + + expect(specs).not.toContain("https://fonts.googleapis.com/css2?family=Inter"); + expect(specs).toContain("./local.css"); + }); + + it("extracts @import from +`; + const imports = extractImports(source, "svelte", ".svelte"); + expect(imports.some((i) => i.moduleSpecifier === "./global-reset.css")).toBe(true); + }); + + it("marks CSS imports with isCssImport flag", () => { + const source = ` + + + +`; + const imports = extractImports(source, "svelte", ".svelte"); + const jsImport = imports.find((i) => i.moduleSpecifier === "svelte"); + const cssImport = imports.find((i) => i.moduleSpecifier === "./variables.css"); + + expect(jsImport?.isCssImport).toBeFalsy(); + expect(cssImport?.isCssImport).toBe(true); + }); + + it("handles no style block", () => { + const source = ` + +
content
+`; + const imports = extractImports(source, "svelte", ".svelte"); + // Should only have script imports, no CSS imports + expect(imports).toHaveLength(1); + expect(imports[0].moduleSpecifier).toBe("svelte/store"); + }); + }); + + describe("CSS @import in Vue style blocks", () => { + it("extracts @import from +`; + const imports = extractImports(source, "vue", ".vue"); + const specs = imports.map((i) => i.moduleSpecifier); + + expect(specs).toContain("vue"); + expect(specs).toContain("./component.css"); + }); + + it("extracts @import url(...) from Vue style", () => { + const source = ` + +`; + const imports = extractImports(source, "vue", ".vue"); + const specs = imports.map((i) => i.moduleSpecifier); + + expect(specs).toContain("./variables.scss"); + expect(specs).toContain("./mixins.css"); + }); + + it("extracts @import from all style tag variants (scoped, module, lang)", () => { + const source = ` + + + + + + + + + +`; + const imports = extractImports(source, "vue", ".vue"); + const specs = imports.map((i) => i.moduleSpecifier); + + expect(specs).toContain("vue"); + expect(specs).toContain("./scoped-scss.scss"); + expect(specs).toContain("./module.css"); + expect(specs).toContain("./theme.less"); + }); + }); + + // ── Stylus @require in style blocks ────────────────────────────────────── + + describe("Stylus @require in style blocks", () => { + it("extracts @require from Svelte +`; + const imports = extractImports(source, "svelte", ".svelte"); + const specs = imports.map((i) => i.moduleSpecifier); + + expect(specs).toContain("./variables.styl"); + expect(specs).toContain("../mixins.styl"); + }); + + it("extracts @import and @require from Vue +`; + const imports = extractImports(source, "vue", ".vue"); + const specs = imports.map((i) => i.moduleSpecifier); + + expect(specs).toContain("./base.styl"); + expect(specs).toContain("./theme.styl"); + }); + }); + + // ── Standalone CSS ────────────────────────────────────────────────────── + + describe("Standalone CSS imports", () => { + it("extracts @import from CSS files", () => { + const source = ` +@import "./variables.css"; +@import url("./mixins.css"); + +body { color: red; } +`; + const imports = extractImports(source, Lang.Css, ".css"); + const specs = imports.map((i) => i.moduleSpecifier); + + expect(specs).toContain("./variables.css"); + expect(specs).toContain("./mixins.css"); + }); + + it("skips external URLs in CSS files", () => { + const source = ` +@import "https://cdn.example.com/reset.css"; +@import "./local.css"; +`; + const imports = extractImports(source, Lang.Css, ".css"); + const specs = imports.map((i) => i.moduleSpecifier); + + expect(specs).not.toContain("https://cdn.example.com/reset.css"); + expect(specs).toContain("./local.css"); + }); + }); + // ── Python ───────────────────────────────────────────────────────────── describe("Python imports", () => { diff --git a/tests/unit/graph-resolution.test.ts b/tests/unit/graph-resolution.test.ts index eab622e..8977d11 100644 --- a/tests/unit/graph-resolution.test.ts +++ b/tests/unit/graph-resolution.test.ts @@ -817,4 +817,393 @@ describe("graph-resolution", () => { expect(result).toBeNull(); }); }); + + // ── Path alias resolution ────────────────────────────────────────────── + + describe("Path alias resolution", () => { + it("resolves $lib/ alias to src/lib/", () => { + project = createTempProject({ + "src/lib/Component.svelte": "", + "src/routes/page.svelte": "", + }); + + const aliases = { + entries: new Map([["$lib/", ["src/lib/"]]]), + }; + + const result = resolveImport( + "$lib/Component.svelte", + path.join(project.root, "src/routes/page.svelte"), + project.root, + project.fileSet, + "svelte", + aliases, + ); + + expect(result).toBe("src/lib/Component.svelte"); + }); + + it("resolves @/ alias to src/", () => { + project = createTempProject({ + "src/utils/helper.ts": "", + "src/index.ts": "", + }); + + const aliases = { + entries: new Map([["@/", ["src/"]]]), + }; + + const result = resolveImport( + "@/utils/helper", + path.join(project.root, "src/index.ts"), + project.root, + project.fileSet, + "typescript", + aliases, + ); + + expect(result).toBe("src/utils/helper.ts"); + }); + + it("resolves alias with extensionless import", () => { + project = createTempProject({ + "src/lib/utils.ts": "", + "src/app.ts": "", + }); + + const aliases = { + entries: new Map([["$lib/", ["src/lib/"]]]), + }; + + const result = resolveImport( + "$lib/utils", + path.join(project.root, "src/app.ts"), + project.root, + project.fileSet, + "typescript", + aliases, + ); + + expect(result).toBe("src/lib/utils.ts"); + }); + + it("returns null when alias does not match any file", () => { + project = createTempProject({ + "src/index.ts": "", + }); + + const aliases = { + entries: new Map([["$lib/", ["src/lib/"]]]), + }; + + const result = resolveImport( + "$lib/NonExistent", + path.join(project.root, "src/index.ts"), + project.root, + project.fileSet, + "typescript", + aliases, + ); + + expect(result).toBeNull(); + }); + + it("falls back to null without aliases (backwards compatible)", () => { + project = createTempProject({ + "src/index.ts": "", + }); + + const result = resolveImport( + "$lib/Component", + path.join(project.root, "src/index.ts"), + project.root, + project.fileSet, + "typescript", + ); + + expect(result).toBeNull(); + }); + + it("tries multiple alias targets in order (first match wins)", () => { + project = createTempProject({ + "src/types.ts": "", + "generated/types.ts": "", + "src/index.ts": "", + }); + + const aliases = { + entries: new Map([["@/", ["src", "generated"]]]), + }; + + const result = resolveImport( + "@/types", + path.join(project.root, "src/index.ts"), + project.root, + project.fileSet, + "typescript", + aliases, + ); + + // src/ is listed first, so it should win over generated/ + expect(result).toBe("src/types.ts"); + }); + + it("falls back to second alias target when first has no match", () => { + project = createTempProject({ + "generated/types.ts": "", + "src/index.ts": "", + }); + + const aliases = { + entries: new Map([["@/", ["src", "generated"]]]), + }; + + const result = resolveImport( + "@/types", + path.join(project.root, "src/index.ts"), + project.root, + project.fileSet, + "typescript", + aliases, + ); + + expect(result).toBe("generated/types.ts"); + }); + + it("resolves CSS alias imports", () => { + project = createTempProject({ + "src/lib/styles/variables.css": "", + "src/app.css": "", + }); + + const aliases = { + entries: new Map([["$lib/", ["src/lib/"]]]), + }; + + const result = resolveImport( + "$lib/styles/variables.css", + path.join(project.root, "src/app.css"), + project.root, + project.fileSet, + "css", + aliases, + ); + + expect(result).toBe("src/lib/styles/variables.css"); + }); + + it("resolves extensionless CSS alias import via extension-try loop", () => { + project = createTempProject({ + "src/lib/styles/variables.scss": "", + "src/app.css": "", + }); + + const aliases = { + entries: new Map([["$lib/", ["src/lib"]]]), + }; + + const result = resolveImport( + "$lib/styles/variables", + path.join(project.root, "src/app.css"), + project.root, + project.fileSet, + "css", + aliases, + ); + + expect(result).toBe("src/lib/styles/variables.scss"); + }); + + it("resolves CSS relative imports", () => { + project = createTempProject({ + "src/styles/variables.css": "", + "src/styles/main.css": "", + }); + + const result = resolveImport( + "./variables.css", + path.join(project.root, "src/styles/main.css"), + project.root, + project.fileSet, + "css", + ); + + expect(result).toBe("src/styles/variables.css"); + }); + + it("resolves SCSS relative imports (language=scss)", () => { + project = createTempProject({ + "src/styles/theme.scss": "", + "src/styles/main.scss": "", + }); + + const result = resolveImport( + "./theme.scss", + path.join(project.root, "src/styles/main.scss"), + project.root, + project.fileSet, + "scss", + ); + + expect(result).toBe("src/styles/theme.scss"); + }); + + it("resolves SCSS partial with _ prefix", () => { + project = createTempProject({ + "src/styles/_variables.scss": "", + "src/styles/main.scss": "", + }); + + const result = resolveImport( + "./variables", + path.join(project.root, "src/styles/main.scss"), + project.root, + project.fileSet, + "scss", + ); + + expect(result).toBe("src/styles/_variables.scss"); + }); + + it("resolves SCSS partial via alias", () => { + project = createTempProject({ + "src/lib/styles/_colors.scss": "", + "src/app.scss": "", + }); + + const aliases = { + entries: new Map([["$lib/", ["src/lib"]]]), + }; + + const result = resolveImport( + "$lib/styles/colors", + path.join(project.root, "src/app.scss"), + project.root, + project.fileSet, + "scss", + aliases, + ); + + expect(result).toBe("src/lib/styles/_colors.scss"); + }); + + it("prefers non-partial over partial when both exist", () => { + project = createTempProject({ + "src/styles/variables.scss": "", + "src/styles/_variables.scss": "", + "src/styles/main.scss": "", + }); + + const result = resolveImport( + "./variables", + path.join(project.root, "src/styles/main.scss"), + project.root, + project.fileSet, + "scss", + ); + + // Direct match with extension should win before trying _ prefix + expect(result).toBe("src/styles/variables.scss"); + }); + + it("resolves SCSS partial when import has explicit .scss extension", () => { + project = createTempProject({ + "src/styles/_variables.scss": "", + "src/styles/main.scss": "", + }); + + const result = resolveImport( + "./variables.scss", + path.join(project.root, "src/styles/main.scss"), + project.root, + project.fileSet, + "scss", + ); + + expect(result).toBe("src/styles/_variables.scss"); + }); + + it("resolves Less relative imports (language=less)", () => { + project = createTempProject({ + "src/styles/theme.less": "", + "src/styles/main.less": "", + }); + + const result = resolveImport( + "./theme.less", + path.join(project.root, "src/styles/main.less"), + project.root, + project.fileSet, + "less", + ); + + expect(result).toBe("src/styles/theme.less"); + }); + + it("resolves Sass relative imports (language=sass)", () => { + project = createTempProject({ + "src/styles/_base.sass": "", + "src/styles/main.sass": "", + }); + + const result = resolveImport( + "./base", + path.join(project.root, "src/styles/main.sass"), + project.root, + project.fileSet, + "sass", + ); + + expect(result).toBe("src/styles/_base.sass"); + }); + + it("exact alias pattern only matches exact specifier", () => { + project = createTempProject({ + "src/index.ts": "", + "src/utils/helper.ts": "", + }); + + const aliases = { + entries: new Map([["~", ["src"]]]), + }; + + // "~utils/helper" should NOT match exact alias "~" + const noMatch = resolveImport( + "~utils/helper", + path.join(project.root, "src/index.ts"), + project.root, + project.fileSet, + "typescript", + aliases, + ); + expect(noMatch).toBeNull(); + + // Exact "~" should resolve to src directory index + const exactMatch = resolveImport( + "~", + path.join(project.root, "src/index.ts"), + project.root, + project.fileSet, + "typescript", + aliases, + ); + expect(exactMatch).toBe("src/index.ts"); + }); + + it("returns null for bare CSS package specifier", () => { + project = createTempProject({ + "src/styles/main.css": "", + }); + + const result = resolveImport( + "normalize.css", + path.join(project.root, "src/styles/main.css"), + project.root, + project.fileSet, + "css", + ); + + expect(result).toBeNull(); + }); + }); }); From f4c5518453afd3752ea4777419b5b04036ffd07d Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Wed, 18 Mar 2026 20:17:51 +0100 Subject: [PATCH 2/3] docs: update language support and graph docs for CSS @import and path aliases - Move Svelte/Vue from "Indexing Only" to "Full Support" in README - Move SASS/LESS to "Code Graph via Regex" in README - Add graph-aliases.ts to DEVELOPER.md service file listing - Update DEVELOPER.md data flow with CSS @import, path alias, and SCSS partial resolution steps --- DEVELOPER.md | 17 ++++++++++++++--- README.md | 8 +++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index d8dd300..39376cc 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -194,8 +194,9 @@ src/ │ ├── logger.ts # Structured JSON logging — stderr (startup/no MCP transport) or MCP notifications/message (when hosted) │ ├── code-graph.ts # AST-based code graph building via ast-grep │ ├── graph-analysis.ts # Graph queries: dependencies, stats, cycles, Mermaid diagrams +│ ├── graph-aliases.ts # Path alias resolution from tsconfig/jsconfig compilerOptions.paths │ ├── graph-imports.ts # Import/require/use extraction for 18+ languages via AST -│ ├── graph-resolution.ts # Module specifier → file path resolution +│ ├── graph-resolution.ts # Module specifier → file path resolution (incl. aliases, SCSS partials) │ ├── startup.ts # Startup lifecycle: auto-resume, graceful shutdown coordination │ └── context-artifacts.ts # Context artifact loading, chunking, indexing, search │ @@ -458,14 +459,24 @@ When `codebase_graph_build` is called: │ ├── Swift: import │ ├── Bash: source, . (dot) │ ├── Dart/Lua: regex-based extraction - │ └── Svelte/Vue: HTML parse →