You cannot feature-detect a refusal
Shipped
The preflight report gained an On dev column, printed immediately beside
On main, and the minimum supported version of the tool underneath it went from
0.4.0 to 0.6.0. Those two lines are the same fix approached from opposite ends.
One puts a fact in front of the operator early enough to act on; the other makes
sure the machinery behind it will actually stop when that fact is true.
The second half is the part I keep thinking about, because it runs against advice I would normally give without hesitating: test for the capability, not the version. That advice quietly assumes the capability leaves a trace.
Put the disagreement in the table they read first
The underlying bug was that a release could be cut against whatever version sat
on main while a higher version sat on dev, unread. The dependency fixed that
by refusing. But a refusal that only speaks at the moment you pull the trigger
is a refusal you meet at the worst possible time, after you have already decided
to release and told somebody you were doing it.
So the fact moved forward, into the report you read before deciding anything.
// The report an operator reads BEFORE deciding to release anything. Its job is
// to put every fact that can block a cut into the same table, so nobody meets a
// blocker for the first time at the moment they trigger it.
export function preflight(components) {
const rows = components.map((c) => ({
Component: c.name,
'Last tag': c.lastTag ?? '—',
'On main': c.versionOnMain ?? '—',
// The column this release added. `On main` and `On dev` disagreeing is the
// fact a cut used to act on silently, so it belongs next to `On main`
// rather than three commands away.
'On dev': c.versionOnDev ?? '—',
Blockers: (c.blockers ?? []).map((b) => b.id).join(', ') || 'none',
}));
return rows;
}
export function renderTable(rows) {
if (rows.length === 0) return '(nothing to report)';
const cols = Object.keys(rows[0]);
const width = (c) =>
Math.max(c.length, ...rows.map((r) => String(r[c]).length));
const line = (cells) =>
'| ' + cols.map((c, i) => String(cells[i]).padEnd(width(c))).join(' | ') + ' |';
return [
line(cols),
'|' + cols.map((c) => '-'.repeat(width(c) + 2)).join('|') + '|',
...rows.map((r) => line(cols.map((c) => r[c]))),
].join('\n');
}
Column placement is doing real work here. On dev sits immediately after
On main because the two disagreeing is the finding, and two numbers only
read as a contradiction when they are adjacent. Put one of them in a different
command and the operator has to already suspect the problem to go looking.
The probe that proves nothing
Now the interesting half. If your tool depends on another tool’s guard, how do you know the guard is there?
The standard answer is feature detection. MDN puts the reasoning plainly:
feature detection means “working out whether a browser supports a certain block
of code, and running different code depending on whether it does”, and it warns
against the alternative of sniffing versions, which it calls
a terrible practice that should be discouraged at all costs.
I agree with that in almost every case. if ("geolocation" in navigator) is
better than a version table, because the feature is a thing you can reach out
and touch.
A refusal is not. Here are two versions of a dependency that differ only in whether the dangerous function honours the fact both of them report:
// Two stand-in versions of the dependency this tool drives. They differ ONLY in
// whether `cut` acts on the fact, which is the guarantee we actually rely on.
const AMBIGUOUS_NOTE = 'main and dev carry different versions';
function readStatus({ name, versionOnMain, versionOnDev }) {
const devAhead = versionOnDev !== versionOnMain ? { version: versionOnDev } : null;
return {
name,
versionOnMain,
versionOnDev,
devAhead,
blockers: devAhead ? [{ id: 'dev-ahead-of-main', detail: AMBIGUOUS_NOTE }] : [],
};
}
// Reports the fact. Does NOT refuse on it.
export const reportsOnly = {
version: '0.5.0',
readStatus,
cut(status) {
return { ok: true, tagged: `v${status.versionOnMain}` };
},
};
// Reports the fact AND refuses to act while it is set.
export const refuses = {
version: '0.6.0',
readStatus,
cut(status) {
if (status.devAhead) {
return { ok: false, error: `ambiguous: ${AMBIGUOUS_NOTE}, refusing to guess` };
}
return { ok: true, tagged: `v${status.versionOnMain}` };
},
};
Probe either one and you get the same answer. devAhead is present in both.
blockers has one entry in both. Every field you could reasonably test for
exists on the version that will happily tag the wrong thing.
That is the general shape and it is worth naming: detecting a data field does
not prove the behaviour that consumes it. Adding a field and acting on it are
separate changes, and only the first one is observable. The only honest probe
for “does cut refuse?” is to hand cut an ambiguous status and see, which
means performing the operation you were trying to protect.
Check the floor yourself, because declaring it does not
If the guarantee is invisible, the version number is the only signal left. The
obvious move is to declare it as a dependency constraint and stop thinking about
it, and for a library that is usually right. For a tool you shell out to, it is
not: npm’s own documentation says the engines field is
advisory only unless the user has set the engine-strict config flag,
producing warnings rather than refusals. A declared floor and an enforced floor
are different things.
So check it in your own process, at startup, before anything can happen.
// The floor check. Runs in your process, at startup, and says what the floor
// buys rather than just quoting two numbers at the operator.
export function cmpSemver(a, b) {
const pa = String(a).split('.').map(Number);
const pb = String(b).split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
}
return 0;
}
export const MIN_DEP = '0.6.0';
// `buys` is the important argument. A floor whose message is "requires >= 0.6.0"
// teaches the reader nothing and invites them to lower it; a floor that names
// the guarantee explains why lowering it is not a config change.
export function requireFloor(actual, min = MIN_DEP, buys = 'the refusal to release an ambiguous target') {
if (cmpSemver(actual, min) >= 0) return { ok: true, actual };
return {
ok: false,
error:
`needs >= ${min}, found ${actual}. ${min} is where ${buys} landed. ` +
`An older version returns a status of the same shape and silently ` +
`lacks it, so this cannot be detected from the response.`,
};
}
The buys argument is a small thing that changes how the floor survives. A
message reading “requires >= 0.6.0” reads as bureaucracy, and the next person
who hits it on a machine with 0.5.0 installed will try lowering the constant to
see if it works. It will appear to work, because the missing behaviour is a
refusal that does not fire on any of the happy paths they test. Naming the
guarantee in the error is what stops that.
Note that cmpSemver parses rather than string-compares. Semantic Versioning
requires numeric comparison of major, minor and patch,
and lexicographic order puts 0.10.0 below 0.9.0, which in a floor check
means silently accepting a version far below your minimum.
Run it
import assert from 'node:assert/strict';
import { preflight, renderTable } from './preflight.mjs';
import { reportsOnly, refuses } from './deps.mjs';
import { requireFloor } from './floor.mjs';
const COMPONENTS = [
{ name: 'widget', lastTag: '0.1.0', versionOnMain: '0.2.1', versionOnDev: '0.3.0' },
{ name: 'gadget', lastTag: '1.4.0', versionOnMain: '1.4.0', versionOnDev: '1.4.0' },
];
// 1. The table an operator reads first, with both branch columns side by side.
const withStatus = COMPONENTS.map((c) => ({ ...c, ...refuses.readStatus(c) }));
console.log(renderTable(preflight(withStatus)));
// 2. Now the probe. Both dependency versions expose the same field.
const ambiguous = COMPONENTS[0];
for (const dep of [reportsOnly, refuses]) {
const status = dep.readStatus(ambiguous);
console.log(
`\n${dep.version}: hasOwn(status,'devAhead') = ${Object.hasOwn(status, 'devAhead')}` +
` | blockers = ${status.blockers.length}`
);
}
// The probe passes on both, so it proves nothing about what cut() will do.
assert.equal(
Object.hasOwn(reportsOnly.readStatus(ambiguous), 'devAhead'),
Object.hasOwn(refuses.readStatus(ambiguous), 'devAhead')
);
// 3. The only thing that distinguishes them is running the irreversible call.
console.log('\nwhat cut() actually does with the same input:');
for (const dep of [reportsOnly, refuses]) {
const result = dep.cut(dep.readStatus(ambiguous));
console.log(` ${dep.version}:`, JSON.stringify(result));
}
assert.equal(reportsOnly.cut(reportsOnly.readStatus(ambiguous)).ok, true);
assert.equal(refuses.cut(refuses.readStatus(ambiguous)).ok, false);
// 4. So check the version instead, at startup, before any of this runs.
console.log('\nfloor check:');
for (const dep of [reportsOnly, refuses]) {
const gate = requireFloor(dep.version);
console.log(` ${dep.version}: ${gate.ok ? 'ok' : gate.error}`);
}
assert.equal(requireFloor(reportsOnly.version).ok, false);
assert.equal(requireFloor(refuses.version).ok, true);
console.log('\nall checks passed');
Save the three blocks as preflight.mjs, deps.mjs and floor.mjs, this one
as check.mjs, and run node check.mjs. It uses nothing but Node’s own assert
module. Here is what it printed for me:
| Component | Last tag | On main | On dev | Blockers |
|-----------|----------|---------|--------|-------------------|
| widget | 0.1.0 | 0.2.1 | 0.3.0 | dev-ahead-of-main |
| gadget | 1.4.0 | 1.4.0 | 1.4.0 | none |
0.5.0: hasOwn(status,'devAhead') = true | blockers = 1
0.6.0: hasOwn(status,'devAhead') = true | blockers = 1
what cut() actually does with the same input:
0.5.0: {"ok":true,"tagged":"v0.2.1"}
0.6.0: {"ok":false,"error":"ambiguous: main and dev carry different versions, refusing to guess"}
floor check:
0.5.0: needs >= 0.6.0, found 0.5.0. 0.6.0 is where the refusal to release an ambiguous target landed. An older version returns a status of the same shape and silently lacks it, so this cannot be detected from the response.
0.6.0: ok
all checks passed
The two middle sections are the whole argument sitting next to each other. The
probe reports identically for both versions. The operation reports ok: true
against one and refuses against the other. If your only evidence is the first
block, you have learned nothing about the second.
Gotchas
A floor you raise without saying why gets lowered. The constant is the easy part; the reason it moved is the part that has to travel with it. Put the guarantee in the error string, and put the same sentence in the changelog entry that raises it. Somebody hitting this on a machine with the old version will test whether lowering it breaks anything, and it will not break anything they can see, because the thing they removed only fires on a case they are not in.
Declaring a dependency floor is not enforcing one. engines warns by
default. A peer range can be satisfied by a hoisted copy you did not expect. If
the tool is invoked as a subprocess rather than imported, package metadata does
not constrain it at all: the binary on PATH is whatever is on PATH. Read the
version from the thing you are actually going to run.
Bundle the version check with the version report. When you shell out, ask the tool for its version and remember where you found it. Reporting “found 0.5.0 at /usr/local/bin/tool” turns a confusing failure into a one-step fix, because the most common cause of a too-old dependency is a stale global install shadowing the one you thought you were running.
A blocker that only appears at the point of action is a blocker discovered too late. This is the ordering half of the same lesson. If a condition can stop an operation, it belongs in whatever report people read while deciding to perform that operation, not only in the guard that fires when they do. The guard still has to exist; it is the last line, not the first.
Do not let the fix for an overclaim be a softer claim. A rationale in this release said its fixtures covered “the grouper and the state machine” when no assertion in it read state at all, and all three fixtures carried the same value. The correction was to name only what is genuinely covered and point at where the rest is actually pinned. Documentation that describes coverage you do not have is worse than documentation that describes less, because it stops anybody from going looking.
Sources
- Feature detection (MDN) — the standard advice to test for capability rather than version, and why
- npm package.json documentation —
enginesis advisory unlessengine-strictis set - Semantic Versioning 2.0.0 — numeric precedence, which is what a floor check has to implement
Changelog
- fix(release): surface dev-ahead-of-main before cut, not only after it refuses (0.2.0) (#179) (29efa06)