diff --git a/README.md b/README.md index 17af6f6..ead103d 100644 --- a/README.md +++ b/README.md @@ -18,35 +18,122 @@ compares](#how-it-compares). - **Walk** a repo deterministically: ignore lists, `.gitignore` and `.git/info/exclude`, binary/lockfile skips, a size cap, symlink-cycle guard. + A symlink that stays inside the repo, file or directory, is an alias: its + target is indexed once, under its own path. `build`, `out`, `target` and + `tmp` are skipped as build output unless git tracks files in them — they + are ordinary package names too (a Go `build` package, `com.acme.build`). Nested repositories (a subdirectory with its own `.git` — linked worktrees, - vendored clones, submodules) are skipped like git does, and `.git` itself is - never walked even when `--ignore-dir` replaces the default ignore list. No + vendored clones, submodules) are skipped like git does, and `.git` itself — + like the engine's own `.codeindex` — is never walked even when + `--ignore-dir` replaces the default ignore list. No file-count cap unless you ask for one (`--max-files`), and asking sets the `capped` flag — never a silent truncation. - **Scan** every file into a `FileRecord`: classification, language, symbols, imports, headings, hashes — with an incremental cache fastpath. Extraction runs across worker threads by default (`--workers`, `CODEINDEX_WORKERS`); artifacts are byte-identical either way, and anything that would make a - worker's result differ falls back to the single-threaded path. + worker's result differ falls back to the single-threaded path. JS/TS and + Python imports are read from code only: comments, docstrings and + string/template-literal text are masked first, so example code quoted in a + JSDoc block, a docstring or a code generator's template never becomes an + edge (JSDoc `import("./x")` types and `@import` tags, which are real type + dependencies, are kept). The same scan runs with or without a grammar, so + `extractAst` and the index report the same imports. Python + `from pkg import name` also links `pkg/name.py` when `name` is a submodule + (and nothing when it is a function or class); PHP group (`use A\{B, C}`) and + comma `use` lists and `__DIR__`-anchored includes are followed, and a trait + `use` inside a class is not an import. Build output committed under an + ordinary name is recognised by its content: minified JavaScript (not only a + `.min.js` name) and bundles (esbuild's `// src/x.ts` module banners, webpack's + and ncc's module loader). It stays in the index with its summary and + imports, flagged `generated: "minified"` or `"bundle"` on its `FileRecord` + and graph node, but its symbols and call sites are not extracted: one-letter + noise for the first, copies of the sources' definitions for the second. - **Extract symbols** via tree-sitter (15 committed grammars, plus 6 more via `grammars pull`) or per-language regex rules (16 languages, always available). Each symbol carries its **complete signature** (parameters and return type, - not the first physical line), its own **doc comment**, its qualified `parent`, + not the first physical line; one line, with no comment and no body — not an + arrow's expression body, a Go interface's method list or a macro's + expansion), its own **doc comment**, its qualified `parent`, and its line span — including the members a declaration-only walk misses: interface members, class fields, enum members, every `declare`/`.d.ts` - declaration, Rust trait method signatures, Go interface method sets, record - components and constructor `val` parameters. -- **Resolve imports** across languages: tsconfig paths, package `exports`, - go.mod, Cargo, Java packages, PSR-4, C# namespaces. + declaration, Rust trait method signatures, Go interface method sets and type + aliases, record components, constructor `val` parameters and their + TypeScript (`private readonly dep: Dep`) and PHP 8 (promoted) twins, every + name of a multi-name declaration (`var a, b int`, `int x, y;`, + `a, b = 1, 2`), C `#define` macros and the members of a `typedef struct`, + Python declarations under `if TYPE_CHECKING:` / `try:` / `with` blocks, Ruby + `class << self` methods, `private def x` definitions and the block of + `Point = Struct.new(…) do`, Elixir clauses with a `when` guard and + `defguard`, and the members of a class bound by `module.exports =` or an + anonymous `export default class` (a default export with no name of its own + is named after the file stem). A doc comment is found across Rust + attributes and TypeScript decorators. Visibility is read from a + declaration's modifiers, never from its parameter names or default values; + an `export { … }` list marks only the bindings of its own scope; and a + Python module's `__all__` (when written as literals) decides which of its + top-level names are public, the names it imports and lists becoming + `reexport` symbols. An out-of-line C++ definition (`void Widget::draw()`) + belongs to its class, and a Lua `function M.go()` to its table; a `.h` + header is parsed as C++ when its content is (a namespace, class or + template), as C otherwise. Vue, Svelte and Astro + single-file components are extracted from their `", // 14 + "", // 15 + "", // 18 +].join("\n"); + +describe("sfcParts", () => { + it("keeps the script blocks at their own offsets and blanks the rest", () => { + const parts = sfcParts(".vue", VUE)!; + expect(parts.ext).toBe(".ts"); + expect(parts.script.length).toBe(VUE.length); + const lines = parts.script.split("\n"); + expect(lines.length).toBe(VUE.split("\n").length); + expect(lines[5]).toBe('import { ref } from "vue";'); + expect(lines[11]).toBe(" helper(e);"); + // Markup, tags and style are spaces, not gone. + expect(lines[1]!.trim()).toBe(""); + expect(lines[4]!.trim()).toBe(""); + expect(lines[16]!.trim()).toBe(""); + // The markup is the complement: the template survives, script and style do not. + const markup = parts.markup.split("\n"); + expect(parts.markup.length).toBe(VUE.length); + expect(markup[1]).toContain("formatDate(when)"); + expect(markup[11]!.trim()).toBe(""); + expect(markup[16]!.trim()).toBe(""); + }); + + it("reads the script language from `lang`, TS winning over JS", () => { + expect(sfcParts(".vue", "")!.ext).toBe(".js"); + expect(sfcParts(".vue", '')!.ext).toBe(".tsx"); + expect(sfcParts(".vue", "")!.ext).toBe(".jsx"); + expect(sfcParts(".vue", '\n')!.ext).toBe(".ts"); + expect(sfcParts(".svelte", "")!.ext).toBe(".ts"); + // Astro's frontmatter is TypeScript by definition. + expect(sfcParts(".astro", "---\nconst a = 1;\n---\n

")!.ext).toBe(".ts"); + expect(sfcParts(".ts", "const a = 1;")).toBeUndefined(); + }); + + it("skips data blocks, other languages, commented-out blocks and bodiless tags", () => { + const src = [ + '', + '', + '', + '", + ].join("\n"); + const script = sfcParts(".vue", src)!.script; + expect(script).not.toContain("coffee"); + expect(script).not.toContain("commented"); + expect(script).not.toContain("ld+json"); + expect(script).toContain('import z from "./real";'); + }); + + it("keeps Astro's frontmatter and its "]; + const kept = new Set([1, 2, 6]); + expect(sfcParts(".astro", src.join("\n"))!.script.split("\n")).toEqual( + src.map((line, i) => (kept.has(i) ? line : " ".repeat(line.length))), + ); + }); +}); + +describe("extractCode on single-file components", () => { + it("extracts a Vue component's symbols, imports and calls at their real lines", () => { + const info = extractCode("src/Comp.vue", ".vue", VUE); + expect(info.symbols.map((s) => `${s.kind} ${s.name}@${s.line} ${s.lang}`)).toEqual([ + "const msg@10 vue", + "function onClick@11 vue", + ]); + expect(info.symbols.find((s) => s.name === "msg")!.doc).toBe("The message."); + expect(info.refs.map((r) => r.spec)).toEqual(["vue", "./Child.vue", "./util"]); + // Script calls from the AST, template calls from the markup; CSS is not markup. + expect(info.calls).toEqual([ + { name: "formatDate", line: 2 }, + { name: "helper", line: 12 }, + { name: "ref", line: 10 }, + ]); + expect(info.importedNames).toEqual(["formatDate", "helper", "ref"]); + // Import specifiers are modelled as refs, never as duplicated literals. + expect(info.literals?.some((l) => l.value === "./util")).toBe(false); + }); + + it("gives the same symbols on the regex tier", () => { + const parts = sfcParts(".vue", VUE)!; + expect(extractSymbols("src/Comp.vue", parts.ext, parts.script).map((s) => `${s.name}@${s.line}`)).toEqual([ + "msg@10", + "onClick@11", + ]); + }); + + it("treats Svelte props and Astro frontmatter exports as not exported, a module script's as exported", () => { + const svelte = [ + '", + '", + "", + ].join("\n"); + const exp = (rel: string, ext: string, src: string) => + Object.fromEntries(extractCode(rel, ext, src).symbols.map((s) => [s.name, s.exported])); + expect(exp("B.svelte", ".svelte", svelte)).toEqual({ preload: true, label: false, focus: false }); + // Svelte 5 spells the module script with a bare attribute. + expect(exp("B.svelte", ".svelte", "")).toEqual({ x: true }); + const astro = "---\nexport interface Props { title: string }\nexport async function getStaticPaths() { return []; }\n---\n

"; + expect(exp("P.astro", ".astro", astro)).toMatchObject({ Props: false, getStaticPaths: false }); + // A Vue ")).toEqual({ shared: true }); + }); + + it("warms the JS/TS grammars a component's script needs", () => { + expect(grammarKeysForExts([".vue"])).toEqual(["javascript", "tsx", "typescript"]); + expect(grammarKeysForExts([".astro"])).toEqual(["typescript"]); + }); + + it("binds component calls in the JS family", () => { + for (const lang of ["vue", "svelte", "astro"]) expect(familyOf(lang)).toBe(familyOf("typescript")); + }); +}); + +describe("components in the graph", () => { + function repo(files: Record): string { + const root = mkdtempSync(join(tmpdir(), "ci-sfc-")); + for (const [rel, text] of Object.entries(files)) { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), text); + } + return root; + } + + it("draws import and call edges from components, and keeps their helpers alive", () => { + const root = repo({ + "src/util.ts": "export function helper(x?: unknown) {}\nexport function formatDate(d?: unknown) { return d; }\nexport function unused() {}\n", + "src/Comp.vue": VUE, + "src/Child.vue": "\n", + "src/Btn.svelte": '\n\n', + }); + const scan = scanRepo(root); + const { graph } = buildArtifactsFromScan(scan); + expect(graph.fileEdges.map((e) => `${e.from} -${e.kind}-> ${e.to}`)).toEqual([ + "src/Btn.svelte -call-> src/util.ts", + "src/Btn.svelte -import-> src/util.ts", + "src/Comp.vue -import-> src/Child.vue", + "src/Comp.vue -call-> src/util.ts", + "src/Comp.vue -import-> src/util.ts", + ]); + // The helpers a component calls — from its script or its template — are + // not dead; the one nothing calls still is, and a prop never is. + expect(findDeadCode(scan).map((d) => `${d.name} ${d.tier}`)).toEqual(["unused unreferenced"]); + }); +}); diff --git a/tests/signature.test.ts b/tests/signature.test.ts new file mode 100644 index 0000000..16075ca --- /dev/null +++ b/tests/signature.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import { extractAst } from "../src/ast/extract.js"; + +// What `signature` holds: the declaration header, from its keyword to its body, +// on one line — and nothing else. Every case below leaked something else into +// it before: a comment, a body with no node of its own, an expression body, a +// formatter's line breaks. Measured on flask, gin, anyhow, cJSON and tsgo: 1,432 +// of 24,001 signatures changed, none of them by losing header text. Grammars are +// warmed by tests/setup.ts. + +const sigs = (rel: string, src: string): Record => { + const ext = rel.slice(rel.lastIndexOf(".")); + const out: Record = {}; + for (const s of extractAst(rel, ext, src)?.symbols ?? []) out[s.parent ? `${s.parent}.${s.name}` : s.name] = s.signature; + return out; +}; + +describe("a comment inside a declaration header", () => { + it("leaves a Python def and class, including the body's first comment", () => { + // 45 flask signatures carried one: `def test_register(client, app): # test + // that viewing the page…` — the grammar hangs a body's leading comment on + // the function, ahead of its block. + const s = sigs( + "m.py", + [ + "def f(x): # noqa: E501", + " return x", + "", + "def g( # type: ignore[override]", + " a: int,", + ") -> None:", + " # set up first", + " pass", + "", + "class K(Base): # pragma: no cover", + " pass", + ].join("\n"), + ); + expect(s).toMatchObject({ f: "def f(x):", g: "def g(a: int) -> None:", K: "class K(Base):" }); + }); + + it("leaves Java, C, Go and Rust parameter lists without fusing or spacing the words around it", () => { + expect( + sigs( + "A.java", + "public class A {\n public void run() // trailing\n {\n }\n abstract void f(int a /* first */, int b);\n void g(/* ctx */ int a) {}\n}", + ), + ).toMatchObject({ "A.run": "public void run()", "A.f": "abstract void f(int a, int b);", "A.g": "void g(int a)" }); + expect(sigs("h.c", "int add(int a, /* the a */ int/*b*/b) { return a + b; }")).toMatchObject({ add: "int add(int a, int b)" }); + expect(sigs("f.go", "package p\nfunc F(a int, // first\n\tb int) error {\n\treturn nil\n}")).toMatchObject({ F: "func F(a int, b int) error" }); + expect(sigs("m.rs", "pub fn add(a: i32, // a\n b: i32) -> i32 { a + b }")).toMatchObject({ add: "pub fn add(a: i32, b: i32) -> i32" }); + }); + + it("leaves a Ruby module, which used to show its first member's doc", () => { + expect(sigs("w.rb", "module Acme\n # A worker.\n class Worker\n def run(a) # trailing\n end\n end\nend")).toMatchObject({ + Acme: "module Acme", + "Acme.Worker": "class Worker", + "Worker.run": "def run(a)", + }); + }); + + it("leaves a constant's initializer, which keeps its value", () => { + expect(sigs("c.ts", "export const obj = {\n // why\n a: 1,\n};\nexport const KEY = \"x\"; // trailing")).toMatchObject({ + obj: "obj = { a: 1 }", + KEY: 'KEY = "x"', + }); + }); +}); + +describe("a body with no node of its own", () => { + it("is cut from a Go interface that declares methods, and kept for a constraint", () => { + // 14 gin interfaces read "Binding interface { Name() string Bind(…) error }". + const s = sigs( + "i.go", + [ + "package p", + "type Reader interface {", + "\tio.Reader", + "\t// Read reads bytes.", + "\tRead(p []byte) (int, error)", + "}", + "type Number interface { ~int | ~float64 }", + "type Pair interface { Reader; Writer }", + ].join("\n"), + ); + expect(s).toMatchObject({ + Reader: "Reader interface", + "Reader.Read": "Read(p []byte) (int, error)", + Number: "Number interface { ~int | ~float64 }", + Pair: "Pair interface { Reader; Writer }", + }); + }); + + it("is cut from a Rust macro_rules and a C function-like macro; an object-like macro keeps its value", () => { + expect(sigs("m.rs", "macro_rules! square {\n ($x:expr) => { $x * $x };\n}\nmacro_rules! nop ( () => {} );")).toMatchObject({ + square: "macro_rules! square", + nop: "macro_rules! nop", + }); + expect(sigs("h.c", "#define SQ(x) \\\n ((x) * (x))\n#define LIMIT \\\n 64\n")).toMatchObject({ + SQ: "#define SQ(x)", + LIMIT: "#define LIMIT 64", + }); + }); + + it("drops the `end` of an empty Ruby body", () => { + expect(sigs("e.rb", "class MyError < StandardError; end\nmodule Empty; end")).toMatchObject({ + MyError: "class MyError < StandardError", + Empty: "module Empty", + }); + }); +}); + +describe("a function value bound to a name", () => { + it("is cut at an expression body, as it always was at a block", () => { + const s = sigs( + "c.tsx", + [ + "export const Card = ({ title }) => (", + '
', + "

{title}

", + "
", + ");", + "export const add = (a: number, b: number): number => a + b;", + "export const blk = async (x: string) => { return x; };", + 'export const toHref = id => "/a/" + id;', + ].join("\n"), + ); + // `toHref = id` is also what literals.ts reads as function-valued. + expect(s).toMatchObject({ + Card: "Card = ({ title })", + add: "add = (a: number, b: number): number", + blk: "blk = async (x: string)", + toHref: "toHref = id", + }); + }); + + it("is cut in Lua, Java and Python too", () => { + expect(sigs("a.lua", "local M = {}\nM.f = function(a) -- note\n return a + 1\nend\nreturn M")).toMatchObject({ "M.f": "M.f = function(a)" }); + expect(sigs("J.java", "class J {\n Runnable r = () -> {\n go();\n };\n}")).toMatchObject({ "J.r": "Runnable r = () ->" }); + expect(sigs("p.py", "inc = lambda x: x + 1")).toMatchObject({ inc: "inc = lambda x:" }); + }); + + it("is left whole when the function is only an operand, not the value", () => { + expect(sigs("t.ts", "export const pick = cond ? () => 1 : () => 2;")).toMatchObject({ pick: "pick = cond ? () => 1 : () => 2" }); + }); +}); + +describe("a list the formatter wrapped", () => { + it("reads as one line with no padding inside the brackets and no trailing comma", () => { + const s = sigs( + "s.ts", + [ + "export class Service {", + " static create(", + " a: U,", + " b: string,", + " ): Service { return null as any; }", + "}", + "export function Card({", + " title,", + " body,", + "}: Props) {}", + ].join("\n"), + ); + expect(s).toMatchObject({ + "Service.create": "static create(a: U, b: string): Service", + Card: "function Card({ title, body }: Props)", + }); + }); + + it("leaves an author's own spacing inside brackets alone", () => { + expect(sigs("h.c", "int add( int a, int b ) { return a + b; }")).toMatchObject({ add: "int add( int a, int b )" }); + }); +}); diff --git a/tests/status.test.ts b/tests/status.test.ts new file mode 100644 index 0000000..72a0914 --- /dev/null +++ b/tests/status.test.ts @@ -0,0 +1,183 @@ +// `codeindex status`: a freshness report for the persisted index. Every way an +// index goes stale used to degrade to a silent cold build; these pin that each +// one is named, that the verdict agrees with what `index` then does, and that +// `--check` is usable as a CI gate. +import { afterAll, describe, expect, it } from "vitest"; +import { execFileSync, spawnSync } from "node:child_process"; +import { appendFileSync, cpSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { indexStatus, type IndexStatus } from "../src/status.js"; +import { headCommit } from "../src/git.js"; +import { ENGINE_VERSION } from "../src/types.js"; + +const FIXTURE = fileURLToPath(new URL("./fixtures/mini-repo", import.meta.url)); +const CLI = fileURLToPath(new URL("../scripts/cli.mjs", import.meta.url)); +const ENV = { ...process.env, CODEINDEX_EMBED_DIR: "" }; + +const dirs: string[] = []; +afterAll(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); +}); +function freshRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "ci-status-")); + dirs.push(dir); + const repo = join(dir, "repo"); + cpSync(FIXTURE, repo, { recursive: true }); + return repo; +} +function cli(args: string[]): { stdout: string; stderr: string; status: number | null } { + const res = spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8", env: ENV }); + return { stdout: res.stdout, stderr: res.stderr, status: res.status }; +} +function index(repo: string, ...flags: string[]): string { + const res = cli(["index", "--repo", repo, "--out", join(repo, ".codeindex"), ...flags]); + expect(res.status).toBe(0); + return res.stderr; +} +function status(repo: string, ...flags: string[]): IndexStatus & { exit: number | null } { + const res = cli(["status", "--repo", repo, "--check", ...flags]); + return { ...(JSON.parse(res.stdout) as IndexStatus), exit: res.status }; +} +function editCache(repo: string, edit: (cache: Record) => void): void { + const path = join(repo, ".codeindex", "cache.json"); + const cache = JSON.parse(readFileSync(path, "utf8")) as Record; + edit(cache); + writeFileSync(path, JSON.stringify(cache) + "\n"); +} + +describe("codeindex status", { timeout: 60_000 }, () => { + it("reports a just-built index fresh, and --check exits 0", () => { + const repo = freshRepo(); + index(repo); + const s = status(repo); + expect(s.exit).toBe(0); + expect(s).toMatchObject({ + indexDir: join(repo, ".codeindex"), + present: true, + usable: true, + engineVersion: { index: ENGINE_VERSION, current: ENGINE_VERSION }, + artifactsFresh: true, + stale: [], + }); + expect(s.reason).toBeUndefined(); + expect(s.files).toEqual({ indexed: 14, unchanged: 14, touched: 0, modified: 0, added: 0, deleted: 0, reextract: 0 }); + }); + + it("names file drift, and agrees with what `index` then does", () => { + const repo = freshRepo(); + index(repo); + const later = new Date(Date.now() + 5_000); + utimesSync(join(repo, "src", "util.ts"), later, later); // same content + let s = status(repo); + expect(s.files).toMatchObject({ touched: 1, modified: 0 }); + expect([s.artifactsFresh, s.exit]).toEqual([true, 0]); // a touch changes no artifact + expect(index(repo)).toContain("unchanged — artifacts reused"); + + appendFileSync(join(repo, "src", "client.ts"), "\nexport const drift = 1;\n"); + writeFileSync(join(repo, "src", "added.ts"), "export const added = 2;\n"); + rmSync(join(repo, "docs", "api.md")); + s = status(repo); + expect(s.files).toMatchObject({ indexed: 14, unchanged: 12, modified: 1, added: 1, deleted: 1 }); + expect(s.stale).toEqual(["files"]); + expect([s.artifactsFresh, s.exit]).toEqual([false, 1]); + expect(index(repo)).not.toContain("unchanged"); + expect(status(repo).artifactsFresh).toBe(true); + }); + + // Only a stat mismatch is read and hashed: an edit that keeps both size and + // mtime is the documented blind spot, and --full-hash looks past it. + it("hashes only stat-changed files unless --full-hash", () => { + const repo = freshRepo(); + const file = join(repo, "src", "util.ts"); + const stamp = new Date(2020, 0, 1); + utimesSync(file, stamp, stamp); + index(repo); + const text = readFileSync(file, "utf8"); + writeFileSync(file, text.replace("export", "exporT")); + utimesSync(file, stamp, stamp); + expect(statSync(file).size).toBe(text.length); + expect(status(repo).files).toMatchObject({ unchanged: 14, modified: 0 }); + expect(status(repo, "--full-hash").files).toMatchObject({ unchanged: 0, touched: 13, modified: 1 }); + }); + + it("names why an index is unusable", () => { + const repo = freshRepo(); + let s = status(repo); + expect(s).toMatchObject({ present: false, usable: false, reason: "absent", files: null, artifactsFresh: false, stale: ["absent"] }); + expect(s.exit).toBe(1); + + index(repo); + const cachePath = join(repo, ".codeindex", "cache.json"); + const original = readFileSync(cachePath, "utf8"); + for (const [edit, reason] of [ + [(c: Record) => (c.schemaVersion = -1), "schema"], + [(c: Record) => (c.extractorVersion = -1), "extractor"], + [(c: Record) => ((c.files as Record)["README.md"]!.hash = "nope"), "corrupt"], + ] as const) { + editCache(repo, edit); + s = status(repo); + expect(s, reason).toMatchObject({ present: true, usable: false, reason, stale: [reason] }); + writeFileSync(cachePath, original); + } + writeFileSync(cachePath, "{ not json"); + expect(status(repo).reason).toBe("corrupt"); + // A read command given that index by name says the same thing. + const res = cli(["symbols", "--repo", repo, "--index", ".codeindex"]); + expect(res.stderr).toContain("(cache.json is not a valid index)"); + expect(cli(["symbols", "--repo", repo, "--index", "nowhere"]).stderr).toContain("(no cache.json there)"); + }); + + it("names a stale engine version, extraction profile or artifact", () => { + const repo = freshRepo(); + index(repo); + editCache(repo, (c) => (c.engineVersion = "0.0.0")); + expect(status(repo)).toMatchObject({ engineVersion: { index: "0.0.0" }, stale: ["engine-version"], exit: 1 }); + + index(repo, "--no-ast"); + const s = status(repo); + expect(s.stale).toEqual(["extraction"]); + expect(s.files!.reextract).toBeGreaterThan(0); + expect(status(repo, "--no-ast").artifactsFresh).toBe(true); // fresh for a --no-ast reader + + index(repo); + writeFileSync(join(repo, ".codeindex", "graph.json"), "{}\n"); + rmSync(join(repo, ".codeindex", "symbols.json")); + expect(status(repo).stale).toEqual(["graph.json", "symbols.json"]); + }); + + // A committed index never matches the commit that contains it; the content + // is what the artifacts describe, and read commands restamp the commit. + it("reports a moved HEAD without calling the artifacts stale", () => { + const repo = freshRepo(); + const git = (...args: string[]): void => { + execFileSync("git", ["-C", repo, "-c", "user.name=t", "-c", "user.email=t@t", ...args]); + }; + git("init", "-q"); + git("add", "-A"); + git("commit", "-qm", "one"); + index(repo); + const indexed = headCommit(repo)!; + git("commit", "-q", "--allow-empty", "-m", "two"); + const s = status(repo); + expect(s.commit).toEqual({ index: indexed, head: headCommit(repo) }); + expect(s.commit.index).not.toBe(s.commit.head); + expect([s.artifactsFresh, s.exit]).toEqual([true, 0]); + }); + + it("judges the index under this run's scan flags, and never scans the index dir", () => { + const repo = freshRepo(); + const custom = join(repo, "idx"); + expect(cli(["index", "--repo", repo, "--out", custom, "--scope", "src"]).status).toBe(0); + const scoped = status(repo, "--index", "idx", "--scope", "src"); + expect(scoped).toMatchObject({ indexDir: custom, artifactsFresh: true }); + const whole = status(repo, "--index", "idx"); + expect(whole.files!.added).toBe(14 - scoped.files!.indexed); // idx/ itself is not among them + expect(whole.stale).toEqual(["files"]); + // The library answers the same. + expect(indexStatus(repo, { scope: "src", out: custom }, "idx")).toEqual( + (({ exit: _, ...rest }) => rest)(scoped), + ); + }); +}); diff --git a/tests/symbolgraph.test.ts b/tests/symbolgraph.test.ts index f4376fd..e05ea17 100644 --- a/tests/symbolgraph.test.ts +++ b/tests/symbolgraph.test.ts @@ -164,8 +164,6 @@ describe("neighborhood", () => { }); it("survives a call cycle", () => { - // Two-letter names on purpose: extraction drops single-character callees as - // noise (`name.length < 2`), so `a()` would never become a call site at all. const g = graphOf({ "aa.ts": ['import { bb } from "./bb.js";', "export function aa(): number {", " return bb();", "}", ""].join("\n"), "bb.ts": ['import { aa } from "./aa.js";', "export function bb(): number {", " return aa();", "}", ""].join("\n"), @@ -195,3 +193,56 @@ describe("symbolId", () => { expect(symbolId({ file: "a.ts", name: "run" })).toBe("a.ts#run"); }); }); + +describe("overrides and dispatch", () => { + // A call binds to the method its receiver's declared type names; the + // overrides living in other files are only reachable by dispatch. + const SHAPES = { + "shapes/base.py": "class Shape:\n def area(self):\n raise NotImplementedError\n\n def name(self):\n return 'shape'\n", + "shapes/square.py": "from shapes.base import Shape\n\n\nclass Square(Shape):\n def area(self):\n return 1\n\n def side(self):\n return 1\n", + "shapes/cube.py": "from shapes.square import Square\n\n\nclass Cube(Square):\n def area(self):\n return 6\n", + "shapes/total.py": "from shapes.base import Shape\n\n\ndef total(x: Shape):\n return x.area()\n", + }; + + it("links each method to the NEAREST supertype method of the same name", () => { + const scan = scanRepo(repoWith(SHAPES)); + const graph = buildSymbolGraph(scan, computeImportPairs(scan)); + const overrides = graph.edges.filter((e) => e.kind === "overrides").map((e) => `${e.from} -> ${e.to}`); + expect(overrides).toEqual([ + "shapes/cube.py#Cube/area -> shapes/square.py#Square/area", + "shapes/square.py#Square/area -> shapes/base.py#Shape/area", + ]); + }); + + it("walking out through a base method reaches its overrides; walking in to an override reaches the base's callers", () => { + const scan = scanRepo(repoWith(SHAPES)); + const graph = buildSymbolGraph(scan, computeImportPairs(scan)); + const out = neighborhood(graph, "total", { direction: "out", depth: 4 }).nodes.map((n) => `${n.depth} ${n.id}`); + expect(out).toEqual([ + "0 shapes/total.py#total", + "1 shapes/base.py#Shape/area", + "2 shapes/square.py#Square/area", + "3 shapes/cube.py#Cube/area", + ]); + const into = neighborhood(graph, "Cube/area", { direction: "in", depth: 4 }).nodes.map((n) => n.id); + expect(into).toEqual(["shapes/cube.py#Cube/area", "shapes/square.py#Square/area", "shapes/base.py#Shape/area", "shapes/total.py#total"]); + // An override is not a caller: walking in to the base does not list them. + const callersOfBase = neighborhood(graph, "Shape/area", { direction: "in" }).nodes.map((n) => n.id); + expect(callersOfBase).toEqual(["shapes/base.py#Shape/area", "shapes/total.py#total"]); + }); + + it("a Go method implementing an interface method overrides it", () => { + const scan = scanRepo( + repoWith({ + "go.mod": "module example.com/m\n\ngo 1.21\n", + "r/r.go": "package r\n\ntype Render interface {\n\tRender() error\n}\n", + "r/json.go": "package r\n\ntype JSON struct{}\n\nfunc (j JSON) Render() error { return nil }\n", + }), + ); + const graph = buildSymbolGraph(scan, computeImportPairs(scan)); + expect(graph.edges.filter((e) => e.kind !== "calls").map((e) => `${e.kind} ${e.from} -> ${e.to}`)).toEqual([ + "overrides r/json.go#JSON/Render -> r/r.go#Render/Render", + "implements r/json.go#JSON -> r/r.go#Render", + ].sort()); + }); +}); diff --git a/tests/tests-map.test.ts b/tests/tests-map.test.ts index 437b6ec..768527c 100644 --- a/tests/tests-map.test.ts +++ b/tests/tests-map.test.ts @@ -124,6 +124,49 @@ describe("computeTestMap", () => { expect(tm.testedByFile.has("src/b.ts")).toBe(false); expect(tm.testedByFile.has("tests/util.ts")).toBe(false); }); + + // gin: path_test.go tests the unexported cleanPath (no edge can say so) and + // render_test.go every renderer through the Render interface; 29 of 58 Go + // sources had no covering test. + it("a Go _test.go file covers every non-test file of its package, and no other", () => { + const g = graphOf( + [mod("root", ["path.go", "path_test.go", "tree.go"]), mod("render", ["render/render.go", "render/json.go", "render/render_test.go"])], + [ + file("path.go", "root"), + file("path_test.go", "root"), + file("tree.go", "root"), + file("render/render.go", "render"), + file("render/json.go", "render"), + file("render/render_test.go", "render"), + ], + [], + ); + const tm = computeTestMap(g); + expect(Object.fromEntries(tm.testedByFile)).toEqual({ + "path.go": ["path_test.go"], + "render/json.go": ["render/render_test.go"], + "render/render.go": ["render/render_test.go"], + "tree.go": ["path_test.go"], + }); + }); + + it("a test named after its subject covers it, in the same directory", () => { + const files = [ + "src/a.ts", "src/a.spec.ts", "src/b.tsx", "src/__tests__/b.test.ts", + "pkg/util.py", "pkg/test_util.py", "pkg/other.py", + "lib/x.rb", "lib/x_spec.rb", + "core/src/main/java/p/Foo.java", "core/src/test/java/p/FooTest.java", + "tests/test_elsewhere.py", "elsewhere.py", + ]; + const g = graphOf([mod("all", files)], files.map((rel) => file(rel, "all")), []); + expect(Object.fromEntries(computeTestMap(g).testedByFile)).toEqual({ + "core/src/main/java/p/Foo.java": ["core/src/test/java/p/FooTest.java"], + "lib/x.rb": ["lib/x_spec.rb"], + "pkg/util.py": ["pkg/test_util.py"], + "src/a.ts": ["src/a.spec.ts"], + "src/b.tsx": ["src/__tests__/b.test.ts"], + }); + }); }); describe("testsForModule / untestedModules", () => { diff --git a/tests/traverse-delta.test.ts b/tests/traverse-delta.test.ts index d72d457..25ca7bf 100644 --- a/tests/traverse-delta.test.ts +++ b/tests/traverse-delta.test.ts @@ -88,6 +88,48 @@ describe("impactOf", () => { }); }); +// gin: `impact gin.go` listed binding/ and render/ files (which cannot import +// gin) through calls inferred from a name, and `impact render/render.go` was +// empty because every import of package render lands on render/bson.go. +describe("impactOf on inferred calls and Go packages", () => { + const files = ["gin.go", "render/bson.go", "render/render.go", "render/render_test.go", "binding/form.go", "ctx.go", "a.py", "b.py"]; + const graphOf = (edges: Edge[]): Graph => + ({ + schemaVersion: 5, + version: "test", + fileCount: files.length, + languages: {}, + files: files.map((rel) => ({ id: rel, kind: "file", rel, fileKind: "code", lang: "go", module: rel.includes("/") ? rel.split("/")[0] : "root", title: rel, symbols: 1, lines: 1, degIn: 0, degOut: 0 })), + modules: [], + fileEdges: edges, + moduleEdges: [], + }) as unknown as Graph; + const inferred = (from: string, to: string): Edge => ({ from, to, kind: "call", weight: 1, confidence: "inferred" }); + const graph = graphOf([ + edge("gin.go", "render/bson.go", "import"), // `import ".../render"` resolves to its first file + edge("ctx.go", "gin.go", "call"), + inferred("binding/form.go", "gin.go"), // `errors.New` read as gin's New + inferred("b.py", "a.py"), + ]); + + it("counts name-inferred dependents instead of walking them, unless asked", () => { + const res = impactOf(graph, "gin.go")!; + expect(res.files.map((f) => f.rel)).toEqual(["ctx.go"]); + expect(res.inferredDependents).toBe(1); + const all = impactOf(graph, "gin.go", Infinity, { includeInferred: true })!; + expect(all.files.map((f) => f.rel)).toEqual(["binding/form.go", "ctx.go"]); + expect(all.inferredDependents).toBeUndefined(); + expect(impactOf(graph, "a.py")!).toMatchObject({ files: [], inferredDependents: 1 }); + }); + + it("reads a Go import as an import of every non-test file of the package", () => { + expect(impactOf(graph, "render/render.go")!.files.map((f) => `${f.rel}:${f.depth}`)).toEqual(["gin.go:1", "ctx.go:2"]); + expect(impactOf(graph, "render/render_test.go")!.files).toEqual([]); + // The plain closure keeps its file-level reading. + expect(reverseClosure(graph.fileEdges, ["render/render.go"]).size).toBe(0); + }); +}); + describe("neighborsOf", () => { const graph = build(); @@ -108,6 +150,71 @@ describe("neighborsOf", () => { }); }); +// gin's `neighbors render` listed `root` only as an (inferred) outgoing call: +// the walk kept the FIRST edge to each node, out-edges first, and hid the real +// incoming import behind it. +describe("neighborsOf reports every relation to a neighbour", () => { + const graphOf = (edges: Edge[]): Graph => + ({ + schemaVersion: 5, + version: "test", + fileCount: 5, + languages: {}, + files: ["render.go", "root.go", "binding.go", "x.go", "y.go"].map((rel) => ({ + id: rel, kind: "file" as const, rel, fileKind: "code", lang: "go", + module: "root", title: rel, symbols: [], lines: 1, degIn: 0, degOut: 0, pagerank: 0, + })), + modules: [], + fileEdges: edges, + moduleEdges: [], + }) as unknown as Graph; + const e = (from: string, to: string, kind: Edge["kind"], weight = 1, confidence?: "extracted" | "inferred"): Edge => + ({ from, to, kind, weight, ...(confidence ? { confidence } : {}) }) as Edge; + + it("keeps each (direction, kind) and lists the strongest evidence first", () => { + const graph = graphOf([ + e("render.go", "root.go", "call", 30, "inferred"), + e("root.go", "render.go", "import", 12), + e("root.go", "render.go", "call", 2, "extracted"), + e("render.go", "binding.go", "call", 1, "inferred"), + e("binding.go", "render.go", "import", 6), + ]); + const links = neighborsOf(graph, "render.go", 1)!.links.map((l) => [l.node, l.direction, l.kind, l.weight]); + // Nodes in the order they were reached; each node's links strongest first. + expect(links).toEqual([ + ["binding.go", "in", "import", 6], + ["binding.go", "out", "call", 1], + ["root.go", "in", "import", 12], + ["root.go", "in", "call", 2], + ["root.go", "out", "call", 30], + ]); + }); + + it("links a deeper node from every frontier node that reaches it, once per (direction, kind)", () => { + const graph = graphOf([ + e("render.go", "root.go", "import"), + e("render.go", "binding.go", "import"), + e("binding.go", "x.go", "import", 5), // binding.go is reached first, so it wins… + e("root.go", "x.go", "import", 3), // …over the same relation from root.go + e("x.go", "binding.go", "use", 1), // a different relation: kept + e("x.go", "root.go", "call", 1), // and another, from the second frontier node + e("y.go", "binding.go", "import", 1), + ]); + const res = neighborsOf(graph, "render.go", 2)!; + expect(res.links.filter((l) => l.depth === 1).map((l) => l.node)).toEqual(["binding.go", "root.go"]); + const deep = res.links.filter((l) => l.depth === 2).map((l) => [l.node, l.direction, l.kind, l.weight]); + expect(deep).toEqual([ + ["x.go", "out", "import", 5], + ["x.go", "in", "call", 1], + ["x.go", "in", "use", 1], + ["y.go", "in", "import", 1], + ]); + // Edges back to the start (render.go → root.go, seen from root.go) and + // between two depth-1 nodes are not new neighbours. + expect(res.links.some((l) => l.node === "render.go")).toBe(false); + }); +}); + describe("symbolsInHunks", () => { const defs = [ { name: "outer", file: "a.ts", line: 1, endLine: 20, kind: "function", exported: true }, @@ -218,6 +325,7 @@ describe("computeDelta", () => { testGap: 20, surprise: 10, dangling: 15, + brokenImport: 40, }); }); diff --git a/tests/util.test.ts b/tests/util.test.ts index d6eb9c5..11bb408 100644 --- a/tests/util.test.ts +++ b/tests/util.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { keywords, rankedKeywords, slugify, rrf, escapeRegExp, clip, clipInline } from "../src/util.js"; +import { keywords, rankedKeywords, slugify, rrf, escapeRegExp, clip, clipInline, foldText } from "../src/util.js"; describe("keywords", () => { it("drops stopwords and short noise, keeps identifiers", () => { @@ -16,6 +16,17 @@ describe("keywords", () => { }); }); +describe("foldText", () => { + it("returns ASCII untouched and folds everything else exactly as NFKD would", () => { + // ASCII skips the ICU call; the output must be what the slow path gives. + const plain = "parse_JSONBody retries 429 ~!@#$%^&*()\t\n\u007f"; + expect(foldText(plain)).toBe(plain); + expect(foldText(plain)).toBe(plain.normalize("NFKD")); + expect(foldText("café")).toBe("cafe"); + expect(foldText("Fullwidth file naïve")).toBe("Fullwidth file naive"); + }); +}); + describe("rankedKeywords", () => { it("ranks numbers and long/identifier tokens before short generic words", () => { const r = rankedKeywords("retry on 429 rate limit exponential backoff"); diff --git a/tests/walk-build-dirs.test.ts b/tests/walk-build-dirs.test.ts new file mode 100644 index 0000000..42e04a1 --- /dev/null +++ b/tests/walk-build-dirs.test.ts @@ -0,0 +1,85 @@ +// `build`, `out`, `target` and `tmp` are build output by convention only: they +// are also ordinary package names. Skipped by name, typescript-go's +// tsc/internal/execute/build (7 Go files) vanished from the index and every +// import of it dangled. In a git worktree the walk keeps such a directory when +// git tracks files in it; untracked or gitignored ones stay skipped. +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { walk, type WalkSkip } from "../src/walk.js"; +import { buildIndexArtifacts } from "../src/pipeline.js"; +import { grepRepo } from "../src/grep.js"; + +const SOURCES: Record = { + "go.mod": "module example.com/m\n\ngo 1.21\n", + "main.go": 'package main\n\nimport "example.com/m/pkg/out"\n\nfunc main() { out.Hello() }\n', + "pkg/out/out.go": 'package out\n\nfunc Hello() string { return "hi" }\n', + "src/main/java/com/acme/build/Builder.java": "package com.acme.build;\n\npublic class Builder { public void run() {} }\n", + "src/main/java/com/acme/app/App.java": + "package com.acme.app;\n\nimport com.acme.build.Builder;\n\npublic class App { void go() { new Builder().run(); } }\n", +}; + +function write(root: string, files: Record): void { + for (const [rel, text] of Object.entries(files)) { + mkdirSync(join(root, dirname(rel)), { recursive: true }); + writeFileSync(join(root, rel), text); + } +} + +// Tracked sources, plus untracked build output beside them. +function repo(): string { + const root = mkdtempSync(join(tmpdir(), "ci-build-dirs-")); + write(root, SOURCES); + const git = (...args: string[]) => execFileSync("git", ["-C", root, "-c", "user.name=t", "-c", "user.email=t@t", ...args]); + git("init", "-q"); + git("add", "-A"); + git("commit", "-qm", "init"); + write(root, { "build/classes/App.class.txt": "generated\n", "pkg/out/tmp/scratch.go": "package tmp\n" }); + return root; +} + +const rels = (root: string, opts = {}): string[] => walk(root, opts).files.map((f) => f.rel).sort(); + +describe("build-output-named directories git tracks", () => { + it("are walked, while untracked ones stay skipped", () => { + const root = repo(); + const skips: WalkSkip[] = []; + expect(walk(root, { onSkip: (s) => skips.push(s) }).files.map((f) => f.rel).sort()).toEqual(Object.keys(SOURCES).sort()); + const dirs = skips.filter((s) => s.reason === "ignore-dir").map((s) => s.rel).sort(); + expect(dirs).toEqual(["build", "pkg/out/tmp"]); + }); + + it("resolve: the Go import and the Java import land on the indexed files", () => { + const { graph } = buildIndexArtifacts(repo(), {}); + const edge = (from: string, to: string) => + graph.fileEdges.find((e) => e.from === from && e.to === to && e.kind === "import"); + expect(edge("main.go", "pkg/out/out.go")?.dangling).toBeUndefined(); + expect(edge("src/main/java/com/acme/app/App.java", "src/main/java/com/acme/build/Builder.java")).toBeDefined(); + expect(graph.fileEdges.some((e) => e.dangling)).toBe(false); + }); + + it("stay skipped by name outside a git worktree, when gitignored, when listed, or on request", () => { + const plain = mkdtempSync(join(tmpdir(), "ci-build-dirs-plain-")); + write(plain, SOURCES); + expect(rels(plain)).not.toContain("pkg/out/out.go"); + + const root = repo(); + expect(rels(root, { ignoreDirs: ["out", "build", ".git"] })).not.toContain("pkg/out/out.go"); + expect(rels(root, { ignoreDirs: ["out"] })).toContain("src/main/java/com/acme/build/Builder.java"); + expect(rels(root, { trackedBuildDirs: false })).not.toContain("pkg/out/out.go"); + writeFileSync(join(root, ".gitignore"), "out/\n"); + expect(rels(root)).not.toContain("pkg/out/out.go"); + expect(rels(root)).toContain("src/main/java/com/acme/build/Builder.java"); + }); + + // ripgrep excludes these names with globs and cannot re-include a tracked + // one, so grep's JS backend keeps the by-name rule: both backends agree. + it("grep's two backends still search the same files", () => { + const root = repo(); + const js = grepRepo(root, "Hello|Builder", { noRipgrep: true }); + expect(js.map((h) => h.file)).not.toContain("pkg/out/out.go"); + expect(grepRepo(root, "Hello|Builder")).toEqual(js); + }); +}); diff --git a/tests/walk-nested.test.ts b/tests/walk-nested.test.ts index 9f0e065..7d81f01 100644 --- a/tests/walk-nested.test.ts +++ b/tests/walk-nested.test.ts @@ -116,6 +116,17 @@ describe(".git is skipped whatever ignoreDirs says", () => { const root = cloneFixture(); expect(rels(root, { ignoreDirs: [], gitignore: false }).some((r) => r.startsWith(`${GIT}/`))).toBe(false); }); + + // The engine's own index dir is structural too: `--ignore-dir node_modules` + // used to put .codeindex/{graph,symbols,cache}.json and the MCP memories + // back into the scan, so search answered with the index itself. + it("a replacement list does not pull the engine's own .codeindex in", () => { + const root = cloneFixture(); + mkfile(root, ".codeindex/graph.json", "{}\n"); + mkfile(root, ".codeindex/memories/note.md", "# a memory\n"); + expect(rels(root, { ignoreDirs: ["node_modules"] }).some((r) => r.startsWith(".codeindex/"))).toBe(false); + expect(rels(root, { ignoreDirs: [], gitignore: false }).some((r) => r.startsWith(".codeindex/"))).toBe(false); + }); }); // The boundary must be a REPOSITORY, not merely the name `.git`. Git accepts a diff --git a/tests/workspaces.test.ts b/tests/workspaces.test.ts new file mode 100644 index 0000000..2d6275b --- /dev/null +++ b/tests/workspaces.test.ts @@ -0,0 +1,240 @@ +// Workspace detection gaps (pnpm flow lists, multi-line Gradle includes and +// type-safe accessors, nested Maven aggregators, go.work-less multi-module Go +// repos), JSONC manifests, surfaced warnings, and the declared-vs-imported +// dependency check (`workspaces --check`). +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { checkWorkspaceDeps, detectWorkspaces, manifestCoordinates, workspaceReport } from "../src/workspaces.js"; +import { buildIndexArtifacts } from "../src/pipeline.js"; +import type { Edge } from "../src/types.js"; + +const CLI = fileURLToPath(new URL("../scripts/cli.mjs", import.meta.url)); +const FIXTURES = fileURLToPath(new URL("./fixtures", import.meta.url)); + +function scratchRepo(files: Record): string { + const root = mkdtempSync(join(tmpdir(), "ci-ws-")); + for (const [rel, body] of Object.entries(files)) { + const abs = join(root, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, body); + } + return root; +} + +const summary = (root: string): string[] => + detectWorkspaces(root).packages.map((p) => `${p.kind}:${p.name}${p.dependsOn ? ` -> ${p.dependsOn.join(",")}` : ""}`); + +describe("pnpm-workspace.yaml sequence styles", () => { + const members = { + "packages/a/package.json": JSON.stringify({ name: "a" }), + "tools/t/package.json": JSON.stringify({ name: "t" }), + "tools/skip/package.json": JSON.stringify({ name: "skip" }), + }; + + it("reads a one-line flow list, quotes and negations included", () => { + const root = scratchRepo({ ...members, "pnpm-workspace.yaml": `packages: ["packages/*", 'tools/*', "!tools/skip"] # flow\n` }); + expect(summary(root)).toEqual(["pnpm:a", "pnpm:t"]); + }); + + it("reads a flow list spanning lines", () => { + const root = scratchRepo({ ...members, "pnpm-workspace.yaml": 'packages: [\n "packages/*", # libs\n "tools/t"\n]\ncatalog:\n x: 1\n' }); + expect(summary(root)).toEqual(["pnpm:a", "pnpm:t"]); + }); + + it("reads block entries at the key's own column, and stops at the next key", () => { + const root = scratchRepo({ ...members, "pnpm-workspace.yaml": "packages:\n- 'packages/*'\n\n- tools/t # the cli\nonlyBuiltDependencies:\n - tools/skip\n" }); + expect(summary(root)).toEqual(["pnpm:a", "pnpm:t"]); + }); +}); + +describe("Gradle settings includes", () => { + it("collects every project of a multi-line include(...) and type-safe accessor edges", () => { + const root = scratchRepo({ + "settings.gradle.kts": 'rootProject.name = "demo"\ninclude(\n ":libs:core",\n ":app", // the app\n)\ninclude(":platform:api")\n/* include(":old") */\nincludeBuild("build-logic")\n', + "libs/core/build.gradle.kts": "plugins { `java-library` }\n", + "platform/api/build.gradle.kts": "dependencies { api(projects.libs.core) }\n", + "app/build.gradle.kts": "dependencies {\n implementation(project(path = \":libs:core\"))\n implementation(projects.platform.api)\n}\n", + "old/build.gradle.kts": "", + "build-logic/build.gradle.kts": "", + }); + expect(summary(root)).toEqual(["gradle:app -> libs/core,platform/api", "gradle:libs/core", "gradle:platform/api -> libs/core"]); + expect(detectWorkspaces(root).topoOrder).toEqual(["libs/core", "platform/api", "app"]); + }); + + it("continues a Groovy include after a trailing comma and camelCases accessor segments", () => { + const root = scratchRepo({ + "settings.gradle": "include ':app',\n ':shared-utils'\n", + "shared-utils/build.gradle": "", + "app/build.gradle": "dependencies { implementation projects.sharedUtils }\n", + }); + expect(summary(root)).toEqual(["gradle:app -> shared-utils", "gradle:shared-utils"]); + }); +}); + +describe("Maven reactor modules", () => { + it("recurses into nested aggregators, relative to the pom that lists them", () => { + const root = scratchRepo({ + "pom.xml": "rootparentsvc", + "parent/pom.xml": "parent-aggchild-a../libs/child-b/pom.xml", + "parent/child-a/pom.xml": "child-a", + "libs/child-b/pom.xml": "child-b", + "gone/pom.xml": "gone", + "svc/pom.xml": + "svcchild-b", + }); + expect(summary(root)).toEqual(["maven:child-b", "maven:parent-agg", "maven:child-a", "maven:svc -> child-b"]); + expect(detectWorkspaces(root).topoOrder).toEqual(["child-a", "child-b", "parent-agg", "svc"]); + }); +}); + +describe("Go modules without go.work", () => { + it("lists every nested go.mod as a module, with replace edges", () => { + const info = detectWorkspaces(join(FIXTURES, "mixed-monorepo")); + expect(info.packages.map((p) => `${p.dir}=${p.name}`)).toEqual([ + "services/api=example.com/api", + "shared-go=example.com/shared", + "tools/cli=example.com/cli", + ]); + expect(info.packages[0]!.dependsOn).toEqual(["example.com/shared"]); + expect(info.topoOrder.indexOf("example.com/shared")).toBeLessThan(info.topoOrder.indexOf("example.com/api")); + }); + + it("skips vendor, testdata, fixtures and _-prefixed dirs, and the root module itself", () => { + const root = scratchRepo({ + "go.mod": "module example.com/root\n", + "svc/go.mod": "module example.com/svc\n", + "vendor/x/go.mod": "module example.com/vendored\n", + "svc/testdata/go.mod": "module example.com/td\n", + "tests/fixtures/repo/go.mod": "module example.com/fixture\n", + "_scratch/go.mod": "module example.com/scratch\n", + }); + expect(summary(root)).toEqual(["go:example.com/svc"]); + }); + + it("leaves module discovery to go.work when there is one", () => { + const root = scratchRepo({ + "go.work": "go 1.22\n\nuse ./a\n", + "a/go.mod": "module example.com/a\n", + "b/go.mod": "module example.com/b\n", + }); + expect(summary(root)).toEqual(["go:example.com/a"]); + }); +}); + +describe("manifest parsing and warnings", () => { + it("accepts JSONC manifests like the resolver does, and still names a broken one", () => { + const root = scratchRepo({ + "package.json": '{\n // monorepo root\n "name": "root",\n "workspaces": ["packages/*"],\n}\n', + "packages/good/package.json": '{ "name": "good", /* ok */ "dependencies": { "bad": "*", }, }', + "packages/bad/package.json": '{"name": "bad",, }', + }); + const info = detectWorkspaces(root); + expect(info.packages.map((p) => p.name)).toEqual(["packages/bad", "good"]); + expect(info.warnings).toHaveLength(1); + expect(info.warnings[0]).toMatch(/^malformed packages\/bad\/package\.json: /); + // The report carries warnings only when there are some, so a clean + // workspace prints the same bytes it always did. + expect(workspaceReport(info).warnings).toEqual(info.warnings); + expect(Object.keys(workspaceReport(detectWorkspaces(join(FIXTURES, "mini-monorepo"))))).toEqual(["packages", "cycle", "topoOrder"]); + }); +}); + +describe("checkWorkspaceDeps", () => { + const edge = (from: string, to: string, kind = "import", dangling?: boolean): Edge => ({ from, to, kind, weight: 1, ...(dangling ? { dangling } : {}) }) as Edge; + + it("flags an import of a sibling the manifest does not declare (mini-monorepo)", () => { + const root = join(FIXTURES, "mini-monorepo"); + const { graph } = buildIndexArtifacts(root); + const check = checkWorkspaceDeps(detectWorkspaces(root), graph); + expect(check).toEqual({ + ok: false, + undeclared: [{ from: "@scope/b", to: "@scope/a", files: 1, example: "packages/b/src/consumer.ts" }], + unusedDeclared: [], + }); + }); + + it("passes declared imports, reports unused declarations, ignores non-import and intra-package edges", () => { + const root = scratchRepo({ + "package.json": JSON.stringify({ name: "root", workspaces: ["packages/*"] }), + "packages/a/package.json": JSON.stringify({ name: "a" }), + "packages/b/package.json": JSON.stringify({ name: "b", dependencies: { a: "*", c: "*" } }), + "packages/c/package.json": JSON.stringify({ name: "c" }), + }); + const info = detectWorkspaces(root); + const check = checkWorkspaceDeps(info, { + fileEdges: [ + edge("packages/b/src/x.ts", "packages/a/src/index.ts"), + edge("packages/b/src/y.ts", "packages/b/src/x.ts"), + edge("packages/c/src/z.ts", "packages/a/src/index.ts", "call"), // not an import + edge("packages/c/src/z.ts", "packages/a/nope", "import", true), // dangling + edge("scripts/tool.ts", "packages/a/src/index.ts"), // outside every package + edge("packages/a/src/index.ts", "packages/c/src/z.ts"), + edge("packages/a/test/t.ts", "packages/c/src/z.ts"), + ], + }); + expect(check).toEqual({ + ok: false, + undeclared: [{ from: "a", to: "c", files: 2, example: "packages/a/src/index.ts" }], + unusedDeclared: [{ from: "b", to: "c" }], + }); + }); + + it("leaves nx members out: nx infers project dependencies from imports", () => { + const root = scratchRepo({ + "nx.json": "{}", + "apps/web/project.json": JSON.stringify({ name: "web" }), + "libs/ui/project.json": JSON.stringify({ name: "ui" }), + }); + const check = checkWorkspaceDeps(detectWorkspaces(root), { fileEdges: [edge("apps/web/main.ts", "libs/ui/index.ts")] }); + expect(check).toEqual({ ok: true, undeclared: [], unusedDeclared: [] }); + }); + + it("CLI: `workspaces --check` prints the check and exits 1 on an undeclared import", () => { + const res = spawnSync(process.execPath, [CLI, "workspaces", "--check", "--no-index-cache", "--repo", join(FIXTURES, "mini-monorepo")], { + encoding: "utf8", + }); + expect(res.status).toBe(1); + const out = JSON.parse(res.stdout) as { check: { ok: boolean; undeclared: { from: string; to: string }[] } }; + expect(out.check.ok).toBe(false); + expect(out.check.undeclared.map((u) => `${u.from}->${u.to}`)).toEqual(["@scope/b->@scope/a"]); + + // Without --check: exit 0, no graph work, no `check` key. + const plain = spawnSync(process.execPath, [CLI, "workspaces", "--repo", join(FIXTURES, "mini-monorepo")], { encoding: "utf8" }); + expect(plain.status).toBe(0); + expect(JSON.parse(plain.stdout).check).toBeUndefined(); + }); +}); + +describe("manifestCoordinates", () => { + it("reads a package's own name and version, never an inherited or plugin one", () => { + const root = scratchRepo({ + "app/pom.xml": [ + "", + " gparent-pom9.9", + " app", + " dep1.0", + " p3.1", + "", + ].join("\n"), + "lib/pom.xml": "lib2.5.0\n", + "php/composer.json": '{ "name": "acme/php", "version": "0.3.0" }\n', + "Cargo.toml": '[workspace]\nmembers = ["crates/*"]\n', + "go.mod": 'module "example.com/quoted"\n', + // JSONC, like every other manifest read here. + "web/package.json": '{\n // the app\n "name": "web",\n}\n', + }); + // The module inherits its version from : none, not the plugin's 3.1. + expect(manifestCoordinates(root, "app", "pom.xml")).toEqual({ manager: "maven", name: "app" }); + expect(manifestCoordinates(root, "lib", "pom.xml")).toEqual({ manager: "maven", name: "lib", version: "2.5.0" }); + expect(manifestCoordinates(root, "php", "composer.json")).toEqual({ manager: "composer", name: "acme/php", version: "0.3.0" }); + // A virtual workspace root names no package, so a walk up the tree continues. + expect(manifestCoordinates(root, "", "Cargo.toml")).toBeUndefined(); + expect(manifestCoordinates(root, "", "go.mod")).toEqual({ manager: "gomod", name: "example.com/quoted" }); + expect(manifestCoordinates(root, "web", "package.json")).toEqual({ manager: "npm", name: "web" }); + expect(manifestCoordinates(root, "missing", "package.json")).toBeUndefined(); + }); +});