mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-15 05:59:11 +08:00
Same-kind, same-name declarations on one line shared an ID and silently overwrote each other in SQLite. Use per-extraction collision tracking to append a UTF-16 column only to later distinct declarations. Apply the same rule across wasm, Rust language walkers, Liquid, and CFML while preserving file IDs and edge endpoints. Add persistence, call-edge, identity-vector, and Unicode parity regressions. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types';
|
|
import { generateNodeId, NodeIdAllocator } from './tree-sitter-helpers';
|
|
|
|
/**
|
|
* LiquidExtractor - Extracts relationships from Liquid template files
|
|
*
|
|
* Liquid is a templating language (used by Shopify, Jekyll, etc.) that doesn't
|
|
* have traditional functions or classes. Instead, we extract:
|
|
* - Section references ({% section 'name' %})
|
|
* - Snippet references ({% render 'name' %} and {% include 'name' %})
|
|
* - Schema blocks ({% schema %}...{% endschema %})
|
|
*/
|
|
export class LiquidExtractor {
|
|
private filePath: string;
|
|
private source: string;
|
|
private nodes: Node[] = [];
|
|
private nodeIds = new NodeIdAllocator();
|
|
private edges: Edge[] = [];
|
|
private unresolvedReferences: UnresolvedReference[] = [];
|
|
private errors: ExtractionError[] = [];
|
|
|
|
constructor(filePath: string, source: string) {
|
|
this.filePath = filePath;
|
|
this.source = source;
|
|
}
|
|
|
|
/**
|
|
* Extract from Liquid source
|
|
*/
|
|
extract(): ExtractionResult {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
// Create file node
|
|
const fileNode = this.createFileNode();
|
|
|
|
// Shopify OS 2.0 JSON template / section group: link each section `type`
|
|
// to its `sections/<type>.liquid` file. (No symbol nodes are emitted — the
|
|
// JSON file just carries the references — so it stays out of any
|
|
// symbol-bearing-file metric while its sections still get their dependents.)
|
|
if (this.filePath.endsWith('.json')) {
|
|
this.extractShopifyJsonSections(fileNode.id);
|
|
} else {
|
|
// Extract render/include statements (snippet references)
|
|
this.extractSnippetReferences(fileNode.id);
|
|
|
|
// Extract section references
|
|
this.extractSectionReferences(fileNode.id);
|
|
|
|
// Extract schema block
|
|
this.extractSchema(fileNode.id);
|
|
|
|
// Extract assign statements as variables
|
|
this.extractAssignments(fileNode.id);
|
|
}
|
|
} catch (error) {
|
|
this.errors.push({
|
|
message: `Liquid extraction error: ${error instanceof Error ? error.message : String(error)}`,
|
|
severity: 'error',
|
|
code: 'parse_error',
|
|
});
|
|
}
|
|
|
|
return {
|
|
nodes: this.nodes,
|
|
edges: this.edges,
|
|
unresolvedReferences: this.unresolvedReferences,
|
|
errors: this.errors,
|
|
durationMs: Date.now() - startTime,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a file node for the Liquid template
|
|
*/
|
|
private createFileNode(): Node {
|
|
const lines = this.source.split('\n');
|
|
const id = generateNodeId(this.filePath, 'file', this.filePath, 1);
|
|
|
|
const fileNode: Node = {
|
|
id,
|
|
kind: 'file',
|
|
name: this.filePath.split('/').pop() || this.filePath,
|
|
qualifiedName: this.filePath,
|
|
filePath: this.filePath,
|
|
language: 'liquid',
|
|
startLine: 1,
|
|
endLine: lines.length,
|
|
startColumn: 0,
|
|
endColumn: lines[lines.length - 1]?.length || 0,
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
this.nodes.push(fileNode);
|
|
return fileNode;
|
|
}
|
|
|
|
/**
|
|
* Shopify OS 2.0 JSON template / section group. Both have a `sections` object
|
|
* mapping an id → `{ "type": "<section-name>", ... }`; the `type` names a
|
|
* `sections/<type>.liquid` file. Emit a `references` edge to each, so a section
|
|
* used only from a JSON template (the OS 2.0 norm) is no longer orphaned.
|
|
*/
|
|
private extractShopifyJsonSections(fromNodeId: string): void {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(this.source);
|
|
} catch {
|
|
return; // not valid JSON (or a partial) — nothing to link
|
|
}
|
|
const sections = (parsed as { sections?: Record<string, { type?: unknown }> })?.sections;
|
|
if (!sections || typeof sections !== 'object') return;
|
|
const seen = new Set<string>();
|
|
for (const key of Object.keys(sections)) {
|
|
const type = sections[key]?.type;
|
|
if (typeof type !== 'string' || seen.has(type)) continue;
|
|
seen.add(type);
|
|
this.unresolvedReferences.push({
|
|
fromNodeId,
|
|
referenceName: `sections/${type}.liquid`,
|
|
referenceKind: 'references',
|
|
line: 1,
|
|
column: 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Every occurrence of a Liquid tag, in BOTH spellings it can be written in.
|
|
*
|
|
* Inside a `{% liquid %}` tag, each line of the body is a tag
|
|
* WITHOUT braces of its own:
|
|
*
|
|
* {% liquid
|
|
* assign heading = section.settings.title
|
|
* render 'card', title: heading
|
|
* %}
|
|
*
|
|
* A pattern anchored on `{%` misses these references and assignments.
|
|
*
|
|
* Returns real offsets into `this.source`, so callers keep using
|
|
* getLineNumber/getLineStart unchanged.
|
|
*/
|
|
private findTagOccurrences(
|
|
tagPattern: string,
|
|
argPattern: string,
|
|
): Array<{ fullMatch: string; groups: string[]; index: number }> {
|
|
const found: Array<{ fullMatch: string; groups: string[]; index: number }> = [];
|
|
|
|
// Consume complete braced tags so strings and comments cannot start a
|
|
// second match inside the same tag. Skip non-executing regions entirely.
|
|
const blocks: Array<{ bodyStart: number; body: string }> = [];
|
|
const tags = /\{%[-]?\s*(\w+|#)([\s\S]*?)[-]?%\}/g;
|
|
const braced = new RegExp(`^\\{%[-]?\\s*(${tagPattern})\\s+${argPattern}`);
|
|
let suppressed: string | undefined;
|
|
let tag;
|
|
while ((tag = tags.exec(this.source)) !== null) {
|
|
const name = tag[1]!;
|
|
if (suppressed) {
|
|
if (name === `end${suppressed}`) suppressed = undefined;
|
|
continue;
|
|
}
|
|
if (name === 'comment' || name === 'raw') {
|
|
suppressed = name;
|
|
continue;
|
|
}
|
|
if (name === 'liquid') {
|
|
blocks.push({
|
|
bodyStart: tag.index + tag[0].indexOf(name) + name.length,
|
|
body: tag[2]!,
|
|
});
|
|
continue;
|
|
}
|
|
const match = braced.exec(tag[0]);
|
|
if (match) {
|
|
found.push({ fullMatch: match[0], groups: match.slice(1) as string[], index: tag.index });
|
|
}
|
|
}
|
|
|
|
/* Inside a `{% liquid %}` body each tag starts its own line. Anchoring on
|
|
the line start keeps prose, filters and inline `#` comments out. */
|
|
const bare = new RegExp(`^[ \\t]*(${tagPattern})[ \\t]+${argPattern}`);
|
|
for (const b of blocks) {
|
|
let offset = b.bodyStart;
|
|
let suppressed: string | undefined;
|
|
for (const line of b.body.split('\n')) {
|
|
const name = /^[ \t]*(\w+)/.exec(line)?.[1];
|
|
if (suppressed) {
|
|
if (name === `end${suppressed}`) suppressed = undefined;
|
|
} else if (name === 'comment' || name === 'raw') {
|
|
suppressed = name;
|
|
} else {
|
|
const inner = bare.exec(line);
|
|
if (inner) {
|
|
found.push({
|
|
fullMatch: inner[0].trimStart(),
|
|
groups: inner.slice(1) as string[],
|
|
index: offset + (inner[0].length - inner[0].trimStart().length),
|
|
});
|
|
}
|
|
}
|
|
offset += line.length + 1;
|
|
}
|
|
}
|
|
|
|
return found.sort((a, b) => a.index - b.index);
|
|
}
|
|
|
|
/**
|
|
* Extract {% render 'snippet' %} and {% include 'snippet' %} references
|
|
*/
|
|
private extractSnippetReferences(fileNodeId: string): void {
|
|
// Both spellings: {% render 'name' %} and a bare `render 'name'` line
|
|
// inside a {% liquid %} block.
|
|
for (const match of this.findTagOccurrences('render|include', `['"]([^'"]+)['"]`)) {
|
|
const fullMatch = match.fullMatch;
|
|
const [tagType, snippetName] = match.groups;
|
|
const line = this.getLineNumber(match.index);
|
|
|
|
// Create an import node for searchability
|
|
const importNodeId = this.nodeIds.generate(this.filePath, 'import', snippetName!, line, match.index - this.getLineStart(line));
|
|
const importNode: Node = {
|
|
id: importNodeId,
|
|
kind: 'import',
|
|
name: snippetName!,
|
|
qualifiedName: `${this.filePath}::import:${snippetName}`,
|
|
filePath: this.filePath,
|
|
language: 'liquid',
|
|
signature: fullMatch,
|
|
startLine: line,
|
|
endLine: line,
|
|
startColumn: match.index - this.getLineStart(line),
|
|
endColumn: match.index - this.getLineStart(line) + fullMatch.length,
|
|
updatedAt: Date.now(),
|
|
};
|
|
this.nodes.push(importNode);
|
|
|
|
// Add containment edge from file to import
|
|
this.edges.push({
|
|
source: fileNodeId,
|
|
target: importNodeId,
|
|
kind: 'contains',
|
|
});
|
|
|
|
// Create a component node for the snippet reference
|
|
const nodeId = this.nodeIds.generate(this.filePath, 'component', `${tagType}:${snippetName}`, line, match.index - this.getLineStart(line));
|
|
|
|
const node: Node = {
|
|
id: nodeId,
|
|
kind: 'component',
|
|
name: snippetName!,
|
|
qualifiedName: `${this.filePath}::${tagType}:${snippetName}`,
|
|
filePath: this.filePath,
|
|
language: 'liquid',
|
|
startLine: line,
|
|
endLine: line,
|
|
startColumn: match.index - this.getLineStart(line),
|
|
endColumn: match.index - this.getLineStart(line) + fullMatch.length,
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
this.nodes.push(node);
|
|
|
|
// Add containment edge from file
|
|
this.edges.push({
|
|
source: fileNodeId,
|
|
target: nodeId,
|
|
kind: 'contains',
|
|
});
|
|
|
|
// Add unresolved reference to the snippet file
|
|
this.unresolvedReferences.push({
|
|
fromNodeId: fileNodeId,
|
|
referenceName: `snippets/${snippetName}.liquid`,
|
|
referenceKind: 'references',
|
|
line,
|
|
column: match.index - this.getLineStart(line),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract {% section 'name' %} references
|
|
*/
|
|
private extractSectionReferences(fileNodeId: string): void {
|
|
// Both spellings, as for render/include above.
|
|
for (const match of this.findTagOccurrences('section', `['"]([^'"]+)['"]`)) {
|
|
const fullMatch = match.fullMatch;
|
|
const sectionName = match.groups[1];
|
|
const line = this.getLineNumber(match.index);
|
|
|
|
// Create an import node for searchability
|
|
const importNodeId = this.nodeIds.generate(this.filePath, 'import', sectionName!, line, match.index - this.getLineStart(line));
|
|
const importNode: Node = {
|
|
id: importNodeId,
|
|
kind: 'import',
|
|
name: sectionName!,
|
|
qualifiedName: `${this.filePath}::import:${sectionName}`,
|
|
filePath: this.filePath,
|
|
language: 'liquid',
|
|
signature: fullMatch,
|
|
startLine: line,
|
|
endLine: line,
|
|
startColumn: match.index - this.getLineStart(line),
|
|
endColumn: match.index - this.getLineStart(line) + fullMatch.length,
|
|
updatedAt: Date.now(),
|
|
};
|
|
this.nodes.push(importNode);
|
|
|
|
// Add containment edge from file to import
|
|
this.edges.push({
|
|
source: fileNodeId,
|
|
target: importNodeId,
|
|
kind: 'contains',
|
|
});
|
|
|
|
// Create a component node for the section reference
|
|
const nodeId = this.nodeIds.generate(this.filePath, 'component', `section:${sectionName}`, line, match.index - this.getLineStart(line));
|
|
|
|
const node: Node = {
|
|
id: nodeId,
|
|
kind: 'component',
|
|
name: sectionName!,
|
|
qualifiedName: `${this.filePath}::section:${sectionName}`,
|
|
filePath: this.filePath,
|
|
language: 'liquid',
|
|
startLine: line,
|
|
endLine: line,
|
|
startColumn: match.index - this.getLineStart(line),
|
|
endColumn: match.index - this.getLineStart(line) + fullMatch.length,
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
this.nodes.push(node);
|
|
|
|
// Add containment edge from file
|
|
this.edges.push({
|
|
source: fileNodeId,
|
|
target: nodeId,
|
|
kind: 'contains',
|
|
});
|
|
|
|
// Add unresolved reference to the section file
|
|
this.unresolvedReferences.push({
|
|
fromNodeId: fileNodeId,
|
|
referenceName: `sections/${sectionName}.liquid`,
|
|
referenceKind: 'references',
|
|
line,
|
|
column: match.index - this.getLineStart(line),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract {% schema %}...{% endschema %} blocks
|
|
*/
|
|
private extractSchema(fileNodeId: string): void {
|
|
// Match {% schema %}...{% endschema %}
|
|
const schemaRegex = /\{%[-]?\s*schema\s*[-]?%\}([\s\S]*?)\{%[-]?\s*endschema\s*[-]?%\}/g;
|
|
let match;
|
|
|
|
while ((match = schemaRegex.exec(this.source)) !== null) {
|
|
const [fullMatch, schemaContent] = match;
|
|
const startLine = this.getLineNumber(match.index);
|
|
const endLine = this.getLineNumber(match.index + fullMatch.length);
|
|
|
|
// Try to parse the schema JSON to get the name
|
|
let schemaName = 'schema';
|
|
try {
|
|
const schemaJson = JSON.parse(schemaContent!);
|
|
if (schemaJson.name) {
|
|
// Shopify schema names can be translation objects like {"en": "...", "fr": "..."}
|
|
schemaName = typeof schemaJson.name === 'string'
|
|
? schemaJson.name
|
|
: schemaJson.name.en || Object.values(schemaJson.name)[0] as string || 'schema';
|
|
}
|
|
} catch {
|
|
// Schema isn't valid JSON, use default name
|
|
}
|
|
|
|
// Create a node for the schema
|
|
const nodeId = this.nodeIds.generate(this.filePath, 'constant', `schema:${schemaName}`, startLine, match.index - this.getLineStart(startLine));
|
|
|
|
const node: Node = {
|
|
id: nodeId,
|
|
kind: 'constant',
|
|
name: schemaName,
|
|
qualifiedName: `${this.filePath}::schema:${schemaName}`,
|
|
filePath: this.filePath,
|
|
language: 'liquid',
|
|
startLine,
|
|
endLine,
|
|
startColumn: match.index - this.getLineStart(startLine),
|
|
endColumn: 0,
|
|
// SECURITY (#383): don't dump the raw {% schema %} JSON (section settings
|
|
// + default values) into the docstring — the schema name is already in
|
|
// `name`, so the data block adds nothing but a potential leak of any
|
|
// IDs/endpoints/keys a developer placed in setting defaults.
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
this.nodes.push(node);
|
|
|
|
// Add containment edge from file
|
|
this.edges.push({
|
|
source: fileNodeId,
|
|
target: nodeId,
|
|
kind: 'contains',
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract {% assign var = value %} statements
|
|
*/
|
|
private extractAssignments(fileNodeId: string): void {
|
|
// Both spellings. Most of a modern theme's assigns are the bare kind.
|
|
for (const match of this.findTagOccurrences('assign', `(\\w+)\\s*=`)) {
|
|
const variableName = match.groups[1];
|
|
const line = this.getLineNumber(match.index);
|
|
|
|
// Create a variable node
|
|
const nodeId = this.nodeIds.generate(this.filePath, 'variable', variableName!, line, match.index - this.getLineStart(line));
|
|
|
|
const node: Node = {
|
|
id: nodeId,
|
|
kind: 'variable',
|
|
name: variableName!,
|
|
qualifiedName: `${this.filePath}::${variableName}`,
|
|
filePath: this.filePath,
|
|
language: 'liquid',
|
|
startLine: line,
|
|
endLine: line,
|
|
startColumn: match.index - this.getLineStart(line),
|
|
endColumn: match.index - this.getLineStart(line) + match.fullMatch.length,
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
this.nodes.push(node);
|
|
|
|
// Add containment edge from file
|
|
this.edges.push({
|
|
source: fileNodeId,
|
|
target: nodeId,
|
|
kind: 'contains',
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the line number for a character index
|
|
*/
|
|
private getLineNumber(index: number): number {
|
|
const substring = this.source.substring(0, index);
|
|
return (substring.match(/\n/g) || []).length + 1;
|
|
}
|
|
|
|
/**
|
|
* Get the character index of the start of a line
|
|
*/
|
|
private getLineStart(lineNumber: number): number {
|
|
const lines = this.source.split('\n');
|
|
let index = 0;
|
|
for (let i = 0; i < lineNumber - 1 && i < lines.length; i++) {
|
|
index += lines[i]!.length + 1; // +1 for newline
|
|
}
|
|
return index;
|
|
}
|
|
}
|