diff --git a/.changeset/debug-log-prefixes.md b/.changeset/debug-log-prefixes.md new file mode 100644 index 0000000..a5b0707 --- /dev/null +++ b/.changeset/debug-log-prefixes.md @@ -0,0 +1,20 @@ +--- +"@maastrich/hashup": patch +--- + +Add structured debug-log prefixes for easy filtering. + +At `--log-level debug` the hasher now emits one line per event, each +starting with a bracketed tag at column zero: + +- `[hash]: ` — a file's content was read and its sha256 computed. +- `[import]: -> "" -> ` — a static import + was resolved. Unresolved specifiers show `` in the third + slot. +- `[skip]: ` — a resolved path was skipped because it lives in + `node_modules`. + +```bash +hashup -l debug 2>&1 | grep '^\[hash\]:' +hashup -l debug 2>&1 | grep -c '^\[skip\]:' +``` diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md index ed379be..8c2d1e3 100644 --- a/docs/guide/how-it-works.md +++ b/docs/guide/how-it-works.md @@ -50,16 +50,38 @@ single byte-accurate input. By default `hashup()` writes nothing to stderr. Pass `logLevel` to opt in: -| Level | What you'll see | -| -------- | ---------------------------------------------------------------------- | -| `silent` | Nothing. Default for programmatic use. | -| `warn` | Files that could not be hashed (read or parse failures). | -| `info` | Higher-level progress messages. | -| `debug` | Per-file decisions, including which `node_modules` paths were skipped. | +| Level | What you'll see | +| -------- | -------------------------------------------------------- | +| `silent` | Nothing. Default for programmatic use. | +| `warn` | Files that could not be hashed (read or parse failures). | +| `info` | Higher-level progress messages. | +| `debug` | Per-file trace of hashing, import resolution, and skips. | The CLI accepts `--log-level ` / `-l`, and `hashup.json` accepts a top-level `"logLevel"` field. The CLI flag wins when both are set. +### Debug-level prefixes + +At `debug` level every line starts with a bracketed tag so you can +filter with `grep`: + +| Prefix | When | +| ----------- | --------------------------------------------------------------- | +| `[hash]:` | A file's content was read and its sha256 computed. | +| `[import]:` | A static import was resolved (or marked ``). | +| `[skip]:` | A resolved path was skipped because it lives in `node_modules`. | + +```bash +# Watch only what got hashed +hashup -l debug 2>&1 | grep '^\[hash\]:' + +# See every unresolved import +hashup -l debug 2>&1 | grep '' + +# Count how many node_modules paths were short-circuited +hashup -l debug 2>&1 | grep -c '^\[skip\]:' +``` + ## Caveats - **Circular imports** terminate deterministically. The cache is seeded with diff --git a/src/lib/hash-file.ts b/src/lib/hash-file.ts index efc1ad1..80fd4b8 100644 --- a/src/lib/hash-file.ts +++ b/src/lib/hash-file.ts @@ -35,6 +35,7 @@ export async function hashFile( const deps: string[] = []; cache.hashes.set(file, selfHash); cache.deps.set(file, deps); + logger.debug(`[hash]: ${file}`); const imports = await extractImports(file, content); await walkDependencies(imports, file, cache, resolver, logger, deps); @@ -58,14 +59,18 @@ async function walkDependencies( ): Promise { for (const imported of imports) { const resolved = await resolveImport(resolver, sourceFile, imported); - if (!resolved) continue; + if (!resolved) { + logger.debug(`[import]: ${sourceFile} -> "${imported}" -> `); + continue; + } + logger.debug(`[import]: ${sourceFile} -> "${imported}" -> ${resolved}`); // Dependencies installed into `node_modules` are opaque: we don't // walk their files. Users that need to pin to installed versions // can add their lockfile (pnpm-lock.yaml / package-lock.json / // yarn.lock) to `extras` so any install-tree change still shifts // the final hash. if (isInNodeModules(resolved)) { - logger.debug(`Skipping node_modules dependency: ${resolved}`); + logger.debug(`[skip]: ${resolved}`); continue; } deps.push(resolved); diff --git a/tests/debug-logs.test.ts b/tests/debug-logs.test.ts new file mode 100644 index 0000000..e1309bd --- /dev/null +++ b/tests/debug-logs.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import { hashup } from "../src/index.js"; + +describe("debug log prefixes", () => { + afterEach(() => vi.restoreAllMocks()); + + test("emits [hash]:, [import]:, [skip]: lines at debug level", async () => { + const messages: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + messages.push(typeof chunk === "string" ? chunk : chunk.toString()); + return true; + }); + + await hashup("./tests/fixtures/skip-node-modules/entry.ts", { logLevel: "debug" }); + + const out = messages.join(""); + + // [hash]: — one per successfully hashed user file + expect(out).toMatch(/\[hash\]: .+entry\.ts\n/); + expect(out).toMatch(/\[hash\]: .+local\.ts\n/); + + // [import]: — each import resolution, with the source and specifier + expect(out).toMatch(/\[import\]: .+entry\.ts -> "fake-lib" -> /); + expect(out).toMatch(/\[import\]: .+entry\.ts -> "\.\/local\.js" -> /); + + // [skip]: — one per node_modules short-circuit + expect(out).toMatch(/\[skip\]: .+node_modules\/fake-lib\//); + }); + + test("prefixes are grep-friendly at column zero of each line", async () => { + const messages: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + messages.push(typeof chunk === "string" ? chunk : chunk.toString()); + return true; + }); + + await hashup("./tests/fixtures/skip-node-modules/entry.ts", { logLevel: "debug" }); + + const lines = messages.join("").split("\n").filter(Boolean); + // Every debug line from the hasher should start with one of our prefixes. + for (const line of lines) { + expect(line).toMatch(/^\[(hash|import|skip)\]: /); + } + }); + + test("warn level suppresses all three prefixes", async () => { + const messages: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + messages.push(typeof chunk === "string" ? chunk : chunk.toString()); + return true; + }); + + await hashup("./tests/fixtures/skip-node-modules/entry.ts", { logLevel: "warn" }); + + const out = messages.join(""); + expect(out).not.toMatch(/\[hash\]:/); + expect(out).not.toMatch(/\[import\]:/); + expect(out).not.toMatch(/\[skip\]:/); + }); +});