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
20 changes: 20 additions & 0 deletions .changeset/debug-log-prefixes.md
Original file line number Diff line number Diff line change
@@ -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]: <file>` — a file's content was read and its sha256 computed.
- `[import]: <source> -> "<specifier>" -> <resolved>` — a static import
was resolved. Unresolved specifiers show `<unresolved>` in the third
slot.
- `[skip]: <file>` — 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\]:'
```
34 changes: 28 additions & 6 deletions docs/guide/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 `<unresolved>`). |
| `[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 '<unresolved>'

# 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
Expand Down
9 changes: 7 additions & 2 deletions src/lib/hash-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -58,14 +59,18 @@ async function walkDependencies(
): Promise<void> {
for (const imported of imports) {
const resolved = await resolveImport(resolver, sourceFile, imported);
if (!resolved) continue;
if (!resolved) {
logger.debug(`[import]: ${sourceFile} -> "${imported}" -> <unresolved>`);
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);
Expand Down
60 changes: 60 additions & 0 deletions tests/debug-logs.test.ts
Original file line number Diff line number Diff line change
@@ -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\]:/);
});
});
Loading