diff --git a/.changeset/skip-node-modules-and-log-levels.md b/.changeset/skip-node-modules-and-log-levels.md new file mode 100644 index 0000000..30ad57b --- /dev/null +++ b/.changeset/skip-node-modules-and-log-levels.md @@ -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 ` / `-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. diff --git a/.gitignore b/.gitignore index 358c617..248b027 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +!tests/fixtures/**/node_modules dist *.log .DS_Store diff --git a/docs/api/hashup.md b/docs/api/hashup.md index 52ed6c8..7d8352d 100644 --- a/docs/api/hashup.md +++ b/docs/api/hashup.md @@ -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[]; @@ -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"; } ``` @@ -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. diff --git a/docs/api/index.md b/docs/api/index.md index 60b1181..1dc6b06 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -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) @@ -46,5 +50,10 @@ import { hashFile, createContentHash, combineHashes, + createLogger, + isLogLevel, + isInNodeModules, + type Logger, + type LogLevel, } from "@maastrich/hashup"; ``` diff --git a/docs/api/utilities.md b/docs/api/utilities.md index 1a37130..e8bbbdf 100644 --- a/docs/api/utilities.md +++ b/docs/api/utilities.md @@ -49,13 +49,20 @@ function hashFile( file: string, cache: Map, resolver: Resolver, + logger?: Logger, ): Promise; ``` 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 @@ -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 diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 90f4665..fdebc6f 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -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 ` — write output to a file instead of stdout (parent directories are created automatically) +- `-l, --log-level ` — verbosity of stderr diagnostics: + `silent` (default), `warn`, `info`, `debug` ```bash hashup src/index.ts -e package.json -e tsconfig.json --json --files @@ -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, diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md index 63a8789..b355e99 100644 --- a/docs/guide/how-it-works.md +++ b/docs/guide/how-it-works.md @@ -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 ` / `-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. diff --git a/docs/guide/usage.md b/docs/guide/usage.md index 7a5b279..48b9423 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -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: diff --git a/src/cli/main.ts b/src/cli/main.ts index 48825d1..0aaf605 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -36,6 +36,7 @@ export async function main(argv: string[]): Promise { baseDirOverride: args.baseDir, json: args.json, files: args.files, + logLevel: args.logLevel, }); await writeOutput(process.cwd(), args.out, output); return; @@ -47,6 +48,7 @@ export async function main(argv: string[]): Promise { baseDirOverride: args.baseDir, json: args.json, files: args.files, + logLevel: args.logLevel, }); if (!result.ok) { die(result.error); diff --git a/src/cli/parse-args.ts b/src/cli/parse-args.ts index eb2c3fd..35133dc 100644 --- a/src/cli/parse-args.ts +++ b/src/cli/parse-args.ts @@ -1,4 +1,5 @@ import { parseArgs } from "node:util"; +import { isLogLevel, type LogLevel } from "../lib/logger.js"; export interface CliArgs { config: string | undefined; @@ -9,6 +10,7 @@ export interface CliArgs { help: boolean; printSchema: boolean; out: string | undefined; + logLevel: LogLevel | undefined; positionals: string[]; } @@ -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) ?? [], @@ -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, }; } diff --git a/src/cli/run-config-mode.ts b/src/cli/run-config-mode.ts index b5e2b70..84446da 100644 --- a/src/cli/run-config-mode.ts +++ b/src/cli/run-config-mode.ts @@ -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"; @@ -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 }; @@ -42,7 +44,8 @@ export async function runConfigMode(input: RunConfigModeInput): Promise { 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)); } diff --git a/src/cli/run-single-file-mode.ts b/src/cli/run-single-file-mode.ts index e688125..ef111ea 100644 --- a/src/cli/run-single-file-mode.ts +++ b/src/cli/run-single-file-mode.ts @@ -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"; @@ -9,11 +10,16 @@ export interface RunSingleFileModeInput { baseDirOverride: string | undefined; json: boolean; files: boolean; + logLevel?: LogLevel | undefined; } export async function runSingleFileMode(input: RunSingleFileModeInput): Promise { 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 }); } diff --git a/src/cli/usage.ts b/src/cli/usage.ts index d1e746a..7643c4e 100644 --- a/src/cli/usage.ts +++ b/src/cli/usage.ts @@ -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 Write output to a file instead of stdout + -l, --log-level Verbosity: silent (default), warn, info, debug -h, --help Show this help `; diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index dd6403a..1e99806 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -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, { diff --git a/src/index.ts b/src/index.ts index b74713f..ad3b907 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/lib/hash-file.ts b/src/lib/hash-file.ts index 3273159..be6755e 100644 --- a/src/lib/hash-file.ts +++ b/src/lib/hash-file.ts @@ -1,6 +1,8 @@ import type { Resolver } from "enhanced-resolve"; import { createContentHash } from "./create-content-hash.js"; import { extractImports } from "./extract-imports.js"; +import { isInNodeModules } from "./is-in-node-modules.js"; +import { createLogger, type Logger } from "./logger.js"; import { pushAll } from "./push-all.js"; import { readFileContent } from "./read-file-content.js"; import { resolveImport } from "./resolve-import.js"; @@ -9,6 +11,7 @@ export async function hashFile( file: string, cache: Map, resolver: Resolver, + logger: Logger = createLogger("silent"), ): Promise { const cached = cache.get(file); if (cached) { @@ -17,20 +20,19 @@ export async function hashFile( try { const content = await readFileContent(file); - const fileHash = createContentHash(content); - const hashes = [fileHash]; + const hashes = [createContentHash(content)]; // Seed the cache before recursing so that circular imports terminate: // on a cycle A → B → A, the revisit of A returns this placeholder // instead of walking forever until the stack blows. cache.set(file, hashes); const imports = await extractImports(file, content); - const dependencyHashes = await hashDependencies(imports, file, cache, resolver); + const dependencyHashes = await hashDependencies(imports, file, cache, resolver, logger); pushAll(hashes, dependencyHashes); return hashes; } catch (error) { - console.warn(`Failed to hash file ${file}:`, error); + logger.warn(`Failed to hash file ${file}:`, error); cache.delete(file); return []; } @@ -41,15 +43,24 @@ async function hashDependencies( sourceFile: string, cache: Map, resolver: Resolver, + logger: Logger, ): Promise { const hashes: string[] = []; for (const imported of imports) { const resolved = await resolveImport(resolver, sourceFile, imported); - if (resolved) { - const resolvedHashes = await hashFile(resolved, cache, resolver); - pushAll(hashes, resolvedHashes); + if (!resolved) continue; + // 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}`); + continue; } + const resolvedHashes = await hashFile(resolved, cache, resolver, logger); + pushAll(hashes, resolvedHashes); } return hashes; diff --git a/src/lib/hashup.ts b/src/lib/hashup.ts index 4910a45..642a6bd 100644 --- a/src/lib/hashup.ts +++ b/src/lib/hashup.ts @@ -2,12 +2,14 @@ import { resolve } from "node:path"; import { combineHashes } from "./combine-hashes.js"; import { createResolver } from "./create-resolver.js"; import { hashFile } from "./hash-file.js"; +import { createLogger, type LogLevel } from "./logger.js"; import { pushAll } from "./push-all.js"; export 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[]; @@ -16,6 +18,18 @@ export interface HashupOptions { * @default process.cwd() */ baseDir?: string; + + /** + * Verbosity of diagnostic messages written to stderr. + * + * - `silent` (default): no output + * - `warn`: file-hash failures + * - `info`: high-level progress + * - `debug`: per-file decisions (e.g. which node_modules paths were skipped) + * + * @default "silent" + */ + logLevel?: LogLevel; } export interface HashupResult { @@ -31,7 +45,10 @@ export interface HashupResult { } /** - * Resolves every import and produces a fully deterministic hash for any entry file. + * Resolves every import in an entry file's user-code graph and produces + * a deterministic hash. Imports that resolve into `node_modules` are + * treated as opaque and skipped — add a lockfile to `extras` if you + * want install-tree changes reflected in the hash. * * @param entryFile - The entry file to hash * @param options - Optional configuration @@ -45,9 +62,9 @@ export interface HashupResult { * const result = await hashup('./src/index.ts'); * console.log(result.hash); // "a1b2c3d4..." * - * // Include extra files + * // Pin dependency versions by folding in the lockfile * const result = await hashup('./src/index.ts', { - * extras: ['./package.json', './tsconfig.json'] + * extras: ['./pnpm-lock.yaml', './package.json'] * }); * ``` */ @@ -55,18 +72,19 @@ export async function hashup( entryFile: string, options: HashupOptions = {}, ): Promise { - const { extras = [], baseDir = process.cwd() } = options; + const { extras = [], baseDir = process.cwd(), logLevel = "silent" } = options; + const logger = createLogger(logLevel); const resolvedEntry = resolve(baseDir, entryFile); const cache = new Map(); const resolver = createResolver(); - const entryHashes = await hashFile(resolvedEntry, cache, resolver); + const entryHashes = await hashFile(resolvedEntry, cache, resolver, logger); const extraHashes: string[] = []; for (const extraFile of extras) { const resolvedExtra = resolve(baseDir, extraFile); - const hashes = await hashFile(resolvedExtra, cache, resolver); + const hashes = await hashFile(resolvedExtra, cache, resolver, logger); pushAll(extraHashes, hashes); } diff --git a/src/lib/is-in-node-modules.ts b/src/lib/is-in-node-modules.ts new file mode 100644 index 0000000..453984e --- /dev/null +++ b/src/lib/is-in-node-modules.ts @@ -0,0 +1,16 @@ +import { sep } from "node:path"; + +const SEGMENT = `${sep}node_modules${sep}`; +const POSIX_SEGMENT = "/node_modules/"; + +/** + * True if `file` lives inside any `node_modules` directory. + * + * Works on both POSIX and Windows paths. Used by the hasher to stop at + * the package boundary: user code gets walked; dependencies are treated + * as opaque and contribute no bytes to the hash. Callers that want to + * pin to installed versions should add their lockfile to `extras`. + */ +export function isInNodeModules(file: string): boolean { + return file.includes(SEGMENT) || file.includes(POSIX_SEGMENT); +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..71752ca --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,54 @@ +export type LogLevel = "silent" | "warn" | "info" | "debug"; + +export interface Logger { + warn(message: string, error?: unknown): void; + info(message: string): void; + debug(message: string): void; +} + +const ORDER: Record = { + silent: 0, + warn: 1, + info: 2, + debug: 3, +}; + +/** + * Create a logger that writes to stderr at or below the given level. + * + * Defaults to `silent` so that `hashup()` never chatters at programmatic + * callers — opt in explicitly when running from the CLI or when + * debugging dependency-graph issues. + */ +export function createLogger(level: LogLevel = "silent"): Logger { + const threshold = ORDER[level]; + return { + warn(message, error) { + if (threshold < ORDER.warn) return; + if (error !== undefined) { + process.stderr.write(`${message} ${formatError(error)}\n`); + return; + } + process.stderr.write(`${message}\n`); + }, + info(message) { + if (threshold < ORDER.info) return; + process.stderr.write(`${message}\n`); + }, + debug(message) { + if (threshold < ORDER.debug) return; + process.stderr.write(`${message}\n`); + }, + }; +} + +function formatError(error: unknown): string { + if (error instanceof Error) { + return error.stack ?? `${error.name}: ${error.message}`; + } + return String(error); +} + +export function isLogLevel(value: string): value is LogLevel { + return value === "silent" || value === "warn" || value === "info" || value === "debug"; +} diff --git a/tests/cli/parse-args.test.ts b/tests/cli/parse-args.test.ts index c7cf7cd..24baae0 100644 --- a/tests/cli/parse-args.test.ts +++ b/tests/cli/parse-args.test.ts @@ -62,4 +62,14 @@ describe("parseCliArgs", () => { test("rejects unknown flags", () => { expect(() => parseCliArgs(["--nope"])).toThrow(); }); + + test("parses --log-level", () => { + expect(parseCliArgs([]).logLevel).toBeUndefined(); + expect(parseCliArgs(["--log-level", "warn"]).logLevel).toBe("warn"); + expect(parseCliArgs(["-l", "debug"]).logLevel).toBe("debug"); + }); + + test("rejects invalid --log-level", () => { + expect(() => parseCliArgs(["--log-level", "trace"])).toThrow(/Invalid --log-level/); + }); }); diff --git a/tests/combine-hashes.test.ts b/tests/combine-hashes.test.ts index 1923650..bf2f44f 100644 --- a/tests/combine-hashes.test.ts +++ b/tests/combine-hashes.test.ts @@ -14,14 +14,14 @@ describe("combineHashes", () => { // A single 64-char hash × 10M = 640M chars, past V8's max string length. // Guards against anyone reintroducing `hashes.join('')`. const oneHash = "a".repeat(64); - const hashes = new Array(10_000_000).fill(oneHash); + const hashes = Array.from({ length: 10_000_000 }, () => oneHash); expect(() => combineHashes(hashes)).not.toThrow(); }); test("reference `hashes.join('')` would throw on the same input", () => { const oneHash = "a".repeat(64); - const hashes = new Array(10_000_000).fill(oneHash); + const hashes = Array.from({ length: 10_000_000 }, () => oneHash); expect(() => hashes.join("")).toThrow(/Invalid string length/); }); diff --git a/tests/fixtures/skip-node-modules/entry.ts b/tests/fixtures/skip-node-modules/entry.ts new file mode 100644 index 0000000..b67401a --- /dev/null +++ b/tests/fixtures/skip-node-modules/entry.ts @@ -0,0 +1,4 @@ +import { marker } from "fake-lib"; +import { local } from "./local.js"; + +export const value = `${marker}:${local}`; diff --git a/tests/fixtures/skip-node-modules/fake-lib.d.ts b/tests/fixtures/skip-node-modules/fake-lib.d.ts new file mode 100644 index 0000000..4ffdaa5 --- /dev/null +++ b/tests/fixtures/skip-node-modules/fake-lib.d.ts @@ -0,0 +1,3 @@ +declare module "fake-lib" { + export const marker: string; +} diff --git a/tests/fixtures/skip-node-modules/local.ts b/tests/fixtures/skip-node-modules/local.ts new file mode 100644 index 0000000..1e2276d --- /dev/null +++ b/tests/fixtures/skip-node-modules/local.ts @@ -0,0 +1 @@ +export const local = "hello"; diff --git a/tests/fixtures/skip-node-modules/node_modules/fake-lib/index.js b/tests/fixtures/skip-node-modules/node_modules/fake-lib/index.js new file mode 100644 index 0000000..464becd --- /dev/null +++ b/tests/fixtures/skip-node-modules/node_modules/fake-lib/index.js @@ -0,0 +1 @@ +export const marker = "fake-lib-v1"; diff --git a/tests/fixtures/skip-node-modules/node_modules/fake-lib/package.json b/tests/fixtures/skip-node-modules/node_modules/fake-lib/package.json new file mode 100644 index 0000000..63067a7 --- /dev/null +++ b/tests/fixtures/skip-node-modules/node_modules/fake-lib/package.json @@ -0,0 +1,5 @@ +{ + "name": "fake-lib", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/large-graph.test.ts b/tests/large-graph.test.ts index a59881d..bd7fe6f 100644 --- a/tests/large-graph.test.ts +++ b/tests/large-graph.test.ts @@ -4,7 +4,7 @@ import { pushAll } from "../src/lib/push-all.js"; describe("pushAll", () => { test("does not stack-overflow when appending a very large array", () => { const target: number[] = []; - const source = new Array(500_000).fill(1); + const source = Array.from({ length: 500_000 }, () => 1); expect(() => pushAll(target, source)).not.toThrow(); expect(target.length).toBe(500_000); @@ -18,7 +18,7 @@ describe("pushAll", () => { }); test("spread-push would overflow at this size (guards against regression)", () => { - const big = new Array(500_000).fill(1); + const big = Array.from({ length: 500_000 }, () => 1); const bad: number[] = []; expect(() => bad.push(...big)).toThrow(RangeError); diff --git a/tests/logger.test.ts b/tests/logger.test.ts new file mode 100644 index 0000000..50b4ab4 --- /dev/null +++ b/tests/logger.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test, vi } from "vite-plus/test"; +import { createLogger, isLogLevel } from "../src/lib/logger.js"; + +function captureStderr(): { messages: string[]; restore: () => void } { + const messages: string[] = []; + const original = process.stderr.write.bind(process.stderr); + const spy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + messages.push(typeof chunk === "string" ? chunk : chunk.toString()); + return true; + }); + return { + messages, + restore: () => { + spy.mockRestore(); + process.stderr.write = original; + }, + }; +} + +describe("createLogger", () => { + test("silent (default) swallows everything", () => { + const cap = captureStderr(); + try { + const log = createLogger(); + log.warn("w"); + log.info("i"); + log.debug("d"); + expect(cap.messages).toEqual([]); + } finally { + cap.restore(); + } + }); + + test("warn level only writes warn", () => { + const cap = captureStderr(); + try { + const log = createLogger("warn"); + log.warn("w"); + log.info("i"); + log.debug("d"); + expect(cap.messages.join("")).toBe("w\n"); + } finally { + cap.restore(); + } + }); + + test("info level writes warn + info", () => { + const cap = captureStderr(); + try { + const log = createLogger("info"); + log.warn("w"); + log.info("i"); + log.debug("d"); + expect(cap.messages.join("")).toBe("w\ni\n"); + } finally { + cap.restore(); + } + }); + + test("debug level writes everything", () => { + const cap = captureStderr(); + try { + const log = createLogger("debug"); + log.warn("w"); + log.info("i"); + log.debug("d"); + expect(cap.messages.join("")).toBe("w\ni\nd\n"); + } finally { + cap.restore(); + } + }); + + test("warn appends an Error's stack when provided", () => { + const cap = captureStderr(); + try { + const log = createLogger("warn"); + const err = new Error("boom"); + log.warn("failed:", err); + const out = cap.messages.join(""); + expect(out).toMatch(/^failed:/); + expect(out).toMatch(/boom/); + } finally { + cap.restore(); + } + }); +}); + +describe("isLogLevel", () => { + test("accepts all four levels", () => { + expect(isLogLevel("silent")).toBe(true); + expect(isLogLevel("warn")).toBe(true); + expect(isLogLevel("info")).toBe(true); + expect(isLogLevel("debug")).toBe(true); + }); + + test("rejects anything else", () => { + expect(isLogLevel("trace")).toBe(false); + expect(isLogLevel("")).toBe(false); + expect(isLogLevel("WARN")).toBe(false); + }); +}); diff --git a/tests/skip-node-modules.test.ts b/tests/skip-node-modules.test.ts new file mode 100644 index 0000000..ad842ef --- /dev/null +++ b/tests/skip-node-modules.test.ts @@ -0,0 +1,71 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { describe, expect, test } from "vite-plus/test"; +import { hashup } from "../src/index.js"; +import { isInNodeModules } from "../src/lib/is-in-node-modules.js"; + +const FIXTURE = "./tests/fixtures/skip-node-modules"; + +describe("isInNodeModules", () => { + test("matches /node_modules/ segments", () => { + expect(isInNodeModules("/a/b/node_modules/foo/index.js")).toBe(true); + expect(isInNodeModules("/app/node_modules/.pnpm/foo@1/node_modules/foo/x.js")).toBe(true); + }); + + test("does not match lookalike paths", () => { + expect(isInNodeModules("/a/b/my_node_modules/foo.js")).toBe(false); + expect(isInNodeModules("/a/b/node_modules_fake/foo.js")).toBe(false); + expect(isInNodeModules("/a/b/src/index.ts")).toBe(false); + }); +}); + +describe("hashup skips node_modules", () => { + test("does not include fake-lib files in the resolved file list", async () => { + const result = await hashup(`${FIXTURE}/entry.ts`); + + expect(result.files.some((f) => f.endsWith("entry.ts"))).toBe(true); + expect(result.files.some((f) => f.endsWith("local.ts"))).toBe(true); + expect(result.files.some((f) => f.includes("node_modules"))).toBe(false); + }); + + test("hash does not change when a node_modules file changes", async () => { + const pkgFile = `${FIXTURE}/node_modules/fake-lib/index.js`; + const original = await readFile(pkgFile, "utf-8"); + + const before = await hashup(`${FIXTURE}/entry.ts`); + try { + await writeFile(pkgFile, `${original}\n// tampered\n`); + const after = await hashup(`${FIXTURE}/entry.ts`); + expect(after.hash).toBe(before.hash); + } finally { + await writeFile(pkgFile, original); + } + }); + + test("hash still changes when user source changes", async () => { + const localFile = `${FIXTURE}/local.ts`; + const original = await readFile(localFile, "utf-8"); + + const before = await hashup(`${FIXTURE}/entry.ts`); + try { + await writeFile(localFile, `${original}\n// tampered\n`); + const after = await hashup(`${FIXTURE}/entry.ts`); + expect(after.hash).not.toBe(before.hash); + } finally { + await writeFile(localFile, original); + } + }); + + test("adding a lockfile as an extra folds install-tree changes back in", async () => { + const lockPath = `${FIXTURE}/pnpm-lock.yaml`; + await writeFile(lockPath, "lockfileVersion: '9.0'\n# v1\n"); + try { + const r1 = await hashup(`${FIXTURE}/entry.ts`, { extras: [lockPath] }); + await writeFile(lockPath, "lockfileVersion: '9.0'\n# v2\n"); + const r2 = await hashup(`${FIXTURE}/entry.ts`, { extras: [lockPath] }); + expect(r2.hash).not.toBe(r1.hash); + } finally { + await writeFile(lockPath, "").catch(() => {}); + await import("node:fs/promises").then((fs) => fs.unlink(lockPath).catch(() => {})); + } + }); +});