staysfixed 0.3.1 → 0.6.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 +159 -3
- package/README.md +611 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +643 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +734 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +931 -0
- package/src/v2/adapters/source.js +1292 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +371 -0
- package/src/v2/check.js +1429 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +670 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1124 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1702 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +500 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +938 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +910 -0
- package/src/v2/run.js +1080 -0
- package/src/v2/sealed.js +568 -0
- package/src/v2/selfcheck.js +729 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +509 -0
- package/src/v2/waiver.js +511 -0
- package/src/v2/watch/focus.js +215 -0
package/src/v2/intent.js
ADDED
|
@@ -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
|
+
}
|