staysfixed 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/README.md +2 -2
- package/docs/getting-started.md +1 -1
- package/docs/settings.md +15 -0
- package/package.json +1 -1
- package/src/cli/status.js +45 -1
- package/src/core/config.js +31 -0
- package/src/core/paths.js +15 -0
- package/src/guard/run.js +21 -2
- package/src/report/console.js +35 -4
- package/src/run.js +11 -0
- package/src/types.js +3 -0
- package/src/v2/adapters/child.js +101 -0
- package/src/v2/adapters/http.js +7 -9
- package/src/v2/adapters/process.js +55 -5
- package/src/v2/adapters/web-driver.js +43 -3
- package/src/v2/adapters/web.js +7 -9
- package/src/v2/browsers.js +57 -2
- package/src/v2/check.js +123 -3
- package/src/v2/cli.js +23 -18
- package/src/v2/cluster.js +56 -1
- package/src/v2/doctor.js +68 -9
- package/src/v2/escalate.js +5 -1
- package/src/v2/init.js +21 -5
- package/src/v2/mcp/tools.js +80 -5
- package/src/v2/reference.js +120 -14
- package/src/v2/ship.js +26 -0
package/src/v2/doctor.js
CHANGED
|
@@ -196,7 +196,7 @@ export const CHANNELS = [
|
|
|
196
196
|
* should be able to read it once and know what to call, what it will get back,
|
|
197
197
|
* and what it must not bother asking for here.
|
|
198
198
|
*
|
|
199
|
-
* @param {{cwd?: string, configFile?: string, offline?: boolean}} [opts]
|
|
199
|
+
* @param {{cwd?: string, configFile?: string, offline?: boolean, machines?: boolean}} [opts]
|
|
200
200
|
* @returns {Promise<Capabilities>}
|
|
201
201
|
*/
|
|
202
202
|
export async function capabilities(opts = {}) {
|
|
@@ -218,7 +218,12 @@ export async function capabilities(opts = {}) {
|
|
|
218
218
|
|
|
219
219
|
const [tools, hosts, repo, reference, drivers, phones, asked] = await Promise.all([
|
|
220
220
|
findTools(cwd, browsers),
|
|
221
|
-
|
|
221
|
+
// Only a product that could actually run somewhere else is a reason to go looking for
|
|
222
|
+
// somewhere else. A website or a command-line tool never needs a Windows desktop, and
|
|
223
|
+
// the hosts list feeds exactly one surface: that one.
|
|
224
|
+
offline
|
|
225
|
+
? Promise.resolve(/** @type {HostReport[]} */ ([]))
|
|
226
|
+
: reachableHosts({ dial: opts.machines === true || desktopApp !== null }),
|
|
222
227
|
isRepo(root).catch(() => false),
|
|
223
228
|
findReference(root),
|
|
224
229
|
whatThisCopyCanDrive(),
|
|
@@ -249,7 +254,7 @@ export async function capabilities(opts = {}) {
|
|
|
249
254
|
},
|
|
250
255
|
surfaces,
|
|
251
256
|
drivers,
|
|
252
|
-
covers: whatThisRunActuallyCovers(surfaces),
|
|
257
|
+
covers: whatThisRunActuallyCovers(surfaces, configFile !== null),
|
|
253
258
|
browsers: {
|
|
254
259
|
willOpen: browsers.chosen,
|
|
255
260
|
borrowingYourOwn: browsers.borrowingHis,
|
|
@@ -1175,13 +1180,40 @@ function androidSdkTool(folder, name) {
|
|
|
1175
1180
|
* answers is a runner the tool already has, and it must never appear in the
|
|
1176
1181
|
* result as something to go and set up.
|
|
1177
1182
|
*
|
|
1183
|
+
* @param {{dial?: boolean}} [opts]
|
|
1178
1184
|
* @returns {Promise<HostReport[]>}
|
|
1179
1185
|
*/
|
|
1180
|
-
export async function reachableHosts() {
|
|
1186
|
+
export async function reachableHosts(opts = {}) {
|
|
1181
1187
|
if (!onPath('ssh')) return [];
|
|
1182
1188
|
const names = await sshConfigHosts();
|
|
1183
1189
|
if (names.length === 0) return [];
|
|
1184
1190
|
|
|
1191
|
+
// READING the ssh config is free and tells nobody anything. DIALLING is neither, and it
|
|
1192
|
+
// is not something this tool may do to somebody who has just installed it.
|
|
1193
|
+
//
|
|
1194
|
+
// The first command a stranger runs is `doctor`. On a brand-new scratch project with no
|
|
1195
|
+
// settings file and nothing that could possibly need a second machine, this opened ssh
|
|
1196
|
+
// connections to every host in their `~/.ssh/config` and ran a command on each — measured
|
|
1197
|
+
// on 2026-08-30: ten hosts configured, connections out within seconds of the first run,
|
|
1198
|
+
// nothing said before or after. Those are production servers in a lot of people's configs,
|
|
1199
|
+
// and in a lot of workplaces that alone is a policy breach. `--offline` existed, but a way
|
|
1200
|
+
// out you only learn about afterwards is not consent.
|
|
1201
|
+
//
|
|
1202
|
+
// So it is asked for now rather than assumed, and the machines are still NAMED either way,
|
|
1203
|
+
// because a machine quietly left out of the answer is the same bug as a folder quietly
|
|
1204
|
+
// skipped while reading source: the list looks complete and the runner somebody needed is
|
|
1205
|
+
// simply not in it.
|
|
1206
|
+
if (opts.dial !== true) {
|
|
1207
|
+
return names.map(
|
|
1208
|
+
(name) =>
|
|
1209
|
+
/** @type {HostReport} */ ({
|
|
1210
|
+
name,
|
|
1211
|
+
reachable: false,
|
|
1212
|
+
how: 'named in your ssh config and deliberately NOT dialled. Nothing here needs a second machine, and this tool does not connect to yours unasked. `staysfixed doctor --machines` checks them.',
|
|
1213
|
+
})
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1185
1217
|
const dialled = await Promise.all(names.slice(0, MAX_HOSTS).map((name) => describeHost(name)));
|
|
1186
1218
|
// Anything past the cap is NAMED rather than dropped. A machine quietly left out of
|
|
1187
1219
|
// this list is the same shape of bug as a folder quietly skipped while reading source:
|
|
@@ -1776,6 +1808,9 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
1776
1808
|
* @property {{name: string, why: string}[]} partly Looked at, but not completely, and why.
|
|
1777
1809
|
* @property {{name: string, why: string, whoFixes: SurfaceState}[]} notCovered
|
|
1778
1810
|
* @property {boolean} everything True only when nothing at all is left out.
|
|
1811
|
+
* @property {boolean} [canRunHere]
|
|
1812
|
+
* False when nothing is set up in this folder, so a check cannot run here at all
|
|
1813
|
+
* whatever this machine could otherwise drive.
|
|
1779
1814
|
*/
|
|
1780
1815
|
|
|
1781
1816
|
/**
|
|
@@ -1787,9 +1822,10 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
1787
1822
|
* covers your website; your iPhone app is not being checked, and here is why.
|
|
1788
1823
|
*
|
|
1789
1824
|
* @param {SurfaceReport[]} surfaces
|
|
1825
|
+
* @param {boolean} setUpHere Whether a check can actually run in this folder at all.
|
|
1790
1826
|
* @returns {Covers}
|
|
1791
1827
|
*/
|
|
1792
|
-
function whatThisRunActuallyCovers(surfaces) {
|
|
1828
|
+
function whatThisRunActuallyCovers(surfaces, setUpHere = true) {
|
|
1793
1829
|
// Three buckets, not two. Folding "partly" into "covered" is exactly the
|
|
1794
1830
|
// over-claim this function exists to stop: an iPhone app whose screens cannot
|
|
1795
1831
|
// be read is not a covered iPhone app.
|
|
@@ -1813,7 +1849,23 @@ function whatThisRunActuallyCovers(surfaces) {
|
|
|
1813
1849
|
|
|
1814
1850
|
/** @type {string[]} */
|
|
1815
1851
|
const parts = [];
|
|
1816
|
-
|
|
1852
|
+
// What this MACHINE can drive and what a check in THIS FOLDER would cover are two
|
|
1853
|
+
// different questions, and only one of them was being answered. In an empty folder — no
|
|
1854
|
+
// settings, no code — this said "A check here covers command-line tools and libraries and
|
|
1855
|
+
// web apps and sites in full" and doctor exited 0, while `check` in that same folder
|
|
1856
|
+
// refused to run at all: "No Stays Fixed config found here, so there is nothing to check."
|
|
1857
|
+
// Measured 2026-08-30. `doctor --json` is the first call an agent is told to make, which
|
|
1858
|
+
// is the worst place there is for a sentence with "here" in it to mean somewhere else.
|
|
1859
|
+
//
|
|
1860
|
+
// Everything below still says what it said — a reader needs the whole picture either way —
|
|
1861
|
+
// it is just no longer written as though a check could run.
|
|
1862
|
+
if (!setUpHere) {
|
|
1863
|
+
out.canRunHere = false;
|
|
1864
|
+
parts.push('Nothing is set up in this folder, so a check cannot run here at all and would cover nothing. Run `staysfixed init` first.');
|
|
1865
|
+
parts.push(full.length > 0 ? `Once it is set up, this machine could cover ${plainList(out.covered)} in full.` : 'Even set up, this machine could cover nothing in full.');
|
|
1866
|
+
} else {
|
|
1867
|
+
parts.push(full.length > 0 ? `A check here covers ${plainList(out.covered)} in full.` : 'A check here covers nothing in full.');
|
|
1868
|
+
}
|
|
1817
1869
|
if (some.length > 0) parts.push(`It covers ${plainList(some.map((s) => s.name))} only partly — read the summary for each before treating a clean result as proof.`);
|
|
1818
1870
|
if (missing.length > 0) {
|
|
1819
1871
|
parts.push(`It does NOT check ${plainList(missing.map((s) => s.name))} at all, so a clean result says nothing whatever about ${missing.length === 1 ? 'that' : 'those'}.`);
|
|
@@ -1888,8 +1940,15 @@ function nextSteps(surfaces, reference, repo) {
|
|
|
1888
1940
|
steps.push({
|
|
1889
1941
|
what: 'record a reference',
|
|
1890
1942
|
why: 'Until one build has been recorded there is nothing to compare a new one against, and a clean result would mean nothing.',
|
|
1891
|
-
|
|
1892
|
-
|
|
1943
|
+
// Both halves of this were wrong, and they were wrong in the direction that matters.
|
|
1944
|
+
// `check --paired` cannot record a reference — run it twice on a fresh project and
|
|
1945
|
+
// both runs answer that there is no build on record, with the reference id still
|
|
1946
|
+
// empty — so anybody following this went round in a circle. And `automatic: true` told
|
|
1947
|
+
// the agent this was its to do, when the one rule underneath this whole product is
|
|
1948
|
+
// that only shipping cuts a reference and no agent may bless its own work. Saying an
|
|
1949
|
+
// agent can do the single thing it must never do is worse than saying nothing.
|
|
1950
|
+
fix: 'staysfixed ship (only shipping records what "working" means — no agent may cut that reference)',
|
|
1951
|
+
automatic: false,
|
|
1893
1952
|
unlocks: 'Every check after this one has something to compare against, so "nothing changed" starts meaning something.',
|
|
1894
1953
|
});
|
|
1895
1954
|
}
|
|
@@ -2072,7 +2131,7 @@ export function describeCapabilities(caps) {
|
|
|
2072
2131
|
* @returns {Promise<number>}
|
|
2073
2132
|
*/
|
|
2074
2133
|
export async function run(ctx) {
|
|
2075
|
-
const caps = await capabilities({ cwd: ctx.cwd, configFile: ctx.configFile, offline: ctx.bool('offline') });
|
|
2134
|
+
const caps = await capabilities({ cwd: ctx.cwd, configFile: ctx.configFile, offline: ctx.bool('offline'), machines: ctx.bool('machines') });
|
|
2076
2135
|
|
|
2077
2136
|
if (ctx.bool('json')) {
|
|
2078
2137
|
// Nothing but the object may reach standard output. Doctor is the first call
|
package/src/v2/escalate.js
CHANGED
|
@@ -517,7 +517,11 @@ function buildEscalations(product, record, verdict) {
|
|
|
517
517
|
kind: 'no-reference',
|
|
518
518
|
what: `There is no build of ${product} on record as working yet, so this run had nothing to compare against.`,
|
|
519
519
|
why: 'Only you can say what "working" means, and you say it by shipping — no agent may cut that reference.',
|
|
520
|
-
|
|
520
|
+
// The order is said out loud because leaving it out sent people round a circle: `ship`
|
|
521
|
+
// on a build nothing has watched answers "run a check before the next release", and
|
|
522
|
+
// this line answered "you say it by shipping". Both are true and neither says which
|
|
523
|
+
// comes first. A check watches the build; shipping then blesses what was watched.
|
|
524
|
+
todo: 'Run `staysfixed check` once so there is a build to bless, then `staysfixed ship`. From the next change onwards it is automatic and you will not see this again.',
|
|
521
525
|
});
|
|
522
526
|
}
|
|
523
527
|
|
package/src/v2/init.js
CHANGED
|
@@ -824,7 +824,10 @@ function sortNeeds(readiness, project, machine) {
|
|
|
824
824
|
what: 'one build on record as working',
|
|
825
825
|
why: 'Until one build has been recorded there is nothing to compare a new one against, and a clean result would mean nothing at all.',
|
|
826
826
|
unlocks: 'every check from then on',
|
|
827
|
-
|
|
827
|
+
// `check --paired` was named here first, and it does not do this: run it twice on a
|
|
828
|
+
// fresh project and both runs answer "there is no build on record as working". Only
|
|
829
|
+
// shipping cuts a reference, on purpose — no agent may bless its own work.
|
|
830
|
+
fix: 'staysfixed ship (only shipping records what "working" means — run it once at the end of your release)',
|
|
828
831
|
who: 'the agent',
|
|
829
832
|
});
|
|
830
833
|
}
|
|
@@ -1148,7 +1151,10 @@ export function configText(project) {
|
|
|
1148
1151
|
w(' // "working" means, so the name is how two of them are told apart.');
|
|
1149
1152
|
if (project.products.length > 1) {
|
|
1150
1153
|
w(' // This repository makes more than one thing. A check covers whichever of them the');
|
|
1151
|
-
|
|
1154
|
+
// `--product` is not an option on `check` and never has been. Naming it here sent people
|
|
1155
|
+
// to a flag the CLI rejects, in the settings file the tool itself wrote for them.
|
|
1156
|
+
w(' // settings below describe. To check one of the others, run `staysfixed check` from');
|
|
1157
|
+
w(' // inside that package, or point at its settings with `--config <file>`.');
|
|
1152
1158
|
}
|
|
1153
1159
|
w(` product: ${JSON.stringify(project.name)},`);
|
|
1154
1160
|
w('');
|
|
@@ -1342,7 +1348,12 @@ export function configText(project) {
|
|
|
1342
1348
|
} else if (project.pages.length > 0) {
|
|
1343
1349
|
w(`${webOn}// ${project.pages.length} page address${project.pages.length === 1 ? '' : 'es'} are read out of your folder names automatically — nothing to list here.`);
|
|
1344
1350
|
w(`${webOn}// Add a screen only for something a walk has to DO rather than just open:`);
|
|
1345
|
-
|
|
1351
|
+
// `fill:` and `with:` are not words this tool knows — the verb is `type:` and the value
|
|
1352
|
+
// is `text:`. An unknown key used to be skipped in silence, so this example, handed to
|
|
1353
|
+
// every stranger with a sign-in, filled nothing, clicked Sign in on an empty form, and
|
|
1354
|
+
// then photographed the login page for every screen behind the wall while reporting a
|
|
1355
|
+
// clean run. The example that teaches the vocabulary has to be IN the vocabulary.
|
|
1356
|
+
w(`${webOn}// screens: [{ name: 'signing in', url: '/login', steps: [{ type: '#email', text: 'a@b.c' }, { type: '#password', text: 'secret' }, { click: 'Sign in' }] }],`);
|
|
1346
1357
|
} else {
|
|
1347
1358
|
w(`${webOn}// screens: [{ name: 'the front page', url: '/' }],`);
|
|
1348
1359
|
}
|
|
@@ -1600,9 +1611,14 @@ function nextCommands(readiness, project) {
|
|
|
1600
1611
|
if (reachable.length > 0) {
|
|
1601
1612
|
next.push({
|
|
1602
1613
|
command: 'staysfixed check --paired',
|
|
1614
|
+
// It does NOT record what "working" means, and saying so here sent everybody round a
|
|
1615
|
+
// loop: run it, be told there is no build on record, run it again, be told the same
|
|
1616
|
+
// thing. Only `ship` cuts a reference — that is the rule the whole product rests on,
|
|
1617
|
+
// because an agent that can bless its own work is not a safety net. So this says what
|
|
1618
|
+
// the run actually does, and `ship` below says what only it can do.
|
|
1603
1619
|
what: reachable.some((r) => r.state === 'ready')
|
|
1604
|
-
? 'The first real run. It
|
|
1605
|
-
: 'The first real run. Nothing here is fully set up yet, so it
|
|
1620
|
+
? 'The first real run. It walks everything and shows you what it sees. It cannot record what "working" means — only shipping does that.'
|
|
1621
|
+
: 'The first real run. Nothing here is fully set up yet, so it walks what it can reach and says plainly what it left out — which is more useful than waiting.',
|
|
1606
1622
|
});
|
|
1607
1623
|
}
|
|
1608
1624
|
if (project.tests.files > 0) {
|
package/src/v2/mcp/tools.js
CHANGED
|
@@ -823,7 +823,12 @@ async function toolCheck(ctx, input) {
|
|
|
823
823
|
);
|
|
824
824
|
|
|
825
825
|
const page = unaccounted.slice(offset, offset + limit);
|
|
826
|
-
|
|
826
|
+
// A run that compared NOTHING is not a clean run, and this is the surface where saying so
|
|
827
|
+
// matters most. The engine had already worked it out and set `ok: false`; this line only
|
|
828
|
+
// ever counted differences, so a project with nothing on record produced zero differences,
|
|
829
|
+
// counted as clean, and the agent was told everything still works.
|
|
830
|
+
const comparedNothing = typeof result?.comparedNothing === 'string' && result.comparedNothing.length > 0;
|
|
831
|
+
const clean = cleanForAgent(result, unaccounted.length, newlyUnstable.length);
|
|
827
832
|
|
|
828
833
|
// What this run did not look at, in the engine's own words. It rides in every reply,
|
|
829
834
|
// clean ones included: a green result on a product with three hundred unopened doors is
|
|
@@ -843,7 +848,16 @@ async function toolCheck(ctx, input) {
|
|
|
843
848
|
if (input.format === 'json') {
|
|
844
849
|
const payload = {
|
|
845
850
|
ok: clean,
|
|
846
|
-
|
|
851
|
+
// "differences found" would be the wrong word for a run that found none because it
|
|
852
|
+
// compared none. There are three outcomes here, not two, and the third is the one
|
|
853
|
+
// that must never be mistaken for either.
|
|
854
|
+
verdict: result?.blocked
|
|
855
|
+
? 'blocked'
|
|
856
|
+
: comparedNothing
|
|
857
|
+
? 'nothing was compared'
|
|
858
|
+
: clean
|
|
859
|
+
? 'nothing unaccounted for'
|
|
860
|
+
: 'differences found',
|
|
847
861
|
mode: result?.mode ?? null,
|
|
848
862
|
note: result?.summary ?? null,
|
|
849
863
|
noiseRemoved: result?.differencesNoise ?? null,
|
|
@@ -866,7 +880,14 @@ async function toolCheck(ctx, input) {
|
|
|
866
880
|
waiversLeft: accounting?.left ?? null,
|
|
867
881
|
note: accounting?.note ?? null,
|
|
868
882
|
},
|
|
869
|
-
|
|
883
|
+
// The class an agent reads has to be the class that DECIDES things, not the engine's
|
|
884
|
+
// first guess. A 20% markup on a price came back as `class: "ordinary"` here while the
|
|
885
|
+
// human text on the same run said "1 of them sealed and not yours to waive" and
|
|
886
|
+
// `staysfixed_waive` refused it outright because it touches money. An agent reading
|
|
887
|
+
// "ordinary" would reasonably believe it may wave a price change through, and would
|
|
888
|
+
// tell somebody so. `waivable` is spelled out beside it so nothing has to be inferred
|
|
889
|
+
// from a word at all.
|
|
890
|
+
findings: page.map(findingForAgent),
|
|
870
891
|
aimedAt: aimed ? { surface: surface ?? 'auto', at: at ?? null, confirmed: missedTheTarget === null } : null,
|
|
871
892
|
aimingWarning: missedTheTarget,
|
|
872
893
|
};
|
|
@@ -913,6 +934,53 @@ async function toolCheck(ctx, input) {
|
|
|
913
934
|
return { content: [{ type: 'text', text: body + tail }], isError: !clean };
|
|
914
935
|
}
|
|
915
936
|
|
|
937
|
+
/**
|
|
938
|
+
* Is this a clean run, as far as the machine asking is concerned?
|
|
939
|
+
*
|
|
940
|
+
* The last line is the one that matters and it is the one that was missing. This surface
|
|
941
|
+
* used to work "clean" out from the difference count ALONE, and a product with nothing on
|
|
942
|
+
* record produces no differences — so zero differences counted as a pass, and an agent was
|
|
943
|
+
* told "everything that worked before still works" about a run that compared nothing at all.
|
|
944
|
+
* The engine had already decided; nobody asked it.
|
|
945
|
+
*
|
|
946
|
+
* So the engine's own verdict is the floor. Whatever else is true, this can never answer
|
|
947
|
+
* clean about a run the engine called not-ok. Counting reasons here will always be a list
|
|
948
|
+
* somebody forgets to add to; deferring to the decision already made cannot be.
|
|
949
|
+
*
|
|
950
|
+
* @param {any} result What `check` returned.
|
|
951
|
+
* @param {number} unaccounted Differences nobody has accounted for.
|
|
952
|
+
* @param {number} newlyUnstable Addresses that were steady and are not any more.
|
|
953
|
+
* @returns {boolean}
|
|
954
|
+
*/
|
|
955
|
+
export function cleanForAgent(result, unaccounted, newlyUnstable) {
|
|
956
|
+
if (result && result.ok === false) return false;
|
|
957
|
+
if (result && result.blocked === true) return false;
|
|
958
|
+
if (result && typeof result.comparedNothing === 'string' && result.comparedNothing.length > 0) return false;
|
|
959
|
+
return unaccounted === 0 && newlyUnstable === 0;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* One finding, shaped for the machine that reads it.
|
|
964
|
+
*
|
|
965
|
+
* Split out and exported because it is a DECISION, not formatting, and a decision only a
|
|
966
|
+
* running MCP server can reach is one nobody notices breaking. The class an agent reads has
|
|
967
|
+
* to be the class that decides things: a 20% markup on a price came back over MCP as
|
|
968
|
+
* `class: "ordinary"` while the human text on the same run said "1 of them sealed and not
|
|
969
|
+
* yours to waive" and `staysfixed_waive` refused it because it touches money. An agent
|
|
970
|
+
* reading "ordinary" would reasonably believe it may wave a price change through, and would
|
|
971
|
+
* say so to a person. `waivable` is spelled out beside it so nothing has to be inferred
|
|
972
|
+
* from a word at all.
|
|
973
|
+
*
|
|
974
|
+
* @param {any} f
|
|
975
|
+
* @returns {any}
|
|
976
|
+
*/
|
|
977
|
+
export function findingForAgent(f) {
|
|
978
|
+
const sealed = classify(f);
|
|
979
|
+
return sealed
|
|
980
|
+
? { ...f, class: sealed.class, sealed: true, waivable: false, sealedBecause: sealed.why, sealedBy: sealed.matched }
|
|
981
|
+
: { ...f, sealed: false, waivable: true };
|
|
982
|
+
}
|
|
983
|
+
|
|
916
984
|
/**
|
|
917
985
|
* @param {object} a
|
|
918
986
|
* @param {CheckResult} a.result
|
|
@@ -931,7 +999,7 @@ async function toolCheck(ctx, input) {
|
|
|
931
999
|
* @param {string|null} a.covers
|
|
932
1000
|
* @returns {string}
|
|
933
1001
|
*/
|
|
934
|
-
function renderCheck({ result, unaccounted, page, offset, limit, waived, expired, waiversLeft, newlyUnstable, intent, clean, missedTheTarget, notChecked, covers }) {
|
|
1002
|
+
export function renderCheck({ result, unaccounted, page, offset, limit, waived, expired, waiversLeft, newlyUnstable, intent, clean, missedTheTarget, notChecked, covers }) {
|
|
935
1003
|
/** @type {string[]} */
|
|
936
1004
|
const out = [];
|
|
937
1005
|
|
|
@@ -942,7 +1010,14 @@ function renderCheck({ result, unaccounted, page, offset, limit, waived, expired
|
|
|
942
1010
|
return out.join('\n');
|
|
943
1011
|
}
|
|
944
1012
|
|
|
945
|
-
if (
|
|
1013
|
+
if (result?.comparedNothing) {
|
|
1014
|
+
out.push(
|
|
1015
|
+
result.comparedNothing === 'no reference'
|
|
1016
|
+
? 'NOTHING WAS ACTUALLY COMPARED. There is no build of this product on record as working, so this run had nothing whatever to hold today\'s behaviour against. This is not a pass and not a failure - it is no answer.'
|
|
1017
|
+
: 'NOTHING WAS ACTUALLY COMPARED. Every journey was walked, and not one of them had anything on record from the build you were happy with. This is not a pass and not a failure - it is no answer.',
|
|
1018
|
+
);
|
|
1019
|
+
out.push('Do not report this as a clean run. Only shipping records what "working" means, and no agent may cut that reference.');
|
|
1020
|
+
} else if (clean) {
|
|
946
1021
|
out.push('NOTHING UNACCOUNTED FOR. Everything that worked before still works, as far as this run could see.');
|
|
947
1022
|
} else if (unaccounted.length) {
|
|
948
1023
|
const sealed = unaccounted.filter((f) => classify(f) !== null).length;
|
package/src/v2/reference.js
CHANGED
|
@@ -233,6 +233,82 @@ async function readJson(file, fallback) {
|
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Read a JSON file, change it, and write it back with nobody else doing the same thing at
|
|
238
|
+
* the same time.
|
|
239
|
+
*
|
|
240
|
+
* `writeJsonAtomic` makes each individual write whole — nobody ever reads half a file. It
|
|
241
|
+
* does nothing at all about two processes READING the same file, each appending to what they
|
|
242
|
+
* read, and each writing their own version over the other's. Measured on 2026-08-30: six
|
|
243
|
+
* `staysfixed ship` commands started at once on one project, all six reported success, four
|
|
244
|
+
* of them each believed they were cutting the very first reference — and four records
|
|
245
|
+
* survived out of six. This is an MCP server. Two agents shipping at once is not an exotic
|
|
246
|
+
* case, it is the design.
|
|
247
|
+
*
|
|
248
|
+
* The lock is a directory, because creating one either succeeds or fails and never half
|
|
249
|
+
* happens, on every platform this runs on. A lock far older than any write could take is
|
|
250
|
+
* rubbish left behind by a killed process and is taken. Waiting for ever is worse than the
|
|
251
|
+
* bug, so after a long wait it is taken anyway — losing a record is bad, and a release that
|
|
252
|
+
* hangs is worse.
|
|
253
|
+
*
|
|
254
|
+
* @template T
|
|
255
|
+
* @param {string} file
|
|
256
|
+
* @param {(current: T) => T | Promise<T>} change
|
|
257
|
+
* @param {T} fallback
|
|
258
|
+
* @returns {Promise<T>}
|
|
259
|
+
*/
|
|
260
|
+
async function updateJsonAtomic(file, change, fallback) {
|
|
261
|
+
return await withLock(`${file}.lock`, async () => {
|
|
262
|
+
const next = await change(await readJson(file, fallback));
|
|
263
|
+
await writeJsonAtomic(file, next);
|
|
264
|
+
return next;
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Do something with nobody else doing it at the same time, across processes.
|
|
270
|
+
*
|
|
271
|
+
* A directory is the lock, because creating one either succeeds or fails and never half
|
|
272
|
+
* happens, on every platform this runs on. A lock far older than the work could take is
|
|
273
|
+
* rubbish left behind by a killed process and is taken. Waiting for ever is worse than the
|
|
274
|
+
* bug it prevents, so after a long wait it is taken anyway: losing a record is bad, and a
|
|
275
|
+
* release that never returns is worse.
|
|
276
|
+
*
|
|
277
|
+
* @template T
|
|
278
|
+
* @param {string} lock
|
|
279
|
+
* @param {() => Promise<T>} work
|
|
280
|
+
* @returns {Promise<T>}
|
|
281
|
+
*/
|
|
282
|
+
async function withLock(lock, work) {
|
|
283
|
+
const STALE_MS = 30_000;
|
|
284
|
+
const GIVE_UP_MS = 15_000;
|
|
285
|
+
await fsp.mkdir(path.dirname(lock), { recursive: true });
|
|
286
|
+
const startedAt = Date.now();
|
|
287
|
+
for (;;) {
|
|
288
|
+
try {
|
|
289
|
+
await fsp.mkdir(lock);
|
|
290
|
+
break;
|
|
291
|
+
} catch {
|
|
292
|
+
let age = 0;
|
|
293
|
+
try {
|
|
294
|
+
age = Date.now() - (await fsp.stat(lock)).mtimeMs;
|
|
295
|
+
} catch {
|
|
296
|
+
continue; // It went away between the failure and the question. Try again.
|
|
297
|
+
}
|
|
298
|
+
if (age > STALE_MS || Date.now() - startedAt > GIVE_UP_MS) {
|
|
299
|
+
await fsp.rm(lock, { recursive: true, force: true }).catch(() => {});
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
await new Promise((done) => setTimeout(done, 15 + Math.floor(Math.random() * 35)));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
return await work();
|
|
307
|
+
} finally {
|
|
308
|
+
await fsp.rm(lock, { recursive: true, force: true }).catch(() => {});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
236
312
|
/**
|
|
237
313
|
* A sortable, file-safe id for one cut.
|
|
238
314
|
* @param {Date} [now]
|
|
@@ -868,6 +944,32 @@ export async function cutReference(store, opts) {
|
|
|
868
944
|
|
|
869
945
|
await ensureStore(store);
|
|
870
946
|
|
|
947
|
+
// ONE AT A TIME, PER PRODUCT. Everything below reads the current reference, decides
|
|
948
|
+
// whether this build is already it, moves the pointer, retires waivers and writes the log
|
|
949
|
+
// — and until 2026-08-30 nothing stopped two of them doing all of that at once.
|
|
950
|
+
//
|
|
951
|
+
// Measured: six `staysfixed ship` commands started together on one project. All six
|
|
952
|
+
// reported success. FOUR of them each said "Nothing was being compared against before
|
|
953
|
+
// this", because all four had read an empty reference and none had seen the others. Four
|
|
954
|
+
// records survived out of six, and the "already the reference, change nothing" path — the
|
|
955
|
+
// one that stops a release script running twice from writing history twice — never fired
|
|
956
|
+
// once. This is an MCP server: two agents shipping at once is the design, not an exotic
|
|
957
|
+
// case, and the file they were racing on is the one that defines what "working" means.
|
|
958
|
+
return await withLock(path.join(store.dir, `cut.${safeName(product)}.lock`), async () =>
|
|
959
|
+
cutReferenceHoldingTheLock(store, opts, product, buildId),
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* The cut itself, with the lock already held.
|
|
965
|
+
*
|
|
966
|
+
* @param {Store} store
|
|
967
|
+
* @param {any} opts
|
|
968
|
+
* @param {string} product
|
|
969
|
+
* @param {string} buildId
|
|
970
|
+
* @returns {Promise<ReferenceCut>}
|
|
971
|
+
*/
|
|
972
|
+
async function cutReferenceHoldingTheLock(store, opts, product, buildId) {
|
|
871
973
|
const decision = await shouldCut(store, product, opts.build);
|
|
872
974
|
if (!decision.ok && opts.force !== true) {
|
|
873
975
|
throw new StaysFixedError(decision.refusal ?? decision.why, {
|
|
@@ -977,20 +1079,24 @@ function summarise(cut, name, decision) {
|
|
|
977
1079
|
*/
|
|
978
1080
|
async function appendToLog(store, cut) {
|
|
979
1081
|
const file = fileIn(store, 'reference-log.json');
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1082
|
+
const archiveFile = fileIn(store, 'reference-log-archive.json');
|
|
1083
|
+
await updateJsonAtomic(
|
|
1084
|
+
file,
|
|
1085
|
+
async (log) => {
|
|
1086
|
+
const all = [...(Array.isArray(log) ? log : []), cut];
|
|
1087
|
+
if (all.length > MAX_LOG_ENTRIES) {
|
|
1088
|
+
const overflow = all.slice(0, all.length - MAX_LOG_ENTRIES);
|
|
1089
|
+
await updateJsonAtomic(
|
|
1090
|
+
archiveFile,
|
|
1091
|
+
(archive) => [...(Array.isArray(archive) ? archive : []), ...overflow],
|
|
1092
|
+
/** @type {ReferenceCut[]} */ ([]),
|
|
1093
|
+
);
|
|
1094
|
+
return all.slice(-MAX_LOG_ENTRIES);
|
|
1095
|
+
}
|
|
1096
|
+
return all;
|
|
1097
|
+
},
|
|
1098
|
+
/** @type {ReferenceCut[]} */ ([]),
|
|
1099
|
+
);
|
|
994
1100
|
}
|
|
995
1101
|
|
|
996
1102
|
/**
|
package/src/v2/ship.js
CHANGED
|
@@ -417,6 +417,32 @@ async function resolveBuild(store, product, release, told, onProblem) {
|
|
|
417
417
|
return typeof told === 'string' ? { id: told, product } : told;
|
|
418
418
|
}
|
|
419
419
|
|
|
420
|
+
// WHAT IS ACTUALLY HERE, before what the commit says is here.
|
|
421
|
+
//
|
|
422
|
+
// Matching on the commit alone blesses the wrong thing the moment the tree is dirty:
|
|
423
|
+
// several builds share one commit, this took the first of them, and that is usually an
|
|
424
|
+
// EARLIER build — one that was checked and came back clean. So editing a file and running
|
|
425
|
+
// `staysfixed ship` answered "was already the reference — nothing changed" and exited 0,
|
|
426
|
+
// about a tree nothing had ever looked at. Measured 2026-08-30 by deleting a button and
|
|
427
|
+
// shipping without a check.
|
|
428
|
+
//
|
|
429
|
+
// A dirty tree therefore has to match EXACTLY, on the fingerprint of what is on disk right
|
|
430
|
+
// now. If nothing on record is that tree, then this tree has not been checked, and the
|
|
431
|
+
// honest answer is the one the tool already knows how to give: no record of this build, so
|
|
432
|
+
// the reference does not move.
|
|
433
|
+
try {
|
|
434
|
+
const { fingerprintWorkingTree } = await import('./check.js');
|
|
435
|
+
const here = await fingerprintWorkingTree(store.root, product);
|
|
436
|
+
if (here?.id) {
|
|
437
|
+
const exact = builds.find((b) => b.fingerprint.id === here.id);
|
|
438
|
+
if (exact) return exact.fingerprint;
|
|
439
|
+
if (here.dirty) return null;
|
|
440
|
+
}
|
|
441
|
+
} catch {
|
|
442
|
+
// No git, or the tree could not be read. The joins below are all that is left, and they
|
|
443
|
+
// are better than refusing to record a release at all.
|
|
444
|
+
}
|
|
445
|
+
|
|
420
446
|
const sha = release.gitSha;
|
|
421
447
|
if (sha) {
|
|
422
448
|
const sameCommit = builds.filter((b) => b.fingerprint.gitSha === sha);
|