diff --git a/src/schema/schema.ts b/src/schema/schema.ts index ac7d4ae..f2409f8 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -74,8 +74,12 @@ export interface TSComment { } export interface TSDecorator { - name: string; // locally written name, e.g. "Get" - qualified_name?: string; // checker-resolved FQN when available + name: string; // the decorator as WRITTEN, e.g. "Get" or "http.route" (python parity) + // Import-table resolution of `name` (#151), e.g. "@nestjs/common.Get" — the module specifier kept + // verbatim, aliases mapped back to the exported name. ABSENT when the head is not an imported + // binding: a same-file declaration, a global, or a spelling nothing in the module can name. The + // checker is not consulted; there is no resolved-FQN tier above this. + qualified_name?: string; positional_arguments: string[]; // raw source fragments keyword_arguments: Record; // object-literal args flattened to key→source start_line: number; diff --git a/src/syntactic_analysis/builders.ts b/src/syntactic_analysis/builders.ts index 402605a..c25c079 100644 --- a/src/syntactic_analysis/builders.ts +++ b/src/syntactic_analysis/builders.ts @@ -31,6 +31,7 @@ import { } from "../schema"; import { computeSignatureForDecl } from "../schema"; import { memberKey } from "../schema/ids"; +import { importTable, resolveWritten } from "./importResolver"; // ---------------------------------------------------------------------------------------------- // dynamic-getter helpers @@ -146,6 +147,20 @@ function jsDocsOf(node: Node): TSComment[] { }); } +// One import table per SourceFile: decoratorsOf runs per node, and a file's imports do not change +// between its nodes. Keyed on the ts-morph wrapper, which is cached on the SourceFile for the +// program's lifetime, so the WeakMap follows it. +const importTables = new WeakMap>(); +function importTableOf(node: Node): Map { + const sf = node.getSourceFile(); + let t = importTables.get(sf); + if (!t) { + t = importTable(buildImports(sf as unknown as Node)); + importTables.set(sf, t); + } + return t; +} + function decoratorsOf(node: Node): TSDecorator[] { const ds = (node as unknown as { getDecorators?: () => Node[] }).getDecorators?.(); if (!ds || !ds.length) return []; @@ -175,10 +190,15 @@ function decoratorsOf(node: Node): TSDecorator[] { } } } - const qualified = dec.getFullName(); + // `name` is the decorator as WRITTEN (`http.route`, not `route`) — python parity, and the only + // place the spelling survives when resolution fails. `qualified_name` is the import-table + // resolution or ABSENT (#151): the checker is not consulted, and `getFullName()` was never a + // resolved name despite the field's old comment claiming so. + const written = dec.getFullName(); + const qualified = resolveWritten(importTableOf(node), written); return { - name: dec.getName(), - ...(qualified != null ? { qualified_name: qualified } : {}), + name: written, + ...(qualified !== undefined ? { qualified_name: qualified } : {}), positional_arguments: positional, keyword_arguments: keyword, ...span(d), diff --git a/src/syntactic_analysis/importResolver.ts b/src/syntactic_analysis/importResolver.ts new file mode 100644 index 0000000..5206daf --- /dev/null +++ b/src/syntactic_analysis/importResolver.ts @@ -0,0 +1,35 @@ +/** + * Import-table resolution of a WRITTEN spelling (#151) — python's `_base_resolver` for TypeScript. + * + * The checker is never consulted for decorator identity: ts-morph's `Decorator.getFullName()` is + * the expression text as typed, so `@HttpGet` stays `HttpGet` even when `import { Get as HttpGet }` + * makes the answer trivial. What the module's import table can name, this names; what it cannot, + * stays unresolved (absent), which is what makes the entrypoint pass's unresolved counter honest. + * + * Package specifiers are kept verbatim (`@nestjs/common.Get`) — that is the spelling a rule names. + * Relative specifiers stay relative; an in-project decorator is a user rule's business. + */ +import type { TSImport } from "../schema"; + +/** Local binding → module-qualified prefix, from one module's imports. */ +export function importTable(imports: TSImport[]): Map { + const t = new Map(); + for (const imp of imports) { + if (imp.import_kind === "default") t.set(imp.name, `${imp.module}.default`); + else if (imp.import_kind === "namespace" && imp.alias) t.set(imp.alias, imp.module); + else if (imp.import_kind === "named") t.set(imp.alias ?? imp.name, `${imp.module}.${imp.name}`); + } + return t; +} + +/** + * Resolve `written` (`Get`, `HttpGet`, `http.route`) through the table. `undefined` when the head + * is not an imported binding — a same-file declaration, a global, or a spelling nothing can name. + */ +export function resolveWritten(table: Map, written: string): string | undefined { + const dot = written.indexOf("."); + const head = dot < 0 ? written : written.slice(0, dot); + const base = table.get(head); + if (base === undefined) return undefined; + return dot < 0 ? base : `${base}${written.slice(dot)}`; +} diff --git a/test/decorator-qualified-name.test.ts b/test/decorator-qualified-name.test.ts new file mode 100644 index 0000000..b329b5e --- /dev/null +++ b/test/decorator-qualified-name.test.ts @@ -0,0 +1,100 @@ +/** + * Decorator identity fields (#151). `qualified_name` was documented as checker-resolved and was + * actually ts-morph's `getFullName()` — the written text — so `import { Get as HttpGet }` emitted + * `HttpGet` even though resolution was trivial. Now: `name` is the spelling as WRITTEN, and + * `qualified_name` is the import-table resolution or ABSENT. Python's rule, minus the Jedi tier. + * + * The absent case matters as much as the resolved ones: a same-file decorator must NOT get a + * fabricated qualified name, or the entrypoint pass's unresolved counter has nothing to count and + * the Neo4j :TSDecorator merge collapses a local `@Get` with a library's. + */ +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { analyze } from "../src/core"; +import { importTable, resolveWritten } from "../src/syntactic_analysis/importResolver"; +import type { AnalysisOptions } from "../src/options"; +import type { TSDecorator, TSImport } from "../src/schema"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-decq-")); +fs.mkdirSync(path.join(dir, "src")); +fs.writeFileSync( + path.join(dir, "src", "decorators.ts"), + [ + "export function Controller(p: string): ClassDecorator { return () => undefined; }", + "export function Get(p: string): MethodDecorator { return () => undefined; }", + ].join("\n"), +); +fs.writeFileSync( + path.join(dir, "src", "c.ts"), + [ + 'import { Controller, Get as HttpGet } from "./decorators";', + 'import * as http from "some-lib";', + 'import Dflt from "some-default";', + "function Local(): ClassDecorator { return () => undefined; }", + "@Controller('/u')", + "@Local()", + "export class C {", + " @HttpGet('/x') a(): string { return ''; }", + " @http.route('/y') b(): string { return ''; }", + " @Dflt.deco() c(): string { return ''; }", + "}", + ].join("\n"), +); +fs.writeFileSync( + path.join(dir, "tsconfig.json"), + JSON.stringify({ compilerOptions: { target: "ES2020", experimentalDecorators: true }, include: ["src/**/*.ts"] }), +); +const opts = { input: dir, appName: "dq", analysisLevel: 1, noBuild: true, emit: "json" } as unknown as AnalysisOptions; + +/** Every decorator in the tree, keyed by written name. */ +function allDecorators(o: unknown, out = new Map()): Map { + if (Array.isArray(o)) for (const v of o) allDecorators(v, out); + else if (o && typeof o === "object") { + const rec = o as Record; + for (const d of (rec.decorators as TSDecorator[] | undefined) ?? []) out.set(d.name, d); + for (const v of Object.values(rec)) allDecorators(v, out); + } + return out; +} + +describe("decorator qualified_name (#151)", () => { + test("import table maps local bindings to module-qualified prefixes", () => { + const imports: TSImport[] = [ + { module: "@nestjs/common", name: "Get", is_type_only: false, import_kind: "named" } as TSImport, + { module: "@nestjs/common", name: "Post", alias: "HttpPost", is_type_only: false, import_kind: "named" } as TSImport, + { module: "some-lib", name: "*", alias: "http", is_type_only: false, import_kind: "namespace" } as TSImport, + { module: "express", name: "express", is_type_only: false, import_kind: "default" } as TSImport, + ]; + const t = importTable(imports); + expect(resolveWritten(t, "Get")).toBe("@nestjs/common.Get"); + expect(resolveWritten(t, "HttpPost")).toBe("@nestjs/common.Post"); // alias mapped BACK to the export + expect(resolveWritten(t, "http.route")).toBe("some-lib.route"); + expect(resolveWritten(t, "express.Router")).toBe("express.default.Router"); + expect(resolveWritten(t, "Local")).toBeUndefined(); + expect(resolveWritten(t, "Local.x")).toBeUndefined(); + }); + + test("emits the written spelling in name and the import-table resolution in qualified_name", async () => { + const res = await analyze(opts); + const d = allDecorators((res.application as { application: unknown }).application); + + expect(d.get("Controller")?.qualified_name).toBe("./decorators.Controller"); + // alias: the written spelling is the alias, the resolution is the EXPORTED name + expect(d.get("HttpGet")?.qualified_name).toBe("./decorators.Get"); + // namespace import: `name` is the full dotted spelling, not the last segment + expect(d.has("http.route")).toBe(true); + expect(d.has("route")).toBe(false); + expect(d.get("http.route")?.qualified_name).toBe("some-lib.route"); + // default import used as a namespace + expect(d.get("Dflt.deco")?.qualified_name).toBe("some-default.default.deco"); + }); + + test("a same-file decorator has NO qualified_name, not a fabricated one", async () => { + const d = allDecorators(((await analyze(opts)).application as { application: unknown }).application); + const local = d.get("Local"); + expect(local).toBeDefined(); + expect("qualified_name" in (local as object)).toBe(false); + }); +});