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
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,4 @@ jobs:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun run compile
- run: xvfb-run -a bun run test
- run: xvfb-run -a bun run test:coverage
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ _.log
*.vsix
.vscode-test
.vscode-test-web
coverage

/.agents/
/.claude/
Expand Down
34 changes: 30 additions & 4 deletions .vscode-test.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,35 @@
import { defineConfig } from "@vscode/test-cli"

export default defineConfig({
files: "dist/test/**/*.test.js",
mocha: {
ui: "tdd",
timeout: 20000,
tests: [
{
files: "dist/test/**/*.test.js",
srcDir: "dist",
mocha: {
ui: "tdd",
timeout: 20000,
},
},
],
coverage: {
include: ["**/*.js"],
exclude: [
"**/test/**",
"**/web/**",
"**/node_modules/**",
// Type-only files (compile to empty modules)
"**/core/types.js",
"**/core/filesystem.js",
"**/core/index.js",
"**/telemetry/types.js",
// VSCode-dependent files (require mocking, not unit testable)
"**/extension.js",
"**/appDiscovery.js",
"**/vscodeFileSystem.js",
"**/telemetry/vscode.js",
],
reporter: ["text", "html", "json-summary"],
output: "./coverage",
includeAll: true,
},
})
72 changes: 41 additions & 31 deletions esbuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import esbuild from "esbuild"

const production = process.argv.includes("--production")
const watch = process.argv.includes("--watch")
const noBundleForCoverage = process.argv.includes("--no-bundle")

const POSTHOG_API_KEY = "phc_s0Qx8NxueJvnqe4YE7NEKYNosJr8aZ81tIByuzm464X"

Expand Down Expand Up @@ -35,10 +36,13 @@ async function main() {
copyWasmFiles()

const testEntryPoints = !production ? globSync("src/test/**/*.test.ts") : []
const sourceEntryPoints = noBundleForCoverage
? globSync("src/**/*.ts")
: ["src/extension.ts"]

// Shared esbuild options
const sharedOptions = {
bundle: true,
bundle: !noBundleForCoverage,
minify: production,
sourcemap: !production,
sourcesContent: false,
Expand All @@ -56,47 +60,53 @@ async function main() {
// Node build (desktop VS Code)
const nodeCtx = await esbuild.context({
...sharedOptions,
entryPoints: ["src/extension.ts", ...testEntryPoints],
entryPoints: [...sourceEntryPoints, ...testEntryPoints],
format: "cjs",
platform: "node",
target: "node20",
outdir: "dist",
outbase: "src",
external: ["vscode", "web-tree-sitter"],
...(noBundleForCoverage ? {} : { external: ["vscode", "web-tree-sitter"] }),
})

// Browser build (vscode.dev)
const browserCtx = await esbuild.context({
...sharedOptions,
entryPoints: ["src/extension.ts"],
format: "cjs",
platform: "browser",
target: "es2022",
outfile: "dist/web/extension.js",
// Polyfill/alias node modules for browser
alias: {
"node:path": "path-browserify",
},
// vscode is provided by the runtime; web-tree-sitter is bundled but
// internally references these Node.js modules for environment detection
// posthog-node uses Node.js APIs, so telemetry is disabled in browser
// util and child_process are used for version detection but not in browser
external: [
"vscode",
"fs/promises",
"module",
"posthog-node",
"util",
"child_process",
],
})
// Browser build (vscode.dev) - skip for unbundled builds
const browserCtx = noBundleForCoverage
? null
: await esbuild.context({
...sharedOptions,
entryPoints: ["src/extension.ts"],
format: "cjs",
platform: "browser",
target: "es2022",
outfile: "dist/web/extension.js",
// Polyfill/alias node modules for browser
alias: {
"node:path": "path-browserify",
},
// vscode is provided by the runtime; web-tree-sitter is bundled but
// internally references these Node.js modules for environment detection
// posthog-node uses Node.js APIs, so telemetry is disabled in browser
// util and child_process are used for version detection but not in browser
external: [
"vscode",
"fs/promises",
"module",
"posthog-node",
"util",
"child_process",
],
})

if (watch) {
await Promise.all([nodeCtx.watch(), browserCtx.watch()])
await Promise.all([nodeCtx.watch(), browserCtx?.watch()].filter(Boolean))
console.log("Watching for changes...")
} else {
await Promise.all([nodeCtx.rebuild(), browserCtx.rebuild()])
await Promise.all([nodeCtx.dispose(), browserCtx.dispose()])
await Promise.all(
[nodeCtx.rebuild(), browserCtx?.rebuild()].filter(Boolean),
)
await Promise.all(
[nodeCtx.dispose(), browserCtx?.dispose()].filter(Boolean),
)
}
}

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@
"publish:marketplace": "vsce publish",
"lint": "biome check --write --unsafe --no-errors-on-unmatched --files-ignore-unknown=true src/",
"test": "bun run compile && vscode-test",
"test:coverage": "bash scripts/test-coverage.sh",
"test:web": "bun run compile && bunx @vscode/test-web --extensionDevelopmentPath=. --browserType=none",
"prepare": "husky"
},
Expand Down
21 changes: 21 additions & 0 deletions scripts/test-coverage.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/bash
set -e

# Build without bundling (required for per-file coverage)
bun run esbuild.js --no-bundle

# Run tests with coverage collection
bunx vscode-test --coverage

# Check coverage threshold
node -e "
const threshold = ${COVERAGE_THRESHOLD:-90};
const summary = require('./coverage/coverage-summary.json');
const pct = summary.total.lines.pct;
console.log('Line coverage: ' + pct + '%');
if (pct < threshold) {
console.error('ERROR: Coverage ' + pct + '% is below threshold ' + threshold + '%');
process.exit(1);
}
console.log('Coverage check passed: ' + pct + '% >= ' + threshold + '%');
"
19 changes: 6 additions & 13 deletions src/core/extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,9 @@ export function extractPathFromNode(node: Node): string {
case "identifier":
case "attribute":
case "call":
default:
// Dynamic values: variable, attribute access, or function call
return `{${node.text}}`

default:
// Fallback: wrap unknown types in braces to indicate dynamic
return node.text ? `{${node.text}}` : ""
}
}

Expand All @@ -102,10 +99,8 @@ export function decoratorExtractor(node: Node): RouteInfo | null {
return null
}

const decoratorNode = node.firstNamedChild
if (!decoratorNode) {
return null
}
// Grammar guarantees: decorated_definition always has a first child (the decorator)
const decoratorNode = node.firstNamedChild!

const callNode = findNodesByType(decoratorNode, "call")[0]
const functionNode = callNode?.childForFieldName("function")
Expand Down Expand Up @@ -152,11 +147,9 @@ export function decoratorExtractor(node: Node): RouteInfo | null {
}
}

const functionDefNode = node.childForFieldName("definition")
const functionNameDefNode = functionDefNode
? functionDefNode.childForFieldName("name")
: null
const functionName = functionNameDefNode ? functionNameDefNode.text : ""
// Grammar guarantees: decorated_definition always has a definition field with a name
const functionDefNode = node.childForFieldName("definition")!
const functionName = functionDefNode.childForFieldName("name")?.text ?? ""

return {
owner: objectNode.text,
Expand Down
12 changes: 3 additions & 9 deletions src/core/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,17 +114,11 @@ function buildPrefixHierarchy(
continue
}

// Check if a router with the exact same prefix already exists - nest under it
// Check if a router with the exact same prefix already exists - merge into it
const existingRouter = prefixToRouter.get(strippedPrefix)
if (existingRouter) {
// If existing is a synthetic group (no routes), add as child; otherwise merge
if (existingRouter.routes.length === 0 && router.routes.length > 0) {
existingRouter.children.push(router)
} else {
// Merge routes and children into the existing router
existingRouter.routes.push(...router.routes)
existingRouter.children.push(...router.children)
}
existingRouter.routes.push(...router.routes)
existingRouter.children.push(...router.children)
continue
}

Expand Down
29 changes: 12 additions & 17 deletions src/providers/testCodeLensProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ interface TestClientCall {
export class TestCodeLensProvider implements CodeLensProvider {
private apps: AppDefinition[] = []
private parser: Parser

private _onDidChangeCodeLenses = new EventEmitter<void>()
readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event

private trackedFiles = new Set<string>()

constructor(parser: Parser, apps: AppDefinition[]) {
Expand All @@ -53,9 +55,8 @@ export class TestCodeLensProvider implements CodeLensProvider {
provideCodeLenses(document: TextDocument): CodeLens[] {
const code = document.getText()
const tree = this.parser.parse(code)
if (!tree) {
return []
}
/* c8 ignore next */
if (!tree) return []

const testClientCalls = this.findTestClientCalls(tree.rootNode)

Expand Down Expand Up @@ -108,26 +109,22 @@ export class TestCodeLensProvider implements CodeLensProvider {
const callNodes = findNodesByType(rootNode, "call")

for (const callNode of callNodes) {
const functionNode = callNode.childForFieldName("function")
if (!functionNode || functionNode.type !== "attribute") {
// Grammar guarantees: call nodes always have a function field
const functionNode = callNode.childForFieldName("function")!
if (functionNode.type !== "attribute") {
continue
}

const methodNode = functionNode.childForFieldName("attribute")
if (!methodNode) {
continue
}
// Grammar guarantees: attribute nodes always have an attribute field
const methodNode = functionNode.childForFieldName("attribute")!

const method = methodNode.text.toLowerCase()
if (!ROUTE_METHODS.has(method)) {
continue
}

// Get the path argument (first argument)
const argumentsNode = callNode.childForFieldName("arguments")
if (!argumentsNode) {
continue
}
// Grammar guarantees: call nodes always have an arguments field
const argumentsNode = callNode.childForFieldName("arguments")!

const args = argumentsNode.namedChildren.filter(
(child) => child.type !== "comment",
Expand All @@ -138,10 +135,8 @@ export class TestCodeLensProvider implements CodeLensProvider {
}

const pathArg = args[0]
// extractPathFromNode always returns a non-empty string for valid AST nodes
const path = extractPathFromNode(pathArg)
if (!path) {
continue
}

calls.push({
method,
Expand Down
12 changes: 12 additions & 0 deletions src/test/core/analyzer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,18 @@ import os
assert.ok(methods.includes("post"))
})

test("returns null when parser fails to parse", async () => {
const nullParser = { parse: () => null } as unknown as Parser
const mockFs = {
readFile: async () => new TextEncoder().encode("x = 1"),
exists: async () => true,
joinPath: (...parts: string[]) => parts.join("/"),
dirname: (p: string) => p.split("/").slice(0, -1).join("/"),
}
const result = await analyzeFile("file:///test.py", nullParser, mockFs)
assert.strictEqual(result, null)
})

test("returns null for non-existent file", async () => {
const result = await analyzeFile(
"file:///nonexistent/file.py",
Expand Down
Loading