reference-target-kind.test.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. /**
  2. * Reference target-kind gate — `extends`/`implements` and `imports`.
  3. *
  4. * The name-matcher treats node kind as a scoring BONUS, never a filter, and
  5. * awards no bonus at all for inheritance refs. When exactly one same-named
  6. * node exists, the single-candidate shortcut adopts it unconditionally at
  7. * confidence 0.9. So a supertype that lives OUTSIDE the repo — imported by a
  8. * bare name — bound to whatever local symbol happened to share that name,
  9. * asserting an inheritance relationship absent from the source:
  10. *
  11. * use std::error::Error; // the supertype is out-of-repo
  12. * impl Error for MapperError {} // ...but `MapperError::Error` is a variant
  13. * → implements: enum MapperError -> enum_member Error
  14. *
  15. * The gate drops any inheritance resolution whose target cannot be a
  16. * supertype. It only ever removes edges, so the tests below pin BOTH
  17. * directions: the false edge is gone, and every legitimate supertype kind
  18. * (in-repo trait, interface, class, and TS object-type alias) still resolves.
  19. */
  20. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  21. import * as fs from 'node:fs';
  22. import * as path from 'node:path';
  23. import * as os from 'node:os';
  24. import { CodeGraph } from '../src';
  25. describe('reference target-kind gate', () => {
  26. let dir: string;
  27. beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'inh-kind-')); });
  28. afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
  29. const write = (rel: string, body: string) => {
  30. const p = path.join(dir, rel);
  31. fs.mkdirSync(path.dirname(p), { recursive: true });
  32. fs.writeFileSync(p, body);
  33. };
  34. type InhEdge = { src: string; srcKind: string; tgt: string; tgtKind: string; kind: string };
  35. const load = async (): Promise<{ edges: InhEdge[]; failed: { name: string; kind: string }[] }> => {
  36. const cg = await CodeGraph.init(dir, { silent: true });
  37. await cg.indexAll();
  38. const db = (cg as any).db.db;
  39. const edges: InhEdge[] = db
  40. .prepare(
  41. `SELECT s.name src, s.kind srcKind, t.name tgt, t.kind tgtKind, e.kind kind
  42. FROM edges e
  43. JOIN nodes s ON s.id = e.source
  44. JOIN nodes t ON t.id = e.target
  45. WHERE e.kind IN ('extends', 'implements')`
  46. )
  47. .all();
  48. const failed: { name: string; kind: string }[] = db
  49. .prepare(
  50. `SELECT reference_name name, reference_kind kind
  51. FROM unresolved_refs
  52. WHERE reference_kind IN ('extends', 'implements')`
  53. )
  54. .all();
  55. cg.close?.();
  56. return { edges, failed };
  57. };
  58. const has = (edges: InhEdge[], src: string, tgt: string, tgtKind: string) =>
  59. edges.some((e) => e.src === src && e.tgt === tgt && e.tgtKind === tgtKind);
  60. it('drops an out-of-repo Rust supertype that name-matched a local enum member', async () => {
  61. write(
  62. 'src/lib.rs',
  63. `use std::error::Error;\n\n` +
  64. `pub enum MapperError {\n Error,\n Missing,\n}\n\n` +
  65. `impl Error for MapperError {}\n`
  66. );
  67. const { edges, failed } = await load();
  68. expect(has(edges, 'MapperError', 'Error', 'enum_member')).toBe(false);
  69. // The reference is not silently forgotten — it stays on record as failed,
  70. // which is the honest outcome for a supertype the repo does not contain.
  71. expect(failed.some((r) => r.name === 'Error')).toBe(true);
  72. });
  73. it('does not relocate the false edge onto a same-named local type alias', async () => {
  74. // The kind filter alone would have moved this edge from the enum member to
  75. // `type Error`, which IS a legal supertype kind — still false data, and
  76. // harder for a consumer to reject. Locality is what removes it.
  77. write('src/alias.rs', `pub type Error = String;\n`);
  78. write(
  79. 'src/lib.rs',
  80. `mod alias;\n\nuse std::error::Error;\n\n` +
  81. `pub enum MapperError {\n Missing,\n}\n\n` +
  82. `impl Error for MapperError {}\n`
  83. );
  84. const { edges, failed } = await load();
  85. expect(edges.filter((e) => e.tgt === 'Error')).toEqual([]);
  86. expect(failed.some((r) => r.name === 'Error')).toBe(true);
  87. });
  88. it('keeps a supertype imported by an in-repo `use` path', async () => {
  89. write('src/ports.rs', `pub trait Sha256Port {\n fn hash(&self) -> String;\n}\n`);
  90. write(
  91. 'src/lib.rs',
  92. `mod ports;\n\nuse crate::ports::Sha256Port;\n\n` +
  93. `pub struct Hasher {\n salt: String,\n}\n\n` +
  94. `impl Sha256Port for Hasher {\n fn hash(&self) -> String { String::new() }\n}\n`
  95. );
  96. const { edges } = await load();
  97. expect(has(edges, 'Hasher', 'Sha256Port', 'trait')).toBe(true);
  98. });
  99. it('keeps a trait reached through a re-exported sibling-crate module', async () => {
  100. // `crate::ports` here is a re-export of ANOTHER crate's module, so no
  101. // `src/ports.rs` exists to walk to. Treating "module path does not resolve
  102. // to a file" as proof of out-of-repo deleted 13 real trait implementations
  103. // on the reference fixture — hence the rule keys on stdlib roots only.
  104. write('Cargo.toml', `[workspace]\nmembers = ["core", "app"]\n`);
  105. write('core/Cargo.toml', `[package]\nname = "pupil_core"\nversion = "0.1.0"\n`);
  106. write('core/src/lib.rs', `pub mod ports;\n`);
  107. write('core/src/ports.rs', `pub trait CacheStore {\n fn get(&self);\n}\n`);
  108. write('app/Cargo.toml', `[package]\nname = "app"\nversion = "0.1.0"\n`);
  109. write('app/src/lib.rs', `pub use pupil_core::ports;\n\npub mod platform;\n`);
  110. write(
  111. 'app/src/platform.rs',
  112. `use crate::ports::CacheStore;\n\npub struct SafStorage {\n root: String,\n}\n\n` +
  113. `impl CacheStore for SafStorage {\n fn get(&self) {}\n}\n`
  114. );
  115. const { edges } = await load();
  116. expect(has(edges, 'SafStorage', 'CacheStore', 'trait')).toBe(true);
  117. });
  118. it('still resolves an in-repo Rust trait (the gate is not a blanket block)', async () => {
  119. write(
  120. 'src/lib.rs',
  121. `pub trait Mapper {\n fn map(&self) -> u32;\n}\n\n` +
  122. `pub enum MapperError {\n Mapper,\n}\n\n` +
  123. `pub struct Real {\n n: u32,\n}\n\n` +
  124. `impl Mapper for Real {\n fn map(&self) -> u32 { 1 }\n}\n`
  125. );
  126. const { edges } = await load();
  127. expect(has(edges, 'Real', 'Mapper', 'trait')).toBe(true);
  128. expect(has(edges, 'Real', 'Mapper', 'enum_member')).toBe(false);
  129. });
  130. it('keeps a TypeScript class implementing an object-type alias', async () => {
  131. write(
  132. 'src/api.ts',
  133. `export type SearchApi = { query(q: string): string };\n\n` +
  134. `export class LocalSearch implements SearchApi {\n` +
  135. ` query(q: string): string { return q; }\n}\n`
  136. );
  137. const { edges } = await load();
  138. expect(has(edges, 'LocalSearch', 'SearchApi', 'type_alias')).toBe(true);
  139. });
  140. it.each([
  141. ['svelte', 'src/Box.svelte', '<script lang="ts">\n$IMPORT$\nexport class SfcBox implements Serializable {\n n = 1;\n}\n</script>\n<div>hi</div>\n'],
  142. ['vue', 'src/Box.vue', '<script lang="ts">\n$IMPORT$\nexport class SfcBox implements Serializable {\n n = 1;\n}\n</script>\n<template><div/></template>\n'],
  143. ['astro', 'src/Box.astro', '---\n$IMPORT$\nexport class SfcBox implements Serializable {\n n = 1;\n}\n---\n<div/>\n'],
  144. ])('drops an npm supertype in a %s single-file component', async (_lang, file, body) => {
  145. // An SFC imports inside its <script> block (Astro: the `---` frontmatter)
  146. // with ordinary ES module syntax, so a bare specifier there is external for
  147. // exactly the same reason it is in a .ts file. Missing that, the npm
  148. // supertype name-matched the local class below.
  149. write('package.json', `{"name":"sfc","version":"1.0.0"}\n`);
  150. write('src/models.ts', `export class Serializable {\n a = 1;\n}\n`);
  151. write(file, body.replace('$IMPORT$', `import { Serializable } from 'some-npm-pkg';\n`));
  152. const { edges, failed } = await load();
  153. expect(edges.filter((e) => e.tgt === 'Serializable')).toEqual([]);
  154. expect(failed.some((r) => r.name === 'Serializable')).toBe(true);
  155. });
  156. it('keeps an SFC supertype imported from a relative path', async () => {
  157. write('package.json', `{"name":"sfc","version":"1.0.0"}\n`);
  158. write('src/models.ts', `export class Serializable {\n a = 1;\n}\n`);
  159. write(
  160. 'src/Box.svelte',
  161. `<script lang="ts">\nimport { Serializable } from './models';\n\n` +
  162. `export class SfcBox implements Serializable {\n n = 1;\n}\n</script>\n<div>hi</div>\n`
  163. );
  164. const { edges } = await load();
  165. expect(has(edges, 'SfcBox', 'Serializable', 'class')).toBe(true);
  166. });
  167. it('does not resolve an import to a type member that shares its name', async () => {
  168. // `import * as path from 'node:path'` is unresolvable — the module is
  169. // external — so the name-matcher looked for any node called `path` and
  170. // found a class property. No language lets you import a type's member.
  171. write('src/types.ts', `export class Request {\n path = '';\n url = '';\n}\n`);
  172. write(
  173. 'src/run.ts',
  174. `import * as path from 'node:path';\n\nexport function run() {\n return path.join('a', 'b');\n}\n`
  175. );
  176. const cg = await CodeGraph.init(dir, { silent: true });
  177. await cg.indexAll();
  178. const db = (cg as any).db.db;
  179. const rows: { tgt: string; tgtKind: string }[] = db
  180. .prepare(
  181. `SELECT t.name tgt, t.kind tgtKind
  182. FROM edges e JOIN nodes t ON t.id = e.target
  183. WHERE e.kind = 'imports'`
  184. )
  185. .all();
  186. cg.close?.();
  187. expect(rows.filter((r) => r.tgtKind === 'property' || r.tgtKind === 'field')).toEqual([]);
  188. });
  189. it('keeps class extends class and class implements interface', async () => {
  190. write(
  191. 'src/base.ts',
  192. `export interface Runner { run(): void }\n` +
  193. `export class Base { run(): void {} }\n` +
  194. `export class Child extends Base implements Runner { run(): void {} }\n`
  195. );
  196. const { edges } = await load();
  197. expect(has(edges, 'Child', 'Base', 'class')).toBe(true);
  198. expect(has(edges, 'Child', 'Runner', 'interface')).toBe(true);
  199. });
  200. });