Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions DEVELOPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -458,14 +459,24 @@ When `codebase_graph_build` is called:
│ ├── Swift: import
│ ├── Bash: source, . (dot)
│ ├── Dart/Lua: regex-based extraction
│ └── Svelte/Vue: HTML parse → <script> extraction → re-parse as TypeScript
│ ├── Svelte/Vue: HTML parse → <script> extraction → re-parse as TypeScript
│ ├── Svelte/Vue: HTML parse → <style> extraction → CSS @import/@require regex
│ └── CSS/SCSS/SASS/LESS: @import/@import url()/@require regex extraction
├── Tag CSS imports with isCssImport flag (for correct resolution extensions)
├── Update progress: filesProcessed++
└── Return ImportInfo[] with module specifiers

4. RESOLVE IMPORTS
├── Load path aliases from tsconfig.json/jsconfig.json (once per build)
│ ├── Parse compilerOptions.paths + baseUrl
│ ├── Follow "extends" chains (up to 10 levels, circular-safe)
│ └── Fall back to jsconfig.json if tsconfig has no paths
├── For each import, resolve module specifier to actual file path
├── Handle relative paths: ./foo → foo.ts, foo/index.ts, etc.
├── Try extension variations per language
├── Try path alias resolution: $lib/foo → src/lib/foo.ts, @/bar → src/bar.ts
├── Try extension variations per language (JS/TS extensions or CSS extensions)
├── SCSS partial resolution: @import "vars" → _vars.scss
├── CSS imports from <style> blocks → resolved with CSS extensions (.css/.scss/.sass/.less/.styl)
├── Check against known file set for existence
└── Skip unresolvable imports (external packages, built-ins)

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -602,15 +602,17 @@ SocratiCode supports languages at three levels:

### Full Support (indexing + code graph + AST chunking)

JavaScript, TypeScript, TSX, Python, Java, Kotlin, Scala, C, C++, C#, Go, Rust, Ruby, PHP, Swift, Bash/Shell, HTML, CSS/SCSS
JavaScript, TypeScript, TSX, Python, Java, Kotlin, Scala, C, C++, C#, Go, Rust, Ruby, PHP, Swift, Bash/Shell, HTML, CSS/SCSS, Svelte, Vue

Svelte and Vue: imports extracted from `<script>` blocks (re-parsed as TypeScript) and CSS `@import`/`@require` from `<style>` blocks (any combination of `lang`, `scoped`, `module`, `global` attributes). Path aliases from `tsconfig.json`/`jsconfig.json` `compilerOptions.paths` are resolved (including `extends` chains). SCSS partial resolution (`_` prefix convention) is supported.

### Code Graph via Regex + Indexing

Dart (import/export/part), Lua (require/dofile/loadfile)
Dart (import/export/part), Lua (require/dofile/loadfile), SASS, LESS (CSS `@import` extraction)

### Indexing Only (hybrid search, line-based chunking)

Vue, Svelte, SASS, LESS, JSON, YAML, TOML, XML, INI/CFG, Markdown/MDX, RST, SQL, R, Dockerfile, TXT, and any file matching a supported extension or special filename (Dockerfile, Makefile, Gemfile, Rakefile, etc.)
JSON, YAML, TOML, XML, INI/CFG, Markdown/MDX, RST, SQL, R, Dockerfile, TXT, and any file matching a supported extension or special filename (Dockerfile, Makefile, Gemfile, Rakefile, etc.)

**54 file extensions** + 8 special filenames supported out of the box.

Expand Down
8 changes: 6 additions & 2 deletions src/services/code-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Lang, registerDynamicLanguage } from "@ast-grep/napi";
import { graphCollectionName, projectIdFromPath } from "../config.js";
import { EXTRA_EXTENSIONS, getLanguageFromExtension, MAX_GRAPH_FILE_BYTES } from "../constants.js";
import type { CodeGraph, CodeGraphEdge, CodeGraphNode } from "../types.js";
import { loadPathAliases } from "./graph-aliases.js";
import { extractImports } from "./graph-imports.js";
import { resolveImport } from "./graph-resolution.js";
import { createIgnoreFilter, shouldIgnore } from "./ignore.js";
Expand Down Expand Up @@ -313,7 +314,7 @@ export function getAstGrepLang(ext: string): Lang | string | null {
".ts": Lang.TypeScript,
".tsx": Lang.Tsx,
".html": Lang.Html, ".htm": Lang.Html,
".css": Lang.Css, ".scss": Lang.Css,
".css": Lang.Css, ".scss": Lang.Css, ".sass": Lang.Css, ".less": Lang.Css, ".styl": Lang.Css,
};
return map[ext] ?? null;
}
Expand Down Expand Up @@ -375,6 +376,7 @@ export async function buildCodeGraph(
ensureDynamicLanguages();

const resolvedPath = path.resolve(projectPath);
const aliases = await loadPathAliases(resolvedPath);
const files = await getGraphableFiles(resolvedPath, extraExtensions);
const fileSet = new Set(files);

Expand Down Expand Up @@ -444,7 +446,9 @@ export async function buildCodeGraph(
node.imports.push(imp.moduleSpecifier);

// Try to resolve to a project file
const resolved = resolveImport(imp.moduleSpecifier, absolutePath, resolvedPath, fileSet, language);
// CSS imports from <style> blocks use CSS resolution even when the source file is Svelte/Vue
const resolutionLanguage = imp.isCssImport ? "css" : language;
const resolved = resolveImport(imp.moduleSpecifier, absolutePath, resolvedPath, fileSet, resolutionLanguage, aliases);
if (resolved) {
node.dependencies.push(resolved);

Expand Down
187 changes: 187 additions & 0 deletions src/services/graph-aliases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
import fs from "node:fs/promises";
import path from "node:path";
import { logger } from "./logger.js";

// ── Path alias resolution ────────────────────────────────────────────────

/** Resolved path aliases from tsconfig/jsconfig */
export interface PathAliases {
/** Map of alias prefix → target directories (relative to project root) */
entries: Map<string, string[]>;
}

/** Empty aliases constant for when no config is found */
const EMPTY_ALIASES: PathAliases = { entries: new Map() };

/**
* Load path aliases from tsconfig.json or jsconfig.json.
* Parses `compilerOptions.paths` and `compilerOptions.baseUrl` to build
* a prefix → directory mapping used during import resolution.
* Follows `extends` chains to find paths in parent configs.
*
* Returns empty aliases if no config is found (graceful degradation).
*/
export async function loadPathAliases(projectPath: string): Promise<PathAliases> {
const configNames = ["tsconfig.json", "jsconfig.json"];

for (const name of configNames) {
const configPath = path.join(projectPath, name);
try {
const raw = await fs.readFile(configPath, "utf-8");
const aliases = parsePathAliases(raw, projectPath);
if (aliases.entries.size > 0) {
logger.info("Loaded path aliases", {
config: name,
aliases: Array.from(aliases.entries.keys()),
});
return aliases;
}
// Config exists but has no paths — follow extends chain
const extended = await followExtendsChain(configPath, projectPath);
if (extended.entries.size > 0) {
logger.info("Loaded path aliases via extends", {
config: name,
aliases: Array.from(extended.entries.keys()),
});
return extended;
}
// No paths in entire chain — try next config file
} catch {
// Config not found — try next
}
}

return EMPTY_ALIASES;
}

/** Maximum depth for tsconfig extends chains to prevent circular references */
const MAX_EXTENDS_DEPTH = 10;

/**
* Follow the `extends` chain of a tsconfig/jsconfig looking for `compilerOptions.paths`.
* Resolves relative paths and package references. Caps at MAX_EXTENDS_DEPTH.
*/
async function followExtendsChain(
configPath: string,
projectPath: string,
): Promise<PathAliases> {
const visited = new Set<string>();
let currentPath = configPath;

for (let depth = 0; depth < MAX_EXTENDS_DEPTH; depth++) {
const resolved = path.resolve(currentPath);
if (visited.has(resolved)) break; // circular
visited.add(resolved);

let raw: string;
try {
raw = await fs.readFile(resolved, "utf-8");
} catch {
break; // file not found
}

const config = parseTsconfigJson(raw);
if (!config) break;

// Check if this config has paths
const co = config.compilerOptions as Record<string, unknown> | undefined;
if (co?.paths) {
const configDir = path.dirname(resolved);
return parsePathAliases(raw, configDir);
}

// Follow extends
const extendsValue = config.extends;
if (!extendsValue || typeof extendsValue !== "string") break;

const configDir = path.dirname(resolved);
if (extendsValue.startsWith(".")) {
// Relative path: "./tsconfig.base.json"
currentPath = path.resolve(configDir, extendsValue);
// Add .json if missing
if (!currentPath.endsWith(".json")) currentPath += ".json";
} else {
// Package reference: "@tsconfig/node20/tsconfig.json"
// Try to resolve from node_modules
try {
currentPath = path.resolve(configDir, "node_modules", extendsValue);
if (!currentPath.endsWith(".json")) currentPath += ".json";
} catch {
break;
}
}
}

return EMPTY_ALIASES;
}

/**
* Parse tsconfig/jsconfig JSON with comment stripping.
* Returns null if parsing fails.
*/
export function parseTsconfigJson(jsonContent: string): Record<string, unknown> | null {
try {
const stripped = jsonContent.replace(
/"(?:[^"\\]|\\.)*"|\/\/.*$|\/\*[\s\S]*?\*\//gm,
(match) => match.startsWith('"') ? match : "",
);
return JSON.parse(stripped);
} catch {
return null;
}
}

/**
* Parse path aliases from tsconfig/jsconfig JSON content.
* Handles JSON with comments (// and /* ... *\/) which tsconfig allows.
*/
export function parsePathAliases(jsonContent: string, projectPath: string): PathAliases {
const entries = new Map<string, string[]>();

try {
const config = parseTsconfigJson(jsonContent);
if (!config) return EMPTY_ALIASES;

const compilerOptions = config.compilerOptions as Record<string, unknown> | undefined;
if (!compilerOptions?.paths) return EMPTY_ALIASES;

const baseUrl = (compilerOptions.baseUrl as string) ?? ".";
const baseDir = path.resolve(projectPath, baseUrl);
const paths = compilerOptions.paths as Record<string, string[]>;

for (const [pattern, targets] of Object.entries(paths)) {
if (!Array.isArray(targets) || targets.length === 0) continue;

// Convert wildcard pattern: "$lib/*" → prefix "$lib/"
// Convert exact pattern: "~" → prefix "~"
const prefix = pattern.endsWith("/*")
? pattern.slice(0, -1) // "$lib/*" → "$lib/"
: pattern;

const resolvedTargets: string[] = [];
for (const target of targets) {
if (typeof target !== "string") continue;
// Convert wildcard target: "src/lib/*" → "src/lib/"
// Convert exact target: "./src" → "src"
const targetPath = target.endsWith("/*")
? target.slice(0, -1) // "src/lib/*" → "src/lib/"
: target;

// Resolve relative to baseUrl, then make relative to project root
const absolute = path.resolve(baseDir, targetPath);
const relative = path.relative(projectPath, absolute);
resolvedTargets.push(relative);
}

if (resolvedTargets.length > 0) {
entries.set(prefix, resolvedTargets);
}
}
} catch (err) {
logger.warn("Failed to parse path aliases from config", { error: String(err) });
}

return { entries };
}
31 changes: 31 additions & 0 deletions src/services/graph-imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,25 @@ import { logger } from "./logger.js";
export interface ImportInfo {
moduleSpecifier: string; // The raw import string
isDynamic: boolean;
isCssImport?: boolean; // True when extracted from a CSS/style context
}

/** Extract CSS/SCSS/Stylus @import statements from raw style source text. */
function extractCssImports(source: string): ImportInfo[] {
const imports: ImportInfo[] = [];
// CSS/SCSS: @import "./foo.css"; @import url("./foo.css");
for (const match of source.matchAll(/@import\s+(?:url\(\s*)?['"]([^'"]+)['"]\s*\)?/gm)) {
const spec = match[1];
if (spec.startsWith("http://") || spec.startsWith("https://")) continue;
imports.push({ moduleSpecifier: spec, isDynamic: false, isCssImport: true });
}
// Stylus: @require "foo" (quoted form only; bare-identifier syntax not supported)
for (const match of source.matchAll(/@require\s+['"]([^'"]+)['"]/gm)) {
const spec = match[1];
if (spec.startsWith("http://") || spec.startsWith("https://")) continue;
imports.push({ moduleSpecifier: spec, isDynamic: false, isCssImport: true });
}
return imports;
}

/** Extract JS/TS imports from an ast-grep root node. Shared by JS/TS and Svelte/Vue handlers. */
Expand Down Expand Up @@ -100,6 +119,13 @@ export function extractImports(source: string, lang: Lang | string, _ext: string
const scriptRoot = parse(Lang.TypeScript, scriptContent).root();
imports.push(...extractJsTsImportsFromNode(scriptRoot));
}

// Also extract CSS @import from <style> blocks
const styleElements = htmlRoot.findAll({ rule: { kind: "style_element" } });
for (const styleEl of styleElements) {
const rawText = styleEl.find({ rule: { kind: "raw_text" } });
if (rawText) imports.push(...extractCssImports(rawText.text()));
}
} catch (err) {
logger.warn("Failed to parse Svelte/Vue file for imports", { error: String(err) });
}
Expand Down Expand Up @@ -134,6 +160,11 @@ export function extractImports(source: string, lang: Lang | string, _ext: string
break;
}

case "Css": {
imports.push(...extractCssImports(source));
break;
}

case "JavaScript":
case "TypeScript":
case "Tsx": {
Expand Down
Loading
Loading