Skip to content
Open
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
25 changes: 25 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4471,6 +4471,31 @@ describe('Razor / Blazor markup extraction', () => {
const deps = [...cg.getImpactRadius(svc!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(deps.some((p) => p.endsWith('List.razor')), '@code usage links the component to the service').toBe(true);
});

it('links inline <script> JS in a .cshtml to a repo JS module/function', async () => {
// Traditional MVC view: front-end logic lives in an inline <script> that
// imports + calls a shared JS helper. The helper must show the view as a
// dependent — proving the JS ref isn't dropped by the dotnet family gate
// (the ref must stay in the `javascript` family, not be tagged `razor`).
fs.writeFileSync(
path.join(tempDir, 'site.js'),
`export function showToast(msg) { return msg; }\n`
);
fs.mkdirSync(path.join(tempDir, 'Views'), { recursive: true });
fs.writeFileSync(
path.join(tempDir, 'Views/Order.cshtml'),
`@model OrderViewModel\n<div>Order</div>\n<script>\n import { showToast } from '../site.js';\n function save() { showToast('saved'); }\n</script>\n`
);

cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();

const helper = cg.getNodesByKind('function').find((n) => n.name === 'showToast');
expect(helper, 'showToast function').toBeDefined();
const deps = [...cg.getImpactRadius(helper!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(deps.some((p) => p.endsWith('Order.cshtml')), 'inline <script> links the view to the JS helper').toBe(true);
});
});

describe('Default import resolution (renamed default export)', () => {
Expand Down
77 changes: 76 additions & 1 deletion src/extraction/razor-extractor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types';
import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Language } from '../types';
import { generateNodeId } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter';
import { isLanguageSupported } from './grammars';
Expand All @@ -17,6 +17,14 @@ import { isLanguageSupported } from './grammars';
* - `<MyComponent .../>` (Blazor only) → the component class (.razor or `.cs : ComponentBase`)
* - `<Grid TItem="CatalogItem">` → the generic type argument
*
* It also links the JavaScript/TypeScript inside HTML `<script>` blocks — the
* `.cshtml` analog of a Blazor `@code` block — to the functions, modules, and
* types it names. Traditional ASP.NET MVC views keep their front-end logic in
* `<script>`, so a helper or module used only from a view's inline script would
* otherwise look unreferenced. Those refs keep their `javascript`/`typescript`
* language family (NOT `razor`/dotnet), so the name-matcher's cross-family gate
* resolves them to `.js`/`.ts` symbols instead of dropping them.
*
* Risk mitigations (see docs/design/template-markup-parser.md):
* - Only PascalCase (`[A-Z]`-initial) tags are treated as components — HTML
* elements are lowercase, so they never match. Known Blazor framework
Expand Down Expand Up @@ -75,6 +83,10 @@ export class RazorExtractor {
// this is where component logic uses services/DTOs, so it covers the types
// referenced only from component code.
this.processCodeBlocks(componentId);
// Delegate the JS/TS inside HTML `<script>` blocks to the JS/TS extractor.
// Traditional MVC `.cshtml` keeps front-end logic there — the markup analog
// of a Blazor `@code` block, but JavaScript instead of C#.
this.processScriptBlocks(componentId);
} catch (error) {
this.errors.push({
message: `Razor extraction error: ${error instanceof Error ? error.message : String(error)}`,
Expand Down Expand Up @@ -277,4 +289,67 @@ export class RazorExtractor {
}
}
}

/**
* Extract inline `<script>` blocks (JS/TS) from the markup. A `<script src=...>`
* with no inline body, and non-JS blocks (`type="text/html"` templates, JSON
* islands), are skipped. Returns each block's content with the 0-indexed line
* where the content begins, so refs map back to the right line in the view.
*/
private extractScriptBlocks(): Array<{ content: string; startLine: number; isTypeScript: boolean }> {
const blocks: Array<{ content: string; startLine: number; isTypeScript: boolean }> = [];
const scriptRe = /<script(\s[^>]*)?>(?<content>[\s\S]*?)<\/script>/gi;
let m: RegExpExecArray | null;
while ((m = scriptRe.exec(this.source)) !== null) {
const attrs = m[1] || '';
const content = m.groups?.content ?? m[2] ?? '';
if (!content.trim()) continue; // <script src="..."> or empty block
// Only treat the block as JS/TS — skip template/data script types.
const type = attrs.match(/type\s*=\s*["']([^"']+)["']/i)?.[1]?.toLowerCase();
if (type && !/(javascript|ecmascript|babel|jsx|typescript|module)/.test(type)) continue;
const isTypeScript = /lang\s*=\s*["'](ts|typescript)["']/i.test(attrs) || type === 'application/typescript';
const beforeScript = this.source.slice(0, m.index);
const scriptTagLine = (beforeScript.match(/\n/g) || []).length;
const openingTag = m[0].slice(0, m[0].indexOf('>') + 1);
const openingTagLines = (openingTag.match(/\n/g) || []).length;
blocks.push({ content, startLine: scriptTagLine + openingTagLines, isTypeScript });
}
return blocks;
}

/**
* Delegate each `<script>` block's JS/TS to the tree-sitter JS/TS extractor and
* attribute the block's external references (calls, imports, type uses) to the
* component. Keep ONLY the dependency references — no per-symbol nodes — so the
* file's node count stays one component node (the design doc's stable-node-count
* invariant).
*
* Crucially, refs keep their `javascript`/`typescript` language, NOT `razor`:
* the name-matcher's cross-family gate resolves `references`/`imports` only
* within the same language family, and `razor` is in the `dotnet` family — so a
* JS ref tagged `razor` would be dropped before reaching a `.js`/`.ts` symbol.
* Degrades gracefully if the JS/TS grammar isn't loaded.
*/
private processScriptBlocks(componentId: string): void {
for (const block of this.extractScriptBlocks()) {
const scriptLanguage: Language = block.isTypeScript ? 'typescript' : 'javascript';
if (!isLanguageSupported(scriptLanguage)) continue;
let result: ExtractionResult;
try {
result = new TreeSitterExtractor(this.filePath, block.content, scriptLanguage).extract();
} catch {
continue; // grammar not loaded / parse failure — skip this block
}
for (const ref of result.unresolvedReferences) {
this.unresolvedReferences.push({
...ref,
fromNodeId: componentId,
line: ref.line + block.startLine,
column: ref.column,
filePath: this.filePath,
language: scriptLanguage,
});
}
}
}
}