staysfixed 0.8.0 → 0.9.1
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 +131 -0
- package/README.md +2 -2
- package/docs/getting-started.md +1 -1
- package/docs/mcp.md +13 -0
- package/docs/running-it-in-ci.md +123 -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/name.js +41 -1
- 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 +135 -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 +29 -6
- package/src/v2/mcp/tools.js +88 -6
- package/src/v2/reference.js +120 -14
- package/src/v2/sealed.js +7 -1
- package/src/v2/ship.js +58 -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;
|
|
@@ -1586,7 +1661,14 @@ function problem(message) {
|
|
|
1586
1661
|
function text(v) {
|
|
1587
1662
|
if (typeof v !== 'string') return null;
|
|
1588
1663
|
const s = v.trim();
|
|
1589
|
-
|
|
1664
|
+
if (s === '') return null;
|
|
1665
|
+
// Capped, because every one of these is a string an AGENT chose and several of them are
|
|
1666
|
+
// echoed straight back in the reply and then written into the store for ever. A megabyte
|
|
1667
|
+
// of summary came back as a megabyte of tool result and stayed there. Nothing legitimate
|
|
1668
|
+
// here is long: a reason, a finding id, a surface name. Cutting says so out loud rather
|
|
1669
|
+
// than quietly keeping the first part.
|
|
1670
|
+
const MOST = 4000;
|
|
1671
|
+
return s.length <= MOST ? s : `${s.slice(0, MOST)} … (cut here: this was ${s.length} characters, and nothing this tool asks for is that long)`;
|
|
1590
1672
|
}
|
|
1591
1673
|
|
|
1592
1674
|
/**
|
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/sealed.js
CHANGED
|
@@ -358,7 +358,13 @@ function readFinding(finding) {
|
|
|
358
358
|
const differences = finding.differences ?? [];
|
|
359
359
|
for (const d of differences) {
|
|
360
360
|
add(d.path, d.path);
|
|
361
|
-
|
|
361
|
+
// NOT `d.describe`. That sentence is the TOOL's, not the product's — it explains what a
|
|
362
|
+
// channel watches and is the same on every finding that channel ever produces. The API
|
|
363
|
+
// shape channel's says "a renamed or dropped field shows up on its own", and `dropped`
|
|
364
|
+
// is a data-loss word, so every API shape change on every project was sealed as losing
|
|
365
|
+
// data, permanently, and no agent could wave any of it through. A seal has to be decided
|
|
366
|
+
// by what the PRODUCT said — its addresses, its values, its journey names — never by the
|
|
367
|
+
// tool's own vocabulary, which no user wrote and nobody can change.
|
|
362
368
|
add(faceOf(d.reference), d.path);
|
|
363
369
|
add(faceOf(d.candidate), d.path);
|
|
364
370
|
add(d.journey, `the ${d.journey} journey`);
|
package/src/v2/ship.js
CHANGED
|
@@ -211,10 +211,13 @@ export async function onShip(opts = {}) {
|
|
|
211
211
|
result.cut = cut.unchanged !== true;
|
|
212
212
|
result.unchanged = cut.unchanged === true;
|
|
213
213
|
|
|
214
|
+
const missed = await whatTheCheckMissed(store);
|
|
215
|
+
|
|
214
216
|
if (cut.unchanged) {
|
|
215
217
|
result.lines = [
|
|
216
218
|
`${product} ${release.describe}`,
|
|
217
219
|
`That build was already what ${product} calls working, so nothing moved and no waivers were retired. Recording a release twice is safe.`,
|
|
220
|
+
...(missed ? [missed] : []),
|
|
218
221
|
];
|
|
219
222
|
result.summary = `${product} ${release.what} was already the reference — nothing changed.`;
|
|
220
223
|
return result;
|
|
@@ -228,6 +231,8 @@ export async function onShip(opts = {}) {
|
|
|
228
231
|
// ran only once, so part of this reference has no steadiness record behind it.
|
|
229
232
|
...(cut.stability.measuredJourneys < cut.stability.journeys ? [cut.stability.note] : []),
|
|
230
233
|
'Nobody has to approve anything. The next check compares against this.',
|
|
234
|
+
// Said in the same breath as the good news, exactly as every other surface says it.
|
|
235
|
+
...(missed ? [missed] : []),
|
|
231
236
|
];
|
|
232
237
|
result.summary = cut.summary;
|
|
233
238
|
return result;
|
|
@@ -247,6 +252,33 @@ export async function onShip(opts = {}) {
|
|
|
247
252
|
|
|
248
253
|
// ---------------------------------------------------------------------------
|
|
249
254
|
// What just shipped?
|
|
255
|
+
/**
|
|
256
|
+
* What the last check did NOT look at, said here too.
|
|
257
|
+
*
|
|
258
|
+
* `ship` is the one command that decides what "working" MEANS from now on, and it printed no
|
|
259
|
+
* coverage caveat at all — not in the text, not in `--json`. Every other surface says it, in
|
|
260
|
+
* the same breath as the good news, because a green result on a product with doors nobody has
|
|
261
|
+
* ever opened is true and is not what it looks like. The command that turns that result into
|
|
262
|
+
* the standard is the last place that should stay quiet about it.
|
|
263
|
+
*
|
|
264
|
+
* @param {Store} store
|
|
265
|
+
* @returns {Promise<string|null>}
|
|
266
|
+
*/
|
|
267
|
+
async function whatTheCheckMissed(store) {
|
|
268
|
+
try {
|
|
269
|
+
const raw = JSON.parse(await fsp.readFile(path.join(store.dir, 'last-check.json'), 'utf8'));
|
|
270
|
+
const coverage = raw?.result?.coverage ?? null;
|
|
271
|
+
if (!coverage) return null;
|
|
272
|
+
const { whatWasNotChecked } = await import('./check.js');
|
|
273
|
+
const said = whatWasNotChecked(coverage);
|
|
274
|
+
return typeof said === 'string' && said.trim() ? said.trim() : null;
|
|
275
|
+
} catch {
|
|
276
|
+
// No record, or unreadable. Saying nothing is right here — inventing a caveat would be
|
|
277
|
+
// its own kind of lie.
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
250
282
|
// ---------------------------------------------------------------------------
|
|
251
283
|
|
|
252
284
|
/**
|
|
@@ -417,6 +449,32 @@ async function resolveBuild(store, product, release, told, onProblem) {
|
|
|
417
449
|
return typeof told === 'string' ? { id: told, product } : told;
|
|
418
450
|
}
|
|
419
451
|
|
|
452
|
+
// WHAT IS ACTUALLY HERE, before what the commit says is here.
|
|
453
|
+
//
|
|
454
|
+
// Matching on the commit alone blesses the wrong thing the moment the tree is dirty:
|
|
455
|
+
// several builds share one commit, this took the first of them, and that is usually an
|
|
456
|
+
// EARLIER build — one that was checked and came back clean. So editing a file and running
|
|
457
|
+
// `staysfixed ship` answered "was already the reference — nothing changed" and exited 0,
|
|
458
|
+
// about a tree nothing had ever looked at. Measured 2026-08-30 by deleting a button and
|
|
459
|
+
// shipping without a check.
|
|
460
|
+
//
|
|
461
|
+
// A dirty tree therefore has to match EXACTLY, on the fingerprint of what is on disk right
|
|
462
|
+
// now. If nothing on record is that tree, then this tree has not been checked, and the
|
|
463
|
+
// honest answer is the one the tool already knows how to give: no record of this build, so
|
|
464
|
+
// the reference does not move.
|
|
465
|
+
try {
|
|
466
|
+
const { fingerprintWorkingTree } = await import('./check.js');
|
|
467
|
+
const here = await fingerprintWorkingTree(store.root, product);
|
|
468
|
+
if (here?.id) {
|
|
469
|
+
const exact = builds.find((b) => b.fingerprint.id === here.id);
|
|
470
|
+
if (exact) return exact.fingerprint;
|
|
471
|
+
if (here.dirty) return null;
|
|
472
|
+
}
|
|
473
|
+
} catch {
|
|
474
|
+
// No git, or the tree could not be read. The joins below are all that is left, and they
|
|
475
|
+
// are better than refusing to record a release at all.
|
|
476
|
+
}
|
|
477
|
+
|
|
420
478
|
const sha = release.gitSha;
|
|
421
479
|
if (sha) {
|
|
422
480
|
const sameCommit = builds.filter((b) => b.fingerprint.gitSha === sha);
|