staysfixed 0.3.0 → 0.4.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.
Files changed (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
@@ -0,0 +1,659 @@
1
+ /**
2
+ * The agent says what it meant to change, BEFORE it sees what broke.
3
+ *
4
+ * This is the gate that turns "I meant to do that" from a story into a claim that can be
5
+ * checked. A reason written after seeing the damage is worthless — a model under pressure to
6
+ * finish writes a perfectly plausible reason for a real regression, and nobody reading it can
7
+ * tell. A reason written BEFORE, that then has to match what actually broke, is falsifiable:
8
+ * either the difference falls inside what was declared, or it does not, and that is arithmetic
9
+ * rather than persuasion.
10
+ *
11
+ * WHY THE TREE IS FINGERPRINTED. An intent is only worth anything if the ordering is real. A
12
+ * timestamp alone is a promise; the working tree at the moment of sealing is evidence. So every
13
+ * intent records what the repository looked like when it was sealed — the commit it sat on, the
14
+ * files that were already modified, and a digest of the whole diff. Three things become
15
+ * checkable that were previously taken on trust:
16
+ *
17
+ * - Whether the intent was written before the edits or after them. If the files it names were
18
+ * already modified when it was sealed, it was written after the work; that is allowed, and
19
+ * it is recorded, and it makes every later claim weaker.
20
+ * - Whether the code being waived is the code the intent was written about. If the tree has
21
+ * not moved since sealing, the intent describes exactly this build.
22
+ * - Whether an intent has gone stale. A reference that moved after the intent was sealed means
23
+ * the world it described has already shipped.
24
+ *
25
+ * WHAT THE FINGERPRINT CANNOT PROVE, said plainly because a safety gate that oversells itself is
26
+ * worse than none. It cannot prove the agent had not already run a check and seen the breakage
27
+ * before sealing — nothing in a working tree records that. What stops that route is the clock
28
+ * gate in waiver.js (an intent must predate the check it is used against, so a peek costs a full
29
+ * re-run), the coverage test (the intent has to NAME the thing, not just mention it), and the
30
+ * budget of five. The fingerprint narrows the hole; it does not close it, and it should never be
31
+ * described as if it did.
32
+ *
33
+ * WHERE THIS WRITES. `<store.dir>/intents/<product>.json`, next to the engine's observations but
34
+ * not inside them. The engine decides what is different; this decides what an agent is allowed
35
+ * to say about it, and a bug in one must not be able to widen the other.
36
+ */
37
+
38
+ import fsp from 'node:fs/promises';
39
+ import path from 'node:path';
40
+ import crypto from 'node:crypto';
41
+ import { execFile } from 'node:child_process';
42
+ import { promisify } from 'node:util';
43
+
44
+ import { safeName } from '../core/paths.js';
45
+ import { StaysFixedError } from '../core/errors.js';
46
+ import { referencePointer } from './store.js';
47
+
48
+ const run = promisify(execFile);
49
+
50
+ /**
51
+ * @typedef {import('./types.js').Store} Store
52
+ * @typedef {import('./types.js').Finding} Finding
53
+ */
54
+
55
+ /** How many intents are kept per product. Enough to explain an old waiver, not a diary. */
56
+ const KEEP_INTENTS = 25;
57
+
58
+ /** Words too general to prove a match on their own. */
59
+ const GENERIC_WORDS = new Set([
60
+ 'src', 'lib', 'app', 'apps', 'index', 'main', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'json',
61
+ 'test', 'tests', 'spec', 'page', 'pages', 'view', 'views', 'screen', 'screens', 'component',
62
+ 'components', 'util', 'utils', 'helper', 'helpers', 'common', 'shared', 'core', 'api', 'new',
63
+ 'old', 'file', 'files', 'code', 'the', 'and', 'for', 'with', 'from', 'into', 'all', 'dist',
64
+ 'build', 'node', 'modules', 'public', 'static', 'assets', 'style', 'styles', 'css', 'data',
65
+ ]);
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // The shapes
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * What the repository looked like at one moment.
73
+ *
74
+ * @typedef {object} TreeFingerprint
75
+ * @property {'git'|'none'} how `none` when this is not a git repository, in which case
76
+ * nothing here proves an ordering and it says so.
77
+ * @property {string|null} head The commit it sat on.
78
+ * @property {string|null} branch
79
+ * @property {boolean} dirty
80
+ * @property {string[]} changedFiles Repo-relative, sorted. Modified, added, deleted, untracked.
81
+ * @property {string} digest One short string standing for the whole state, so two
82
+ * moments can be compared without keeping the diff.
83
+ * @property {string} at ISO. When it was taken.
84
+ * @property {string} note One plain sentence about what this does and does not prove.
85
+ */
86
+
87
+ /**
88
+ * A sealed statement of what an agent set out to do.
89
+ *
90
+ * @typedef {object} Intent
91
+ * @property {string} id
92
+ * @property {string} product
93
+ * @property {string} summary One plain sentence, in the agent's own words.
94
+ * @property {string[]} files Files, folders or named areas it expects to affect. A
95
+ * difference outside these cannot be waived.
96
+ * @property {string[]} expect Differences it expects to see, in its own words.
97
+ * @property {string} sealedAt ISO. Written by this file, never supplied by the caller.
98
+ * @property {string} reference The reference in force when it was sealed, as a stamp.
99
+ * @property {TreeFingerprint} tree
100
+ * @property {'before-the-edits'|'after-the-edits'|'unknown'} sealedRelativeToEdits
101
+ * @property {string} ordering One plain sentence about what the ordering proves.
102
+ * @property {string} [by] Who sealed it, when anybody knows.
103
+ */
104
+
105
+ /**
106
+ * Whether a finding falls inside what an intent declared, and how sure we are.
107
+ *
108
+ * This is a JUDGEMENT, not a proof, and the shape says so. `confidence` is on the outside of the
109
+ * answer rather than folded into a boolean precisely so that a weak match cannot masquerade as a
110
+ * strong one on its way through a caller.
111
+ *
112
+ * @typedef {object} IntentCoverage
113
+ * @property {boolean} covers True only at `strong` or `fair`.
114
+ * @property {'strong'|'fair'|'weak'|'none'} confidence
115
+ * @property {string} why One plain sentence a person can check.
116
+ * @property {string[]} matched What actually lined up: the named file, the named area.
117
+ */
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Sealing
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Record what an agent set out to do, and what the repository looked like while it said so.
125
+ *
126
+ * Call this BEFORE running a check — ideally before the edits, which is when it is worth most.
127
+ * Sealing a second intent does not hand out a fresh waiver budget; the budget is counted against
128
+ * the reference, in waiver.js, for exactly that reason.
129
+ *
130
+ * @param {Store} store
131
+ * @param {{product: string, summary: string, files?: string[], touches?: string[], expect?: string[], by?: string}} what
132
+ * `touches` is accepted as another name for `files`, because that is what the MCP tool
133
+ * already calls it and two names for one idea is cheaper than a breaking rename.
134
+ * @returns {Promise<Intent>}
135
+ */
136
+ export async function sealIntent(store, what) {
137
+ const product = clean(what?.product);
138
+ const summary = clean(what?.summary);
139
+ const files = list(what?.files ?? what?.touches);
140
+ const expect = list(what?.expect);
141
+
142
+ if (!product) throw new StaysFixedError('An intent has to say which product it is about.');
143
+ if (!summary) {
144
+ throw new StaysFixedError('An intent has to say, in one plain sentence, what you meant to change.', {
145
+ hint: 'Write it the way you would tell a person: "make the basket show the delivery date".',
146
+ });
147
+ }
148
+ if (files.length === 0) {
149
+ throw new StaysFixedError('An intent has to name at least one file, folder or area you expect this change to affect.', {
150
+ hint: 'That is the whole point of sealing one: a difference outside what you named cannot later be waived, so an empty list would leave you able to waive nothing at all.',
151
+ });
152
+ }
153
+
154
+ const tree = await fingerprintTree(store.root);
155
+ const relative = orderingOf(tree, files);
156
+
157
+ /** @type {Intent} */
158
+ const intent = {
159
+ id: `intent-${crypto.randomBytes(5).toString('hex')}`,
160
+ product,
161
+ summary,
162
+ files,
163
+ expect,
164
+ sealedAt: new Date().toISOString(),
165
+ reference: await referenceStamp(store, product),
166
+ tree,
167
+ sealedRelativeToEdits: relative,
168
+ ordering: sayOrdering(relative, tree),
169
+ };
170
+ const by = clean(what?.by);
171
+ if (by) intent.by = by;
172
+
173
+ const file = intentsFile(store, product);
174
+ const kept = (await readIntents(store, product)).filter((i) => i.id !== intent.id);
175
+ kept.push(intent);
176
+ await writeJsonAtomic(file, kept.slice(-KEEP_INTENTS));
177
+ return intent;
178
+ }
179
+
180
+ /**
181
+ * The intent in force for a product: the most recent one sealed.
182
+ *
183
+ * @param {Store} store
184
+ * @param {string} product
185
+ * @returns {Promise<Intent|null>}
186
+ */
187
+ export async function readIntent(store, product) {
188
+ const all = await readIntents(store, product);
189
+ return all.length > 0 ? all[all.length - 1] : null;
190
+ }
191
+
192
+ /**
193
+ * One particular intent, so a waiver written months ago can still explain itself.
194
+ *
195
+ * @param {Store} store
196
+ * @param {string} product
197
+ * @param {string} id
198
+ * @returns {Promise<Intent|null>}
199
+ */
200
+ export async function readIntentById(store, product, id) {
201
+ const all = await readIntents(store, product);
202
+ return all.find((i) => i.id === id) ?? null;
203
+ }
204
+
205
+ /**
206
+ * Every intent kept for a product, oldest first.
207
+ *
208
+ * @param {Store} store
209
+ * @param {string} product
210
+ * @returns {Promise<Intent[]>}
211
+ */
212
+ export async function readIntents(store, product) {
213
+ const raw = await readJsonFile(intentsFile(store, product), []);
214
+ if (!Array.isArray(raw)) return [];
215
+ return raw.filter((i) => i && typeof i === 'object' && typeof i.id === 'string' && typeof i.summary === 'string');
216
+ }
217
+
218
+ /**
219
+ * Forget a product's intents. Housekeeping, and the way a test starts clean.
220
+ *
221
+ * @param {Store} store
222
+ * @param {string} product
223
+ * @returns {Promise<void>}
224
+ */
225
+ export async function forgetIntents(store, product) {
226
+ await fsp.rm(intentsFile(store, product), { force: true });
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Does this finding fall inside what was declared?
231
+ // ---------------------------------------------------------------------------
232
+
233
+ /**
234
+ * Does this finding plausibly fall inside what the intent declared?
235
+ *
236
+ * Honest about what it is: a judgement about words, made by comparing what an agent said it was
237
+ * touching with what the engine says a difference points at. It can be fooled by an agent that
238
+ * names a whole folder, and it says so — a wide intent gets `fair` at best, never `strong`.
239
+ *
240
+ * The four grades, and where each line sits:
241
+ *
242
+ * strong A file the intent NAMED is one the engine says this finding comes from, or the agent
243
+ * predicted this exact difference in `expect`. There is nothing left to argue about.
244
+ * fair The named area lines up with the addresses, or the finding sits inside code that was
245
+ * edited and the engine could not tell us which files it came from. Good enough to
246
+ * waive, because the alternative is refusing the one case the gate exists to allow.
247
+ * weak One general word lined up, or the finding sits in code that was edited but in a file
248
+ * the intent never mentioned. That second case is the one worth being strict about: an
249
+ * edit the agent made but never declared is exactly a side effect, and waving it
250
+ * through on the grounds that it is near the edit would empty the gate out.
251
+ * none Nothing lined up.
252
+ *
253
+ * @param {Intent|null} intent
254
+ * @param {Finding} finding
255
+ * @returns {IntentCoverage}
256
+ */
257
+ export function intentCovers(intent, finding) {
258
+ if (!intent) {
259
+ return { covers: false, confidence: 'none', why: 'Nothing was sealed, so there is nothing to check this against.', matched: [] };
260
+ }
261
+
262
+ const near = (finding.nearFiles ?? []).filter((f) => typeof f === 'string').map(lower);
263
+ const addresses = [...(finding.paths ?? []), ...(finding.differences ?? []).map((d) => d.path)].map(lower);
264
+ const prose = [finding.title ?? '', finding.summary ?? '', finding.sample?.path ?? '', ...(finding.differences ?? []).map((d) => d.describe ?? '')]
265
+ .filter((s) => s !== '')
266
+ .map(lower);
267
+ const everything = [...near, ...addresses, ...prose];
268
+
269
+ // The strongest signal there is: the agent wrote down the difference it expected, and here it
270
+ // is. Checked first because it needs no file paths at all, so it works on a product where the
271
+ // engine cannot say which source a difference came from.
272
+ for (const line of intent.expect) {
273
+ const words = meaningfulWords(line);
274
+ if (words.length >= 2 && words.every((w) => everything.some((h) => h.includes(w)))) {
275
+ return {
276
+ covers: true,
277
+ confidence: 'strong',
278
+ why: `You said before the run that you expected this: "${trim(line, 120)}".`,
279
+ matched: [line],
280
+ };
281
+ }
282
+ }
283
+
284
+ /** @type {string[]} */
285
+ const weakMatches = [];
286
+
287
+ for (const raw of intent.files) {
288
+ const needle = normalisePath(raw);
289
+ if (!needle) continue;
290
+
291
+ // A named file against the files the engine says this finding comes from. Either direction
292
+ // counts: the intent may name a folder that contains the file, or the exact file itself.
293
+ for (const file of near) {
294
+ if (file === needle || file.startsWith(`${needle}/`) || needle.startsWith(`${file}/`) || file.endsWith(`/${needle}`)) {
295
+ return {
296
+ covers: true,
297
+ confidence: 'strong',
298
+ why: `You said you were changing ${raw}, and this difference comes from ${file}.`,
299
+ matched: [raw, file],
300
+ };
301
+ }
302
+ }
303
+
304
+ // The whole name turning up in an address or a title. Weaker, because an address is not a
305
+ // file, but a real match all the same: 'the basket page' inside 'web.basket.page.total'.
306
+ if (everything.some((h) => h.includes(needle))) {
307
+ return {
308
+ covers: true,
309
+ confidence: 'fair',
310
+ why: `You said you were changing ${raw}, and that is named in what changed.`,
311
+ matched: [raw],
312
+ };
313
+ }
314
+
315
+ // A file path broken into the parts that carry meaning, so 'src/checkout/total.js' still
316
+ // covers an address the tool reports as 'cli.checkout.total'.
317
+ const words = meaningfulWords(needle);
318
+ const hits = words.filter((w) => everything.some((h) => h.includes(w)));
319
+ if (words.length >= 2 && hits.length === words.length) {
320
+ return {
321
+ covers: true,
322
+ confidence: 'fair',
323
+ why: `You said you were changing ${raw}, and every part of that name appears in what changed.`,
324
+ matched: [raw, ...hits],
325
+ };
326
+ }
327
+ if (hits.length > 0) weakMatches.push(...hits);
328
+ }
329
+
330
+ // Inside the edit itself. The engine puts a finding at distance zero when it sits in a file
331
+ // that was just changed. If it also told us WHICH files, and none of them is one the intent
332
+ // named, this is an edit the agent made and never declared — which is the definition of the
333
+ // thing the gate is here to stop, so it stays weak and gets refused.
334
+ if (finding.distance === 0) {
335
+ if (near.length === 0) {
336
+ return {
337
+ covers: true,
338
+ confidence: 'fair',
339
+ why: 'This sits inside code you just edited, and nothing here can say which file it came from, so it is taken as part of the change you declared.',
340
+ matched: [],
341
+ };
342
+ }
343
+ return {
344
+ covers: false,
345
+ confidence: 'weak',
346
+ why: `This sits in code you edited — ${near.slice(0, 3).join(', ')} — but you did not name any of that when you sealed your intent.`,
347
+ matched: weakMatches,
348
+ };
349
+ }
350
+
351
+ if (weakMatches.length > 0) {
352
+ return {
353
+ covers: false,
354
+ confidence: 'weak',
355
+ why: `Only the word "${weakMatches[0]}" lines up with what you said you were changing, and one word in common is not a match.`,
356
+ matched: [...new Set(weakMatches)],
357
+ };
358
+ }
359
+
360
+ return {
361
+ covers: false,
362
+ confidence: 'none',
363
+ why: `Nothing about this difference matches what you said you were changing: ${intent.files.join(', ')}.`,
364
+ matched: [],
365
+ };
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // The working tree
370
+ // ---------------------------------------------------------------------------
371
+
372
+ /**
373
+ * What the repository looks like right now.
374
+ *
375
+ * Everything here is read-only and everything is allowed to fail: Stays Fixed runs in folders
376
+ * that are not repositories, and one that is not simply cannot have its ordering checked. That
377
+ * is a real state, it is reported in those words, and it is never dressed up as a fingerprint
378
+ * that proves something.
379
+ *
380
+ * @param {string} root
381
+ * @returns {Promise<TreeFingerprint>}
382
+ */
383
+ export async function fingerprintTree(root) {
384
+ const at = new Date().toISOString();
385
+ const head = await git(['rev-parse', 'HEAD'], root);
386
+ if (head === null) {
387
+ return {
388
+ how: 'none',
389
+ head: null,
390
+ branch: null,
391
+ dirty: false,
392
+ changedFiles: [],
393
+ digest: 'no-repository',
394
+ at,
395
+ note: 'This folder is not a git repository, so nothing here can show what was edited when. The order an intent was sealed in rests on the clock alone.',
396
+ };
397
+ }
398
+
399
+ const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD'], root);
400
+ const status = (await git(['status', '--porcelain'], root)) ?? '';
401
+ const changedFiles = filesFromStatus(status);
402
+ // The diff itself is hashed rather than kept. Two moments only ever need to be compared, and
403
+ // keeping the text would put a copy of the working tree in a file people commit by accident.
404
+ const diff = (await git(['diff', 'HEAD'], root)) ?? '';
405
+ const digest = shortDigest([head, status, diff]);
406
+
407
+ return {
408
+ how: 'git',
409
+ head,
410
+ branch: branch === 'HEAD' ? null : branch,
411
+ dirty: changedFiles.length > 0,
412
+ changedFiles,
413
+ digest,
414
+ at,
415
+ note:
416
+ changedFiles.length === 0
417
+ ? `Nothing was edited at this point: a clean tree on ${head.slice(0, 7)}.`
418
+ : `${changedFiles.length} file${changedFiles.length === 1 ? '' : 's'} already edited on top of ${head.slice(0, 7)}.`,
419
+ };
420
+ }
421
+
422
+ /**
423
+ * Has the working tree moved since this intent was sealed?
424
+ *
425
+ * @param {Intent} intent
426
+ * @param {TreeFingerprint} now
427
+ * @returns {{moved: boolean, knowable: boolean, say: string}}
428
+ */
429
+ export function treeMovedSince(intent, now) {
430
+ if (intent.tree.how !== 'git' || now.how !== 'git') {
431
+ return {
432
+ moved: false,
433
+ knowable: false,
434
+ say: 'There is no repository here, so whether the code moved after you sealed your intent cannot be checked.',
435
+ };
436
+ }
437
+ if (intent.tree.digest === now.digest) {
438
+ return {
439
+ moved: false,
440
+ knowable: true,
441
+ say: 'The code has not changed since you sealed this intent, so it describes exactly the build that was checked.',
442
+ };
443
+ }
444
+ return {
445
+ moved: true,
446
+ knowable: true,
447
+ say: 'The code changed after you sealed this intent, which is what an intent sealed before the work looks like.',
448
+ };
449
+ }
450
+
451
+ /**
452
+ * Was this intent written before the work or after it?
453
+ *
454
+ * Read from the files it names: if one of them was already modified when the intent was sealed,
455
+ * the intent was written on top of work that was already done. That is allowed — the design says
456
+ * an agent may seal one right before or right after it edits — but it is a weaker claim and it
457
+ * is recorded as one rather than quietly treated the same.
458
+ *
459
+ * @param {TreeFingerprint} tree
460
+ * @param {string[]} files
461
+ * @returns {'before-the-edits'|'after-the-edits'|'unknown'}
462
+ */
463
+ function orderingOf(tree, files) {
464
+ if (tree.how !== 'git') return 'unknown';
465
+ const named = files.map(normalisePath).filter((f) => f.includes('/') || /\.[a-z0-9]+$/i.test(f));
466
+ if (named.length === 0) return 'unknown'; // Only areas were named, so no file to look for.
467
+ const changed = tree.changedFiles.map(lower);
468
+ const alreadyEdited = named.some((n) => changed.some((c) => c === n || c.startsWith(`${n}/`) || c.endsWith(`/${n}`)));
469
+ return alreadyEdited ? 'after-the-edits' : 'before-the-edits';
470
+ }
471
+
472
+ /**
473
+ * @param {'before-the-edits'|'after-the-edits'|'unknown'} relative
474
+ * @param {TreeFingerprint} tree
475
+ * @returns {string}
476
+ */
477
+ function sayOrdering(relative, tree) {
478
+ if (relative === 'before-the-edits') {
479
+ return 'Sealed before the files it names were touched, so it says what you were about to do rather than what you had already done.';
480
+ }
481
+ if (relative === 'after-the-edits') {
482
+ return 'Sealed after the work was already in the working tree. That is allowed, but it is a weaker claim: you could already see the change while you wrote it.';
483
+ }
484
+ return tree.how === 'git'
485
+ ? 'This intent names areas rather than files, so whether it was written before or after the work cannot be checked.'
486
+ : 'There is no repository here, so whether this was written before or after the work cannot be checked.';
487
+ }
488
+
489
+ /**
490
+ * @param {string} status Output of `git status --porcelain`.
491
+ * @returns {string[]}
492
+ */
493
+ function filesFromStatus(status) {
494
+ /** @type {Set<string>} */
495
+ const out = new Set();
496
+ for (const line of status.split('\n')) {
497
+ if (line.trim() === '') continue;
498
+ const body = line.slice(3).trim();
499
+ // A rename is written 'old -> new'. Both halves count as touched.
500
+ for (const part of body.split(' -> ')) {
501
+ const file = part.replace(/^"|"$/g, '').trim();
502
+ if (file) out.add(file);
503
+ }
504
+ }
505
+ return [...out].sort();
506
+ }
507
+
508
+ /**
509
+ * @param {string[]} args
510
+ * @param {string} cwd
511
+ * @returns {Promise<string|null>}
512
+ */
513
+ async function git(args, cwd) {
514
+ try {
515
+ const { stdout } = await run('git', args, { cwd, timeout: 10_000, maxBuffer: 32 * 1024 * 1024 });
516
+ return stdout.trimEnd();
517
+ } catch {
518
+ return null;
519
+ }
520
+ }
521
+
522
+ // ---------------------------------------------------------------------------
523
+ // The bookkeeping this lane shares
524
+ // ---------------------------------------------------------------------------
525
+
526
+ /**
527
+ * Where intents and waivers live: beside the engine's observations, not inside them.
528
+ *
529
+ * @param {Store} store
530
+ * @returns {string}
531
+ */
532
+ export function stateDir(store) {
533
+ return store.dir;
534
+ }
535
+
536
+ /**
537
+ * @param {Store} store
538
+ * @param {string} product
539
+ * @returns {string}
540
+ */
541
+ export function intentsFile(store, product) {
542
+ return path.join(store.dir, 'intents', `${safeName(product)}.json`);
543
+ }
544
+
545
+ /**
546
+ * Which reference is in force for this product, as one short string.
547
+ *
548
+ * A waiver dies when this moves, so it has to change whenever the answer to "what counts as
549
+ * working" changes — and only then. It is taken from the product's own pointer, so shipping one
550
+ * product does not retire waivers written about another; the buildId AND the time it was set are
551
+ * both in it, so re-pointing at the same build after a rethink still counts as a move.
552
+ *
553
+ * @param {Store} store
554
+ * @param {string} product
555
+ * @returns {Promise<string>}
556
+ */
557
+ export async function referenceStamp(store, product) {
558
+ const pointer = await referencePointer(store, product).catch(() => null);
559
+ // Nothing has ever shipped with the hook in place. That is the cold start, it is expected on
560
+ // any existing product, and it is a real state rather than an error — but a waiver written now
561
+ // must not survive the day the first reference is cut, so it gets a stamp of its own.
562
+ if (!pointer) return 'no-reference-yet';
563
+ return `ref-${shortDigest([pointer.buildId, pointer.setAt])}`;
564
+ }
565
+
566
+ /**
567
+ * @param {string} file
568
+ * @param {any} fallback
569
+ * @returns {Promise<any>}
570
+ */
571
+ export async function readJsonFile(file, fallback) {
572
+ try {
573
+ return JSON.parse(await fsp.readFile(file, 'utf8'));
574
+ } catch {
575
+ // Not there, or hand-edited into nonsense. Either way start clean rather than refuse to run:
576
+ // losing a waiver is safe — it means a person looks at something — and refusing to check is
577
+ // not.
578
+ return fallback;
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Write so nobody can ever read it half-finished.
584
+ *
585
+ * @param {string} file
586
+ * @param {unknown} value
587
+ * @returns {Promise<void>}
588
+ */
589
+ export async function writeJsonAtomic(file, value) {
590
+ await fsp.mkdir(path.dirname(file), { recursive: true });
591
+ const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.part`;
592
+ await fsp.writeFile(temp, `${JSON.stringify(value, null, 2)}\n`);
593
+ await fsp.rename(temp, file);
594
+ }
595
+
596
+ /**
597
+ * @param {unknown[]} parts
598
+ * @returns {string}
599
+ */
600
+ export function shortDigest(parts) {
601
+ return crypto.createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16);
602
+ }
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // Small helpers
606
+ // ---------------------------------------------------------------------------
607
+
608
+ /**
609
+ * @param {unknown} value
610
+ * @returns {string}
611
+ */
612
+ function clean(value) {
613
+ return typeof value === 'string' ? value.trim() : '';
614
+ }
615
+
616
+ /**
617
+ * @param {unknown} value
618
+ * @returns {string[]}
619
+ */
620
+ function list(value) {
621
+ if (!Array.isArray(value)) return [];
622
+ return value.map((v) => clean(v)).filter((v) => v !== '');
623
+ }
624
+
625
+ /**
626
+ * @param {string} value
627
+ * @returns {string}
628
+ */
629
+ function lower(value) {
630
+ return String(value).toLowerCase();
631
+ }
632
+
633
+ /**
634
+ * @param {string} value
635
+ * @returns {string}
636
+ */
637
+ function normalisePath(value) {
638
+ return lower(value).trim().replace(/^\.\//, '').replace(/\/+$/, '');
639
+ }
640
+
641
+ /**
642
+ * The parts of a name that carry meaning — long enough, and not a word every project uses.
643
+ *
644
+ * @param {string} value
645
+ * @returns {string[]}
646
+ */
647
+ function meaningfulWords(value) {
648
+ return [...new Set(lower(value).split(/[^a-z0-9]+/))].filter((w) => w.length > 2 && !GENERIC_WORDS.has(w));
649
+ }
650
+
651
+ /**
652
+ * @param {string} text
653
+ * @param {number} max
654
+ * @returns {string}
655
+ */
656
+ function trim(text, max) {
657
+ const one = String(text).replace(/\s+/g, ' ').trim();
658
+ return one.length > max ? `${one.slice(0, max - 1)}…` : one;
659
+ }