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
8 changes: 6 additions & 2 deletions src/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; // object-literal args flattened to key→source
start_line: number;
Expand Down
26 changes: 23 additions & 3 deletions src/syntactic_analysis/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from "../schema";
import { computeSignatureForDecl } from "../schema";
import { memberKey } from "../schema/ids";
import { importTable, resolveWritten } from "./importResolver";

// ----------------------------------------------------------------------------------------------
// dynamic-getter helpers
Expand Down Expand Up @@ -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<object, Map<string, string>>();
function importTableOf(node: Node): Map<string, string> {
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 [];
Expand Down Expand Up @@ -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),
Expand Down
35 changes: 35 additions & 0 deletions src/syntactic_analysis/importResolver.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const t = new Map<string, string>();
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<string, string>, 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)}`;
}
100 changes: 100 additions & 0 deletions test/decorator-qualified-name.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, TSDecorator>()): Map<string, TSDecorator> {
if (Array.isArray(o)) for (const v of o) allDecorators(v, out);
else if (o && typeof o === "object") {
const rec = o as Record<string, unknown>;
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);
});
});
Loading