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
24 changes: 24 additions & 0 deletions .changeset/no-hash-sentinel.md
Original file line number Diff line number Diff line change
@@ -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 `<no-hash>` 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 <no-hash>
worker 0c4b8d9f…
```

In `--json` mode the sentinel appears as `{ "hash": "<no-hash>",
"files": [] }`. The sentinel is also exported from
`@maastrich/hashup/cli` as `NO_HASH` for programmatic callers of
`runConfigMode`.
16 changes: 13 additions & 3 deletions docs/guide/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>: pattern "<glob>" 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 `<no-hash>` 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 <no-hash>
worker 0c4b8d9f…
```

## Editor integration

Expand Down
18 changes: 13 additions & 5 deletions src/cli/run-config-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<no-hash>";

export async function runConfigMode(input: RunConfigModeInput): Promise<RunConfigModeResult> {
const configPath = resolve(input.cwd, input.configPath ?? "hashup.json");
const loaded = await loadConfig(configPath);
Expand All @@ -45,14 +52,15 @@ export async function runConfigMode(input: RunConfigModeInput): Promise<RunConfi
for (const [name, entry] of Object.entries(loaded.data.entries)) {
const baseDir = entry.baseDir !== undefined ? resolveFrom(configDir, entry.baseDir) : rootBase;
const entryFiles = await expandPaths([entry.entry], baseDir);
const logLevel = input.logLevel ?? loaded.data.logLevel;
if (entryFiles.length === 0) {
return {
ok: false,
error: `entries.${name}: pattern "${entry.entry}" matched no files`,
};
// Zero-match globs are a valid state (feature flags off, package
// doesn't have tests yet, etc.) — emit a sentinel hash instead
// of failing the whole run. Downstream tooling can detect it.
results[name] = { hash: NO_HASH, files: [] };
continue;
}
const extras = entry.extras ? await expandPaths(entry.extras, baseDir) : [];
const logLevel = input.logLevel ?? loaded.data.logLevel;
results[name] = await hashEntrySet(entryFiles, extras, baseDir, logLevel, cache, resolver);
}

Expand Down
34 changes: 28 additions & 6 deletions tests/cli/run-modes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,19 +224,41 @@ describe("runConfigMode", () => {
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 <no-hash> 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,
baseDirOverride: undefined,
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+<no-hash>/);
expect(result.output).toMatch(/real\s+[a-f0-9]{64}/);
}
});

test("zero-match glob in --json mode emits <no-hash> 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("<no-hash>");
expect(parsed.none.files).toEqual([]);
}
});

Expand Down
Loading