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 .changeset/skip-node-modules-and-log-levels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@maastrich/hashup": minor
---

Skip `node_modules` when hashing, and add configurable log levels.

**Skip node_modules.** Imports that resolve into any `node_modules` directory
are now treated as opaque: the file is never read, its imports are never
walked, and it contributes nothing to the hash. This fixes out-of-memory
crashes on large monorepos where the transitive dependency graph ran into
the millions of files. To pin installed dependency versions, add your
lockfile (`pnpm-lock.yaml`, `package-lock.json`, or `yarn.lock`) to
`extras` — the raw bytes capture every direct, transitive, and peer-dep
change. **This changes hashes** for any graph that previously reached into
`node_modules` through a static import.

**Log levels.** `hashup()` now accepts `logLevel?: "silent" | "warn" | "info"
| "debug"` (default `"silent"`). The CLI exposes the same via
`--log-level <level>` / `-l`, and `hashup.json` accepts a top-level
`"logLevel"` field. Diagnostics — including previous `console.warn` calls
for files that fail to hash — are now suppressed by default and written to
stderr when enabled. Exports `createLogger`, `isLogLevel`, `isInNodeModules`,
plus the `Logger` and `LogLevel` types.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules
!tests/fixtures/**/node_modules
dist
*.log
.DS_Store
Expand Down
16 changes: 15 additions & 1 deletion docs/api/hashup.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ absolute.
interface HashupOptions {
/**
* Additional files to include in the hash calculation
* (e.g. configuration files like package.json, tsconfig.json).
* (e.g. configuration files like package.json, tsconfig.json,
* or a lockfile to pin installed dependency versions).
*/
extras?: string[];

Expand All @@ -29,6 +30,13 @@ interface HashupOptions {
* @default process.cwd()
*/
baseDir?: string;

/**
* Verbosity of diagnostic messages written to stderr.
* One of `"silent"`, `"warn"`, `"info"`, `"debug"`.
* @default "silent"
*/
logLevel?: "silent" | "warn" | "info" | "debug";
}
```

Expand Down Expand Up @@ -64,3 +72,9 @@ console.log(result.files);
- The `hash` is stable for a given graph and set of file contents — it does not
depend on timestamps or on which absolute directory the project lives in
(assuming `baseDir` is set consistently).
- Imports that resolve into `node_modules` are treated as opaque: they contribute
nothing to the hash and their own imports are never walked. To pin installed
dependency versions, add your lockfile (`pnpm-lock.yaml`, `package-lock.json`,
or `yarn.lock`) to `extras`.
- By default nothing is written to stderr. Pass `logLevel: "warn"` (or higher)
to surface parse failures and skipped dependencies while debugging.
9 changes: 9 additions & 0 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ lower-level utilities for advanced use cases.
- [`createContentHash()`](./utilities#createcontenthash) — SHA-256 a buffer.
- [`combineHashes()`](./utilities#combinehashes) — fold a list of hashes into a
single deterministic digest.
- [`createLogger(level?)`](./utilities#createlogger) — build the `Logger` used
internally. Exports `type LogLevel` and `type Logger`.
- [`isInNodeModules(file)`](./utilities#isinnodemodules) — predicate used by
the hasher to decide whether to walk into a resolved path.

## Config (subpath export)

Expand All @@ -46,5 +50,10 @@ import {
hashFile,
createContentHash,
combineHashes,
createLogger,
isLogLevel,
isInNodeModules,
type Logger,
type LogLevel,
} from "@maastrich/hashup";
```
38 changes: 36 additions & 2 deletions docs/api/utilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,20 @@ function hashFile(
file: string,
cache: Map<string, string[]>,
resolver: Resolver,
logger?: Logger,
): Promise<string[]>;
```

Hashes a file and all its transitive static imports. Results are memoized in
`cache` — pass the same `Map` across multiple calls to dedupe work. On error
(file read or parse failure), a warning is logged and an empty array is
returned.
(file read or parse failure) the failure is sent through `logger.warn` and an
empty array is returned. `logger` defaults to a silent logger; build one with
[`createLogger`](#createlogger) when you want diagnostics on stderr.

Imports that resolve into `node_modules` are treated as opaque: the resolved
path is skipped, its files are never read, and the dependency's own imports
are never walked. Add your lockfile to `extras` if you need install-tree
changes reflected in the hash.

## createContentHash

Expand All @@ -65,6 +72,33 @@ function createContentHash(content: string): string;

SHA-256 (hex) of a string.

## createLogger

```ts
type LogLevel = "silent" | "warn" | "info" | "debug";

interface Logger {
warn(message: string, error?: unknown): void;
info(message: string): void;
debug(message: string): void;
}

function createLogger(level?: LogLevel): Logger;
function isLogLevel(value: string): value is LogLevel;
```

Build a stderr logger filtered to `level` (default `"silent"`). `isLogLevel`
is a type-narrowing predicate for user-supplied strings.

## isInNodeModules

```ts
function isInNodeModules(file: string): boolean;
```

Returns `true` if the path contains a `node_modules` directory segment.
Handles both POSIX and Windows separators.

## combineHashes

```ts
Expand Down
7 changes: 7 additions & 0 deletions docs/guide/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Prints the hash of `src/index.ts` and its transitive import graph. Flags:
- `--files` — include the resolved file list in the JSON output
- `-o, --out <path>` — write output to a file instead of stdout
(parent directories are created automatically)
- `-l, --log-level <lvl>` — verbosity of stderr diagnostics:
`silent` (default), `warn`, `info`, `debug`

```bash
hashup src/index.ts -e package.json -e tsconfig.json --json --files
Expand Down Expand Up @@ -74,6 +76,11 @@ $ hashup --json -o dist/hashes.json # write to file instead of stdout
interface HashupConfig {
/** Default base directory for every entry. Relative to the config file. */
baseDir?: string;
/**
* Verbosity of diagnostic messages written to stderr. Defaults to
* "silent". The CLI --log-level flag overrides this.
*/
logLevel?: "silent" | "warn" | "info" | "debug";
/** Map of name → entry definition. Names appear in the output. */
entries: Record<
string,
Expand Down
31 changes: 30 additions & 1 deletion docs/guide/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,35 @@ It does **not** depend on:
- Type-only imports (`import type`) — erased at compile time.
- Dynamic imports whose specifier is not a static string literal.
- Files outside the reachable import graph, unless passed via `extras`.
- **Anything under `node_modules`**. Imports that resolve into `node_modules`
are treated as opaque: they contribute nothing to the hash and their own
imports are never walked. This keeps the graph bounded on large monorepos
and avoids re-hashing code you didn't write.

If you care about changes in those (e.g. a lockfile determining which package
version is installed), pass them explicitly via `extras`.
version is installed), pass them explicitly via `extras`. Adding your
`pnpm-lock.yaml` / `package-lock.json` / `yarn.lock` captures every
direct, transitive, and peer-dep change — including integrity bumps — in a
single byte-accurate input.

## Logging

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. |

The CLI accepts `--log-level <level>` / `-l`, and `hashup.json` accepts a
top-level `"logLevel"` field. The CLI flag wins when both are set.

## Caveats

- **Circular imports** terminate deterministically, but the exact hash of a
cycle depends on which member was the entry point — the cache is seeded
with the entry's content hash first, so cycle re-visits return that
placeholder. Entering the same cycle from a different file produces a
different (still deterministic) hash.
31 changes: 31 additions & 0 deletions docs/guide/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,37 @@ const result = await hashup("./src/index.ts", {
Each extra is resolved as its own file graph too — if your extra imports other
files, those are included as well.

## Dependencies (`node_modules`)

Imports that resolve into `node_modules` are treated as opaque: hashup does not
walk their files and they contribute nothing to the hash. This keeps hashing
fast on large projects and avoids making your cache key depend on code you
didn't write.

If you want the installed dependency versions to influence the hash — so a
`pnpm install` that bumps a transitive version shifts your cache key — add
your lockfile to `extras`:

```ts
const result = await hashup("./src/index.ts", {
extras: ["./pnpm-lock.yaml"], // or package-lock.json / yarn.lock
});
```

The lockfile's bytes capture every direct, transitive, and peer-dep version
change, so there's no need to parse it.

## Logging

`hashup()` is silent by default. Pass `logLevel` to see diagnostics on stderr:

```ts
await hashup("./src/index.ts", { logLevel: "warn" }); // show hash failures
await hashup("./src/index.ts", { logLevel: "debug" }); // plus skipped node_modules paths
```

Levels: `silent` (default) < `warn` < `info` < `debug`.

## Supported File Types

The resolver handles the common web/Node module formats:
Expand Down
2 changes: 2 additions & 0 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export async function main(argv: string[]): Promise<void> {
baseDirOverride: args.baseDir,
json: args.json,
files: args.files,
logLevel: args.logLevel,
});
await writeOutput(process.cwd(), args.out, output);
return;
Expand All @@ -47,6 +48,7 @@ export async function main(argv: string[]): Promise<void> {
baseDirOverride: args.baseDir,
json: args.json,
files: args.files,
logLevel: args.logLevel,
});
if (!result.ok) {
die(result.error);
Expand Down
12 changes: 12 additions & 0 deletions src/cli/parse-args.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseArgs } from "node:util";
import { isLogLevel, type LogLevel } from "../lib/logger.js";

export interface CliArgs {
config: string | undefined;
Expand All @@ -9,6 +10,7 @@ export interface CliArgs {
help: boolean;
printSchema: boolean;
out: string | undefined;
logLevel: LogLevel | undefined;
positionals: string[];
}

Expand All @@ -25,8 +27,17 @@ export function parseCliArgs(argv: string[]): CliArgs {
help: { type: "boolean", short: "h", default: false },
"print-schema": { type: "boolean", default: false },
out: { type: "string", short: "o" },
"log-level": { type: "string", short: "l" },
},
});

const rawLogLevel = values["log-level"] as string | undefined;
if (rawLogLevel !== undefined && !isLogLevel(rawLogLevel)) {
throw new Error(
`Invalid --log-level "${rawLogLevel}". Expected one of: silent, warn, info, debug.`,
);
}

return {
config: values.config as string | undefined,
extras: (values.extra as string[] | undefined) ?? [],
Expand All @@ -36,6 +47,7 @@ export function parseCliArgs(argv: string[]): CliArgs {
help: values.help === true,
printSchema: values["print-schema"] === true,
out: values.out as string | undefined,
logLevel: rawLogLevel,
positionals,
};
}
9 changes: 7 additions & 2 deletions src/cli/run-config-mode.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { dirname, resolve } from "node:path";
import { combineHashes } from "../lib/combine-hashes.js";
import { hashup, type HashupResult } from "../lib/hashup.js";
import type { LogLevel } from "../lib/logger.js";
import { expandPaths } from "./expand-paths.js";
import { formatNamedResults } from "./format-output.js";
import { loadConfig } from "./load-config.js";
Expand All @@ -12,6 +13,7 @@ export interface RunConfigModeInput {
baseDirOverride: string | undefined;
json: boolean;
files: boolean;
logLevel?: LogLevel | undefined;
}

export type RunConfigModeResult = { ok: true; output: string } | { ok: false; error: string };
Expand Down Expand Up @@ -42,7 +44,8 @@ export async function runConfigMode(input: RunConfigModeInput): Promise<RunConfi
};
}
const extras = entry.extras ? await expandPaths(entry.extras, baseDir) : [];
results[name] = await hashEntrySet(entryFiles, extras, baseDir);
const logLevel = input.logLevel ?? loaded.data.logLevel;
results[name] = await hashEntrySet(entryFiles, extras, baseDir, logLevel);
}

return {
Expand Down Expand Up @@ -79,11 +82,13 @@ async function hashEntrySet(
entryFiles: string[],
extras: string[],
baseDir: string,
logLevel: LogLevel | undefined,
): Promise<HashupResult> {
const perFile: HashupResult[] = [];
for (let i = 0; i < entryFiles.length; i++) {
const entry = entryFiles[i]!;
const options = i === 0 && extras.length > 0 ? { extras, baseDir } : { baseDir };
const options =
i === 0 && extras.length > 0 ? { extras, baseDir, logLevel } : { baseDir, logLevel };
perFile.push(await hashup(entry, options));
}

Expand Down
8 changes: 7 additions & 1 deletion src/cli/run-single-file-mode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { hashup } from "../lib/hashup.js";
import type { LogLevel } from "../lib/logger.js";
import { formatSingleResult } from "./format-output.js";
import { resolveFrom } from "./resolve-from.js";

Expand All @@ -9,11 +10,16 @@ export interface RunSingleFileModeInput {
baseDirOverride: string | undefined;
json: boolean;
files: boolean;
logLevel?: LogLevel | undefined;
}

export async function runSingleFileMode(input: RunSingleFileModeInput): Promise<string> {
const baseDir =
input.baseDirOverride !== undefined ? resolveFrom(input.cwd, input.baseDirOverride) : input.cwd;
const result = await hashup(input.file, { extras: input.extras, baseDir });
const result = await hashup(input.file, {
extras: input.extras,
baseDir,
logLevel: input.logLevel,
});
return formatSingleResult(result, { json: input.json, files: input.files });
}
1 change: 1 addition & 0 deletions src/cli/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ Options:
--files Include resolved file list in JSON output
--print-schema Print the JSON schema for hashup.json to stdout
-o, --out <path> Write output to a file instead of stdout
-l, --log-level <lvl> Verbosity: silent (default), warn, info, debug
-h, --help Show this help
`;
6 changes: 6 additions & 0 deletions src/config/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export const configSchema = z
.min(1)
.optional()
.describe("Default base directory for every entry. Relative to the config file."),
logLevel: z
.enum(["silent", "warn", "info", "debug"])
.optional()
.describe(
"Verbosity of diagnostic messages written to stderr. Defaults to 'silent'. The CLI --log-level flag overrides this.",
),
entries: z
.record(z.string().min(1), entrySchema)
.refine((value) => Object.keys(value).length > 0, {
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export { hashup, type HashupOptions, type HashupResult } from "./lib/hashup.js";
export { createLogger, isLogLevel, type Logger, type LogLevel } from "./lib/logger.js";
export { isInNodeModules } from "./lib/is-in-node-modules.js";
export { combineHashes } from "./lib/combine-hashes.js";
export { createContentHash } from "./lib/create-content-hash.js";
export { createResolver } from "./lib/create-resolver.js";
Expand Down
Loading
Loading