From 85b60075d0063096b7c9fd06480ce1e9ec5522f4 Mon Sep 17 00:00:00 2001 From: Mathis Pinsault Date: Fri, 24 Apr 2026 15:04:21 +0200 Subject: [PATCH] feat: emit for zero-match entries instead of failing Monorepo configs often glob files that aren't always present (unreleased features, feature-flagged directories, packages that don't have tests yet). A single zero-match entry shouldn't abort the entire invocation. Return a sentinel and keep going. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/no-hash-sentinel.md | 24 ++++++++++++++++++++++++ docs/guide/cli.md | 16 +++++++++++++--- src/cli/run-config-mode.ts | 18 +++++++++++++----- tests/cli/run-modes.test.ts | 34 ++++++++++++++++++++++++++++------ 4 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 .changeset/no-hash-sentinel.md diff --git a/.changeset/no-hash-sentinel.md b/.changeset/no-hash-sentinel.md new file mode 100644 index 0000000..1e31cbe --- /dev/null +++ b/.changeset/no-hash-sentinel.md @@ -0,0 +1,24 @@ +--- +"@maastrich/hashup": minor +--- + +Zero-match entry globs no longer fail the run — the entry's hash is +emitted as the sentinel `` and the remaining entries continue +processing. + +Motivation: in a monorepo a package may not have tests yet, a feature +flag may empty a directory, or a glob may intentionally target files +that aren't always present. Previously any single zero-match entry +aborted the whole invocation; now it's a local, addressable signal +downstream tooling can detect. + +``` +app 48adf62a70c2645d0fc15ee3060973245af5dc30a542372791a7e1f05eaeacf6 +visual-tests +worker 0c4b8d9f… +``` + +In `--json` mode the sentinel appears as `{ "hash": "", +"files": [] }`. The sentinel is also exported from +`@maastrich/hashup/cli` as `NO_HASH` for programmatic callers of +`runConfigMode`. diff --git a/docs/guide/cli.md b/docs/guide/cli.md index ac4a2c1..5e33526 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -130,9 +130,19 @@ this set change?" style cache keys. A single-match glob produces the same hash as the literal form, so converting a literal entry to a glob that still only matches one file is a no-op. -If a glob matches **zero** files, the CLI exits with an error -(`entries.: pattern "" matched no files`) — almost always a -typo or stale path is the cause. +If a glob matches **zero** files, that entry's hash is emitted as the +sentinel `` and the run continues with the remaining entries. +This keeps CI configs stable through normal churn — a package that +doesn't have visual tests yet, a feature flag that removes a whole +directory — without forcing every entry to gate-keep the whole run. +Downstream tooling can detect the sentinel to decide whether to skip, +warn, or fail. + +``` +app 48adf62a70c2645d0fc15ee3060973245af5dc30a542372791a7e1f05eaeacf6 +visual-tests +worker 0c4b8d9f… +``` ## Editor integration diff --git a/src/cli/run-config-mode.ts b/src/cli/run-config-mode.ts index 65e5afb..5553926 100644 --- a/src/cli/run-config-mode.ts +++ b/src/cli/run-config-mode.ts @@ -20,6 +20,13 @@ export interface RunConfigModeInput { export type RunConfigModeResult = { ok: true; output: string } | { ok: false; error: string }; +/** + * Sentinel emitted when an entry's `entry` pattern matches zero files. + * Chosen to be visually distinct and lexicographically invalid as a + * hex digest so consumers can't confuse it with a real hash. + */ +export const NO_HASH = ""; + export async function runConfigMode(input: RunConfigModeInput): Promise { const configPath = resolve(input.cwd, input.configPath ?? "hashup.json"); const loaded = await loadConfig(configPath); @@ -45,14 +52,15 @@ export async function runConfigMode(input: RunConfigModeInput): Promise { expect(third.ok && third.output).not.toBe(first.ok && first.output); }); - test("glob that matches nothing produces a readable error", async () => { - await writeConfigFile({ entries: { none: { entry: "src/*.missing" } } }); + test("zero-match glob emits and does not abort other entries", async () => { + await writeConfigFile({ + entries: { + none: { entry: "src/*.missing" }, + real: { entry: "src/a.ts" }, + }, + }); const result = await runConfigMode({ cwd: workDir, configPath: undefined, @@ -233,10 +238,27 @@ describe("runConfigMode", () => { json: false, files: false, }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error).toMatch(/entries\.none/); - expect(result.error).toMatch(/matched no files/); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.output).toMatch(/none\s+/); + expect(result.output).toMatch(/real\s+[a-f0-9]{64}/); + } + }); + + test("zero-match glob in --json mode emits with an empty files list", async () => { + await writeConfigFile({ entries: { none: { entry: "src/*.missing" } } }); + const result = await runConfigMode({ + cwd: workDir, + configPath: undefined, + baseDirOverride: undefined, + json: true, + files: true, + }); + expect(result.ok).toBe(true); + if (result.ok) { + const parsed = JSON.parse(result.output); + expect(parsed.none.hash).toBe(""); + expect(parsed.none.files).toEqual([]); } });