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();
+ });
+});