@am_shork/attest 0.4.3 → 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 +541 -32
- package/README.md +16 -8
- package/dist/cli/index.js +21 -3
- package/dist/core/apply.d.ts +15 -0
- package/dist/core/apply.js +43 -1
- package/dist/core/compiler.d.ts +40 -0
- package/dist/core/compiler.js +64 -0
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +7 -0
- package/dist/core/gate.d.ts +40 -3
- package/dist/core/gate.js +56 -6
- package/dist/core/locate.d.ts +62 -4
- package/dist/core/locate.js +91 -16
- package/dist/core/merge.d.ts +54 -0
- package/dist/core/merge.js +244 -0
- package/dist/core/pipeline.d.ts +13 -9
- package/dist/core/pipeline.js +289 -46
- package/dist/core/registry.d.ts +63 -4
- package/dist/core/registry.js +30 -4
- package/dist/core/runner.js +41 -4
- package/dist/core/schema.js +9 -1
- package/dist/core/skill.js +104 -13
- package/dist/core/splice.d.ts +52 -0
- package/dist/core/splice.js +189 -0
- package/dist/core/static-registry.d.ts +30 -0
- package/dist/core/static-registry.js +42 -2
- package/dist/core/types.d.ts +10 -0
- package/package.json +1 -1
package/dist/core/runner.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// never crosses the process boundary; the serialized task tree does.
|
|
5
5
|
import { startVitest } from 'vitest/node';
|
|
6
6
|
import { configDefaults } from 'vitest/config';
|
|
7
|
+
import { relativePath } from './paths.js';
|
|
8
|
+
import { byCodeUnit } from './order.js';
|
|
7
9
|
/** requirement() names each describe block `[reqId]`; recover the id from that. */
|
|
8
10
|
const REQ_SUITE = /^\[(.+)\]$/;
|
|
9
11
|
/**
|
|
@@ -17,8 +19,21 @@ const REQ_SUITE = /^\[(.+)\]$/;
|
|
|
17
19
|
* command goes through — the boundary the gate exists to hold.
|
|
18
20
|
*/
|
|
19
21
|
export const BASE_EXCLUDE = configDefaults.exclude;
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Proposed / archived changes are excluded from a normal run (design §7, §8).
|
|
24
|
+
*
|
|
25
|
+
* The `.proposed.spec.ts` glob is the one carrying the weight now: a proposal's
|
|
26
|
+
* specs sit at their merged location, next to the code they attest, so nothing
|
|
27
|
+
* about *where* they are keeps them out of a run. They are red by construction
|
|
28
|
+
* until their change is implemented, and a default run that swept them up would
|
|
29
|
+
* report a project as broken for the whole life of every change in flight.
|
|
30
|
+
*/
|
|
31
|
+
const DEFAULT_EXCLUDE = [
|
|
32
|
+
...BASE_EXCLUDE,
|
|
33
|
+
'**/*.proposed.spec.ts',
|
|
34
|
+
'**/changes/**',
|
|
35
|
+
'**/archive/**',
|
|
36
|
+
];
|
|
22
37
|
/**
|
|
23
38
|
* Build the child-run options. Attest owns the run *scope* — include/exclude/
|
|
24
39
|
* root/watch always come from here, so the caller can neither widen the spec
|
|
@@ -88,20 +103,42 @@ export async function runAndCollect(options = {}) {
|
|
|
88
103
|
// that notices if the task tree ever stops having a `type`, a `name` or a
|
|
89
104
|
// `result.state`. An `as unknown as` here bought nothing and blinded it to
|
|
90
105
|
// exactly the change that would silently empty the runtime coverage.
|
|
91
|
-
|
|
106
|
+
const unloadedFiles = [];
|
|
107
|
+
for (const file of vitest.state.getFiles()) {
|
|
92
108
|
walk(file);
|
|
109
|
+
// Relative and POSIX for the reason everything derived from the root is
|
|
110
|
+
// (`paths.ts`): this becomes an `Issue.file`, which a `--json` consumer
|
|
111
|
+
// diffs across two CI runs that may not share an operating system.
|
|
112
|
+
if (failedToLoad(file)) {
|
|
113
|
+
unloadedFiles.push(relativePath(options.root ?? process.cwd(), file.filepath));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
unloadedFiles.sort(byCodeUnit);
|
|
93
117
|
// No optional call and no `?? 0` default: `getCountOfFailedTests` is a
|
|
94
118
|
// required method on Vitest's state, so the guard was dead at the type
|
|
95
119
|
// level — and had it ever become live it defaulted the wrong way, reading a
|
|
96
120
|
// missing API as zero failures and turning a red suite green. This is the
|
|
97
121
|
// one boolean in the engine that must never fail open.
|
|
98
122
|
const passed = vitest.state.getCountOfFailedTests() === 0;
|
|
99
|
-
return { passed, runtimeCoverage, outcomes };
|
|
123
|
+
return { passed, runtimeCoverage, outcomes, unloadedFiles };
|
|
100
124
|
}
|
|
101
125
|
finally {
|
|
102
126
|
await vitest.close();
|
|
103
127
|
}
|
|
104
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Did this file fail before any of its tests could exist?
|
|
131
|
+
*
|
|
132
|
+
* Errors recorded on the *file* rather than on a test are collection errors —
|
|
133
|
+
* the module threw while being imported. Measured rather than assumed: a spec
|
|
134
|
+
* whose assertion fails carries `errors: 0` at the file level and one failing
|
|
135
|
+
* test task, while a spec whose import throws carries `errors: 1` and no tasks
|
|
136
|
+
* at all. Both leave `result.state` at `fail`, which is why the state alone
|
|
137
|
+
* cannot tell them apart.
|
|
138
|
+
*/
|
|
139
|
+
function failedToLoad(file) {
|
|
140
|
+
return (file.result?.errors?.length ?? 0) > 0;
|
|
141
|
+
}
|
|
105
142
|
/** The outcome of a task that ran, or `undefined` when it did not run at all. */
|
|
106
143
|
function executedOutcome(task) {
|
|
107
144
|
const state = task.result?.state;
|
package/dist/core/schema.js
CHANGED
|
@@ -14,10 +14,18 @@ export const RequirementSchema = z.object({
|
|
|
14
14
|
// blacklists, id sets) are the most drift-prone constants, so keeping them out
|
|
15
15
|
// of params left the highest-risk values unguarded; an array still has exactly
|
|
16
16
|
// one owner (the spec) read by exactly one place (the scenario).
|
|
17
|
+
// The union carries its own message because a union's default one is the
|
|
18
|
+
// word "Invalid input", which names neither what was given nor what is
|
|
19
|
+
// accepted. A nested object is the value that reaches it — a table of
|
|
20
|
+
// kind -> weight is the natural thing to try — and the constraint that
|
|
21
|
+
// refuses it is stated nowhere the author is looking, so the message is
|
|
22
|
+
// where they find it.
|
|
17
23
|
params: z
|
|
18
24
|
.record(z.string(), (() => {
|
|
19
25
|
const scalar = z.union([z.number(), z.string(), z.boolean()]);
|
|
20
|
-
return z.union([scalar, z.array(scalar)]
|
|
26
|
+
return z.union([scalar, z.array(scalar)], {
|
|
27
|
+
errorMap: () => ({ message: 'expected a string, number, boolean, or an array of those' }),
|
|
28
|
+
});
|
|
21
29
|
})())
|
|
22
30
|
.default({}),
|
|
23
31
|
outOfScope: z.array(z.string()).default([]),
|
package/dist/core/skill.js
CHANGED
|
@@ -74,12 +74,22 @@ promises is a two-stage workflow, and the stages are separate on purpose.
|
|
|
74
74
|
a limit, a list of names — is written **once**, in the requirement's \`params\`,
|
|
75
75
|
and the scenario reads it from there. The number then cannot drift between the
|
|
76
76
|
spec and the assertion, because there is only one of it.
|
|
77
|
+
- **What earns a param: promises, not tuning.** A value belongs in \`params\` when
|
|
78
|
+
the requirement promises it — a timeout the user is owed, a budget someone would
|
|
79
|
+
file a bug about. A value that only tunes behaviour (a search depth, a cache
|
|
80
|
+
size) stays an ordinary constant: nothing is owed if it changes, and a registry
|
|
81
|
+
that accumulates every knob in the project is one of the ways intent bloats.
|
|
77
82
|
- **What that does not buy.** \`attest check\` executes nothing, so editing a
|
|
78
83
|
param value leaves it at \`✓ No issues\` — nothing became unbound or uncovered.
|
|
79
84
|
The single source makes a value impossible to *diverge*; it does not announce
|
|
80
85
|
that it *moved*. Only \`verify\` goes red, and only when a scenario asserts on
|
|
81
|
-
the value it read from params.
|
|
82
|
-
|
|
86
|
+
the value it read from params.
|
|
87
|
+
- **Reading the param is necessary, not sufficient.** An assertion that
|
|
88
|
+
recomputes its expectation from the same param the code under test just read
|
|
89
|
+
has no independent term: both sides move together, and the scenario stays green
|
|
90
|
+
through any edit. Pin the expectation to something that does not move with the
|
|
91
|
+
param — a fixture, a literal in the test, or a second independently derived
|
|
92
|
+
value.
|
|
83
93
|
|
|
84
94
|
## Rules the engine enforces
|
|
85
95
|
|
|
@@ -128,10 +138,43 @@ Then write the change folder. \`<name>\` is one directory name inside
|
|
|
128
138
|
\`\`\`
|
|
129
139
|
changes/<name>/
|
|
130
140
|
├── proposal.md # why, and what is out of scope. Prose, for humans.
|
|
131
|
-
|
|
132
|
-
└── specs/*.spec.ts # the scenarios that must pass for it to be done
|
|
141
|
+
└── requirements.delta.ts # the intent change
|
|
133
142
|
\`\`\`
|
|
134
143
|
|
|
144
|
+
**The change's scenarios do not live in that folder.** Each goes where it will
|
|
145
|
+
live once the change is merged — beside the code it attests, in the same
|
|
146
|
+
directory as the specs already there — under the name \`*.proposed.spec.ts\`:
|
|
147
|
+
|
|
148
|
+
\`\`\`
|
|
149
|
+
lib/game/specs/
|
|
150
|
+
├── board.spec.ts # already merged
|
|
151
|
+
└── fog.proposed.spec.ts # this change's, and ../fog already resolves
|
|
152
|
+
\`\`\`
|
|
153
|
+
|
|
154
|
+
That name is the whole mechanism. It keeps the file out of \`attest verify\` and
|
|
155
|
+
out of a plain \`vitest run\` while the change is in flight — the scenarios are
|
|
156
|
+
red by construction — and \`attest archive <name>\` pulls it in by the requirement
|
|
157
|
+
ids it covers: a change claims the proposed specs that declare a scenario for an
|
|
158
|
+
id it ADDs, renames to, or MODIFIEs. Nothing lists paths anywhere, so nothing can
|
|
159
|
+
fall out of step. A proposed spec no change claims is a \`proposed-spec-unclaimed\`
|
|
160
|
+
ERROR from \`attest check\`, because it would otherwise run nowhere at all.
|
|
161
|
+
|
|
162
|
+
Writing it at its merged location is what makes merging it a rename. Its relative
|
|
163
|
+
imports resolve now exactly as they will afterwards, so \`../fog\` never becomes
|
|
164
|
+
\`../../../lib/game/fog\` and back again.
|
|
165
|
+
|
|
166
|
+
Two things to get right when you create the file:
|
|
167
|
+
|
|
168
|
+
- **Do not put it under \`changes/\`.** Nothing walks that directory, so a spec
|
|
169
|
+
left there executes in no suite and no gate — \`spec-in-change-dir\` from
|
|
170
|
+
\`attest check\`. The change folder holds \`proposal.md\`,
|
|
171
|
+
\`requirements.delta.ts\` and \`first-run.json\`, and nothing else.
|
|
172
|
+
- **Pick a name whose merged form is free.** Merging renames
|
|
173
|
+
\`fog.proposed.spec.ts\` to \`fog.spec.ts\`, so if that module already has a
|
|
174
|
+
\`fog.spec.ts\` the rename would overwrite it — \`proposed-spec-name-taken\`.
|
|
175
|
+
Name it \`fog.2fa.proposed.spec.ts\` instead. Several spec files may sit beside
|
|
176
|
+
one module; Attest attributes each by the ids it declares, not by its path.
|
|
177
|
+
|
|
135
178
|
\`\`\`ts
|
|
136
179
|
// changes/add-2fa/requirements.delta.ts
|
|
137
180
|
import { delta } from '@am_shork/attest/define';
|
|
@@ -156,8 +199,22 @@ The delta applies \`RENAMED → REMOVED → MODIFIED → ADDED\`, idempotently:
|
|
|
156
199
|
ADDED id that already exists with identical content is a no-op, with different
|
|
157
200
|
content it is a conflict.
|
|
158
201
|
|
|
202
|
+
**A delta also reads as the registry of what it adds**, so a scenario written now
|
|
203
|
+
reads a proposed param exactly as it will after the merge:
|
|
204
|
+
|
|
205
|
+
\`\`\`ts
|
|
206
|
+
import reqs from '../../changes/add-2fa/requirements.delta.ts'; // ← only this line changes at merge
|
|
207
|
+
const window = reqs['AUTH-7'].params.totpWindowSec; // typed at 30, no cast
|
|
208
|
+
\`\`\`
|
|
209
|
+
|
|
210
|
+
Never hand-write an accessor for a proposed param, and never copy the literal
|
|
211
|
+
into the assertion: both are a second place the value lives, opened during the
|
|
212
|
+
one stage where the assertion is being authored against a value nobody has
|
|
213
|
+
implemented yet. MODIFIED ids are deliberately not readable this way — the end
|
|
214
|
+
state is the base entry with the patch applied, and the base is not in that file.
|
|
215
|
+
|
|
159
216
|
**The scenarios must be red.** Every added requirement needs at least one
|
|
160
|
-
scenario
|
|
217
|
+
scenario in a \`*.proposed.spec.ts\`, and at this stage they must fail —
|
|
161
218
|
they describe behaviour that does not exist yet. Prove it:
|
|
162
219
|
|
|
163
220
|
\`\`\`
|
|
@@ -227,10 +284,15 @@ once. Branch on \`issues[].code\`, never on \`message\`:
|
|
|
227
284
|
| \`never-red\` | an added requirement's scenario has no recorded failing run |
|
|
228
285
|
| \`uncovered-requirement\` | a requirement in the applied registry has no scenario |
|
|
229
286
|
| \`declared-not-run\` | a scenario was declared but never executed (\`skip\` / \`only\`?) |
|
|
287
|
+
| \`proposed-spec-unclaimed\` | a \`*.proposed.spec.ts\` no change's delta claims |
|
|
288
|
+
| \`added-id-unmerged\` | a spec failed to load, and this change adds an id the registry on disk lacks |
|
|
230
289
|
| \`unbound-param\` | a \`{placeholder}\` has no matching \`params\` key |
|
|
231
290
|
| \`registry-not-static\` | a registry file is not a literal the engine can read |
|
|
232
291
|
| \`add-conflict\` | the delta adds an id that already exists with different content |
|
|
233
292
|
| \`change-not-found\` | no \`requirements.delta.ts\` for that name |
|
|
293
|
+
| \`proposed-spec-name-taken\` | a proposed spec's merged name is already held by another spec |
|
|
294
|
+
| \`apply-unsupported-delta\` | \`--apply\` writes back ADDED only, and this delta carries more |
|
|
295
|
+
| \`apply-no-prefix-owner\` | no registry file owns the prefix of an id this change adds |
|
|
234
296
|
|
|
235
297
|
### Four things you must not do
|
|
236
298
|
|
|
@@ -247,6 +309,12 @@ failure this framework exists to make visible:
|
|
|
247
309
|
is the drift the single source exists to prevent, and the param is typed at
|
|
248
310
|
the value written in the registry, so a stale expectation stops compiling
|
|
249
311
|
rather than silently passing.
|
|
312
|
+
*One thing to know before implementation code reads a param this change
|
|
313
|
+
ADDs:* the suite imports your \`*.reqs.ts\` from disk, while the gate applies
|
|
314
|
+
the delta in memory — so that read throws at import and the gate answers
|
|
315
|
+
\`added-id-unmerged\`. Merge the added requirement into the registry and run
|
|
316
|
+
again; the \`added:\` entry stays, because applying it a second time with
|
|
317
|
+
identical content is a no-op.
|
|
250
318
|
4. **Do not write or edit \`first-run.json\`.** It is the one file here the gate
|
|
251
319
|
trusts without being able to check it, so a hand-written \`"fail"\` clears
|
|
252
320
|
\`never-red\` while proving nothing at all. Run the gate before implementing;
|
|
@@ -256,13 +324,36 @@ failure this framework exists to make visible:
|
|
|
256
324
|
|
|
257
325
|
Then, and not before:
|
|
258
326
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
327
|
+
\`\`\`
|
|
328
|
+
attest archive <name> --apply
|
|
329
|
+
\`\`\`
|
|
330
|
+
|
|
331
|
+
That finishes the merge the verdict just approved: it splices the change's ADDED
|
|
332
|
+
requirements into the registry file owning their id prefix, repoints each proposed
|
|
333
|
+
spec's import of \`requirements.delta.ts\` at that registry, renames the specs in
|
|
334
|
+
place, and moves \`changes/<name>/\` to \`archive/<date>-<name>/\`. It re-runs the
|
|
335
|
+
gate first and writes nothing if that fails, and it prints every path it touched.
|
|
336
|
+
If it stops partway, run it again — each step is derived from the tree as it is,
|
|
337
|
+
so a second run finishes the job rather than repeating it.
|
|
338
|
+
|
|
339
|
+
Two things it does not do, and both are still yours:
|
|
340
|
+
|
|
341
|
+
1. **Regenerate a committed rendering.** Nothing records where yours lives, so run
|
|
342
|
+
\`attest render --out <file>\` afterwards. A document that no longer matches the
|
|
343
|
+
registry is a \`stale-spec-doc\` ERROR.
|
|
344
|
+
2. **Run the merged suite.** \`attest verify\`, on the result, reported.
|
|
345
|
+
|
|
346
|
+
**It writes back ADDED only.** A delta also carrying RENAMED, REMOVED or MODIFIED
|
|
347
|
+
is refused whole as \`apply-unsupported-delta\`, with nothing written — the gate
|
|
348
|
+
still checked all four, so nothing about the change is unverified, but the rest is
|
|
349
|
+
merged by hand.
|
|
350
|
+
|
|
351
|
+
Merging by hand, when it refuses: splice the delta's entries into the registry
|
|
352
|
+
file that owns their prefix; then for each \`*.proposed.spec.ts\`, **repoint its
|
|
353
|
+
import of \`requirements.delta.ts\` at that registry** and rename it to
|
|
354
|
+
\`*.spec.ts\` in place. That import is the one specifier a merge changes, and it
|
|
355
|
+
changes because the delta moves to \`archive/\` — the spec file itself does not
|
|
356
|
+
move, so nothing else about it does either. Then move the change folder.
|
|
266
357
|
|
|
267
358
|
## The commands
|
|
268
359
|
|
|
@@ -272,7 +363,7 @@ Then, and not before:
|
|
|
272
363
|
| \`attest verify\` | runs the suite, then reports coverage and drift. |
|
|
273
364
|
| \`attest cover\` | which requirements lack a scenario. |
|
|
274
365
|
| \`attest render\` | the registry as Markdown for human readers; \`--check\` gates a committed copy. |
|
|
275
|
-
| \`attest archive <change>\` | the completion gate for a proposed change. |
|
|
366
|
+
| \`attest archive <change>\` | the completion gate for a proposed change; \`--apply\` also performs the merge it approves. |
|
|
276
367
|
| \`attest status <change>\` | what that gate still wants, without running the suite. |
|
|
277
368
|
|
|
278
369
|
\`verify\` starts the child run **isolated** — it does not read \`vitest.config.ts\`,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Registry, Requirement } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* One registry entry, at `indent`, with no trailing comma.
|
|
4
|
+
*
|
|
5
|
+
* The separator is the caller's because only the caller knows what it is
|
|
6
|
+
* inserting between: continuing a list needs a comma before, opening an empty
|
|
7
|
+
* body needs one after, and putting that decision here would mean this function
|
|
8
|
+
* had to be told which case it was in anyway.
|
|
9
|
+
*
|
|
10
|
+
* `params` and `outOfScope` are omitted when empty rather than written as `{}`
|
|
11
|
+
* and `[]`. The schema defaults both, so the two spellings mean the same thing,
|
|
12
|
+
* and the shorter one is what a person writing this entry by hand would have
|
|
13
|
+
* produced — which is the standard for a file `--apply` is merging into rather
|
|
14
|
+
* than generating.
|
|
15
|
+
*/
|
|
16
|
+
export declare function requirementSource(id: string, req: Requirement, indent: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* `source` with `additions` inserted into its registry literal.
|
|
19
|
+
*
|
|
20
|
+
* `undefined` when the file holds no literal this can be inserted into — the
|
|
21
|
+
* same condition `readRegistrySource` reports, and one the caller has already
|
|
22
|
+
* checked, so it is a refusal to guess rather than a diagnosis.
|
|
23
|
+
*
|
|
24
|
+
* Ids are inserted in code-unit order for the reason they are emitted in it:
|
|
25
|
+
* the same delta must produce the same file twice.
|
|
26
|
+
*/
|
|
27
|
+
export declare function spliceRequirements(file: string, source: string, additions: Registry): string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* `source` with every import of `from` repointed at `to`.
|
|
30
|
+
*
|
|
31
|
+
* The second edit `--apply` makes to a file it did not write, and the one the
|
|
32
|
+
* design record missed. Merging a proposed spec was described as a rename in
|
|
33
|
+
* place, which is true of its *location*: the file already sits where it lands,
|
|
34
|
+
* so no relative specifier moves. But a stage-1 scenario reads its proposed
|
|
35
|
+
* params out of the change's delta (ATX-48, and the whole reason a delta reads
|
|
36
|
+
* as the registry it proposes), and the delta is what step 3 moves into
|
|
37
|
+
* `archive/`. Renaming without this leaves a merged spec importing a path that
|
|
38
|
+
* no longer exists — a suite that loads nothing, reported as `declared-not-run`
|
|
39
|
+
* against scenarios that are perfectly good.
|
|
40
|
+
*
|
|
41
|
+
* The expression around the import needs nothing done to it: `reqs['AUTH-7']
|
|
42
|
+
* .params.x` reads the same on both sides, which is exactly what ATX-48 bought.
|
|
43
|
+
* So this replaces one string literal and touches nothing else — the same
|
|
44
|
+
* discipline as the splice, for the same reason.
|
|
45
|
+
*
|
|
46
|
+
* The extension is taken from the specifier being replaced rather than chosen
|
|
47
|
+
* here. Whether a project writes `./x.reqs.js` or `./x.reqs.ts` is a property of
|
|
48
|
+
* its module resolution, uniform across the project, and already answered by the
|
|
49
|
+
* specifier sitting in front of us.
|
|
50
|
+
*/
|
|
51
|
+
export declare function repointImport(file: string, source: string, from: string, to: string): string;
|
|
52
|
+
//# sourceMappingURL=splice.d.ts.map
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Writing a requirement into a registry file as text (design §7).
|
|
2
|
+
//
|
|
3
|
+
// This is the half of `--apply` the `AGENTS.md` rejection was about. That
|
|
4
|
+
// proposal was refused because "each of its failure modes is destructive on a
|
|
5
|
+
// file the user cannot regenerate", and the argument for why merging a delta is
|
|
6
|
+
// different has one load-bearing clause: the result of a splice is checkable by
|
|
7
|
+
// re-reading it. That clause only holds if the splice is a **pure insertion**.
|
|
8
|
+
//
|
|
9
|
+
// So nothing here renders a registry. Rendering one out of a `Registry` object
|
|
10
|
+
// would be far easier and would silently drop every comment, every blank line
|
|
11
|
+
// and every layout choice in a hand-written file — destroying exactly what
|
|
12
|
+
// cannot be regenerated, while passing a re-read with flying colours because the
|
|
13
|
+
// *values* all survived. Instead `registryInsertionPoint` hands back an offset,
|
|
14
|
+
// and the only edit made to the file is text inserted at it. Every other byte is
|
|
15
|
+
// the byte that was already there, which is a property a test can state.
|
|
16
|
+
//
|
|
17
|
+
// The generated text is therefore the one thing here that has to be right on its
|
|
18
|
+
// own, and it is generated conservatively: strings are escaped rather than
|
|
19
|
+
// interpolated, params are emitted in code-unit key order so the same delta
|
|
20
|
+
// produces the same bytes twice, and anything the schema does not permit cannot
|
|
21
|
+
// reach this file because the gate validated the delta before `--apply` ran.
|
|
22
|
+
import ts from 'typescript';
|
|
23
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
24
|
+
import { registryInsertionPoint } from './static-registry.js';
|
|
25
|
+
import { toPosixPath } from './paths.js';
|
|
26
|
+
import { byCodeUnit } from './order.js';
|
|
27
|
+
/**
|
|
28
|
+
* A TypeScript single-quoted string literal holding exactly `value`.
|
|
29
|
+
*
|
|
30
|
+
* Hand-escaped rather than `JSON.stringify`, for one reason that is not style:
|
|
31
|
+
* the registries this writes into are single-quoted throughout, and a merged
|
|
32
|
+
* entry that arrives double-quoted is a diff hunk about quotation marks in the
|
|
33
|
+
* middle of a merge the reviewer is trying to read. Control characters go out as
|
|
34
|
+
* `\uXXXX` rather than raw, so a statement someone pasted a newline into cannot
|
|
35
|
+
* produce a file that no longer parses.
|
|
36
|
+
*/
|
|
37
|
+
function tsString(value) {
|
|
38
|
+
let out = "'";
|
|
39
|
+
for (const ch of value) {
|
|
40
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
41
|
+
if (ch === '\\')
|
|
42
|
+
out += '\\\\';
|
|
43
|
+
else if (ch === "'")
|
|
44
|
+
out += "\\'";
|
|
45
|
+
else if (ch === '\n')
|
|
46
|
+
out += '\\n';
|
|
47
|
+
else if (ch === '\r')
|
|
48
|
+
out += '\\r';
|
|
49
|
+
else if (ch === '\t')
|
|
50
|
+
out += '\\t';
|
|
51
|
+
else if (code < 0x20 || code === 0x7f)
|
|
52
|
+
out += `\\u${code.toString(16).padStart(4, '0')}`;
|
|
53
|
+
else
|
|
54
|
+
out += ch;
|
|
55
|
+
}
|
|
56
|
+
return `${out}'`;
|
|
57
|
+
}
|
|
58
|
+
/** A param key, bare when it is a plain identifier and quoted when it is not. */
|
|
59
|
+
function keySource(key) {
|
|
60
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : tsString(key);
|
|
61
|
+
}
|
|
62
|
+
function paramSource(value) {
|
|
63
|
+
if (Array.isArray(value))
|
|
64
|
+
return `[${value.map((v) => paramSource(v)).join(', ')}]`;
|
|
65
|
+
return typeof value === 'string' ? tsString(value) : String(value);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* One registry entry, at `indent`, with no trailing comma.
|
|
69
|
+
*
|
|
70
|
+
* The separator is the caller's because only the caller knows what it is
|
|
71
|
+
* inserting between: continuing a list needs a comma before, opening an empty
|
|
72
|
+
* body needs one after, and putting that decision here would mean this function
|
|
73
|
+
* had to be told which case it was in anyway.
|
|
74
|
+
*
|
|
75
|
+
* `params` and `outOfScope` are omitted when empty rather than written as `{}`
|
|
76
|
+
* and `[]`. The schema defaults both, so the two spellings mean the same thing,
|
|
77
|
+
* and the shorter one is what a person writing this entry by hand would have
|
|
78
|
+
* produced — which is the standard for a file `--apply` is merging into rather
|
|
79
|
+
* than generating.
|
|
80
|
+
*/
|
|
81
|
+
export function requirementSource(id, req, indent) {
|
|
82
|
+
const inner = `${indent} `;
|
|
83
|
+
const lines = [
|
|
84
|
+
`${indent}${tsString(id)}: {`,
|
|
85
|
+
`${inner}statement: ${tsString(req.statement)},`,
|
|
86
|
+
`${inner}rationale: ${tsString(req.rationale)},`,
|
|
87
|
+
];
|
|
88
|
+
// Code-unit key order, so one delta applied twice writes the same bytes — the
|
|
89
|
+
// property `--apply`'s re-runnability rests on, and the reason `apply.ts`
|
|
90
|
+
// sorts the same way when it canonicalises for `add-conflict`.
|
|
91
|
+
const params = Object.entries(req.params).sort(([a], [b]) => byCodeUnit(a, b));
|
|
92
|
+
if (params.length > 0) {
|
|
93
|
+
const body = params.map(([k, v]) => `${keySource(k)}: ${paramSource(v)}`).join(', ');
|
|
94
|
+
lines.push(`${inner}params: { ${body} },`);
|
|
95
|
+
}
|
|
96
|
+
if (req.outOfScope.length > 0) {
|
|
97
|
+
lines.push(`${inner}outOfScope: [${req.outOfScope.map((s) => tsString(s)).join(', ')}],`);
|
|
98
|
+
}
|
|
99
|
+
lines.push(`${indent}}`);
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* `source` with `additions` inserted into its registry literal.
|
|
104
|
+
*
|
|
105
|
+
* `undefined` when the file holds no literal this can be inserted into — the
|
|
106
|
+
* same condition `readRegistrySource` reports, and one the caller has already
|
|
107
|
+
* checked, so it is a refusal to guess rather than a diagnosis.
|
|
108
|
+
*
|
|
109
|
+
* Ids are inserted in code-unit order for the reason they are emitted in it:
|
|
110
|
+
* the same delta must produce the same file twice.
|
|
111
|
+
*/
|
|
112
|
+
export function spliceRequirements(file, source, additions) {
|
|
113
|
+
const ids = Object.keys(additions).sort(byCodeUnit);
|
|
114
|
+
if (ids.length === 0)
|
|
115
|
+
return source;
|
|
116
|
+
const point = registryInsertionPoint(file, source);
|
|
117
|
+
if (!point)
|
|
118
|
+
return undefined;
|
|
119
|
+
const entries = ids
|
|
120
|
+
.map((id) => requirementSource(id, additions[id], point.indent))
|
|
121
|
+
.join(',\n');
|
|
122
|
+
const text = point.leadingComma ? `,\n${entries}` : `\n${entries},\n`;
|
|
123
|
+
return source.slice(0, point.offset) + text + source.slice(point.offset);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* `source` with every import of `from` repointed at `to`.
|
|
127
|
+
*
|
|
128
|
+
* The second edit `--apply` makes to a file it did not write, and the one the
|
|
129
|
+
* design record missed. Merging a proposed spec was described as a rename in
|
|
130
|
+
* place, which is true of its *location*: the file already sits where it lands,
|
|
131
|
+
* so no relative specifier moves. But a stage-1 scenario reads its proposed
|
|
132
|
+
* params out of the change's delta (ATX-48, and the whole reason a delta reads
|
|
133
|
+
* as the registry it proposes), and the delta is what step 3 moves into
|
|
134
|
+
* `archive/`. Renaming without this leaves a merged spec importing a path that
|
|
135
|
+
* no longer exists — a suite that loads nothing, reported as `declared-not-run`
|
|
136
|
+
* against scenarios that are perfectly good.
|
|
137
|
+
*
|
|
138
|
+
* The expression around the import needs nothing done to it: `reqs['AUTH-7']
|
|
139
|
+
* .params.x` reads the same on both sides, which is exactly what ATX-48 bought.
|
|
140
|
+
* So this replaces one string literal and touches nothing else — the same
|
|
141
|
+
* discipline as the splice, for the same reason.
|
|
142
|
+
*
|
|
143
|
+
* The extension is taken from the specifier being replaced rather than chosen
|
|
144
|
+
* here. Whether a project writes `./x.reqs.js` or `./x.reqs.ts` is a property of
|
|
145
|
+
* its module resolution, uniform across the project, and already answered by the
|
|
146
|
+
* specifier sitting in front of us.
|
|
147
|
+
*/
|
|
148
|
+
export function repointImport(file, source, from, to) {
|
|
149
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
|
|
150
|
+
const dir = dirname(file);
|
|
151
|
+
const edits = [];
|
|
152
|
+
for (const statement of sf.statements) {
|
|
153
|
+
if (!ts.isImportDeclaration(statement))
|
|
154
|
+
continue;
|
|
155
|
+
const spec = statement.moduleSpecifier;
|
|
156
|
+
if (!ts.isStringLiteral(spec))
|
|
157
|
+
continue;
|
|
158
|
+
if (!resolvesTo(dir, spec.text, from))
|
|
159
|
+
continue;
|
|
160
|
+
const ext = spec.text.endsWith('.js') ? '.js' : spec.text.endsWith('.ts') ? '.ts' : '';
|
|
161
|
+
let target = toPosixPath(relative(dir, to));
|
|
162
|
+
if (ext)
|
|
163
|
+
target = target.replace(/\.[^./]+$/, ext);
|
|
164
|
+
// A bare `x.reqs.js` is a package specifier, not a sibling file.
|
|
165
|
+
if (!target.startsWith('.'))
|
|
166
|
+
target = `./${target}`;
|
|
167
|
+
// Inside the quotes: the file's own quote style is left exactly as it was.
|
|
168
|
+
edits.push({ start: spec.getStart(sf) + 1, end: spec.getEnd() - 1, text: target });
|
|
169
|
+
}
|
|
170
|
+
let out = source;
|
|
171
|
+
for (const edit of edits.reverse()) {
|
|
172
|
+
out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Whether `specifier`, written in a file under `dir`, names `target`.
|
|
178
|
+
*
|
|
179
|
+
* The `.js`-for-`.ts` spelling is accepted because NodeNext resolution requires
|
|
180
|
+
* it, and a project using it writes every specifier that way — including the one
|
|
181
|
+
* this is looking for.
|
|
182
|
+
*/
|
|
183
|
+
function resolvesTo(dir, specifier, target) {
|
|
184
|
+
if (!specifier.startsWith('.'))
|
|
185
|
+
return false;
|
|
186
|
+
const resolved = resolve(dir, specifier);
|
|
187
|
+
return resolved === target || resolved.replace(/\.js$/, '.ts') === target;
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=splice.js.map
|
|
@@ -39,4 +39,34 @@ export type DeltaReadResult = {
|
|
|
39
39
|
* (write the value inline, or `--eval`) is the same sentence either way.
|
|
40
40
|
*/
|
|
41
41
|
export declare function readDeltaSource(file: string, source: string): DeltaReadResult;
|
|
42
|
+
/**
|
|
43
|
+
* Where a new entry may be written into a registry file's literal, as an offset
|
|
44
|
+
* into its source (design §7).
|
|
45
|
+
*
|
|
46
|
+
* The insertion point rather than a rewritten file, because `--apply` must not
|
|
47
|
+
* regenerate a `*.reqs.ts`. Every registry this tool merges into is hand-written
|
|
48
|
+
* and hand-commented, and rendering one back out of a `Registry` object would
|
|
49
|
+
* silently drop every comment and every choice of layout in it — the
|
|
50
|
+
* "destructive on a file the user cannot regenerate" shape the `AGENTS.md`
|
|
51
|
+
* proposal was rejected for. An offset lets the splice be a pure insertion: every
|
|
52
|
+
* other byte of the file is the byte that was there before, which is a property
|
|
53
|
+
* that can be stated and tested rather than hoped for.
|
|
54
|
+
*
|
|
55
|
+
* Lives here because this is the module that already knows how to find the
|
|
56
|
+
* literal, and the one place `typescript` is imported for that job. A second
|
|
57
|
+
* walker would be a second answer to "where does this registry's body end".
|
|
58
|
+
*
|
|
59
|
+
* `undefined` when the file is not a registry this tool can read — the same
|
|
60
|
+
* condition `readRegistrySource` reports as `registry-not-static` or
|
|
61
|
+
* `registry-no-default`, and the caller has already run that check.
|
|
62
|
+
*/
|
|
63
|
+
export interface RegistryInsertion {
|
|
64
|
+
/** Offset to insert at. Everything before and after it is preserved. */
|
|
65
|
+
offset: number;
|
|
66
|
+
/** The indentation the file's existing entries use, reproduced for new ones. */
|
|
67
|
+
indent: string;
|
|
68
|
+
/** Whether the insertion has to open with a `,` — false only for an empty registry. */
|
|
69
|
+
leadingComma: boolean;
|
|
70
|
+
}
|
|
71
|
+
export declare function registryInsertionPoint(file: string, source: string): RegistryInsertion | undefined;
|
|
42
72
|
//# sourceMappingURL=static-registry.d.ts.map
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// value is fixed by the source text — a second walker would be a second answer
|
|
21
21
|
// to "is this a literal", and those two answers must not be able to disagree.
|
|
22
22
|
import ts from 'typescript';
|
|
23
|
-
import { RegistryValidationError } from './registry.js';
|
|
23
|
+
import { RegistryValidationError, withProposedRequirements } from './registry.js';
|
|
24
24
|
import { RegistrySchema } from './schema.js';
|
|
25
25
|
/** The authoring function a registry file must default-export the result of. */
|
|
26
26
|
const DEFINE = 'defineRequirements';
|
|
@@ -142,7 +142,11 @@ export function readDeltaSource(file, source) {
|
|
|
142
142
|
// function deliberately does not make (see the note above on why a delta is
|
|
143
143
|
// not schema-validated here), which is the worst thing an assertion can do —
|
|
144
144
|
// look like verification.
|
|
145
|
-
|
|
145
|
+
// Augmented exactly as `delta()` augments the evaluated one, through the same
|
|
146
|
+
// function: a delta reads as the registry of what it adds (design §7), and the
|
|
147
|
+
// two readers must not disagree about that — the differential suite is the
|
|
148
|
+
// thing that would notice, and it should have nothing to notice.
|
|
149
|
+
return { ok: true, delta: withProposedRequirements(value) };
|
|
146
150
|
}
|
|
147
151
|
/**
|
|
148
152
|
* The expression a file default-exports, following one level of local binding.
|
|
@@ -318,4 +322,40 @@ function numericValue(node) {
|
|
|
318
322
|
throw new NotStatic(node, 'a number this reader cannot spell out');
|
|
319
323
|
return value;
|
|
320
324
|
}
|
|
325
|
+
export function registryInsertionPoint(file, source) {
|
|
326
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
|
|
327
|
+
const exported = defaultExportExpression(sf);
|
|
328
|
+
if (!exported)
|
|
329
|
+
return undefined;
|
|
330
|
+
const arg = authoringCall(exported, sf, 'defineRequirements');
|
|
331
|
+
if (!arg || !ts.isObjectLiteralExpression(arg))
|
|
332
|
+
return undefined;
|
|
333
|
+
const last = arg.properties[arg.properties.length - 1];
|
|
334
|
+
if (!last) {
|
|
335
|
+
// An empty registry: open the body rather than continue it. The trailing
|
|
336
|
+
// newline is written by the caller, so `{` does not end up sharing a line
|
|
337
|
+
// with the entry and the closing `}`.
|
|
338
|
+
return { offset: arg.getStart(sf) + 1, indent: indentOf(sf, source, arg) + ' ', leadingComma: false };
|
|
339
|
+
}
|
|
340
|
+
// Deliberately the *end of the last property*, not the end of the literal:
|
|
341
|
+
// inserting here sits before any trailing comma the file already has, so one
|
|
342
|
+
// leading comma is correct whether or not that comma is present, and the
|
|
343
|
+
// file's own trailing-comma style is left exactly as it was.
|
|
344
|
+
return { offset: last.getEnd(), indent: indentOf(sf, source, last), leadingComma: true };
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* The whitespace a node's line opens with.
|
|
348
|
+
*
|
|
349
|
+
* Read out of the source rather than computed from the column, so a file
|
|
350
|
+
* indented with tabs is continued with tabs. Anything else on the line (a
|
|
351
|
+
* property sharing a line with another) falls back to two spaces, which is the
|
|
352
|
+
* only case where this guesses.
|
|
353
|
+
*/
|
|
354
|
+
function indentOf(sf, source, node) {
|
|
355
|
+
const start = node.getStart(sf);
|
|
356
|
+
const { line } = sf.getLineAndCharacterOfPosition(start);
|
|
357
|
+
const lineStart = sf.getPositionOfLineAndCharacter(line, 0);
|
|
358
|
+
const prefix = source.slice(lineStart, start);
|
|
359
|
+
return /^[\t ]*$/.test(prefix) ? prefix : ' ';
|
|
360
|
+
}
|
|
321
361
|
//# sourceMappingURL=static-registry.js.map
|
package/dist/core/types.d.ts
CHANGED
|
@@ -40,6 +40,16 @@ export interface RunResult {
|
|
|
40
40
|
* it, which is what §6's mechanism 2 records.
|
|
41
41
|
*/
|
|
42
42
|
outcomes: Map<string, Map<string, Outcome>>;
|
|
43
|
+
/**
|
|
44
|
+
* Spec files that failed to *load*, as project-relative POSIX paths.
|
|
45
|
+
*
|
|
46
|
+
* A file whose module threw at import has no tasks at all, so every other
|
|
47
|
+
* signal it produces is an absence: no coverage, no outcomes, and scenarios
|
|
48
|
+
* the static plan declared but the runtime never saw. Those absences are
|
|
49
|
+
* indistinguishable from a `skip` unless the load failure is carried
|
|
50
|
+
* separately, which is what this is.
|
|
51
|
+
*/
|
|
52
|
+
unloadedFiles: string[];
|
|
43
53
|
}
|
|
44
54
|
/** Severity levels for graded reporting (design §5.3). */
|
|
45
55
|
export type Level = 'ERROR' | 'WARNING' | 'INFO';
|
package/package.json
CHANGED