@am_shork/attest 0.7.2 → 0.7.3
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 +284 -93
- package/README.md +10 -1
- package/bin/attest.js +0 -0
- package/dist/cli/json.d.ts +3 -2
- package/dist/cli/json.js +3 -2
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +1 -0
- package/dist/core/loader.js +63 -33
- package/dist/core/paths.d.ts +16 -0
- package/dist/core/paths.js +20 -1
- package/dist/core/pipeline.js +67 -4
- package/dist/core/render.js +190 -21
- package/dist/core/skill.js +19 -0
- package/package.json +17 -29
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,288 @@ input, removes/renames a public API or `--json` field, or changes a default
|
|
|
13
13
|
runtime behavior an existing invocation relies on — diagnostic message text is
|
|
14
14
|
not API.
|
|
15
15
|
|
|
16
|
+
## [0.7.3] - 2026-08-07
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **A loader that never opened left its scratch directory behind — one per
|
|
21
|
+
`attest` invocation, i.e. one per CI build.** `createLoader` creates the
|
|
22
|
+
directory and writes the stub, and the only `rm` of it lives inside the `close`
|
|
23
|
+
of the object it returns — so nothing between those two points can be cleaned
|
|
24
|
+
up by anyone, because until the caller holds that object no `close` exists to
|
|
25
|
+
call. It is the exact leak the comment on `close` records as already fixed,
|
|
26
|
+
surviving on the other side of the same function. The window is now closed by
|
|
27
|
+
the function that opened it.
|
|
28
|
+
*Stated over the window rather than over `createServer`, which is where it was
|
|
29
|
+
found.* `writeFileSync` is inside it too, and a full disk or a temp directory
|
|
30
|
+
that turns read-only between the two calls reaches it with no project
|
|
31
|
+
involved — so scoping the repair to the call it was noticed at would have left
|
|
32
|
+
half of it.
|
|
33
|
+
*The trigger this was filed on does not reproduce, and that is the useful
|
|
34
|
+
half.* The entry proposed a malformed `package.json` at the root, on the
|
|
35
|
+
reasoning that `configFile: false` still leaves Vite reading it for `type`.
|
|
36
|
+
Measured: it starts a server cleanly, as do eight other shapes — a
|
|
37
|
+
`package.json` holding an array, one that is a directory, an unknown `type`, a
|
|
38
|
+
`node_modules` occupied by a file, a `node_modules/.vite` occupied by a file, a
|
|
39
|
+
`.env` that is a directory, a deleted root, and a well-formed control. Nine
|
|
40
|
+
candidates, zero throws. **`configFile: false` is why**, and it is worth
|
|
41
|
+
recording as a property rather than an accident: the option set chosen to stop
|
|
42
|
+
the loader reading a project's Vite config, and to stop it opening a socket,
|
|
43
|
+
also leaves almost no project I/O that can fail hard. The throw surface was
|
|
44
|
+
closed as a side effect of decisions taken for other reasons.
|
|
45
|
+
*So the repair is justified by ownership rather than by a symptom*, which is a
|
|
46
|
+
weaker case than this file usually accepts and is why the alternative was to
|
|
47
|
+
reject the entry outright. What decides it is the asymmetry of cost: the fan-out
|
|
48
|
+
entry below stays open because bounding concurrency changes how every run
|
|
49
|
+
behaves, whereas a `try` around a window nothing currently reaches changes
|
|
50
|
+
nothing observable on any path that exists. A resource whose ownership transfer
|
|
51
|
+
is conditional on nothing throwing is a shape, and the shape is what recurs.
|
|
52
|
+
*A cleanup that fails must not replace the error that caused it*, so the `rm`
|
|
53
|
+
swallows its own failure and the original throw propagates unchanged. The
|
|
54
|
+
regression test asserts the identity of what comes out, not its wording.
|
|
55
|
+
**`close` had the same hazard and now makes the same choice, which is a change
|
|
56
|
+
to an existing path rather than a new one.** Its `rm` runs in a `finally`, so a
|
|
57
|
+
failed cleanup replaced whatever `close` was doing — and all five callers in
|
|
58
|
+
`pipeline.ts` call `close` from a `finally` of their own, where the damage is
|
|
59
|
+
larger than symmetry: a real error on the way out is lost, and a *successful*
|
|
60
|
+
command becomes a crash about a temp directory. What that reader is handed is a
|
|
61
|
+
misdiagnosis pointing at their own correct files, which this project already
|
|
62
|
+
treats as worse than the gap it fills — the argument `ATX-49` records against
|
|
63
|
+
reporting a load failure as a wall of skipped scenarios, in a much smaller
|
|
64
|
+
place. Given up with it: the one signal a failed cleanup could have raised,
|
|
65
|
+
which no caller could have acted on.
|
|
66
|
+
*The test goes red without the fix, which is the only reason it is worth
|
|
67
|
+
having.* Verified by reverting `src/` and re-running: `expected [
|
|
68
|
+
'attest-loader-z7jhoK' ] to deeply equal []`, the leaked directory named in the
|
|
69
|
+
failure. It injects the throw at the module boundary — no project input could
|
|
70
|
+
produce one — and mocks `tmpdir` to a directory of its own, because on the
|
|
71
|
+
throwing path there is no loader to ask for its `scratchDir` and counting
|
|
72
|
+
`attest-loader-*` in the shared temp directory is the flake the lifecycle test
|
|
73
|
+
beside it already documents.
|
|
74
|
+
*It is its own file*, which is a rule this repo already wrote down and which
|
|
75
|
+
the first draft broke: `tests/locate-fanout.spec.ts` is separate because
|
|
76
|
+
`vi.mock` is file-scoped and it mocks a Node builtin, and the four lifecycle
|
|
77
|
+
tests boot the *real* Vite loader — sharing a module registry with a
|
|
78
|
+
`createServer` that throws is exactly what that convention exists to prevent.
|
|
79
|
+
The first attempt scoped the mocks with `doMock` and `resetModules` inside the
|
|
80
|
+
shared file instead, which is machinery bought to work around a constraint the
|
|
81
|
+
repo had already decided how to solve. Following it deleted the machinery.
|
|
82
|
+
*No new requirement, and the precedent cuts the other way, so it is worth
|
|
83
|
+
saying why.* `ATX-36` is the sibling property — what a live loader leaves on the
|
|
84
|
+
user's machine, stated as a count, with a scenario in `self/` — and "leaves no
|
|
85
|
+
scratch directory" is the same shape. What decides against filing one here is
|
|
86
|
+
that the success-path half has never had a requirement either, so a new one
|
|
87
|
+
covering only the throwing path would state half a contract and cost a
|
|
88
|
+
permanent scenario for the half nothing reaches. If it is ever filed it should
|
|
89
|
+
be filed whole, and that is a larger change than this fix.
|
|
90
|
+
|
|
91
|
+
- **The `cmd.exe` guard in `tests/consumer.spec.ts` checked one character of a
|
|
92
|
+
class, and the comment above it claimed the quoting was doing work it cannot
|
|
93
|
+
do here.** `shellArg` asserted the argument held no `"` and then quoted only
|
|
94
|
+
when it held whitespace, so an argument with neither went to the shell bare
|
|
95
|
+
with `&`, `|`, `^`, `<`, `>`, `(` unexamined. Measured against the twelve
|
|
96
|
+
shapes: the old assertion let **11 of 12** through.
|
|
97
|
+
*What decided it, and it is the thing the entry this replaces did not have.*
|
|
98
|
+
That entry framed the choice as "widen the class, or fix the prose", and
|
|
99
|
+
argued that widening while leaving the prose is the change that looks like a
|
|
100
|
+
fix and settles nothing. Both halves turn on a fact neither named: **every
|
|
101
|
+
command this suite spawns is a `.cmd`**, which is the whole reason
|
|
102
|
+
`NEEDS_SHELL` exists, and a batch file expands `%*` into its own line *before*
|
|
103
|
+
anything parses it — so a metacharacter survives double quotes and is acted on
|
|
104
|
+
at the far end. That is the argument-injection hole Node closed in 22.x,
|
|
105
|
+
arrived at the long way round. So the quoting is not a defence that the
|
|
106
|
+
assertion merely backstops; **the assertion is the only defence**, and a
|
|
107
|
+
defence that covers one character of its class is the defect rather than the
|
|
108
|
+
prose being generous. Both are fixed together: the class is named, and the
|
|
109
|
+
comment now says the quotes do whitespace and nothing else.
|
|
110
|
+
*`%` and `!` are in the class for a different reason* — expansion rather than
|
|
111
|
+
syntax, and `!` only under delayed expansion, which the spawned shell does not
|
|
112
|
+
enable but a batch file can enable for itself. Neither costs anything to
|
|
113
|
+
refuse.
|
|
114
|
+
*The cost, which is real and is the right cost.* Windows permits `&`, `(` and
|
|
115
|
+
`%` in a user name, and `os.tmpdir()` sits under it — so on such a machine this
|
|
116
|
+
suite now stops with *"argument would need escaping, not quoting"* instead of
|
|
117
|
+
running. That is correct rather than a false red: through a batch target those
|
|
118
|
+
arguments genuinely are not safe, and the invariant the assertion guards is
|
|
119
|
+
that every argument is a literal flag or a path from `mkdtemp`. Stopping is
|
|
120
|
+
right even where quoting would have survived it, because the value is then not
|
|
121
|
+
the kind of value the helper accepts.
|
|
122
|
+
*What is measured, and what is not.* `pnpm test:consumer` was run on Windows,
|
|
123
|
+
where `NEEDS_SHELL` is true and the changed path actually executes: 11 tests
|
|
124
|
+
passed, none skipped — the distinction that matters, since the failure this
|
|
125
|
+
file's own comment records reported ten tests as *skipped* at zero run. CI is
|
|
126
|
+
Linux, where `shellArg` returns before the assertion, so the pipeline says
|
|
127
|
+
nothing about this either way. Not measured end to end: an argument carrying a
|
|
128
|
+
metacharacter actually reaching the guard, because no call site can produce
|
|
129
|
+
one — the class is verified as a predicate over the twelve shapes above and
|
|
130
|
+
the sixteen arguments the suite really passes, not by driving the suite red.
|
|
131
|
+
|
|
132
|
+
- **A statement or rationale can no longer become HTML, or open a section of
|
|
133
|
+
its own.** `sanitised` is the entry §9.1 names and it strips control
|
|
134
|
+
characters and nothing else, so `statement` and `rationale` reached the
|
|
135
|
+
Markdown by concatenation exactly as written. Measured: a `rationale`
|
|
136
|
+
containing a newline and `## ATX-999` rendered a whole section — heading,
|
|
137
|
+
statement, `**Why:**` — for a requirement no registry holds, sitting between
|
|
138
|
+
two real ones and worded identically, with the overview table above it listing
|
|
139
|
+
three ids and the body carrying four sections. `<script>` and `<img onerror>`
|
|
140
|
+
survived the same way into a file that is committed and served. `check` and
|
|
141
|
+
`cover` saw nothing, because the id is in no registry, and `render --check`
|
|
142
|
+
could not object, because that *was* what the registry rendered to.
|
|
143
|
+
*Which of the three is intended, which is the part the fix had to decide.*
|
|
144
|
+
Marking prose up stays — `plain` escapes a param value precisely because the
|
|
145
|
+
value is data and the sentence around it is not — and that decision had never
|
|
146
|
+
been separated from allowing raw HTML or a heading. It is now a **fifth
|
|
147
|
+
obligation in design §9.1** rather than a patch at an emitter: prose may be
|
|
148
|
+
marked up, it may not become a tag, and it may not open a section. Stated as a
|
|
149
|
+
count over the document — one heading per requirement id — because what a
|
|
150
|
+
reader trusts is that a section means a requirement (`ATX-67`).
|
|
151
|
+
*Escaped rather than refused, on an asymmetry.* A rule that misses something
|
|
152
|
+
ships the document it ships today; a rule that fires wrongly makes a
|
|
153
|
+
legitimate rationale a hard error in the one command whose value is that it
|
|
154
|
+
always produces the document — the `ATX-29` argument about `--check` seen from
|
|
155
|
+
the other side. So there is no new diagnostic and no exit code moves.
|
|
156
|
+
*And escaped for the position, which is what keeps it lossless.* A code span
|
|
157
|
+
is literal text in every renderer, so `<` written inside one is **visible**
|
|
158
|
+
— Markdown does not decode entities there, and this repository documents
|
|
159
|
+
`attest status <change>` in a statement. The scan therefore tracks spans and
|
|
160
|
+
leaves their contents alone, conservatively: a backtick run opens a span only
|
|
161
|
+
if a run of exactly that length closes it, and an unmatched run is text with
|
|
162
|
+
the defence still applied after it.
|
|
163
|
+
*One rendering change beyond the fix, and it is a repair.* A param
|
|
164
|
+
substituted inside the author's own code span was emitted as `**value**`,
|
|
165
|
+
which in a code span is two asterisks around the value; knowing the position
|
|
166
|
+
is what removes them. `SPEC.md` in this repository moves by 12 lines, all of
|
|
167
|
+
that shape — `` `**changes/<name>/first-run.json**` `` becomes
|
|
168
|
+
`` `changes/<name>/first-run.json` `` — and no `<` appears anywhere in it,
|
|
169
|
+
which is the measurement that the span exception works on real prose.
|
|
170
|
+
*The defence had the defect it was written next to, and `ATX-59` gained a
|
|
171
|
+
third shape because of it.* The first code-span scan searched forward from
|
|
172
|
+
each backtick run for a run of the same length, so a rationale whose runs are
|
|
173
|
+
all of *different* lengths paid a scan to the end of the string for every one
|
|
174
|
+
of them: 500 KB cost 1.6 s and 2 MB cost 15 s, from `attest render` with no
|
|
175
|
+
flag, on text the registry chooses — the same "a registry must not decide what
|
|
176
|
+
rendering costs" defect the padded statement and the backtick-run param
|
|
177
|
+
already stand for. The runs are now read once and indexed by length, each
|
|
178
|
+
length holding a forward-only cursor, which is linear: 40 ms at 2 MB. It is
|
|
179
|
+
filed under `ATX-59` rather than here because the obligation is that one, and
|
|
180
|
+
it is worth saying where the site came from — the *defence another requirement
|
|
181
|
+
added*, which is the direction the next one will arrive from too.
|
|
182
|
+
*This leaves `Planned`.* The forged section was reproduced first, then the
|
|
183
|
+
four scenarios were run against the pre-fix renderer: three went red, and the
|
|
184
|
+
fourth — the setext underline, which heads the line *above* it — went green,
|
|
185
|
+
because counting `##` lines cannot see a heading that has no `##` in it. That
|
|
186
|
+
scenario was rewritten to assert the neutralised underline instead. A scenario
|
|
187
|
+
that cannot fail is the defect `archive` exists to catch, and it was caught
|
|
188
|
+
here only by running it against the old code.
|
|
189
|
+
|
|
190
|
+
- **`attest init` no longer follows a symbolic link out of the project, and
|
|
191
|
+
refuses the whole run when a destination leads there.** `runInit` did
|
|
192
|
+
`join(root, target.file)`, `mkdir -p` and then the write, and nothing asked
|
|
193
|
+
whether the path was still under `root` once the filesystem had had its say.
|
|
194
|
+
A `.claude/skills/attest` planted in a repository as a link to anywhere the
|
|
195
|
+
user can write was followed by both: measured on Windows with a junction, a
|
|
196
|
+
file at the far end holding `USER OWNED FILE - DO NOT OVERWRITE` came back
|
|
197
|
+
holding the skill, and the report printed the relative path, so nothing on
|
|
198
|
+
screen said where it had gone. Every destination is now resolved segment by
|
|
199
|
+
segment before the first byte of any of them is written, and one that escapes
|
|
200
|
+
is an `unsafe-target-path` ERROR that writes nothing at all (`ATX-66`).
|
|
201
|
+
*What the exposure was, stated precisely, because two nearby things are not
|
|
202
|
+
it.* `writeAtomic`'s exclusive creation is not the gap and was never scoped to
|
|
203
|
+
be one: it defends the *temporary* path against a planted link, and it does
|
|
204
|
+
that perfectly in whichever directory the destination turns out to be. And
|
|
205
|
+
`workflowBody()` is a pure function of nothing, so no byte of the checked
|
|
206
|
+
project reaches the file — there is nothing to exfiltrate. What was exposed is
|
|
207
|
+
the **path**, which is `targets.ts`'s first scoping rule ("Attest owns the
|
|
208
|
+
path") having been an assumption rather than a check.
|
|
209
|
+
*Why the check is a resolution rather than a comparison.* `join` normalises
|
|
210
|
+
`..`, so a lexical containment test was already satisfied — the escape is a
|
|
211
|
+
segment that *is* a link, which only `realpath` can see. Containment itself
|
|
212
|
+
goes through `isInside` in `src/core/paths.ts`, asked as a relative path
|
|
213
|
+
rather than a string prefix: `/repo-backup` starts with `/repo`, and on
|
|
214
|
+
Windows a path on another drive has no relative spelling at all, so
|
|
215
|
+
`path.relative` answers with an absolute one instead of a chain of `..`. Both
|
|
216
|
+
read as contained under `startsWith`.
|
|
217
|
+
*The limit, recorded rather than papered over.* A link planted between the
|
|
218
|
+
resolution and the write is not caught, and no check outside the filesystem
|
|
219
|
+
can catch it. The exposure this closes is one committed to a repository, which
|
|
220
|
+
is the one a fresh clone and every merge-request checkout hands you.
|
|
221
|
+
*Behaviour change:* an `init` that used to write through such a link now
|
|
222
|
+
fails with exit 1. The only input that reaches it is a path that was already
|
|
223
|
+
leaving the project. The JSON surface is unchanged beyond one more `code`
|
|
224
|
+
value, which is additive — no `SCHEMA_VERSION` bump.
|
|
225
|
+
*This leaves `Planned`*, where it was filed with the measurement above; the
|
|
226
|
+
first act of building it was re-running that measurement, and it reproduced.
|
|
227
|
+
|
|
228
|
+
### Changed
|
|
229
|
+
|
|
230
|
+
- **The guidance now draws the line between a literal that is drift and a literal
|
|
231
|
+
that is a pin — the shipped text forbade the only defence against a shortened
|
|
232
|
+
domain.** `[0.7.2]` fixed the repo half: every param a scenario iterates gained
|
|
233
|
+
a literal pinning its extent, after three of that release's four green
|
|
234
|
+
mutations turned out to be a domain silently losing cases rather than the
|
|
235
|
+
tautology the experiment was looking for. The user-facing half was held back,
|
|
236
|
+
and it was not merely absent. The `init` skill's "Four things you must not do"
|
|
237
|
+
says **"Do not hardcode a value that lives in `params`"**, and the pin every one
|
|
238
|
+
of those fixes adds is, read literally, exactly that. An adopter who writes a
|
|
239
|
+
roster param — which the design actively pushes them toward, since a quantifier
|
|
240
|
+
a scenario can iterate is the shape `QUANTIFIED` recommends — was being handed
|
|
241
|
+
an instruction that forbids the only thing standing between them and a run that
|
|
242
|
+
covers less every time someone edits the list.
|
|
243
|
+
*The distinction is real and one sentence long*, and it is now drawn in all four
|
|
244
|
+
places the claim is made: the rule is about the **expectation** the system is
|
|
245
|
+
measured against, and the pin asserts what the **intent** is. A list a scenario
|
|
246
|
+
loops over is not a value under test — it is the set of cases the run covers,
|
|
247
|
+
so dropping a member removes a case while every assertion inside still passes,
|
|
248
|
+
with the expectation as independent as it ever was. That is why the `[0.5.0]`
|
|
249
|
+
rule (*the expectation must not be a function of the param the code under test
|
|
250
|
+
consumed*) does not reach it, and why a literal is the **only** independent term
|
|
251
|
+
available: anything derived from the list shrinks with it, so a length assertion
|
|
252
|
+
written as a function of the list is true at every length.
|
|
253
|
+
*Where it landed, and why it is four documents rather than one line appended.*
|
|
254
|
+
README §"killer move", the `init` skill (a sibling bullet in the model section
|
|
255
|
+
and a carve-out inside rule 3 itself, where the contradiction was), the
|
|
256
|
+
`possible-drift` troubleshooting section with a worked ✓/✗ pair, and design §11
|
|
257
|
+
as a residual weakness beside the tautology bullet it shares a gap with — both
|
|
258
|
+
languages. `[0.5.0]` records the reason this shape of change is done in one
|
|
259
|
+
deliberate pass rather than incrementally: it is the central claim of the tool
|
|
260
|
+
being reworded, and the last time it was split the halves disagreed.
|
|
261
|
+
*No engine change, and none is proposed here.* Nothing in this is detectable —
|
|
262
|
+
which surface a statement claims is prose judgement, the same judgement §11
|
|
263
|
+
already records as the reason obligation counting can never be an engine rule.
|
|
264
|
+
The counter-pressure that exists is a hand-judged table, and §11 now says so,
|
|
265
|
+
including what it cannot reach: a domain whose size is carried by a scalar.
|
|
266
|
+
*This leaves `Planned`*, where it was filed with the measurement above; the
|
|
267
|
+
measurement was already taken, and what it needed was the pass.
|
|
268
|
+
|
|
269
|
+
- **The release publishes through `pnpm` again, and the check that read the
|
|
270
|
+
attestation back is gone.** Two corrections to the provenance job shipped in
|
|
271
|
+
`[0.7.2]`, both from what the first real tag pipeline showed.
|
|
272
|
+
*`pnpm publish --provenance` works on the pinned 10.28.0; the claim that it
|
|
273
|
+
did not was read off `pnpm publish --help`, where the flag is absent.* The
|
|
274
|
+
parser knows it — `--provenanceX` is rejected with *"Did you mean
|
|
275
|
+
'provenance'?"*, and `--provenance --dry-run` runs clean. A help text is a
|
|
276
|
+
document, not the interface; checking a flag means asking the parser. So the
|
|
277
|
+
job is pnpm like every other job here, `--no-git-checks` comes back with it
|
|
278
|
+
(pnpm inspects the branch, CI builds tags in detached HEAD), and
|
|
279
|
+
`COREPACK_ENABLE_STRICT=0` goes away, since it existed only so a bare `npm`
|
|
280
|
+
could run under a `packageManager` that pins pnpm.
|
|
281
|
+
*The post-publish check failed the 0.7.2 release, and the publish it failed
|
|
282
|
+
was correctly signed.* Six attempts over a minute all got a 404 from the
|
|
283
|
+
registry's attestation endpoint; the same endpoint now serves
|
|
284
|
+
`slsa.dev/provenance/v0.2` for `0.7.2`. Lengthening the budget was the wrong
|
|
285
|
+
repair, and the entry it replaces already contains the reason without drawing
|
|
286
|
+
the conclusion: the check runs *after* an irreversible step, so its red cannot
|
|
287
|
+
be cleared — the republish that would rerun it fails `EPUBLISHCONFLICT` on a
|
|
288
|
+
version that now exists. Every failure it can reach is therefore either a
|
|
289
|
+
false one or an unfixable one, on a job nobody can make green again, which
|
|
290
|
+
makes it a machine for teaching that red is routine — the exact habit the
|
|
291
|
+
`audit` job's comment refuses. A gate has to sit where the answer can still
|
|
292
|
+
change what happens. Verification moves to where it can: `npm audit
|
|
293
|
+
signatures` in an installing project, or the package page's Provenance panel.
|
|
294
|
+
*What is unchanged:* the attestation itself, the three preconditions, and the
|
|
295
|
+
fact that npm rejects the publish rather than degrading to an unsigned one if
|
|
296
|
+
any is missing. `0.7.2` is signed, and shipped signed, despite the red job.
|
|
297
|
+
|
|
16
298
|
## [0.7.2] - 2026-08-07
|
|
17
299
|
|
|
18
300
|
### Added
|
|
@@ -3375,26 +3657,6 @@ symptom are worth batching**: individually none justifies a commit, together the
|
|
|
3375
3657
|
cost about an hour, and they are the group no gate could have found, since having
|
|
3376
3658
|
no symptom is the definition of what a gate cannot see.
|
|
3377
3659
|
|
|
3378
|
-
- **Telling adopters about the domain trap, which the shipped guidance
|
|
3379
|
-
currently walks them into.** The repo half landed under `[Unreleased]`; the
|
|
3380
|
-
user-facing half has not, and it is not merely absent. `skill.ts`'s "Four
|
|
3381
|
-
things you must not do" says **"Do not hardcode a value that lives in
|
|
3382
|
-
`params`"**, and the pin every fix in that entry adds is, read literally,
|
|
3383
|
-
exactly that. An adopter who writes a roster param — which the design
|
|
3384
|
-
actively pushes them toward, since a quantifier a scenario can iterate is the
|
|
3385
|
-
shape `QUANTIFIED` recommends — gets an instruction that forbids the only
|
|
3386
|
-
defence against shortening it. The distinction is real and one sentence long:
|
|
3387
|
-
the rule is about the **expectation** the system is measured against, and the
|
|
3388
|
-
pin asserts what the **intent** is. Nothing shipped draws it.
|
|
3389
|
-
*Why it is filed rather than done in the same pass.* It reaches README §"killer
|
|
3390
|
-
move", the `init` skill, `possible-drift`'s troubleshooting section and design
|
|
3391
|
-
§11, in both languages — the same surface `[0.5.0]` rewrote, and that entry
|
|
3392
|
-
records the lesson: rewording the central claim of the tool is not a line
|
|
3393
|
-
appended, and it was held back once precisely so it could be done deliberately
|
|
3394
|
-
and in one pass. The measurement it needs is already taken; what it needs next
|
|
3395
|
-
is the pass. *Not* a `possible-drift` change: nothing here is detectable by
|
|
3396
|
-
the engine, for the reason the entry below gives about prose judgement.
|
|
3397
|
-
|
|
3398
3660
|
- **Binding the `✗` samples to fixtures whose rejection is asserted.** A sample
|
|
3399
3661
|
showing input the engine must *reject* can be bound the way a runnable one is,
|
|
3400
3662
|
to a fixture whose rejection is the thing under test — which is what
|
|
@@ -3413,43 +3675,6 @@ no symptom is the definition of what a gate cannot see.
|
|
|
3413
3675
|
`[0.4.3]` — that the gate and the run proving the fixture still works live in
|
|
3414
3676
|
different suites — applies here identically.
|
|
3415
3677
|
|
|
3416
|
-
- **`render`'s document can carry raw HTML and a forged requirement section.**
|
|
3417
|
-
`sanitised` is the entry §9.1 names and it strips control characters and
|
|
3418
|
-
nothing else, so `statement` and `rationale` reach the Markdown by
|
|
3419
|
-
concatenation exactly as written. Measured: a `rationale` containing newlines
|
|
3420
|
-
and `## ATX-999` renders a whole section — heading, statement, `**Why:**` —
|
|
3421
|
-
for a requirement no registry holds, sitting between two real ones and worded
|
|
3422
|
-
identically; `<script>` and `<img onerror>` survive the same way. `check` and
|
|
3423
|
-
`cover` see only the real id, and `render --check` cannot object, because that
|
|
3424
|
-
*is* what the registry renders to now.
|
|
3425
|
-
*Not an accepted risk, which is the part worth pinning.* §9.1 and the
|
|
3426
|
-
reference both scope their guarantee to control characters, and the
|
|
3427
|
-
reference's heading over that paragraph is "Safe to read, and safe to keep",
|
|
3428
|
-
with "a site generator" named in the threat it describes. What *is* a decision
|
|
3429
|
-
is that prose may be marked up — `plain()` escapes a param value precisely
|
|
3430
|
-
because it is data while the statement around it is not — and that decision
|
|
3431
|
-
has never been separated from allowing raw HTML or a heading. So the entry is
|
|
3432
|
-
as much about stating which of the three is intended as about the escaping,
|
|
3433
|
-
and if the answer is that Markdown stays and HTML and headings go, that is a
|
|
3434
|
-
fourth obligation in §9.1 rather than a patch at an emitter.
|
|
3435
|
-
|
|
3436
|
-
- **`attest init` follows a symlink out of the project root.** `runInit` does
|
|
3437
|
-
`mkdir(dirname(dest), { recursive: true })` and then `writeAtomic`, and
|
|
3438
|
-
neither asks whether `dest` is still under `root`. A `.claude/skills/attest`
|
|
3439
|
-
planted in the repository as a link to anywhere the user can write is followed
|
|
3440
|
-
by both: measured on Windows with a junction, a file at the target holding
|
|
3441
|
-
`USER OWNED FILE - DO NOT OVERWRITE` came back holding the skill. The report
|
|
3442
|
-
prints the relative path, so nothing on screen says where it went.
|
|
3443
|
-
*`write.ts` is not the gap and neither is `targets.ts`'s content.* The `wx`
|
|
3444
|
-
flag defends the *temporary* path against a planted link and does exactly
|
|
3445
|
-
that; the destination's directory was never in its scope. And `workflowBody()`
|
|
3446
|
-
is a pure function of nothing, so no byte of the checked project reaches the
|
|
3447
|
-
file — the exposure is the path, not the content, which is `targets.ts`'s
|
|
3448
|
-
first rule ("Attest owns the path") being an assumption rather than a check.
|
|
3449
|
-
The check belongs beside `resolveTargets`, which is already all-or-nothing for
|
|
3450
|
-
the same reason: a name known to be unwritable must not leave half a set
|
|
3451
|
-
behind.
|
|
3452
|
-
|
|
3453
3678
|
## Under consideration
|
|
3454
3679
|
|
|
3455
3680
|
Candidates with **no decision yet**, between the two sections either side of it
|
|
@@ -3463,7 +3688,7 @@ decision, because that is what a re-proposal a year from now would otherwise
|
|
|
3463
3688
|
have to gather again.
|
|
3464
3689
|
|
|
3465
3690
|
- **Trusted publishing, which would delete `NPM_TOKEN` rather than add to it.**
|
|
3466
|
-
The provenance work in `[
|
|
3691
|
+
The provenance work in `[0.7.2]` signs the release but does not change
|
|
3467
3692
|
what authorises it: a long-lived automation token, masked and protected, that
|
|
3468
3693
|
publishes as us to anyone holding it. npm's trusted publishing exchanges the
|
|
3469
3694
|
same OIDC identity the attestation already uses for a short-lived credential,
|
|
@@ -3837,41 +4062,6 @@ have to gather again.
|
|
|
3837
4062
|
because a project whose requirement count only rises should have decided in
|
|
3838
4063
|
advance what would make one leave.
|
|
3839
4064
|
|
|
3840
|
-
- **The `cmd.exe` quoting in `tests/consumer.spec.ts` guards one character of
|
|
3841
|
-
several.** `shellArg` asserts the argument holds no `"` and then quotes only
|
|
3842
|
-
when it holds whitespace, so an argument with none goes to the shell bare —
|
|
3843
|
-
and `&`, `|`, `^`, `<`, `>`, `(` are metacharacters there that the assertion
|
|
3844
|
-
does not name. The fix is one character class, which is why the entry is not
|
|
3845
|
-
about the fix.
|
|
3846
|
-
*What blocks the decision is that no input reaches it.* Both arguments are
|
|
3847
|
-
either a literal flag or a path from `mkdtemp`, `NEEDS_SHELL` is win32 only
|
|
3848
|
-
and CI is Linux, and the one path an attacker could plausibly influence — the
|
|
3849
|
-
checkout directory on a fork MR pipeline — comes from a GitLab project path,
|
|
3850
|
-
whose character set excludes every metacharacter above. So this is an
|
|
3851
|
-
observation with no attack path, and the honest question is not whether to
|
|
3852
|
-
widen the class but whether the comment above it should stop claiming more
|
|
3853
|
-
than the assertion covers: it says "the assertion keeps that true" about
|
|
3854
|
-
arguments generally, where what it checks is one character. Widening the class
|
|
3855
|
-
and leaving the prose is the change that would look like a fix and settle
|
|
3856
|
-
nothing.
|
|
3857
|
-
|
|
3858
|
-
- **A loader that fails to start leaves its scratch directory behind.**
|
|
3859
|
-
`createLoader` calls `mkdtempSync` and writes the stub *before* `createServer`,
|
|
3860
|
-
and the only `rm` of that directory is inside the `close` of the object
|
|
3861
|
-
`createServer` returns — so a throw from it leaks one `attest-loader-*` per
|
|
3862
|
-
invocation, which on a CI runner is one per build. That is the exact failure
|
|
3863
|
-
shape the comment on `close` records as already fixed, surviving on the other
|
|
3864
|
-
path.
|
|
3865
|
-
*What blocks it is that nothing has been made to throw there.* With
|
|
3866
|
-
`configFile: false` Vite still reads the `package.json` at the root to decide
|
|
3867
|
-
`type`, so a malformed one in the checked project is the obvious candidate and
|
|
3868
|
-
it is the project's to write — but it was not tried, and an entry claiming a
|
|
3869
|
-
trigger it has not seen is the thing this section exists to not accumulate.
|
|
3870
|
-
The repair is three lines (a `try` around the two calls that `rm`s and
|
|
3871
|
-
rethrows) and is not what the decision turns on; measuring whether the throw
|
|
3872
|
-
is reachable at all is, because if it is not, this is a leak with no input
|
|
3873
|
-
that produces it.
|
|
3874
|
-
|
|
3875
4065
|
## Considered and rejected
|
|
3876
4066
|
|
|
3877
4067
|
Decisions **not** to build something, kept where they can be found before the
|
|
@@ -4196,6 +4386,7 @@ requirement, and human review at propose is still the whole answer. What is
|
|
|
4196
4386
|
rejected is grouping as a way to assist it, and this reopens only on a
|
|
4197
4387
|
contradiction that grouping would have caught.
|
|
4198
4388
|
|
|
4389
|
+
[0.7.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.3
|
|
4199
4390
|
[0.7.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.2
|
|
4200
4391
|
[0.7.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.1
|
|
4201
4392
|
[0.7.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.0
|
package/README.md
CHANGED
|
@@ -29,6 +29,15 @@ together and the test stays green through any edit. Pin the expectation to
|
|
|
29
29
|
something that does not move with the param — a fixture, a literal in the test,
|
|
30
30
|
or a second independently derived value.
|
|
31
31
|
|
|
32
|
+
A composite param has a second failure of its own, and it runs the other way. When
|
|
33
|
+
a test **loops over** a list it read from `params`, that list is the set of cases
|
|
34
|
+
the run covers: drop a member and every assertion inside the loop still passes
|
|
35
|
+
over what is left, so the suite quietly tests less with nothing to show for it.
|
|
36
|
+
Pin the extent beside the loop — the members against a literal when their identity
|
|
37
|
+
is the promise, the length when the size is. That literal is not the copy the
|
|
38
|
+
single source exists to prevent: it is not what the system is measured against,
|
|
39
|
+
it is what the intent claimed to cover.
|
|
40
|
+
|
|
32
41
|
## Prerequisites
|
|
33
42
|
|
|
34
43
|
- Node ≥ 20.19
|
|
@@ -157,7 +166,7 @@ Every diagnostic carries a `code`, and every code has a section in
|
|
|
157
166
|
```
|
|
158
167
|
ERROR registry-not-static (requirements/upload.reqs.ts:5)
|
|
159
168
|
Value is not a literal.
|
|
160
|
-
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.7.
|
|
169
|
+
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.7.3/docs/en/troubleshooting.md#registry-not-static
|
|
161
170
|
```
|
|
162
171
|
|
|
163
172
|
The anchor **is** the code, so the link cannot point somewhere the section
|
package/bin/attest.js
CHANGED
|
File without changes
|
package/dist/cli/json.d.ts
CHANGED
|
@@ -74,8 +74,9 @@ export declare function renderReport(version: string, issues: Issue[], outFile?:
|
|
|
74
74
|
/**
|
|
75
75
|
* `init`. Carries the paths written so a consumer learns where the instructions
|
|
76
76
|
* went without hardcoding the convention. `issues` is empty on the success path
|
|
77
|
-
* — `init` writes or throws — apart from
|
|
78
|
-
*
|
|
77
|
+
* — `init` writes or throws — apart from the two ERRORs it refuses on,
|
|
78
|
+
* `unknown-target` and `unsafe-target-path`. Both are decided before anything is
|
|
79
|
+
* written and so report no files at all.
|
|
79
80
|
*
|
|
80
81
|
* `outFile` survives beside `outFiles` for the one-file run, which is what the
|
|
81
82
|
* default invocation still is: dropping it would break the consumer the field
|
package/dist/cli/json.js
CHANGED
|
@@ -92,8 +92,9 @@ export function renderReport(version, issues, outFile) {
|
|
|
92
92
|
/**
|
|
93
93
|
* `init`. Carries the paths written so a consumer learns where the instructions
|
|
94
94
|
* went without hardcoding the convention. `issues` is empty on the success path
|
|
95
|
-
* — `init` writes or throws — apart from
|
|
96
|
-
*
|
|
95
|
+
* — `init` writes or throws — apart from the two ERRORs it refuses on,
|
|
96
|
+
* `unknown-target` and `unsafe-target-path`. Both are decided before anything is
|
|
97
|
+
* written and so report no files at all.
|
|
97
98
|
*
|
|
98
99
|
* `outFile` survives beside `outFiles` for the one-file run, which is what the
|
|
99
100
|
* default invocation still is: dropping it would break the consumer the field
|
package/dist/core/docs.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* and the `##` headings of both language documents, so landing here cannot
|
|
9
9
|
* produce a dead link.
|
|
10
10
|
*/
|
|
11
|
-
export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "non-scalar-interpolation", "orphan-test", "possible-drift", "proposed-spec-name-taken", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "spec-in-change-dir", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target", "unreadable-file"];
|
|
11
|
+
export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "non-scalar-interpolation", "orphan-test", "possible-drift", "proposed-spec-name-taken", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "spec-in-change-dir", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target", "unreadable-file", "unsafe-target-path"];
|
|
12
12
|
export type IssueCode = (typeof ISSUE_CODES)[number];
|
|
13
13
|
/**
|
|
14
14
|
* The page explaining `code`, or `undefined` when nothing explains it.
|
package/dist/core/docs.js
CHANGED
package/dist/core/loader.js
CHANGED
|
@@ -60,38 +60,59 @@ const VITEST_STUB = 'export const describe=()=>{};export const it=()=>{};export
|
|
|
60
60
|
'export default {};';
|
|
61
61
|
export async function createLoader() {
|
|
62
62
|
const dir = mkdtempSync(join(tmpdir(), 'attest-loader-'));
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
63
|
+
// Everything between creating the directory and returning its owner runs
|
|
64
|
+
// inside this `try`, because until the caller holds the object below, nothing
|
|
65
|
+
// else can call `close` — so a throw here is the one path where the directory
|
|
66
|
+
// outlives the only code that knows about it. That is the same leak the
|
|
67
|
+
// comment on `close` records as already fixed, surviving on the other side of
|
|
68
|
+
// the same function.
|
|
69
|
+
//
|
|
70
|
+
// Stated over the window rather than over `createServer`, which is where it
|
|
71
|
+
// was found: `writeFileSync` is inside it too, and a disk that is full or a
|
|
72
|
+
// temp directory that turns read-only between the two calls reaches it without
|
|
73
|
+
// any project being involved.
|
|
74
|
+
let server;
|
|
75
|
+
try {
|
|
76
|
+
const stub = join(dir, 'vitest-stub.mjs');
|
|
77
|
+
writeFileSync(stub, VITEST_STUB);
|
|
78
|
+
server = await createServer({
|
|
79
|
+
configFile: false,
|
|
80
|
+
logLevel: 'error',
|
|
81
|
+
// Not just quiet — sanitised. See `sanitisedLogger`: what survives
|
|
82
|
+
// `logLevel: 'error'` is exactly the message that carries the checked
|
|
83
|
+
// project's own text.
|
|
84
|
+
customLogger: sanitisedLogger(),
|
|
85
|
+
appType: 'custom',
|
|
86
|
+
// `ws: false` is load-bearing, and `middlewareMode` is not enough on its
|
|
87
|
+
// own: it suppresses the HTTP server but Vite still starts the HMR
|
|
88
|
+
// WebSocket server, which binds `::` — every interface, not loopback — on
|
|
89
|
+
// port 24678. Every `attest` command that reads a registry by evaluating
|
|
90
|
+
// it therefore opened a network port for the length of the run, on a
|
|
91
|
+
// developer's machine and on every CI runner, for a channel that does
|
|
92
|
+
// nothing here: nothing subscribes to HMR, because nothing is watching.
|
|
93
|
+
//
|
|
94
|
+
// The visible symptom was the collision. Two attest processes at once —
|
|
95
|
+
// ordinary in a CI matrix, and what this repo's own concurrent specs do —
|
|
96
|
+
// and the second printed `WebSocket server error: Port is already in use`
|
|
97
|
+
// into the middle of a report, through a `logLevel: 'error'` that was
|
|
98
|
+
// supposed to have silenced the loader entirely.
|
|
99
|
+
//
|
|
100
|
+
// `hmr: false` does *not* close it (measured, Vite 5.4): the ws server is
|
|
101
|
+
// created before the hmr option is consulted. `ws: false` is the one that
|
|
102
|
+
// leaves no listening handle at all.
|
|
103
|
+
server: { middlewareMode: true, ws: false },
|
|
104
|
+
resolve: { alias: { vitest: stub } },
|
|
105
|
+
ssr: { noExternal: ['vitest'] },
|
|
106
|
+
optimizeDeps: { noDiscovery: true },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
// The original error is what the caller has to see, so a cleanup that fails
|
|
111
|
+
// must not replace it. `force` already forgives a directory that is not
|
|
112
|
+
// there; this forgives one that will not go.
|
|
113
|
+
await rm(dir, { recursive: true, force: true }).catch(() => { });
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
95
116
|
let closed = false;
|
|
96
117
|
return {
|
|
97
118
|
scratchDir: dir,
|
|
@@ -111,7 +132,16 @@ export async function createLoader() {
|
|
|
111
132
|
await server.close();
|
|
112
133
|
}
|
|
113
134
|
finally {
|
|
114
|
-
|
|
135
|
+
// Swallowed for the same reason as the `rm` on the failing path above,
|
|
136
|
+
// and it counts for more here: every caller closes from a `finally`, so
|
|
137
|
+
// a throw from this line replaces whatever the block was doing — the
|
|
138
|
+
// real error on the way out, or a *successful* return turned into a
|
|
139
|
+
// crash about a temp directory. What that reader would then be handed
|
|
140
|
+
// is a misdiagnosis pointing at their own correct files, which this
|
|
141
|
+
// repository already treats as worse than the gap it fills.
|
|
142
|
+
// Given up with it: the one signal a failed cleanup could have raised,
|
|
143
|
+
// which no caller could have acted on anyway.
|
|
144
|
+
await rm(dir, { recursive: true, force: true }).catch(() => { });
|
|
115
145
|
}
|
|
116
146
|
},
|
|
117
147
|
};
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -12,4 +12,20 @@
|
|
|
12
12
|
export declare function toPosixPath(path: string, separator?: string): string;
|
|
13
13
|
/** `path.relative`, in the one spelling the rest of the engine expects. */
|
|
14
14
|
export declare function relativePath(from: string, to: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Whether `path` is `root` or sits below it, both given as resolved paths.
|
|
17
|
+
*
|
|
18
|
+
* Asked through `relativePath` rather than by comparing prefixes, because the
|
|
19
|
+
* two ways this goes wrong are both invisible in a `startsWith`. A sibling
|
|
20
|
+
* directory shares the prefix — `/repo-backup` starts with `/repo` — and on
|
|
21
|
+
* Windows a path on another drive has *no* relative spelling at all, so
|
|
22
|
+
* `path.relative` answers with an absolute one rather than a chain of `..`.
|
|
23
|
+
* Testing the relative form catches both: an escape is either `..`-led or
|
|
24
|
+
* absolute, and nothing else is.
|
|
25
|
+
*
|
|
26
|
+
* The caller owes the resolution. Nothing here follows a symbolic link, so a
|
|
27
|
+
* path that is lexically inside can still be physically outside — that is the
|
|
28
|
+
* question `join` cannot answer and this function does not pretend to.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isInside(root: string, path: string): boolean;
|
|
15
31
|
//# sourceMappingURL=paths.d.ts.map
|
package/dist/core/paths.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// Normalising here rather than at the glob has a second dividend: a report is
|
|
15
15
|
// then byte-identical across platforms, so a `--json` consumer diffing two CI
|
|
16
16
|
// runs is not reading the runner's operating system.
|
|
17
|
-
import { relative, sep } from 'node:path';
|
|
17
|
+
import { isAbsolute, relative, sep } from 'node:path';
|
|
18
18
|
/**
|
|
19
19
|
* A native path as a POSIX one.
|
|
20
20
|
*
|
|
@@ -33,4 +33,23 @@ export function toPosixPath(path, separator = sep) {
|
|
|
33
33
|
export function relativePath(from, to) {
|
|
34
34
|
return toPosixPath(relative(from, to));
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether `path` is `root` or sits below it, both given as resolved paths.
|
|
38
|
+
*
|
|
39
|
+
* Asked through `relativePath` rather than by comparing prefixes, because the
|
|
40
|
+
* two ways this goes wrong are both invisible in a `startsWith`. A sibling
|
|
41
|
+
* directory shares the prefix — `/repo-backup` starts with `/repo` — and on
|
|
42
|
+
* Windows a path on another drive has *no* relative spelling at all, so
|
|
43
|
+
* `path.relative` answers with an absolute one rather than a chain of `..`.
|
|
44
|
+
* Testing the relative form catches both: an escape is either `..`-led or
|
|
45
|
+
* absolute, and nothing else is.
|
|
46
|
+
*
|
|
47
|
+
* The caller owes the resolution. Nothing here follows a symbolic link, so a
|
|
48
|
+
* path that is lexically inside can still be physically outside — that is the
|
|
49
|
+
* question `join` cannot answer and this function does not pretend to.
|
|
50
|
+
*/
|
|
51
|
+
export function isInside(root, path) {
|
|
52
|
+
const rel = relativePath(root, path);
|
|
53
|
+
return rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel);
|
|
54
|
+
}
|
|
36
55
|
//# sourceMappingURL=paths.js.map
|
package/dist/core/pipeline.js
CHANGED
|
@@ -13,9 +13,9 @@ import { DEFAULT_TARGET, resolveTargets } from './targets.js';
|
|
|
13
13
|
import { writeAtomic } from './write.js';
|
|
14
14
|
import { applyMerge, mergedSpecPath } from './merge.js';
|
|
15
15
|
import { compilerIssue } from './compiler.js';
|
|
16
|
-
import { mkdir, readFile } from 'node:fs/promises';
|
|
16
|
+
import { mkdir, readFile, realpath } from 'node:fs/promises';
|
|
17
17
|
import { basename, dirname, join } from 'node:path';
|
|
18
|
-
import { relativePath } from './paths.js';
|
|
18
|
+
import { isInside, relativePath } from './paths.js';
|
|
19
19
|
import { hasError } from './types.js';
|
|
20
20
|
// The runner half of the engine, reached only when a command actually needs it.
|
|
21
21
|
//
|
|
@@ -450,15 +450,78 @@ export async function runInit(root, names) {
|
|
|
450
450
|
const { targets, issues } = resolveTargets(names.length > 0 ? names : [DEFAULT_TARGET]);
|
|
451
451
|
if (issues.length > 0)
|
|
452
452
|
return { files: [], issues };
|
|
453
|
-
|
|
453
|
+
// Every destination is resolved before any of them is written, for the reason
|
|
454
|
+
// `resolveTargets` refuses the whole set on one unknown name: a run that stops
|
|
455
|
+
// partway leaves a repository carrying instructions for some agents and a
|
|
456
|
+
// failing command that does not say which.
|
|
457
|
+
// A root that does not exist yet is `init <dir>` on a directory this run
|
|
458
|
+
// creates, and nothing can have been planted inside a directory that is not
|
|
459
|
+
// there — so it stands for itself and every segment below it resolves
|
|
460
|
+
// lexically, which is what the old implementation did for all of them.
|
|
461
|
+
const realRoot = await realpath(root).catch(() => root);
|
|
462
|
+
const writes = [];
|
|
463
|
+
const refusals = [];
|
|
454
464
|
for (const target of targets) {
|
|
455
|
-
const
|
|
465
|
+
const resolved = await resolveDest(realRoot, target.file);
|
|
466
|
+
if (resolved.ok) {
|
|
467
|
+
writes.push({ target, dest: resolved.dest });
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
refusals.push({
|
|
471
|
+
level: 'ERROR',
|
|
472
|
+
code: 'unsafe-target-path',
|
|
473
|
+
file: target.file,
|
|
474
|
+
message: `Refusing to write ${target.file}: it resolves to ${resolved.escape}, outside the project. Remove the link at that path and re-run.`,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (refusals.length > 0)
|
|
479
|
+
return { files: [], issues: refusals };
|
|
480
|
+
const files = [];
|
|
481
|
+
for (const { target, dest } of writes) {
|
|
456
482
|
await mkdir(dirname(dest), { recursive: true });
|
|
457
483
|
await writeAtomic(dest, target.content());
|
|
458
484
|
files.push(target.file);
|
|
459
485
|
}
|
|
460
486
|
return { files, issues };
|
|
461
487
|
}
|
|
488
|
+
/**
|
|
489
|
+
* Where a target's file really goes, or the path outside the project that
|
|
490
|
+
* asking put it at (design §9: ownership of the path is checked, not assumed).
|
|
491
|
+
*
|
|
492
|
+
* `join(root, file)` answers where the path *reads* as going, and that is not
|
|
493
|
+
* the same question: a segment that is a link makes a path lexically inside the
|
|
494
|
+
* root and physically outside it, which `mkdir -p` and the write both follow.
|
|
495
|
+
* So the resolution is the check, and a lexical one would not be — `join`
|
|
496
|
+
* normalises `..`, so it is already satisfied here. `writeAtomic`'s `wx` is not
|
|
497
|
+
* the defence either and was never scoped to be: it guards the *temporary* path
|
|
498
|
+
* against a planted link, in whichever directory the destination turns out to
|
|
499
|
+
* be.
|
|
500
|
+
*
|
|
501
|
+
* So every segment is resolved, not just the leaf. A link anywhere along the
|
|
502
|
+
* way is the escape, and the leaf is checked too — `rename` replaces a symlink
|
|
503
|
+
* rather than following it, so a link there would not leak the content, but it
|
|
504
|
+
* would silently destroy a file the user made deliberately.
|
|
505
|
+
*
|
|
506
|
+
* A segment that does not exist cannot be a link, and neither can the ones
|
|
507
|
+
* below it, so `realpath` failing means the rest of the path is Attest's own
|
|
508
|
+
* `mkdir` to create. What this cannot close is a link planted between this
|
|
509
|
+
* resolution and the write; the exposure it does close is one committed to the
|
|
510
|
+
* repository, which is the one a checkout hands you.
|
|
511
|
+
*/
|
|
512
|
+
async function resolveDest(realRoot, file) {
|
|
513
|
+
let current = realRoot;
|
|
514
|
+
// `AgentTarget.file` is spelled with `/` on every platform (ATX-28), so the
|
|
515
|
+
// segments are the same list here as they are in the report.
|
|
516
|
+
for (const segment of file.split('/')) {
|
|
517
|
+
const next = join(current, segment);
|
|
518
|
+
const real = await realpath(next).catch(() => next);
|
|
519
|
+
if (!isInside(realRoot, real))
|
|
520
|
+
return { ok: false, escape: relativePath(realRoot, real) };
|
|
521
|
+
current = real;
|
|
522
|
+
}
|
|
523
|
+
return { ok: true, dest: current };
|
|
524
|
+
}
|
|
462
525
|
/**
|
|
463
526
|
* A change name must name one directory inside `changes/`, and nothing else.
|
|
464
527
|
*
|
package/dist/core/render.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// back, so the direction OpenSpec's pure-Markdown model went (Markdown as
|
|
7
7
|
// truth, and the free-form drift that comes with it) stays closed.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
9
|
+
// Five properties this file must keep:
|
|
10
10
|
// - **Intent only.** The document says what the system promises, never what is
|
|
11
11
|
// proven or green: coverage and results are verdicts, and verdicts belong to
|
|
12
12
|
// `cover` / `verify`, which recompute them on demand. Putting them here also
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
// and it is a *file* — committed, served, and read again long after the run
|
|
26
26
|
// that wrote it. See `sanitised` for why the defence sits here rather than
|
|
27
27
|
// at the terminal write.
|
|
28
|
+
// - **Prose stays prose.** A string the registry owns may be marked up and may
|
|
29
|
+
// not become a tag or a heading: the document promises one section per
|
|
30
|
+
// requirement, and a rationale that opens its own breaks the only structural
|
|
31
|
+
// claim the document makes. See `prose` (design §9.1).
|
|
28
32
|
import { byCodeUnit, sortDeep } from './order.js';
|
|
29
33
|
import { control } from './terminal.js';
|
|
30
34
|
/** Whether a value reads as words in a sentence — a scalar, or a list of them. */
|
|
@@ -199,7 +203,7 @@ function compareIds(a, b) {
|
|
|
199
203
|
function overviewTable(registry, ids) {
|
|
200
204
|
const rows = ids.map((id) => {
|
|
201
205
|
const req = registry[id];
|
|
202
|
-
return `| [${id}](${anchor(id)}) | ${cell(
|
|
206
|
+
return `| [${id}](${anchor(id)}) | ${cell(statementText(req))} |`;
|
|
203
207
|
});
|
|
204
208
|
return ['| ID | Requirement |', '| --- | --- |', ...rows];
|
|
205
209
|
}
|
|
@@ -207,9 +211,9 @@ function section(id, req) {
|
|
|
207
211
|
const out = [
|
|
208
212
|
`## ${id}`,
|
|
209
213
|
'',
|
|
210
|
-
|
|
214
|
+
statementText(req),
|
|
211
215
|
'',
|
|
212
|
-
`**Why:** ${req.rationale}`,
|
|
216
|
+
`**Why:** ${prose(req.rationale)}`,
|
|
213
217
|
];
|
|
214
218
|
const params = Object.entries(req.params);
|
|
215
219
|
if (params.length > 0) {
|
|
@@ -225,33 +229,200 @@ function section(id, req) {
|
|
|
225
229
|
}
|
|
226
230
|
}
|
|
227
231
|
if (req.outOfScope.length > 0) {
|
|
228
|
-
out.push('', '**Out of scope**', '', ...req.outOfScope.map((s) => `- ${s}`));
|
|
232
|
+
out.push('', '**Out of scope**', '', ...req.outOfScope.map((s) => `- ${prose(s)}`));
|
|
229
233
|
}
|
|
230
234
|
return out;
|
|
231
235
|
}
|
|
236
|
+
/** The statement as the document says it, with its params in place. */
|
|
237
|
+
function statementText(req) {
|
|
238
|
+
return prose(req.statement, req.params);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Author prose as Markdown that cannot become HTML and cannot open a section,
|
|
242
|
+
* with `{param}` placeholders substituted as it goes (design §9.1).
|
|
243
|
+
*
|
|
244
|
+
* One pass rather than two because the two jobs need the same fact — which
|
|
245
|
+
* regime each character is in. Escaping first and substituting after would run
|
|
246
|
+
* the value through a scanner reading backticks as structure; substituting
|
|
247
|
+
* first and escaping after would let a value's own backtick open a code span
|
|
248
|
+
* that was never in the statement, and carry the rest of the sentence out of
|
|
249
|
+
* the defence. Knowing the position is also what lets a value be escaped
|
|
250
|
+
* *for* it, which is the difference between `emphasised` and `literal` below.
|
|
251
|
+
*
|
|
252
|
+
* Which of the three things a registry string could do here is intended, and
|
|
253
|
+
* why this escapes rather than refuses, is §9.1. What is local to this function
|
|
254
|
+
* is where it stops: **a code span is left exactly as written**, because a
|
|
255
|
+
* span's contents are literal text in every renderer and an entity written
|
|
256
|
+
* there would be visible rather than decoded. The match is therefore
|
|
257
|
+
* deliberately conservative — a backtick run opens a span only if a run of
|
|
258
|
+
* *exactly* that length closes it (CommonMark), and an unmatched run is text
|
|
259
|
+
* with the defence still applied after it. Being wrong the other way, reading
|
|
260
|
+
* text as a span the renderer will not, is the only direction that leaks.
|
|
261
|
+
*/
|
|
262
|
+
function prose(text, params = {}) {
|
|
263
|
+
const spans = spanReader(text);
|
|
264
|
+
const out = [];
|
|
265
|
+
let i = 0;
|
|
266
|
+
let atLineStart = true;
|
|
267
|
+
while (i < text.length) {
|
|
268
|
+
const ch = text[i];
|
|
269
|
+
if (ch === '`') {
|
|
270
|
+
const end = spans.spanEndAt(i);
|
|
271
|
+
out.push(substitute(text.slice(i, end), params, literal));
|
|
272
|
+
atLineStart = false;
|
|
273
|
+
i = end;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (ch === '{') {
|
|
277
|
+
const placeholder = /^\{\w+\}/.exec(text.slice(i));
|
|
278
|
+
if (placeholder !== null) {
|
|
279
|
+
out.push(substitute(placeholder[0], params, emphasised));
|
|
280
|
+
atLineStart = false;
|
|
281
|
+
i += placeholder[0].length;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (ch === '\n') {
|
|
286
|
+
out.push(ch);
|
|
287
|
+
atLineStart = true;
|
|
288
|
+
i += 1;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (atLineStart) {
|
|
292
|
+
// Leading whitespace does not end the line's opening position: ` # x` is
|
|
293
|
+
// a heading, and four spaces make an indented code block where the escape
|
|
294
|
+
// is visible but harmless. Counting the indent to tell those apart would
|
|
295
|
+
// be a second Markdown reader for one cosmetic case.
|
|
296
|
+
if (ch === ' ' || ch === '\t') {
|
|
297
|
+
out.push(ch);
|
|
298
|
+
i += 1;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
atLineStart = false;
|
|
302
|
+
// A heading opens a section; a line of `=` or `-` alone makes a heading
|
|
303
|
+
// out of the line *above* it, which is the same forgery approaching from
|
|
304
|
+
// behind. A backslash before either renders the character and nothing else.
|
|
305
|
+
if (ch === '#' || SETEXT.test(lineAt(text, i))) {
|
|
306
|
+
out.push('\\');
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
out.push(ch === '<' ? '<' : ch);
|
|
311
|
+
i += 1;
|
|
312
|
+
}
|
|
313
|
+
return out.join('');
|
|
314
|
+
}
|
|
315
|
+
/** A line that would turn the one above it into a heading. */
|
|
316
|
+
const SETEXT = /^(?:=+|-+)[ \t]*$/;
|
|
317
|
+
function lineAt(text, from) {
|
|
318
|
+
const end = text.indexOf('\n', from);
|
|
319
|
+
return end === -1 ? text.slice(from) : text.slice(from, end);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Where each code span in `text` ends, answered in constant time per question.
|
|
323
|
+
*
|
|
324
|
+
* The obvious implementation searches forward from the opening run for a run of
|
|
325
|
+
* the same length, and it is the ATX-59 defect in a second costume: a run that
|
|
326
|
+
* closes nothing scans to the end of the string, so prose whose runs are all of
|
|
327
|
+
* *different* lengths pays that for every one of them. Measured, before this:
|
|
328
|
+
* 500 KB of such a rationale took 1.6 s and 2 MB took 15 s — from `attest
|
|
329
|
+
* render` with no flag, on text the registry chooses.
|
|
330
|
+
*
|
|
331
|
+
* So the runs are read once and indexed by length, and each length keeps a
|
|
332
|
+
* cursor into its own list. Openers are visited left to right, so a cursor only
|
|
333
|
+
* ever moves forward and the total work is bounded by the number of runs. A
|
|
334
|
+
* shorter or longer run is not a closer and is never a candidate here, which is
|
|
335
|
+
* the CommonMark rule and also what stops a scanner believing in a span the
|
|
336
|
+
* renderer does not.
|
|
337
|
+
*/
|
|
338
|
+
function spanReader(text) {
|
|
339
|
+
const runs = [];
|
|
340
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
341
|
+
if (text[i] !== '`')
|
|
342
|
+
continue;
|
|
343
|
+
const start = i;
|
|
344
|
+
while (text[i] === '`')
|
|
345
|
+
i += 1;
|
|
346
|
+
runs.push({ at: start, length: i - start });
|
|
347
|
+
i -= 1;
|
|
348
|
+
}
|
|
349
|
+
const byLength = new Map();
|
|
350
|
+
runs.forEach((run, index) => {
|
|
351
|
+
const list = byLength.get(run.length);
|
|
352
|
+
if (list === undefined)
|
|
353
|
+
byLength.set(run.length, [index]);
|
|
354
|
+
else
|
|
355
|
+
list.push(index);
|
|
356
|
+
});
|
|
357
|
+
const cursors = new Map();
|
|
358
|
+
let nextRun = 0;
|
|
359
|
+
return {
|
|
360
|
+
/** The end of the span opening at `at`, or the end of that run if none. */
|
|
361
|
+
spanEndAt: (at) => {
|
|
362
|
+
while (runs[nextRun].at < at)
|
|
363
|
+
nextRun += 1;
|
|
364
|
+
const opener = runs[nextRun];
|
|
365
|
+
const candidates = byLength.get(opener.length);
|
|
366
|
+
let cursor = cursors.get(opener.length) ?? 0;
|
|
367
|
+
while (cursor < candidates.length && candidates[cursor] <= nextRun)
|
|
368
|
+
cursor += 1;
|
|
369
|
+
cursors.set(opener.length, cursor);
|
|
370
|
+
const closer = cursor < candidates.length ? runs[candidates[cursor]] : undefined;
|
|
371
|
+
return closer === undefined ? at + opener.length : closer.at + closer.length;
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
}
|
|
232
375
|
/**
|
|
233
|
-
* Substitute `{param}` placeholders
|
|
234
|
-
*
|
|
376
|
+
* Substitute `{param}` placeholders in one region with `render`, the spelling
|
|
377
|
+
* that region's position calls for.
|
|
378
|
+
*
|
|
235
379
|
* An unbound placeholder is left verbatim — `check` already reports it as an
|
|
236
380
|
* `unbound-param` error, and silently swallowing it here would hide that.
|
|
237
381
|
*/
|
|
238
|
-
function
|
|
382
|
+
function substitute(region, params, render) {
|
|
239
383
|
// `Object.hasOwn`, never `in`: `'toString' in {}` is true, and an `in` probe
|
|
240
384
|
// would splice `function toString() { [native code] }` into a document that
|
|
241
385
|
// reviewers and audit read as the system's promise.
|
|
242
|
-
return
|
|
386
|
+
return region.replace(/\{(\w+)\}/g, (whole, name) => Object.hasOwn(params, name) ? render(params[name]) : whole);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* A param value in prose position: escaped, and emphasised so a reader can see
|
|
390
|
+
* which words in the sentence are pinned to a single source.
|
|
391
|
+
*
|
|
392
|
+
* Escaped unlike the statement around it, because those are prose an author may
|
|
393
|
+
* want to mark up and a value is data that must survive verbatim — an unescaped
|
|
394
|
+
* `*` would otherwise break out of the emphasis it is wrapped in. `<` and `#`
|
|
395
|
+
* are in the class for the same reason the rest of it is: they are structure
|
|
396
|
+
* *here*. A value is spliced into a sentence, so a tag in one is a tag in the
|
|
397
|
+
* document, and a value carrying a newline puts what follows at the start of a
|
|
398
|
+
* line, where `#` opens a section (design §9.1).
|
|
399
|
+
*/
|
|
400
|
+
function emphasised(value) {
|
|
401
|
+
const one = (v) => v.replace(/([*_`[\]\\#])/g, '\\$1').replaceAll('<', '<');
|
|
402
|
+
return `**${listed(value, (v) => one(inlineText(v)))}**`;
|
|
243
403
|
}
|
|
244
404
|
/**
|
|
245
|
-
* A param value
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
405
|
+
* A param value inside the author's code span: the value, and nothing added.
|
|
406
|
+
*
|
|
407
|
+
* A code span's contents are literal in every renderer, so escaping there is
|
|
408
|
+
* not merely unnecessary, it is *visible* — Markdown does not decode `<`
|
|
409
|
+
* inside one, and this repository's own `ATX-30` renders a path with `<name>`
|
|
410
|
+
* in it. Emphasis is dropped for the same reason: `**` in a code span is two
|
|
411
|
+
* asterisks, which is what the document said before this knew where it was.
|
|
412
|
+
*
|
|
413
|
+
* The exception is a value that would end the span early — a backtick, or the
|
|
414
|
+
* blank line a code span cannot contain. There the author has asked for two
|
|
415
|
+
* incompatible things, and the prose escape is the safe answer rather than the
|
|
416
|
+
* pretty one: what follows the broken span is author text this pass has already
|
|
417
|
+
* walked past.
|
|
249
418
|
*/
|
|
250
|
-
function
|
|
251
|
-
const
|
|
252
|
-
return
|
|
253
|
-
|
|
254
|
-
|
|
419
|
+
function literal(value) {
|
|
420
|
+
const text = listed(value, inlineText);
|
|
421
|
+
return text.includes('`') || /\n[ \t]*\n/.test(text) ? emphasised(value) : text;
|
|
422
|
+
}
|
|
423
|
+
/** A list param reads as its members; a scalar reads as itself. */
|
|
424
|
+
function listed(value, one) {
|
|
425
|
+
return Array.isArray(value) ? value.map(one).join(', ') : one(value);
|
|
255
426
|
}
|
|
256
427
|
/**
|
|
257
428
|
* One param value as a run of text.
|
|
@@ -273,9 +444,7 @@ function inlineText(value) {
|
|
|
273
444
|
function formatValue(value) {
|
|
274
445
|
if (!isFlat(value))
|
|
275
446
|
return '_see below_';
|
|
276
|
-
return
|
|
277
|
-
? value.map((v) => code(inlineText(v))).join(', ')
|
|
278
|
-
: code(inlineText(value));
|
|
447
|
+
return listed(value, (v) => code(inlineText(v)));
|
|
279
448
|
}
|
|
280
449
|
/**
|
|
281
450
|
* A structured param as a fenced JSON block.
|
package/dist/core/skill.js
CHANGED
|
@@ -93,6 +93,15 @@ promises is a two-stage workflow, and the stages are separate on purpose.
|
|
|
93
93
|
through any edit. Pin the expectation to something that does not move with the
|
|
94
94
|
param — a fixture, a literal in the test, or a second independently derived
|
|
95
95
|
value.
|
|
96
|
+
- **A param the scenario loops over is the *domain* of the run, and shortening it
|
|
97
|
+
is silent.** Drop a member and you drop a case; every assertion inside the loop
|
|
98
|
+
still passes over what is left, with the expectation as independent as it ever
|
|
99
|
+
was — so the rule above does not reach this. Pin the extent beside the loop:
|
|
100
|
+
the members against a literal when their identity is the promise, the count
|
|
101
|
+
when the size is. That literal is **not** the hardcoded value stage 2's third
|
|
102
|
+
rule forbids, and the distinction is the whole of it — that rule is about the
|
|
103
|
+
**expectation** the system is measured against; this pin asserts what the
|
|
104
|
+
**intent** is.
|
|
96
105
|
|
|
97
106
|
## Rules the engine enforces
|
|
98
107
|
|
|
@@ -313,6 +322,16 @@ failure this framework exists to make visible:
|
|
|
313
322
|
is the drift the single source exists to prevent, and the param is typed at
|
|
314
323
|
the value written in the registry, so a stale expectation stops compiling
|
|
315
324
|
rather than silently passing.
|
|
325
|
+
*This forbids one literal and not the other, and the difference decides
|
|
326
|
+
whether a domain can be shortened.* Forbidden is a literal standing in for the
|
|
327
|
+
param **as the value the system is measured against** — that is the second
|
|
328
|
+
copy. Required, when a scenario **iterates** a list it read from \`params\`, is
|
|
329
|
+
a literal pinning that list or its length beside the loop: the list is then
|
|
330
|
+
the set of cases the run covers rather than a value under test, and dropping a
|
|
331
|
+
member removes a case while every assertion inside still passes. A literal is
|
|
332
|
+
the only independent term available when what is at risk is the **size** of
|
|
333
|
+
the set, so the two never collide — one asserts what the system does, the
|
|
334
|
+
other what the intent promised.
|
|
316
335
|
*One thing to know before implementation code reads a param this change
|
|
317
336
|
ADDs:* the suite imports your \`*.reqs.ts\` from disk, while the gate applies
|
|
318
337
|
the delta in memory — so that read throws at import and the gate answers
|
package/package.json
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@am_shork/attest",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "TDD-native spec framework: tests are the source of truth for verification, ID-bound requirements the source of truth for intent.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"packageManager": "pnpm@10.28.0",
|
|
7
6
|
"engines": {
|
|
8
7
|
"node": ">=20.19"
|
|
9
8
|
},
|
|
@@ -37,24 +36,6 @@
|
|
|
37
36
|
"publishConfig": {
|
|
38
37
|
"access": "public"
|
|
39
38
|
},
|
|
40
|
-
"scripts": {
|
|
41
|
-
"clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
|
|
42
|
-
"build": "pnpm run clean && tsc -p tsconfig.json",
|
|
43
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
44
|
-
"typecheck:all": "tsc -p tsconfig.typecheck.json",
|
|
45
|
-
"test": "vitest run",
|
|
46
|
-
"test:watch": "vitest",
|
|
47
|
-
"test:consumer": "pnpm run build && vitest run --config vitest.consumer.config.ts",
|
|
48
|
-
"lint": "eslint .",
|
|
49
|
-
"prepack": "pnpm run build",
|
|
50
|
-
"prepublishOnly": "pnpm run test:consumer",
|
|
51
|
-
"attest": "node bin/attest.js",
|
|
52
|
-
"check:self": "node bin/attest.js check self",
|
|
53
|
-
"verify:self": "node bin/attest.js verify self",
|
|
54
|
-
"cover:self": "node bin/attest.js cover self",
|
|
55
|
-
"render:self": "node bin/attest.js render self --out self/requirements/SPEC.md",
|
|
56
|
-
"render:self:check": "node bin/attest.js render self --out self/requirements/SPEC.md --check"
|
|
57
|
-
},
|
|
58
39
|
"keywords": [
|
|
59
40
|
"tdd",
|
|
60
41
|
"spec",
|
|
@@ -81,13 +62,20 @@
|
|
|
81
62
|
"vite": "^8.1.5",
|
|
82
63
|
"vitest": "^4.1.10"
|
|
83
64
|
},
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
"
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
65
|
+
"scripts": {
|
|
66
|
+
"clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
|
|
67
|
+
"build": "pnpm run clean && tsc -p tsconfig.json",
|
|
68
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
69
|
+
"typecheck:all": "tsc -p tsconfig.typecheck.json",
|
|
70
|
+
"test": "vitest run",
|
|
71
|
+
"test:watch": "vitest",
|
|
72
|
+
"test:consumer": "pnpm run build && vitest run --config vitest.consumer.config.ts",
|
|
73
|
+
"lint": "eslint .",
|
|
74
|
+
"attest": "node bin/attest.js",
|
|
75
|
+
"check:self": "node bin/attest.js check self",
|
|
76
|
+
"verify:self": "node bin/attest.js verify self",
|
|
77
|
+
"cover:self": "node bin/attest.js cover self",
|
|
78
|
+
"render:self": "node bin/attest.js render self --out self/requirements/SPEC.md",
|
|
79
|
+
"render:self:check": "node bin/attest.js render self --out self/requirements/SPEC.md --check"
|
|
92
80
|
}
|
|
93
|
-
}
|
|
81
|
+
}
|