Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/core.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { buildProgramGraphs, startExtraction } from "./dataflow";
import { type LinkerResolutions, mergeCallGraphs, runDefuseLinker, tscProvider } from "./semantic_analysis";
Expand Down Expand Up @@ -25,11 +26,33 @@ export type { AnalysisResult } from "./schema/emit";
* Shard enumeration for an orchestrator: it must be able to find the shards before running them.
*/
export function discoverPrograms(opts: AnalysisOptions): string[] {
assertInputDir(opts.input);
const log = new Logger(opts.verbosity);
return materialize(opts, log).programs.map((spec) => programName(spec, opts.input));
}

/** A user-facing usage error (bad input path): printed as one line, exit 1, no stack, no FATAL. */
export class InputError extends Error {}

/**
* #181: the input must be a readable directory BEFORE anything runs. Discovery swallows a failed
* `readdir` and returns no files, which used to turn a mistyped path into a schema-valid
* `analysis.json` describing an empty application (id minted from a directory never read) and an
* exit code of 0. An empty directory is still a real answer — "no analysable sources" — this only
* refuses a path that is not one.
*/
function assertInputDir(input: string): void {
let stat: fs.Stats;
try {
stat = fs.statSync(input);
} catch {
throw new InputError(`input path does not exist: ${input}`);
}
if (!stat.isDirectory()) throw new InputError(`input path is not a directory: ${input}`);
}

export async function analyze(opts: AnalysisOptions): Promise<AnalysisResult> {
assertInputDir(opts.input);
const log = new Logger(opts.verbosity);
log.info(`analyzing ${opts.input} (level ${opts.analysisLevel})`);
resetCheckerFailures();
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { analyze, discoverPrograms } from "./core";
import { InputError, analyze, discoverPrograms } from "./core";
import { parseArgs } from "./cli";
import { RulesError } from "./entrypoints";
import { emit, emitSchema } from "./utils";
Expand All @@ -19,7 +19,7 @@ async function main(): Promise<void> {
const result = await analyze(opts);
await emit(result.application, opts);
} catch (e) {
if (e instanceof RulesError) {
if (e instanceof RulesError || e instanceof InputError) {
// A user configuration error, not an analyzer bug: no stack trace, no "FATAL".
process.stderr.write(`[codeanalyzer-ts] ${e.message}\n`);
process.exit(1);
Expand Down
62 changes: 62 additions & 0 deletions test/input-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* #181 — a missing or non-directory `--input` is an error, not an empty application.
*
* Discovery used to swallow the `readdir` failure and return no files, so the analyzer wrote a
* schema-valid `analysis.json` describing an empty application (its id derived from a path that
* was never read) and exited 0. A consumer running this as a subprocess has only the exit code
* as a cheap health signal, and "the input did not exist" must never read as "this project has
* no analysable sources".
*/
import { describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { InputError, analyze, discoverPrograms } from "../src/core";
import type { AnalysisOptions } from "../src/options";

const ROOT = path.resolve(import.meta.dir, "..");
const MISSING = path.join(os.tmpdir(), "cants-181-does-not-exist");
const FILE = path.join(os.tmpdir(), "cants-181-a-file.ts");
fs.writeFileSync(FILE, "export const x = 1;");

function cli(args: string[]): { code: number; stderr: string } {
const p = Bun.spawnSync(["bun", "run", path.join(ROOT, "src/index.ts"), ...args], { cwd: ROOT, stdout: "pipe", stderr: "pipe" });
return { code: p.exitCode, stderr: p.stderr.toString() };
}

describe("#181 input path", () => {
test("analyze() refuses a nonexistent input with a usage error naming the path", async () => {
const opts = { input: MISSING, appName: "x", analysisLevel: 1, noBuild: true, emit: "json" } as unknown as AnalysisOptions;
await expect(analyze(opts)).rejects.toBeInstanceOf(InputError);
await expect(analyze(opts)).rejects.toThrow(MISSING);
});

test("a file, not a directory, is refused the same way", async () => {
const opts = { input: FILE, appName: "x", analysisLevel: 1, noBuild: true, emit: "json" } as unknown as AnalysisOptions;
await expect(analyze(opts)).rejects.toBeInstanceOf(InputError);
});

test("--list-programs refuses too (it walks the same root)", () => {
expect(() => discoverPrograms({ input: MISSING, verbosity: 0 } as unknown as AnalysisOptions)).toThrow(InputError);
});

test("CLI: exit 1, one-line message, no FATAL, and nothing written", () => {
const out = fs.mkdtempSync(path.join(os.tmpdir(), "cants-181-out-"));
fs.rmSync(out, { recursive: true });
const r = cli(["-i", MISSING, "-o", out]);
expect(r.code).toBe(1);
expect(r.stderr).toContain("[codeanalyzer-ts]");
expect(r.stderr).toContain(MISSING);
expect(r.stderr).not.toContain("FATAL");
expect(fs.existsSync(path.join(out, "analysis.json"))).toBe(false);
});

test("CLI: an empty but real directory is a real answer — exit 0, empty symbol table", () => {
const empty = fs.mkdtempSync(path.join(os.tmpdir(), "cants-181-empty-"));
const out = path.join(empty, "out");
const r = cli(["-i", empty, "-o", out, "-a", "1", "--no-build", "--app-name", "e"]);
expect(r.code).toBe(0);
const j = JSON.parse(fs.readFileSync(path.join(out, "analysis.json"), "utf8"));
expect(j.application.symbol_table).toEqual({});
});
});
Loading