@am_shork/attest 0.9.2 → 0.9.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 +582 -1
- package/README.md +1 -1
- package/dist/core/archive.d.ts +59 -0
- package/dist/core/archive.js +142 -0
- package/dist/core/red-record.d.ts +40 -1
- package/dist/core/red-record.js +61 -7
- package/dist/core/skill.js +8 -0
- package/dist/inspect.d.ts +92 -0
- package/dist/inspect.js +55 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,211 @@ 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.9.3] - 2026-08-18
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **The archive is read back, as `inspectRedEvidence` on `@am_shork/attest/inspect`
|
|
21
|
+
(`ATX-80`).** `archive --apply` moves each merged change into
|
|
22
|
+
`archive/<date>-<name>/`, carrying `proposal.md`, the delta and the record of
|
|
23
|
+
how every one of its scenarios ended the *first* time it ran — and nothing
|
|
24
|
+
read any of it. The only store here that keeps a decision with its evidence
|
|
25
|
+
attached was write-only. It now answers, for each scenario a project declares,
|
|
26
|
+
which of `fail` / `pass` / `unobserved` / `unarchived` the archived records
|
|
27
|
+
place it in. Promoted from `Planned`, where the blockers were an archive
|
|
28
|
+
nobody had produced and an entry point nobody had chosen.
|
|
29
|
+
|
|
30
|
+
*The question the entry was promoted with was the wrong granularity, and
|
|
31
|
+
finding that is most of what this change is.* It asked which **requirements**
|
|
32
|
+
ever had a scenario observed failing. Under the gate as written that is true
|
|
33
|
+
by construction for every archived id — `neverRedIssues` walks *every*
|
|
34
|
+
scenario of every ADDED id and blocks on any one without a recorded red, so a
|
|
35
|
+
change that archived at all archived with the whole set proven. Where the gate
|
|
36
|
+
genuinely stops is one level down, and it leaks two ways: a scenario grown
|
|
37
|
+
onto a requirement after it archived can never acquire evidence, because
|
|
38
|
+
`never-red` fires only on ids a change is currently ADDing and an archived id
|
|
39
|
+
is ADDed by nothing again; and MODIFIED carries no such obligation at all, so
|
|
40
|
+
a requirement whose statement is later rewritten keeps evidence only for the
|
|
41
|
+
form it had when it archived. Both accumulate silently. The report is
|
|
42
|
+
therefore over `(reqId, file, name)`, which the record already keys by.
|
|
43
|
+
|
|
44
|
+
*Four states rather than two, and the two absences are the whole design.*
|
|
45
|
+
`unarchived` — the id is in no archived change at all — is a requirement
|
|
46
|
+
written straight into the registry, which is how existing behaviour is
|
|
47
|
+
described in a brownfield adoption and which design §6 gives no obligation to
|
|
48
|
+
have been red. `unobserved` — the id **is** archived and this scenario is not
|
|
49
|
+
in the record — is the drift above. Folding them together would report a
|
|
50
|
+
design working exactly as intended as though it were a backlog, and on a
|
|
51
|
+
first-day adoption that is every row. `pass` stays separate from both because
|
|
52
|
+
the gate cannot produce it, so an archived one means a hand-edited record or a
|
|
53
|
+
format that moved.
|
|
54
|
+
|
|
55
|
+
*Two things it deliberately does not do.* It does not judge: whether an
|
|
56
|
+
`unobserved` scenario matters is a judgement about that scenario, which is the
|
|
57
|
+
ground truth §0 refuses to compute for someone else. And it does not hand back
|
|
58
|
+
the records — the archive read returns rows, because the record is a nested map
|
|
59
|
+
keyed by ids, paths and scenario names read off disk, and exporting the
|
|
60
|
+
container would export the prototype hazard `red-record.ts` exists to hold.
|
|
61
|
+
|
|
62
|
+
*`proposal.md` is not read, and that is the blocker dissolving rather than
|
|
63
|
+
being cleared.* The prose half was filed as the one carrying the reasoning.
|
|
64
|
+
In this repository it carries a link: `CLAUDE.md` makes `CHANGELOG.md` the
|
|
65
|
+
single decision record, so a proposal restating an argument would be a second
|
|
66
|
+
store of it with nothing comparing the two. There is nothing in it for an
|
|
67
|
+
index to offer but its path.
|
|
68
|
+
|
|
69
|
+
*The three scan exclusions are untouched.* `locate.ts`, `runner.ts` and
|
|
70
|
+
`pipeline.ts` each still exclude `archive/`, and they are §7 and §8 — an
|
|
71
|
+
archived change's requirements and specs must not re-enter a normal run. The
|
|
72
|
+
new read walks the archive for facts *about* archived changes, never to fold
|
|
73
|
+
their contents back into a registry or a plan.
|
|
74
|
+
|
|
75
|
+
*Blocker **(2)** is settled against the fifth static command, on the roster's
|
|
76
|
+
own terms.* `ATX-16`'s subject is reading TypeScript *without evaluating it* —
|
|
77
|
+
its rationale is about a top-level `fetch` in a registry file reaching CI
|
|
78
|
+
secrets on a fork merge request. `first-run.json` is JSON; joining that roster
|
|
79
|
+
would quietly turn a security promise into a list of commands that do not run
|
|
80
|
+
the suite. The trial datum that seemed to point at a command measured demand
|
|
81
|
+
to read the archive **as prose**, which is a different question. And the
|
|
82
|
+
direction is reversible: adding a command later is additive, narrowing a
|
|
83
|
+
requirement's roster back is not. Two things measured while deciding —
|
|
84
|
+
`archive.ts`'s runtime closure names no `vite`/`vitest`, so `ATX-79`'s
|
|
85
|
+
peer-free promise holds over the new reachability; and `intent-quality.md`
|
|
86
|
+
already exists for findings of exactly this kind, where `cli-reference.md`
|
|
87
|
+
has no place to state one.
|
|
88
|
+
|
|
89
|
+
*What it cost elsewhere, because it is the part that does not announce
|
|
90
|
+
itself.* The fixture that exercises all four states adds a registry, and the
|
|
91
|
+
judgement tables in `tests/intent-rule-candidates.spec.ts` score the fixtures
|
|
92
|
+
too — so the corpus moved 87 → 90 and four measurements were re-read rather
|
|
93
|
+
than re-baselined. `ATX-80` is judged compound (2), and `compound-requirement`
|
|
94
|
+
flags it correctly; its quantifier ranges over values, the shape that table
|
|
95
|
+
prefers; `ATX-80.states` is an iterated domain and carries its pin. One new
|
|
96
|
+
divergence needed judging — `states:ATX-32,ATX-80` — and it is worth a line
|
|
97
|
+
because of how it arrived rather than how it reads: two reports each naming
|
|
98
|
+
their own closed set of labels, neither author aware of the other, which is a
|
|
99
|
+
second generic param name starting the climb `code` made to nineteen members.
|
|
100
|
+
The count of judgements a shippable `divergent-param` would demand grows with
|
|
101
|
+
the registry, from nothing anyone did wrong.
|
|
102
|
+
|
|
103
|
+
*Mutation-checked, and one assertion did not survive it.* Every new test was
|
|
104
|
+
run against a deliberately broken implementation. The one pinning archived
|
|
105
|
+
folder order passed with the sort deleted, because `readdir` returns names
|
|
106
|
+
already ordered on Windows — the fixture-with-no-separator shape from the
|
|
107
|
+
`Fixed` entry below, arriving in a new test on the day it was written. Fixed
|
|
108
|
+
by choosing names where the two orders differ, measured rather than assumed,
|
|
109
|
+
with the casing called out in the test so it is not tidied away.
|
|
110
|
+
|
|
111
|
+
*One more scenario, added before cutting a release, and writing it corrected
|
|
112
|
+
the reason it was added.* Every state above was read out of a hand-written
|
|
113
|
+
`first-run.json` at a folder this repository spelled, so the reader had never
|
|
114
|
+
met an archive the tool itself produced. `self/merge.spec.ts` now runs
|
|
115
|
+
`--apply` for real and joins the result: the folder is the one the run's own
|
|
116
|
+
date stamp named, and the report's paths are derived from whatever it picked.
|
|
117
|
+
Mutation-checked to be worth its runtime — an enumeration that finds nothing,
|
|
118
|
+
and the two absences folded into one, each fail it and no other scenario in
|
|
119
|
+
that file.
|
|
120
|
+
|
|
121
|
+
**The risk it was written to close turned out not to exist, which is the more
|
|
122
|
+
useful half.** The reason given for it was the proposed → merged rename: the
|
|
123
|
+
record is written while the spec is `*.proposed.spec.ts` and `--apply` renames
|
|
124
|
+
that file, so `fileKey` is what keeps the two meeting. Reading the code to
|
|
125
|
+
write the assertion showed the archive reader cannot exercise that at all.
|
|
126
|
+
`recordedOutcome` normalises the **ref**, and the record is keyed in merged
|
|
127
|
+
spelling from the start — so by the time a change is archived, every ref this
|
|
128
|
+
reader passes is already post-merge and `fileKey` is the identity on all of
|
|
129
|
+
them. The translation exists for the gate reading a record mid-flight, and the
|
|
130
|
+
archive reader is downstream of the point where it can matter. The scenario
|
|
131
|
+
stays, for what it does cover; the claim it was justified with is written out.
|
|
132
|
+
|
|
133
|
+
*A hazard found by running the mutations, worth recording because nothing
|
|
134
|
+
reports it.* Stubbing `mergedSpecPath` and running the suite **rewrote two
|
|
135
|
+
committed fixtures** — `fixtures/e2e-archive` and `fixtures/stdout-pollution`
|
|
136
|
+
each hold a `first-run.json` that a real archive run rewrites in place, so a
|
|
137
|
+
mutation run leaves the working tree dirty with plausible-looking changes to
|
|
138
|
+
files nobody edited. Caught by `git status`, not by any gate. It also settles
|
|
139
|
+
the write half by accident: those records really are produced through
|
|
140
|
+
`fileKey`, because under the stub they came out keyed by the proposed
|
|
141
|
+
spelling.
|
|
142
|
+
|
|
143
|
+
- **The propose stage now asks three questions, where it previously said how to
|
|
144
|
+
write a delta and nothing about what to check you had thought about.** They sit
|
|
145
|
+
at the end of Stage 1, immediately before the instruction to present and stop:
|
|
146
|
+
what pins each expectation, does each statement carry one obligation, and was
|
|
147
|
+
any of this ever red. Promoted from `Planned`, where the blocker was the
|
|
148
|
+
selection rather than the idea — spec-kit's `clarify` carries nine generic
|
|
149
|
+
categories, and copying them wholesale was the specific thing to avoid, because
|
|
150
|
+
§10's economics put every line of that document into every adopting project's
|
|
151
|
+
agent context forever.
|
|
152
|
+
|
|
153
|
+
*The selection is by what this repository has actually been bitten by*, which
|
|
154
|
+
is a different list from a product-spec taxonomy. **(1)** is the measured one:
|
|
155
|
+
the mine-density band moved 3.3× with the whole suite green because the
|
|
156
|
+
assertion recomputed its expectation from the param the code under test
|
|
157
|
+
consumed — the entry still under `Under consideration`, whose *guidance* half
|
|
158
|
+
shipped in `[0.5.0]` as a statement in *The model*. What is new here is asking
|
|
159
|
+
it as a question at the moment the assertion is being authored, which is a
|
|
160
|
+
different mechanism from stating the rule 80 lines earlier in the same file.
|
|
161
|
+
**(2)** is measured too: 34 of this repository's requirements carry more than
|
|
162
|
+
one obligation, so the question is doing work no shipped diagnostic does — and
|
|
163
|
+
deliberately names no code, because none exists to name. **(3)** answers what
|
|
164
|
+
the archive gate structurally cannot ask: `never-red` demands a recorded
|
|
165
|
+
failing run only for the ids a change ADDs, so a requirement written for
|
|
166
|
+
behaviour already shipped never had a moment when it should have failed.
|
|
167
|
+
|
|
168
|
+
*Two more went to `CLAUDE.md` instead*, and that split is the finding the entry
|
|
169
|
+
did not anticipate — the checklist wanted two destinations, not one. Crossing a
|
|
170
|
+
platform boundary, and whether a change leaves `check`, `cover`, `render` and
|
|
171
|
+
`status` executing nothing, are about this engine's internals; shipping them
|
|
172
|
+
would have been the dead weight the cost argument was worried about without
|
|
173
|
+
being able to name.
|
|
174
|
+
|
|
175
|
+
*Re-measured before building, per the discipline that section runs on, and the
|
|
176
|
+
claim held exactly*: the document was 405 lines with Stage 1 at 87–255, still
|
|
177
|
+
exactly 169. **The cost claim did not hold, and the entry's own number was
|
|
178
|
+
wrong.** It priced this at "three lines rather than nine" by counting one line
|
|
179
|
+
per category, which is what nine generic categories would have cost as a bare
|
|
180
|
+
list. Three questions with the clause each needs to be answerable cost **eight**
|
|
181
|
+
lines: Stage 1 is now 177 and the document 413. Still under half of what the
|
|
182
|
+
nine would have been, so the argument survives its own number being off, but the
|
|
183
|
+
number is corrected here rather than quietly restated.
|
|
184
|
+
|
|
185
|
+
*And the question the entry left open is answered: no new `ATX-n`.* The
|
|
186
|
+
document's content is gated only by `ATX-57`, which asks that every diagnostic
|
|
187
|
+
code it names be one the engine can emit — and asking *what this makes
|
|
188
|
+
reachable for the first time* is what that check turned into here: the only
|
|
189
|
+
hyphenated code-voice token the three questions add is `never-red`, which the
|
|
190
|
+
document already named. What they earn instead is **one scenario on `ATX-27`**,
|
|
191
|
+
which already obliges the body to carry the workflow rather than a pointer to
|
|
192
|
+
it. A requirement of their own would make every rewording of three sentences a
|
|
193
|
+
registry change; no assertion at all would leave a checklist with no diagnostic
|
|
194
|
+
behind it able to be dropped in silence — the same decay shape as the fixture
|
|
195
|
+
assertion fixed below, which is what decided it.
|
|
196
|
+
|
|
197
|
+
### Fixed
|
|
198
|
+
|
|
199
|
+
- **The assertion guarding the one fixture that exercises a path separator
|
|
200
|
+
passed on a corpus with no separator in it.** `CLAUDE.md` says not to flatten
|
|
201
|
+
`fixtures/nested-spec/`, because its spec is the only one that sits below a
|
|
202
|
+
root and a relative spec path becomes the child run's `include` glob, where a
|
|
203
|
+
backslash is an escape character. What stood behind that instruction was
|
|
204
|
+
`tests/paths.spec.ts` scanning the fixture and asserting that no relative path
|
|
205
|
+
contains a backslash — and a flat filename contains no backslash either, so
|
|
206
|
+
flattening the fixture would have left the test green while removing the only
|
|
207
|
+
thing it was there to exercise. The rule with a written warning against silent
|
|
208
|
+
decay was guarded by an assertion that decayed silently.
|
|
209
|
+
|
|
210
|
+
Now a second assertion requires the scan to find a spec below the root, which
|
|
211
|
+
is the property the fixture exists for. Checked against a flattened copy
|
|
212
|
+
before being written: the predicate is true on the fixture as committed and
|
|
213
|
+
false on the same files laid flat.
|
|
214
|
+
|
|
215
|
+
*Found by the `CLAUDE.md` audit filed under `Considered and rejected`*, whose
|
|
216
|
+
answer to "which of these rules should become requirement ids" was none — this
|
|
217
|
+
is the one actionable thing it turned up, and it is a plain test rather than an
|
|
218
|
+
`ATX-n` because it is a property of this repository's fixture corpus and not a
|
|
219
|
+
promise Attest makes to anyone.
|
|
220
|
+
|
|
16
221
|
## [0.9.2] - 2026-08-17
|
|
17
222
|
|
|
18
223
|
### Added
|
|
@@ -5134,6 +5339,10 @@ have to gather again.
|
|
|
5134
5339
|
**changing the value of an existing param goes through no gate at all.**
|
|
5135
5340
|
`archive` gates a change; a param edit is not a change. That is a cleaner
|
|
5136
5341
|
statement of the gap than "params can be tautological".
|
|
5342
|
+
**That observation is now its own entry at the foot of this section**, split
|
|
5343
|
+
out and measured on 2026-08-18, because it asks what happens when a value
|
|
5344
|
+
moves rather than whether an assertion is a real comparison, and neither
|
|
5345
|
+
answer settles the other. What stays here is the detector.
|
|
5137
5346
|
*What blocks the decision — three things, all of them real.* **(1)** Every
|
|
5138
5347
|
mutation above is synthetic. Across 8 commits touching `*.reqs.ts` in that
|
|
5139
5348
|
repo, **no param value was ever modified** — every `params:` line in every diff
|
|
@@ -5279,7 +5488,7 @@ have to gather again.
|
|
|
5279
5488
|
pass and separating them is the finding.
|
|
5280
5489
|
|
|
5281
5490
|
- **Whether a requirement can ever be retired, and what it costs that none can.**
|
|
5282
|
-
The registry is at **
|
|
5491
|
+
The registry is at **79 requirements and 221 scenarios, 2.80 each**, and
|
|
5283
5492
|
every one is a permanent obligation: `uncovered-requirement` is an ERROR, so a
|
|
5284
5493
|
requirement without a scenario cannot exist, and nothing ever removes one.
|
|
5285
5494
|
`delta.removed` exists and `applyDelta` applies it, but `--apply` refuses to
|
|
@@ -5324,6 +5533,19 @@ have to gather again.
|
|
|
5324
5533
|
the ratio: 2.56 → 2.78. The cost of a requirement is not flat and it is not
|
|
5325
5534
|
falling, which is the premise the whole entry rests on, and it is the first
|
|
5326
5535
|
time this file has had two measurements of it to compare.
|
|
5536
|
+
*A third measurement, on 2026-08-18, and it points the other way from the
|
|
5537
|
+
second.* **79 requirements and 221 scenarios**, counted the way `verify self`
|
|
5538
|
+
prints them, which is the control the 2026-08-08 restatement established. The
|
|
5539
|
+
ratio runs 2.56 → 2.78 → **2.80**, so the step that carried the argument was
|
|
5540
|
+
the first one and the second is **+0.015**, which is flat. The premise this
|
|
5541
|
+
entry rests on now has one measurement for it and one against, and the honest
|
|
5542
|
+
statement is the narrower one: per-requirement cost has held at about 2.8
|
|
5543
|
+
scenarios across ten days and ten requirements. What is unchanged is the
|
|
5544
|
+
absolute total, which only rises — that half of the argument is untouched, and
|
|
5545
|
+
it was always the half about permanence rather than about acceleration. What is
|
|
5546
|
+
no longer supported is the acceleration. **Reopening on cost needs the ratio to
|
|
5547
|
+
move again**; reopening on the three meanings of "retire" needs none of these
|
|
5548
|
+
figures, and that is still the part nobody has separated.
|
|
5327
5549
|
|
|
5328
5550
|
- **Running `verify` over a subset of the scenarios, and what a partial run may
|
|
5329
5551
|
be allowed to say.** `verify` is all-or-nothing and `check` executes nothing,
|
|
@@ -5549,6 +5771,191 @@ have to gather again.
|
|
|
5549
5771
|
follows; if it comes back different, `inspect` was the whole answer and the
|
|
5550
5772
|
schema never needs to exist.
|
|
5551
5773
|
|
|
5774
|
+
- **A registry cannot say a value is deliberately undecided, and an agent that
|
|
5775
|
+
cannot determine one will invent it.** From a read of GitHub's spec-kit
|
|
5776
|
+
against this design — the first time another SDD toolkit has been compared
|
|
5777
|
+
here rather than OpenSpec, and the source of this entry and the three that
|
|
5778
|
+
followed it — of which two have since been decided: the audit of this
|
|
5779
|
+
repository's own rules is rejected below, and the propose-stage coverage scan
|
|
5780
|
+
shipped under `[0.9.3]`, as three questions rather than nine categories.
|
|
5781
|
+
spec-kit marks an unresolved decision inline as `[NEEDS CLARIFICATION: …]`,
|
|
5782
|
+
greppable, with a command whose whole job is converging them. Attest's
|
|
5783
|
+
registry has two states: schema-valid, or an ERROR. There is no third.
|
|
5784
|
+
*Why that matters here specifically, rather than as a general nicety.* The
|
|
5785
|
+
workflow is written for an agent to follow — that is what `init` writes — and
|
|
5786
|
+
an agent drafting a delta that cannot determine a timeout does not leave the
|
|
5787
|
+
field out, because the schema will not let it. It picks a number. A fabricated
|
|
5788
|
+
`params` value, then pinned by a scenario written to match it, is exactly the
|
|
5789
|
+
drift this project exists to prevent, arriving through the mechanism meant to
|
|
5790
|
+
stop it: the single source is perfectly consistent and describes a promise
|
|
5791
|
+
nobody made.
|
|
5792
|
+
*A precedent that is not one, recorded because it reads like one.*
|
|
5793
|
+
`rationale-placeholder` already warns about authoring sloppiness in a prose
|
|
5794
|
+
field, and the `init` document already teaches it — but it is about
|
|
5795
|
+
interpolation syntax written into the wrong field, not about a value nobody
|
|
5796
|
+
has decided. The engine's willingness to warn on prose is the precedent; the
|
|
5797
|
+
diagnostic is not the feature.
|
|
5798
|
+
*The gap is narrower than "the registry cannot express doubt".* `changes/` is
|
|
5799
|
+
already the place where a whole proposal is undecided, and the archive gate is
|
|
5800
|
+
what settles it. What has no expression is **one field** of an otherwise
|
|
5801
|
+
well-formed requirement.
|
|
5802
|
+
*Shape, if it is built:* an additive `open?: string[]`, naming the fields or
|
|
5803
|
+
questions still unresolved; a WARNING from `check`, so a proposal may be
|
|
5804
|
+
legitimately half-finished, and an ERROR at the archive gate, so "done" is not
|
|
5805
|
+
diluted. The same asymmetry `never-red` already runs on.
|
|
5806
|
+
*What blocks the decision — three things.* **(1)** The frequency is entirely
|
|
5807
|
+
unmeasured. No adoption report has recorded an agent inventing a param; the
|
|
5808
|
+
argument above is structural, and this project has twice been wrong about a
|
|
5809
|
+
hazard it reasoned to rather than measured. **(2)** The cheap half already
|
|
5810
|
+
works: writing the uncertainty into `rationale` as prose costs nothing, and
|
|
5811
|
+
the propose stage is human-reviewed regardless. What that does not buy is a
|
|
5812
|
+
gate, which is the whole question. **(3)** It is permanent surface — a schema
|
|
5813
|
+
field, a code, two troubleshooting sections, a History row, an `ATX-n` and its
|
|
5814
|
+
scenarios forever — for a hazard nobody has yet reported.
|
|
5815
|
+
|
|
5816
|
+
- **Prefix ownership is inferred rather than declared, and the cost arrives only
|
|
5817
|
+
when adoption succeeds.** Design §11 records that the id grammar has no level
|
|
5818
|
+
above the requirement: the prefix is a convention, nothing allocates it, and
|
|
5819
|
+
`duplicate-prefix` can only report a collision that already happened.
|
|
5820
|
+
spec-kit's answer is a roadmap table — a stable id, an intent, a scope
|
|
5821
|
+
boundary, dependencies — authored above the specs it decomposes into.
|
|
5822
|
+
*Two things have changed since that reading, and they point opposite ways.*
|
|
5823
|
+
The proposal is **smaller** than it looked: the mechanism partly exists.
|
|
5824
|
+
`loadRegistry` returns `prefixOwners` — first claim in sorted file order —
|
|
5825
|
+
`merge.ts` uses it to decide which file an ADDED id belongs in, and
|
|
5826
|
+
`apply-no-prefix-owner` already refuses a prefix nobody owns. So what is
|
|
5827
|
+
proposed is declaration replacing inference, not a new layer. And it is
|
|
5828
|
+
**weaker**: `/inspect` shipped in `[0.9.2]`, so the cross-repository half —
|
|
5829
|
+
the one §11 says no command can see across — is now something an adopter
|
|
5830
|
+
computes from two registries in about ten lines. Attest does not have to own
|
|
5831
|
+
it to make it possible.
|
|
5832
|
+
*What blocks the decision:* nobody has the problem. §11 states the cost
|
|
5833
|
+
arrives "exactly when adoption succeeds: two teams sharing a registry have
|
|
5834
|
+
someone owning prefix allocation by hand", and no such adoption has been
|
|
5835
|
+
reported. Building the declared form now would be deciding prefix semantics
|
|
5836
|
+
for everyone from one repository whose five prefixes — `ATX` and the four
|
|
5837
|
+
fixture ones — are each declared by exactly one file, so the allocation
|
|
5838
|
+
question this would answer has never been asked here.
|
|
5839
|
+
|
|
5840
|
+
|
|
5841
|
+
- **A merged requirement carries no trace of the change that produced it, and
|
|
5842
|
+
the rename chain is the part actually lost.** `RequirementSchema` is
|
|
5843
|
+
`statement`, `rationale`, `params`, `outOfScope`; the splice writes those and
|
|
5844
|
+
nothing else, so after `--apply` no field and no sidecar records which change
|
|
5845
|
+
introduced an id, which modified it, or that one id used to be another. The
|
|
5846
|
+
delta that knew is in `archive/`, which nothing reads. Proposed from
|
|
5847
|
+
OpenViking's `.relations.json`: links between memories live in a
|
|
5848
|
+
directory-level sidecar, deliberately kept out of the content, and are
|
|
5849
|
+
rendered on demand at retrieval time (`docs/design/memory-link-design.md`).
|
|
5850
|
+
*The argument for the sidecar over a field is in their competitor analysis
|
|
5851
|
+
rather than their design.* The same document reads GBrain, which writes links
|
|
5852
|
+
into the page body, and records what that costs: rename a page and every
|
|
5853
|
+
inbound link still points at the old slug, with fuzzy matching at the read end
|
|
5854
|
+
as the only mitigation. A `supersedes:` or `introducedBy:` field on a
|
|
5855
|
+
requirement would be that mistake in this repository's terms — the registry is
|
|
5856
|
+
a literal and a single source (§2), and a field naming a folder that a later
|
|
5857
|
+
rename can move is a second source with nothing comparing it against the
|
|
5858
|
+
first. A derived index has no such copy to go stale: it is recomputed from the
|
|
5859
|
+
archived deltas every time it is read.
|
|
5860
|
+
*What blocks the decision:* the empty archive — the subject of the entry that
|
|
5861
|
+
shipped under `[0.9.3]`, whose promotion this one does **not** inherit,
|
|
5862
|
+
because the two changes that produced that corpus were ADDED-only and
|
|
5863
|
+
exercised no rename at all — which makes a backlink index testable only
|
|
5864
|
+
against fixtures; and one question the OpenViking reading
|
|
5865
|
+
does not answer, which is whether the chain is wanted at all. §7 applies
|
|
5866
|
+
`RENAMED` to a `Map<id, Requirement>` and the old id is then simply gone —
|
|
5867
|
+
deliberately, as far as anything written says. The case for recovering it is an
|
|
5868
|
+
external reference that outlives the rename, a ticket or a commit message
|
|
5869
|
+
naming a retired id, and nobody has reported one.
|
|
5870
|
+
|
|
5871
|
+
- **The instruction document says how to write intent and nothing about how to
|
|
5872
|
+
read the record back.** §10's one-document-many-targets file is entirely about
|
|
5873
|
+
authoring, and the moment anything reads `archive/` — the entry above, and the
|
|
5874
|
+
one that shipped under `[0.9.3]` — an
|
|
5875
|
+
agent is holding prose from a proposal agreed on some past date against a
|
|
5876
|
+
registry that is current, with no stated order between them. OpenViking states
|
|
5877
|
+
one for its own store in the skill it ships to coding agents
|
|
5878
|
+
(`agent-plugins/skills/openviking-memory/SKILL.md`): retrieved memory is
|
|
5879
|
+
advisory, and the order is system and developer instructions, then the current
|
|
5880
|
+
request, then current environment and tool evidence, then memory — with "prior
|
|
5881
|
+
success never authorizes a destructive action now", and "store conclusions,
|
|
5882
|
+
not scrollback".
|
|
5883
|
+
*Here the same rule has a harder form, because the authoritative half is
|
|
5884
|
+
machine-checked.* The registry and the gate's verdict are evidence; an
|
|
5885
|
+
archived proposal is testimony about a decision taken once. Where they
|
|
5886
|
+
disagree, the disagreement is a finding — the exact thing this tool exists to
|
|
5887
|
+
report — and not something for a reader to reconcile quietly in favour of
|
|
5888
|
+
whichever it read last. That is a property of the design rather than of a
|
|
5889
|
+
file, so if it is taken it lands in design §11 first and in the instruction
|
|
5890
|
+
document second.
|
|
5891
|
+
*What blocks the decision:* it is a rule for reading a store nothing reads yet,
|
|
5892
|
+
so on its own it would document a hazard that cannot currently arise. It is the
|
|
5893
|
+
cheapest of the three and could ship first, at the price of being the only
|
|
5894
|
+
paragraph in that file describing a workflow the tool does not have — and §10's
|
|
5895
|
+
economics make that price the same in every adopter's agent instructions at
|
|
5896
|
+
once.
|
|
5897
|
+
**That blocker is retired, and what replaced it is narrower than the entry
|
|
5898
|
+
assumed.** Something reads `archive/` as of `[0.9.3]`, so the hazard can
|
|
5899
|
+
now arise. But what shipped reads the **typed** half — `first-run.json`, whose
|
|
5900
|
+
every value is an `Outcome` in a schema-validated envelope — and evidence that
|
|
5901
|
+
parses cannot disagree with the registry in the way testimony can: a scenario
|
|
5902
|
+
either is in the record or is not. The rule this entry wants is about the prose
|
|
5903
|
+
half, and the prose half is still read by nothing. So the store is no longer
|
|
5904
|
+
write-only and the entry's own subject is still hypothetical, which is a
|
|
5905
|
+
different blocker from the one it was filed with rather than none.
|
|
5906
|
+
|
|
5907
|
+
- **Changing the value of an existing param passes no gate, and for seven of
|
|
5908
|
+
them there is no record either.** Split out of the tautology entry at the
|
|
5909
|
+
head of this section on 2026-08-18, where it was one sentence inside the
|
|
5910
|
+
reframing paragraph. It is a different question: that entry asks whether an
|
|
5911
|
+
assertion is a real comparison, this one asks what happens when the number
|
|
5912
|
+
itself moves, and neither answer settles the other. `archive` gates a
|
|
5913
|
+
**change** — a delta, proposed specs, a recorded failing run — and a param edit
|
|
5914
|
+
is none of those. It is one literal in a merged `*.reqs.ts`, and every gate
|
|
5915
|
+
reads it as fine: `check` finds it schema-valid, because a param is any JSON
|
|
5916
|
+
value; `possible-drift` is quiet by construction, since `ATX-8` makes it ask
|
|
5917
|
+
whether *any* covering scenario reads the params at all rather than whether a
|
|
5918
|
+
value moved; and `verify` stays green wherever the assertion derives its
|
|
5919
|
+
expectation from the same param, which is the pair of green rows measured in
|
|
5920
|
+
the entry this was split from.
|
|
5921
|
+
*What is left is `render`, and it was measured here rather than assumed.*
|
|
5922
|
+
Across **89 params on 59 of this repository's 79 requirements, 82 — 92.1% —
|
|
5923
|
+
are interpolated into their own statement**, so editing one changes
|
|
5924
|
+
`self/requirements/SPEC.md` and `render --check` fails until the document is
|
|
5925
|
+
regenerated. That is a review surface and not a gate: the new value lands in a
|
|
5926
|
+
committed generated file where a reader can see it, and nothing anywhere asks
|
|
5927
|
+
whether the move was intended.
|
|
5928
|
+
*The other seven leave no trace outside the line that was edited, and which
|
|
5929
|
+
seven they are is the finding rather than the count.* Five are **structurally
|
|
5930
|
+
uninterpolatable**: `ATX-7.payloadKinds` and `ATX-63.payloadKinds` are
|
|
5931
|
+
kind → payload tables, and `ATX-15.sortMethods`, `ATX-15.bannedApis` and
|
|
5932
|
+
`ATX-72.quotes` are lists. A statement cannot carry any of them —
|
|
5933
|
+
`non-scalar-interpolation` is the diagnostic that refuses it — so the params
|
|
5934
|
+
with no record are disproportionately the shape §2 widened the params rule to
|
|
5935
|
+
cover **precisely because lists and tables are the most drift-prone constants
|
|
5936
|
+
there are**. The remaining two, `ATX-15.comparator` and `ATX-26.code`, are
|
|
5937
|
+
scalars their author simply did not interpolate.
|
|
5938
|
+
*What this is not.* It is not a re-proposal of the committed params snapshot
|
|
5939
|
+
rejected below. That was rejected on the argument that the mechanism guarding a
|
|
5940
|
+
param is a scenario reading it rather than a file recording it, and nothing
|
|
5941
|
+
here disputes that or needs a second copy of the values. The observation is
|
|
5942
|
+
narrower: the **edit** is unremarkable to every gate, which is a different
|
|
5943
|
+
claim from the values being unguarded.
|
|
5944
|
+
*What blocks the decision — and the first is the one that blocks the entry this
|
|
5945
|
+
came from.* **(1)** Frequency is unmeasured, and the one corpus anybody has
|
|
5946
|
+
looked at points at zero: across 8 commits touching `*.reqs.ts` in the
|
|
5947
|
+
reporting repository, no param value was ever modified — every `params:` line
|
|
5948
|
+
in every diff is an addition. A defect nobody has committed is a hazard rather
|
|
5949
|
+
than a report. **(2)** No shape is obvious. A param edit is not a change, so
|
|
5950
|
+
`archive` is the wrong place; making `check` notice one needs a previous value
|
|
5951
|
+
to compare against, which is the rejected snapshot wearing a different hat. The
|
|
5952
|
+
only mechanism needing no stored copy is review, and review already sees the
|
|
5953
|
+
`SPEC.md` diff for 82 of the 89. **(3)** What is left after that is seven
|
|
5954
|
+
params in one repository, and the right answer for the five that matter may be
|
|
5955
|
+
nothing more than a line in `intent-quality.md` — the four things no gate is
|
|
5956
|
+
looking at already being that page's subject, and this being a fifth of the
|
|
5957
|
+
same kind.
|
|
5958
|
+
|
|
5552
5959
|
## Considered and rejected
|
|
5553
5960
|
|
|
5554
5961
|
Decisions **not** to build something, kept where they can be found before the
|
|
@@ -6282,6 +6689,180 @@ requirement, and human review at propose is still the whole answer. What is
|
|
|
6282
6689
|
rejected is grouping as a way to assist it, and this reopens only on a
|
|
6283
6690
|
contradiction that grouping would have caught.
|
|
6284
6691
|
|
|
6692
|
+
### An append-only writer for `status`, following spec-kit's `converge`
|
|
6693
|
+
|
|
6694
|
+
spec-kit's `converge` assesses the codebase against a feature's artifacts and
|
|
6695
|
+
appends the remaining work as new tasks, under a strict contract: its only write
|
|
6696
|
+
is a new section at the foot of `tasks.md`, and it may not rewrite, renumber,
|
|
6697
|
+
reorder or delete anything already there. Proposed here as the shape `status`
|
|
6698
|
+
would need if it ever produced todos rather than a report, on the grounds that
|
|
6699
|
+
append-only is the safe way to write into a file a human owns.
|
|
6700
|
+
|
|
6701
|
+
*Rejected because this repository already has a strictly finer instrument, and
|
|
6702
|
+
the first reading missed it by comparing the wrong pair.* `ATX-76`: with
|
|
6703
|
+
`--apply`, a MODIFIED requirement is written back by replacing the source span
|
|
6704
|
+
of **each value that changed** — never the entry holding them — and the whole
|
|
6705
|
+
merge is refused, with nothing written, when a span it would overwrite carries a
|
|
6706
|
+
comment. That is a smaller unit than "append at the foot", and it solves the
|
|
6707
|
+
harder problem: editing in place without destroying what a person wrote around
|
|
6708
|
+
it. Append-only avoids the question by never touching existing text; `--apply`
|
|
6709
|
+
answers it. The comparison that produced the proposal was `converge` against
|
|
6710
|
+
`status`, and it never looked at what the command that actually writes already
|
|
6711
|
+
does.
|
|
6712
|
+
|
|
6713
|
+
*And the premise is unbuilt anyway.* `status` is a report, deliberately — it
|
|
6714
|
+
answers what the gate still wants, from the delta, the specs and the record,
|
|
6715
|
+
running nothing. No adoption report, and nobody here, has asked it to become a
|
|
6716
|
+
writer. A write discipline for a command that does not write is a solution
|
|
6717
|
+
looking for its problem, which is what `Speculative Generality` names.
|
|
6718
|
+
|
|
6719
|
+
*Reopening this needs `status` to have a reason to write at all* — and if that
|
|
6720
|
+
ever happens, the discipline to reach for is `ATX-76`'s, not this one.
|
|
6721
|
+
|
|
6722
|
+
### A Sync Impact Report at the head of a governance document
|
|
6723
|
+
|
|
6724
|
+
spec-kit's `constitution.md` opens with a comment block recording what a change
|
|
6725
|
+
to it touched: the version bump and its rationale, the principles defined, and a
|
|
6726
|
+
checklist of every downstream template reviewed for alignment, each carrying a
|
|
6727
|
+
note on what was checked. Proposed here as a general form of the rule that a `docs/en` change
|
|
6728
|
+
carries its `docs/zh` counterpart in the same commit — a persisted record, per
|
|
6729
|
+
change, of what was checked downstream.
|
|
6730
|
+
|
|
6731
|
+
*Rejected because `pre-commit-check` already produces exactly that, per commit,
|
|
6732
|
+
and produces it where it is useful.* The skill's opening instruction is to
|
|
6733
|
+
report which items applied and which did not, saying plainly when something was
|
|
6734
|
+
skipped and why, because "a silent omission is the failure mode this exists to
|
|
6735
|
+
prevent". That output is a Sync Impact Report under another name, produced at
|
|
6736
|
+
the moment of the change rather than pinned to the head of a file, and it covers
|
|
6737
|
+
more than one document's downstream — packaging, the `Issue` code contract,
|
|
6738
|
+
`SCHEMA_VERSION`, both language trees.
|
|
6739
|
+
|
|
6740
|
+
*What persisting a second copy would cost is the thing this project has been
|
|
6741
|
+
most consistent about avoiding:* it splits a store deliberately kept single. The
|
|
6742
|
+
same argument keeps decisions in this file rather than an ADR directory, and it
|
|
6743
|
+
applies with more force here, because the two copies would disagree the first
|
|
6744
|
+
time a commit's report says something the header block does not.
|
|
6745
|
+
|
|
6746
|
+
*The other half of the constitution reading* — that this repository's
|
|
6747
|
+
load-bearing rules have no ids and nothing reads them — survived the rejection of
|
|
6748
|
+
the document that carried it and was filed under `Under consideration`. Its audit
|
|
6749
|
+
was done on 2026-08-18 and it is now rejected too, in the section directly below
|
|
6750
|
+
this one, on an argument neither half of the reading predicted.
|
|
6751
|
+
|
|
6752
|
+
### Promoting this repository's own rules in `CLAUDE.md` to requirement ids
|
|
6753
|
+
|
|
6754
|
+
The audit this entry was filed for was done on 2026-08-18 and its answer is
|
|
6755
|
+
**none**. The entry named its own first step — an audit whose primary output is a
|
|
6756
|
+
rejection list — and asked which `CLAUDE.md` rules should become ids, on the
|
|
6757
|
+
observation that rules whose violation has already produced silent defects are
|
|
6758
|
+
gated by nothing.
|
|
6759
|
+
|
|
6760
|
+
*Three were already ids, which the entry knew.* `ATX-28` is the path-spelling
|
|
6761
|
+
rule, `ATX-29` the line-endings rule in the form that matters (the freshness
|
|
6762
|
+
check compares the document rather than its bytes), and `ATX-16` with `ATX-17`
|
|
6763
|
+
the two-readers rule: the four-command static roster, and the refusal to fall
|
|
6764
|
+
back to evaluating a registry that will not read.
|
|
6765
|
+
|
|
6766
|
+
*One is already gated and the entry did not know it.* `docs/zh/**` mirroring
|
|
6767
|
+
`docs/en/**` one-for-one is asserted twice in `tests/docs-consistency.spec.ts` —
|
|
6768
|
+
every English page has exactly one Chinese counterpart, and each pair shares a
|
|
6769
|
+
heading tree and a code-sample sequence. What has no gate is whether the two
|
|
6770
|
+
still *say* the same thing, which is what `CLAUDE.md` and `pre-commit-check` §6
|
|
6771
|
+
both mean by "checked by eye only": the structural half is held, the semantic
|
|
6772
|
+
half cannot be.
|
|
6773
|
+
|
|
6774
|
+
*The rest fall into three groups and none of them wants an id.* **Six are
|
|
6775
|
+
working habits** — `vitest run` does not run `self/`; it also runs the type
|
|
6776
|
+
tests; `bin/attest.js` loads `dist/`; some fixtures fail on purpose; a scenario
|
|
6777
|
+
starting a child run needs an explicit timeout; a package manager is spawned only
|
|
6778
|
+
through the one helper in `tests/consumer.spec.ts`. Each is about how to work in
|
|
6779
|
+
this repository rather than what Attest promises anyone, and §1 is decisive:
|
|
6780
|
+
a requirement costs a mandatory scenario forever, so these would be six scenarios
|
|
6781
|
+
asserting facts about a maintainer's habits. **Two are the design vocabulary and
|
|
6782
|
+
the filing rule**, which are definitions and a destination — there is nothing to
|
|
6783
|
+
assert. **And one constrains a consumer rather than the engine**, which is the
|
|
6784
|
+
finding worth keeping.
|
|
6785
|
+
|
|
6786
|
+
*`Issue.message` is prose, never API — the most load-bearing ungated rule in the
|
|
6787
|
+
file, and no id can hold it.* The promise is that a consumer may branch on `code`
|
|
6788
|
+
and `reqId` and must not branch on wording, so the obligation falls on the
|
|
6789
|
+
consumer's tests and not on any behaviour a run of this engine can exhibit. The
|
|
6790
|
+
checkable half is already structural: `code` is typed to the roster in
|
|
6791
|
+
`src/core/docs.ts`, and `ATX-40` obliges every code to carry a troubleshooting
|
|
6792
|
+
section in both languages. A requirement stating the rest would either restate a
|
|
6793
|
+
type or assert something no run can observe. **This is the audit's general
|
|
6794
|
+
result rather than one rule's exception** — the file is mostly a working
|
|
6795
|
+
agreement with this repository's maintainers, and the three rules in it that were
|
|
6796
|
+
contracts Attest owes an adopter had all been promoted before the question was
|
|
6797
|
+
asked.
|
|
6798
|
+
|
|
6799
|
+
*The same argument retires the entry's own sharpener.* `[0.9.2]` edited
|
|
6800
|
+
`.claude/skills/pre-commit-check/SKILL.md` with no gate having an opinion about
|
|
6801
|
+
its contents, and that is true — but measured, the machine-checkable surface of
|
|
6802
|
+
that document is **one token**: it names exactly one issue code,
|
|
6803
|
+
`spec-load-failed`, and that code exists. An `ATX-57` analogue over it would gate
|
|
6804
|
+
a roster of one. Everything else in the document is judgement, which is both what
|
|
6805
|
+
a gate cannot hold and the reason the document is prose.
|
|
6806
|
+
|
|
6807
|
+
*One actionable finding survives, and it is not an id.* `CLAUDE.md` says not to
|
|
6808
|
+
flatten `fixtures/nested-spec/`, because its spec is the only one below a root
|
|
6809
|
+
and therefore the only thing exercising a separator. The guard is
|
|
6810
|
+
`tests/paths.spec.ts`, which scans that fixture and asserts that no relative path
|
|
6811
|
+
contains a backslash — **and that assertion passes on a flattened corpus**,
|
|
6812
|
+
because a flat filename contains no backslash either. The rule carrying a written
|
|
6813
|
+
warning against silent decay is guarded by an assertion that decays silently. The
|
|
6814
|
+
fix is one line in that test, requiring at least one scanned path to sit below the
|
|
6815
|
+
root, and it is a plain test rather than a requirement for the same reason as
|
|
6816
|
+
everything else here: it is a property of this repository's fixture corpus, not a
|
|
6817
|
+
promise about Attest's behaviour. **Done under `[0.9.3]`**, and the guard
|
|
6818
|
+
was checked against a flattened copy of the fixture before it was written — the
|
|
6819
|
+
predicate reads true on `fixtures/nested-spec/` and false on the same files laid
|
|
6820
|
+
flat, which is the difference the old assertion could not see.
|
|
6821
|
+
|
|
6822
|
+
*Worth reopening only for a rule that is a contract* — one Attest owes an
|
|
6823
|
+
adopter, added to that file later. It belongs in the registry when it is written,
|
|
6824
|
+
not in a second sweep of prose.
|
|
6825
|
+
|
|
6826
|
+
### Semantic retrieval over the intent layer
|
|
6827
|
+
|
|
6828
|
+
Priced on 2026-08-18 while reading OpenViking (volcengine/OpenViking, AGPL-3.0,
|
|
6829
|
+
at `87d5142e`) for what a context database for agents has that this repository
|
|
6830
|
+
does not. The proposal was its retrieval path
|
|
6831
|
+
(`docs/en/concepts/07-retrieval.md`): embed requirements, specs and archived
|
|
6832
|
+
proposals, then answer a question with an LLM intent analysis that emits zero to
|
|
6833
|
+
five typed queries, a priority-queue recursion down the directory tree with
|
|
6834
|
+
score propagation, and a rerank pass over the survivors.
|
|
6835
|
+
|
|
6836
|
+
Rejected on what that machinery is *for*. Every part of it is a way to find
|
|
6837
|
+
things in a corpus that has lost its structure — free text, no ids, no grammar,
|
|
6838
|
+
tens of thousands of entries. This one has 79 requirements in a single file,
|
|
6839
|
+
each with an id matching `/^[A-Z]+-\d+$/`, grouped by a prefix, carrying a
|
|
6840
|
+
statement written to contain SHALL or MUST. Lookup by id is exact and free;
|
|
6841
|
+
`grep` over the statements answers the rest. Ranking is what you reach for when
|
|
6842
|
+
you cannot address; addressing is the thing this design already has, and §5.4
|
|
6843
|
+
extends it to evidence by keying the red record on requirement id, spec file and
|
|
6844
|
+
scenario name.
|
|
6845
|
+
|
|
6846
|
+
The second reason is the one that would survive a bigger corpus. A retriever of
|
|
6847
|
+
that shape puts a non-deterministic, network-dependent, unfalsifiable scorer
|
|
6848
|
+
beside four commands whose defining property is that they execute nothing and
|
|
6849
|
+
whose output is a verdict a reader has to be able to reproduce. Their own
|
|
6850
|
+
document specifies the degradation frankly — "if rerank returns an invalid
|
|
6851
|
+
result or the API call fails, retrieval falls back to vector scores" — and a
|
|
6852
|
+
silent fallback to a weaker mechanism is precisely what §5.1 refuses when it
|
|
6853
|
+
makes a non-static registry a `registry-not-static` ERROR rather than a
|
|
6854
|
+
fallback to evaluation. The same answer applies here for the same reason.
|
|
6855
|
+
|
|
6856
|
+
Worth reopening if an adopter's registry reaches a size where prefix grouping
|
|
6857
|
+
and `grep` genuinely stop working — but the first thing to try then is the
|
|
6858
|
+
declared prefix ownership already under consideration above, which addresses,
|
|
6859
|
+
rather than a scorer, which ranks.
|
|
6860
|
+
|
|
6861
|
+
## Releases
|
|
6862
|
+
|
|
6863
|
+
[0.9.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.3
|
|
6864
|
+
[0.9.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.2
|
|
6865
|
+
[0.9.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.1
|
|
6285
6866
|
[0.9.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.0
|
|
6286
6867
|
[0.8.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.8.0
|
|
6287
6868
|
[0.7.4]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.4
|
package/README.md
CHANGED
|
@@ -167,7 +167,7 @@ Every diagnostic carries a `code`, and every code has a section in
|
|
|
167
167
|
```
|
|
168
168
|
ERROR registry-not-static (requirements/upload.reqs.ts:5)
|
|
169
169
|
Value is not a literal.
|
|
170
|
-
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.9.
|
|
170
|
+
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.9.3/docs/en/troubleshooting.md#registry-not-static
|
|
171
171
|
```
|
|
172
172
|
|
|
173
173
|
The anchor **is** the code, so the link cannot point somewhere the section
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { RedRecord } from './red-record.js';
|
|
2
|
+
/** One archived change folder, as this module found it. */
|
|
3
|
+
export interface ArchivedChange {
|
|
4
|
+
/** The folder name, `<YYYY-MM-DD>-<changeName>` as `--apply` spelled it. */
|
|
5
|
+
readonly name: string;
|
|
6
|
+
/**
|
|
7
|
+
* The folder relative to the project root, through `relativePath`.
|
|
8
|
+
*
|
|
9
|
+
* Not `join`ed by a caller from {@link ArchivedChange.name}: a value derived
|
|
10
|
+
* from the root is spelled one way here or it becomes a report field and a
|
|
11
|
+
* comparison key that depends on the host platform (`paths.ts`).
|
|
12
|
+
*/
|
|
13
|
+
readonly path: string;
|
|
14
|
+
/**
|
|
15
|
+
* What this change's `first-run.json` holds, or an empty record.
|
|
16
|
+
*
|
|
17
|
+
* Empty covers both "the folder carries no record" and "the record did not
|
|
18
|
+
* validate", which `readRedRecordIn` does not distinguish — deliberately, and
|
|
19
|
+
* the same way round as the gate: an unreadable record is exactly as strict
|
|
20
|
+
* as a missing one. Kept per change rather than only merged so a caller can
|
|
21
|
+
* say which folders contributed nothing, which is the difference between a
|
|
22
|
+
* report and an assertion about an archive nobody counted.
|
|
23
|
+
*/
|
|
24
|
+
readonly firstRun: RedRecord;
|
|
25
|
+
}
|
|
26
|
+
/** Everything `archive/` says about first runs, and where it said it. */
|
|
27
|
+
export interface ArchivedEvidence {
|
|
28
|
+
/** Every archived change folder, in folder-name order. */
|
|
29
|
+
readonly changes: readonly ArchivedChange[];
|
|
30
|
+
/** Their records merged. See {@link keepOutcome} for the precedence. */
|
|
31
|
+
readonly firstRun: RedRecord;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Read every archived change folder under `root`.
|
|
35
|
+
*
|
|
36
|
+
* A missing `archive/` is an empty list rather than an error: a project that has
|
|
37
|
+
* archived nothing is the normal state, and it is the state every project starts
|
|
38
|
+
* in. Directories only — a symbolic link is not followed, because this walks a
|
|
39
|
+
* tree to report on it and following one would let a link decide which files a
|
|
40
|
+
* report describes.
|
|
41
|
+
*
|
|
42
|
+
* Ordered by folder name, which is `<date>-<name>` and so is chronological, and
|
|
43
|
+
* through the same code-unit comparator as everything else this tool commits or
|
|
44
|
+
* compares — the report is then a function of the input rather than of the order
|
|
45
|
+
* a filesystem happened to hand back.
|
|
46
|
+
*/
|
|
47
|
+
export declare function readArchivedChanges(root: string): Promise<ArchivedChange[]>;
|
|
48
|
+
/**
|
|
49
|
+
* The archive's first-run evidence, merged across every archived change.
|
|
50
|
+
*
|
|
51
|
+
* One `RedRecord` rather than a list to search, because the three questions a
|
|
52
|
+
* caller has of it are the three `recordedOutcome` already answers, at the
|
|
53
|
+
* granularity the gate leaves open: this scenario was observed failing; this
|
|
54
|
+
* scenario's id never came through the workflow; this id did and this scenario
|
|
55
|
+
* is not in it. Merging costs nothing else — the reader, the validator and the
|
|
56
|
+
* accessors are the ones the gate uses, unchanged.
|
|
57
|
+
*/
|
|
58
|
+
export declare function readArchivedEvidence(root: string): Promise<ArchivedEvidence>;
|
|
59
|
+
//# sourceMappingURL=archive.d.ts.map
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The read side of `archive/`, which until now had none.
|
|
2
|
+
//
|
|
3
|
+
// `--apply` moves a change folder whole into `archive/<date>-<name>/`
|
|
4
|
+
// (`merge.ts`), carrying `proposal.md`, the delta and `first-run.json` — why the
|
|
5
|
+
// change was agreed, every id it touched, and the recorded failing run behind
|
|
6
|
+
// each one it ADDed. Nothing read any of it back: `archive` is in `locate.ts`'s
|
|
7
|
+
// scan exclusions, in `runner.ts`'s child-run exclusions and in `pipeline.ts`'s.
|
|
8
|
+
//
|
|
9
|
+
// **Those exclusions stay exactly as they are.** They are design §7 and §8 — an
|
|
10
|
+
// archived change's requirements and specs must not re-enter a normal run — and
|
|
11
|
+
// nothing here relaxes them. This module walks `archive/` on its own terms, for
|
|
12
|
+
// facts *about* archived changes rather than to fold their contents back into a
|
|
13
|
+
// registry or a plan.
|
|
14
|
+
//
|
|
15
|
+
// **Only `first-run.json` is read, and that is a selection rather than a first
|
|
16
|
+
// instalment.** It is the typed half: a `RedRecord` in a versioned envelope,
|
|
17
|
+
// schema-validated on read and discarded whole if it does not parse
|
|
18
|
+
// (`red-record.ts`). `proposal.md` is prose, and in this repository it is a
|
|
19
|
+
// pointer — `CLAUDE.md` makes `CHANGELOG.md` the single decision record, so a
|
|
20
|
+
// proposal restating an argument would be a second store of it with nothing
|
|
21
|
+
// comparing the two. There is nothing in the prose half for an index to offer
|
|
22
|
+
// but its path.
|
|
23
|
+
//
|
|
24
|
+
// **What the merged record is for.** Design §11 records that the never-red
|
|
25
|
+
// guarantee is a property of the `changes/` workflow, not of the registry as a
|
|
26
|
+
// whole, so nothing else in the tool can say across a project which scenarios
|
|
27
|
+
// were ever observed failing. Note the granularity: the gate blocks on *every*
|
|
28
|
+
// scenario of an ADDED id (`gate.ts`), so asking this per requirement comes back
|
|
29
|
+
// yes for every archived id and answers nothing. Where the gate genuinely stops
|
|
30
|
+
// is one level down — a scenario grown onto a requirement after it archived
|
|
31
|
+
// carries no obligation, because `never-red` fires only on ids a change is
|
|
32
|
+
// currently ADDing, and MODIFIED carries no such obligation at all. Those are
|
|
33
|
+
// the absences this record makes visible, and a `RedRecord` answers them
|
|
34
|
+
// directly: an id absent from it never came through the workflow, while an id
|
|
35
|
+
// present without a given scenario is one that did and then drifted.
|
|
36
|
+
import { readdir } from 'node:fs/promises';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import { byCodeUnit } from './order.js';
|
|
39
|
+
import { relativePath } from './paths.js';
|
|
40
|
+
import { keepOutcome, readRedRecordIn } from './red-record.js';
|
|
41
|
+
/** The directory archived changes are moved into, under the project root. */
|
|
42
|
+
const ARCHIVE_DIR = 'archive';
|
|
43
|
+
/**
|
|
44
|
+
* Read every archived change folder under `root`.
|
|
45
|
+
*
|
|
46
|
+
* A missing `archive/` is an empty list rather than an error: a project that has
|
|
47
|
+
* archived nothing is the normal state, and it is the state every project starts
|
|
48
|
+
* in. Directories only — a symbolic link is not followed, because this walks a
|
|
49
|
+
* tree to report on it and following one would let a link decide which files a
|
|
50
|
+
* report describes.
|
|
51
|
+
*
|
|
52
|
+
* Ordered by folder name, which is `<date>-<name>` and so is chronological, and
|
|
53
|
+
* through the same code-unit comparator as everything else this tool commits or
|
|
54
|
+
* compares — the report is then a function of the input rather than of the order
|
|
55
|
+
* a filesystem happened to hand back.
|
|
56
|
+
*/
|
|
57
|
+
export async function readArchivedChanges(root) {
|
|
58
|
+
const dir = join(root, ARCHIVE_DIR);
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const names = entries
|
|
67
|
+
.filter((e) => e.isDirectory())
|
|
68
|
+
.map((e) => e.name)
|
|
69
|
+
.sort(byCodeUnit);
|
|
70
|
+
return Promise.all(names.map(async (name) => {
|
|
71
|
+
const path = join(dir, name);
|
|
72
|
+
return { name, path: relativePath(root, path), firstRun: await readRedRecordIn(path) };
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The archive's first-run evidence, merged across every archived change.
|
|
77
|
+
*
|
|
78
|
+
* One `RedRecord` rather than a list to search, because the three questions a
|
|
79
|
+
* caller has of it are the three `recordedOutcome` already answers, at the
|
|
80
|
+
* granularity the gate leaves open: this scenario was observed failing; this
|
|
81
|
+
* scenario's id never came through the workflow; this id did and this scenario
|
|
82
|
+
* is not in it. Merging costs nothing else — the reader, the validator and the
|
|
83
|
+
* accessors are the ones the gate uses, unchanged.
|
|
84
|
+
*/
|
|
85
|
+
export async function readArchivedEvidence(root) {
|
|
86
|
+
const changes = await readArchivedChanges(root);
|
|
87
|
+
return { changes, firstRun: mergeRecords(changes.map((c) => c.firstRun)) };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Fold records into one, `fail` winning.
|
|
91
|
+
*
|
|
92
|
+
* The precedence is `keepOutcome`'s and is order-independent, so nothing here
|
|
93
|
+
* depends on the order `readArchivedChanges` returned — which matters because
|
|
94
|
+
* one id can legitimately appear in two archived changes (removed, then added
|
|
95
|
+
* again) and neither folder's date makes its observation the truer one.
|
|
96
|
+
*
|
|
97
|
+
* Built into prototype-free maps at every level for the reason `red-record.ts`
|
|
98
|
+
* gives at length: the keys are requirement ids, paths and scenario names read
|
|
99
|
+
* off disk, and evidence that can be inherited is not evidence. The records
|
|
100
|
+
* being merged already have no prototype; the container this builds must not
|
|
101
|
+
* reintroduce one.
|
|
102
|
+
*/
|
|
103
|
+
function mergeRecords(records) {
|
|
104
|
+
const merged = Object.create(null);
|
|
105
|
+
for (const record of records) {
|
|
106
|
+
for (const [reqId, byFile] of Object.entries(record)) {
|
|
107
|
+
const files = (merged[reqId] ??= Object.create(null));
|
|
108
|
+
for (const [file, outcomes] of Object.entries(byFile)) {
|
|
109
|
+
const kept = (files[file] ??= Object.create(null));
|
|
110
|
+
for (const [name, outcome] of Object.entries(outcomes)) {
|
|
111
|
+
kept[name] = keepOutcome(kept[name], outcome);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return sortRecord(merged);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Rebuild the record in key order at every level.
|
|
120
|
+
*
|
|
121
|
+
* The same byte-stability rule `mergeRedRecord` and `render` follow: two
|
|
122
|
+
* machines reading one archive produce one document, so key order has to be a
|
|
123
|
+
* function of the data rather than of insertion. `sortDeep` is not used because
|
|
124
|
+
* it would have to be told these containers must stay prototype-free.
|
|
125
|
+
*/
|
|
126
|
+
function sortRecord(record) {
|
|
127
|
+
const sorted = Object.create(null);
|
|
128
|
+
for (const reqId of Object.keys(record).sort(byCodeUnit)) {
|
|
129
|
+
const byFile = record[reqId];
|
|
130
|
+
const files = Object.create(null);
|
|
131
|
+
for (const file of Object.keys(byFile).sort(byCodeUnit)) {
|
|
132
|
+
const outcomes = byFile[file];
|
|
133
|
+
const names = Object.create(null);
|
|
134
|
+
for (const name of Object.keys(outcomes).sort(byCodeUnit))
|
|
135
|
+
names[name] = outcomes[name];
|
|
136
|
+
files[file] = names;
|
|
137
|
+
}
|
|
138
|
+
sorted[reqId] = files;
|
|
139
|
+
}
|
|
140
|
+
return sorted;
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=archive.js.map
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import type { AttestPlan, Outcome, RunResult, ScenarioRef } from './types.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Where the record lives inside a change folder, wherever that folder is.
|
|
4
|
+
*
|
|
5
|
+
* **The unit is the folder, not the project plus a name**, because `--apply`
|
|
6
|
+
* moves the folder whole into `archive/<date>-<name>/` (`merge.ts`) and this
|
|
7
|
+
* file travels inside it. An archived record is therefore the same file at a
|
|
8
|
+
* different path, not a different format — so a reader spelled `root` +
|
|
9
|
+
* `changeName` could not reach it, and that is exactly where the reading side
|
|
10
|
+
* stopped: the archive is written and nothing reads it back.
|
|
11
|
+
*
|
|
12
|
+
* The two spellings below are the two places a change folder is, and they are
|
|
13
|
+
* the whole of the difference.
|
|
14
|
+
*/
|
|
15
|
+
export declare function redRecordPathIn(changeDir: string): string;
|
|
16
|
+
/** Where the record lives for a change still in flight, from the project root. */
|
|
3
17
|
export declare function redRecordPath(root: string, changeName: string): string;
|
|
4
18
|
/** The file name, exported so a diagnostic can name it without rebuilding it. */
|
|
5
19
|
export declare const RED_RECORD_FILE = "first-run.json";
|
|
@@ -34,6 +48,16 @@ export type RedRecord = Record<string, Record<string, Record<string, Outcome>>>;
|
|
|
34
48
|
* the whole one.
|
|
35
49
|
*/
|
|
36
50
|
export declare function readRedRecord(root: string, changeName: string): Promise<RedRecord>;
|
|
51
|
+
/**
|
|
52
|
+
* The same read, against a change folder given directly.
|
|
53
|
+
*
|
|
54
|
+
* Every rule in the comment above is this function's; `readRedRecord` is the
|
|
55
|
+
* in-flight spelling of it. Split out rather than parameterised on a directory
|
|
56
|
+
* name because an archived folder is not a variant of `changes/` — it is the
|
|
57
|
+
* same folder after `--apply` moved it, and naming the folder says so where a
|
|
58
|
+
* `'changes' | 'archive'` argument would invite a caller to pick.
|
|
59
|
+
*/
|
|
60
|
+
export declare function readRedRecordIn(changeDir: string): Promise<RedRecord>;
|
|
37
61
|
/**
|
|
38
62
|
* What the record says about one scenario, or undefined if it never saw it.
|
|
39
63
|
*
|
|
@@ -54,6 +78,21 @@ export declare function recordedOutcome(record: RedRecord, ref: ScenarioRef): Ou
|
|
|
54
78
|
* `uncoveredIssues` from the commands that had a copy each.
|
|
55
79
|
*/
|
|
56
80
|
export declare function hasRecordedRed(record: RedRecord, ref: ScenarioRef): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Which of two observations of one scenario the record keeps.
|
|
83
|
+
*
|
|
84
|
+
* **Monotonic toward `fail`**: a recorded fail is final, a recorded pass can
|
|
85
|
+
* still be corrected by a real one. The reasoning is at the top of this file;
|
|
86
|
+
* what is here is the one place it is decided, because two callers now decide
|
|
87
|
+
* on it — a run being folded into a change's record, and two records being
|
|
88
|
+
* merged. A second spelling of `=== 'fail'` would let them disagree about what
|
|
89
|
+
* the same pair of observations means, which is the argument that gave
|
|
90
|
+
* `hasRecordedRed` its single definition.
|
|
91
|
+
*
|
|
92
|
+
* Order-independent by construction — `keepOutcome(a, b)` and `keepOutcome(b, a)`
|
|
93
|
+
* agree — so a caller merging many records owes no sequencing rule.
|
|
94
|
+
*/
|
|
95
|
+
export declare function keepOutcome(existing: Outcome | undefined, incoming: Outcome): Outcome;
|
|
57
96
|
/**
|
|
58
97
|
* Fold this run's outcomes into the record, for the scenarios that carry an
|
|
59
98
|
* obligation — those covering a requirement this change ADDs.
|
package/dist/core/red-record.js
CHANGED
|
@@ -97,9 +97,35 @@ function fileKey(file) {
|
|
|
97
97
|
function emptyMap() {
|
|
98
98
|
return Object.create(null);
|
|
99
99
|
}
|
|
100
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Where the record lives inside a change folder, wherever that folder is.
|
|
102
|
+
*
|
|
103
|
+
* **The unit is the folder, not the project plus a name**, because `--apply`
|
|
104
|
+
* moves the folder whole into `archive/<date>-<name>/` (`merge.ts`) and this
|
|
105
|
+
* file travels inside it. An archived record is therefore the same file at a
|
|
106
|
+
* different path, not a different format — so a reader spelled `root` +
|
|
107
|
+
* `changeName` could not reach it, and that is exactly where the reading side
|
|
108
|
+
* stopped: the archive is written and nothing reads it back.
|
|
109
|
+
*
|
|
110
|
+
* The two spellings below are the two places a change folder is, and they are
|
|
111
|
+
* the whole of the difference.
|
|
112
|
+
*/
|
|
113
|
+
export function redRecordPathIn(changeDir) {
|
|
114
|
+
return join(changeDir, RED_RECORD_FILE);
|
|
115
|
+
}
|
|
116
|
+
/** Where the record lives for a change still in flight, from the project root. */
|
|
101
117
|
export function redRecordPath(root, changeName) {
|
|
102
|
-
return
|
|
118
|
+
return redRecordPathIn(inFlightChangeDir(root, changeName));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The folder a change occupies before it is archived.
|
|
122
|
+
*
|
|
123
|
+
* One spelling, because both public entry points below would otherwise carry a
|
|
124
|
+
* copy of `'changes'` and they must not be able to disagree about where a
|
|
125
|
+
* record is read from versus where a diagnostic says it is.
|
|
126
|
+
*/
|
|
127
|
+
function inFlightChangeDir(root, changeName) {
|
|
128
|
+
return join(root, 'changes', changeName);
|
|
103
129
|
}
|
|
104
130
|
/** The file name, exported so a diagnostic can name it without rebuilding it. */
|
|
105
131
|
export const RED_RECORD_FILE = 'first-run.json';
|
|
@@ -132,9 +158,21 @@ const RedRecordSchema = z.record(z.string(), z.record(z.string(), z.record(z.str
|
|
|
132
158
|
* the whole one.
|
|
133
159
|
*/
|
|
134
160
|
export async function readRedRecord(root, changeName) {
|
|
161
|
+
return readRedRecordIn(inFlightChangeDir(root, changeName));
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The same read, against a change folder given directly.
|
|
165
|
+
*
|
|
166
|
+
* Every rule in the comment above is this function's; `readRedRecord` is the
|
|
167
|
+
* in-flight spelling of it. Split out rather than parameterised on a directory
|
|
168
|
+
* name because an archived folder is not a variant of `changes/` — it is the
|
|
169
|
+
* same folder after `--apply` moved it, and naming the folder says so where a
|
|
170
|
+
* `'changes' | 'archive'` argument would invite a caller to pick.
|
|
171
|
+
*/
|
|
172
|
+
export async function readRedRecordIn(changeDir) {
|
|
135
173
|
let raw;
|
|
136
174
|
try {
|
|
137
|
-
raw = await readFile(
|
|
175
|
+
raw = await readFile(redRecordPathIn(changeDir), 'utf8');
|
|
138
176
|
}
|
|
139
177
|
catch {
|
|
140
178
|
return emptyMap();
|
|
@@ -218,6 +256,23 @@ export function recordedOutcome(record, ref) {
|
|
|
218
256
|
export function hasRecordedRed(record, ref) {
|
|
219
257
|
return recordedOutcome(record, ref) === 'fail';
|
|
220
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Which of two observations of one scenario the record keeps.
|
|
261
|
+
*
|
|
262
|
+
* **Monotonic toward `fail`**: a recorded fail is final, a recorded pass can
|
|
263
|
+
* still be corrected by a real one. The reasoning is at the top of this file;
|
|
264
|
+
* what is here is the one place it is decided, because two callers now decide
|
|
265
|
+
* on it — a run being folded into a change's record, and two records being
|
|
266
|
+
* merged. A second spelling of `=== 'fail'` would let them disagree about what
|
|
267
|
+
* the same pair of observations means, which is the argument that gave
|
|
268
|
+
* `hasRecordedRed` its single definition.
|
|
269
|
+
*
|
|
270
|
+
* Order-independent by construction — `keepOutcome(a, b)` and `keepOutcome(b, a)`
|
|
271
|
+
* agree — so a caller merging many records owes no sequencing rule.
|
|
272
|
+
*/
|
|
273
|
+
export function keepOutcome(existing, incoming) {
|
|
274
|
+
return existing === 'fail' ? 'fail' : incoming;
|
|
275
|
+
}
|
|
221
276
|
/**
|
|
222
277
|
* Fold this run's outcomes into the record, for the scenarios that carry an
|
|
223
278
|
* obligation — those covering a requirement this change ADDs.
|
|
@@ -259,11 +314,10 @@ export function mergeRedRecord(existing, plan, run, addedIds) {
|
|
|
259
314
|
continue;
|
|
260
315
|
const forId = (record[s.reqId] ??= emptyMap());
|
|
261
316
|
const forFile = (forId[fileKey(s.file)] ??= emptyMap());
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
if (forFile[s.name] === 'fail' || forFile[s.name] === outcome)
|
|
317
|
+
const kept = keepOutcome(forFile[s.name], outcome);
|
|
318
|
+
if (kept === forFile[s.name])
|
|
265
319
|
continue;
|
|
266
|
-
forFile[s.name] =
|
|
320
|
+
forFile[s.name] = kept;
|
|
267
321
|
changed = true;
|
|
268
322
|
}
|
|
269
323
|
// Sort within each requirement, and within each file, for the same
|
package/dist/core/skill.js
CHANGED
|
@@ -318,6 +318,14 @@ blocks the gate with the suite fully green; so does a spec file that will not
|
|
|
318
318
|
import, or a proposed spec no delta claims. A clean table plus green tests is
|
|
319
319
|
still not a verdict — \`attest archive\` is the only thing that decides.
|
|
320
320
|
|
|
321
|
+
**Three questions before you present it**, each a category no diagnostic reports:
|
|
322
|
+
|
|
323
|
+
- **What pins each expectation** — a fixture, a literal, an independently
|
|
324
|
+
derived value; never the param the code under test consumed?
|
|
325
|
+
- **Does each statement carry one obligation**, or several under one SHALL?
|
|
326
|
+
- **Was any of this ever red**, or does part of it already work? \`never-red\`
|
|
327
|
+
asks for a recorded failing run only on the ids you ADD.
|
|
328
|
+
|
|
321
329
|
Then present the proposal, the ids, and the red output, and **stop**. Wait for
|
|
322
330
|
agreement before implementing.
|
|
323
331
|
|
package/dist/inspect.d.ts
CHANGED
|
@@ -51,4 +51,96 @@ export interface Inspection {
|
|
|
51
51
|
* not ours to choose.
|
|
52
52
|
*/
|
|
53
53
|
export declare function inspectProject(root: string): Promise<Inspection>;
|
|
54
|
+
/**
|
|
55
|
+
* What the archived first-run records say about one scenario a spec declares.
|
|
56
|
+
*
|
|
57
|
+
* Four values, not two, and the two absences are the reason. `'fail'` and
|
|
58
|
+
* `'pass'` are the record's own outcomes, spelled the same so a caller
|
|
59
|
+
* comparing against `'fail'` is comparing against the string the gate compares
|
|
60
|
+
* against. The other two are both "no observation", and folding them together
|
|
61
|
+
* would report a design working as intended as if it were a defect:
|
|
62
|
+
*
|
|
63
|
+
* - `'unarchived'` — this scenario's requirement appears in no archived change
|
|
64
|
+
* at all. It was written straight into the registry, which is how existing
|
|
65
|
+
* behaviour is described in a brownfield adoption, and it carries **no**
|
|
66
|
+
* obligation to have been red. Nothing is wrong.
|
|
67
|
+
* - `'unobserved'` — the requirement *is* archived and this scenario is not in
|
|
68
|
+
* the record. It was added after the change was archived, and nothing will
|
|
69
|
+
* ever require it to have failed first: the gate raises `never-red` only for
|
|
70
|
+
* ids a change is currently ADDing, and an archived id is ADDed by nothing
|
|
71
|
+
* again. This is the one no gate can see.
|
|
72
|
+
*
|
|
73
|
+
* `'pass'` is a state the gate cannot produce — it blocks a change whose
|
|
74
|
+
* scenario passed on its first run — so an archived `'pass'` means the record
|
|
75
|
+
* was hand-edited or written by a format that has since moved. Reported rather
|
|
76
|
+
* than folded into the absences, because the fix is different.
|
|
77
|
+
*
|
|
78
|
+
* Attest computes which of the four holds and stops there. Whether an
|
|
79
|
+
* `'unobserved'` scenario matters is a judgement about that scenario, which is
|
|
80
|
+
* the caller's (design §0).
|
|
81
|
+
*/
|
|
82
|
+
export type ScenarioEvidence = 'fail' | 'pass' | 'unobserved' | 'unarchived';
|
|
83
|
+
/** One declared scenario, and what the archive records about it. */
|
|
84
|
+
export interface EvidenceRow {
|
|
85
|
+
readonly reqId: string;
|
|
86
|
+
/** The spec file, relative to the root and POSIX-spelled, as {@link ParsedScenario} spells it. */
|
|
87
|
+
readonly file: string;
|
|
88
|
+
readonly name: string;
|
|
89
|
+
readonly line: number;
|
|
90
|
+
readonly evidence: ScenarioEvidence;
|
|
91
|
+
}
|
|
92
|
+
/** An archived change folder the evidence was read from. */
|
|
93
|
+
export interface ArchivedChangeRef {
|
|
94
|
+
/** The folder name, `<YYYY-MM-DD>-<change>`, as `archive --apply` spelled it. */
|
|
95
|
+
readonly name: string;
|
|
96
|
+
/** The folder relative to the root, POSIX-spelled. */
|
|
97
|
+
readonly path: string;
|
|
98
|
+
}
|
|
99
|
+
/** What {@link inspectRedEvidence} answers. */
|
|
100
|
+
export interface RedEvidenceInspection {
|
|
101
|
+
/**
|
|
102
|
+
* One row per scenario declared under the root, in the order
|
|
103
|
+
* {@link Inspection.scenarios} lists them.
|
|
104
|
+
*
|
|
105
|
+
* Rows for *declared* scenarios rather than for recorded ones: the question is
|
|
106
|
+
* what today's suite can show about itself, so a record naming a scenario no
|
|
107
|
+
* spec declares any more contributes nothing here. It is not lost — the folder
|
|
108
|
+
* it came from is in {@link RedEvidenceInspection.archived} — but a row about
|
|
109
|
+
* a scenario that no longer exists is not something a caller can act on.
|
|
110
|
+
*/
|
|
111
|
+
readonly scenarios: readonly EvidenceRow[];
|
|
112
|
+
/**
|
|
113
|
+
* Every archived change folder that was read, in folder-name order.
|
|
114
|
+
*
|
|
115
|
+
* Provenance, and it is load-bearing: an empty list means the project has
|
|
116
|
+
* archived nothing, in which case every row is `'unarchived'` and the report
|
|
117
|
+
* says nothing about the project rather than something bad about it.
|
|
118
|
+
*/
|
|
119
|
+
readonly archived: readonly ArchivedChangeRef[];
|
|
120
|
+
/** Everything that stopped a file being read, exactly as {@link Inspection.issues}. */
|
|
121
|
+
readonly issues: readonly Issue[];
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Join what a project declares against what its archive recorded, executing
|
|
125
|
+
* nothing.
|
|
126
|
+
*
|
|
127
|
+
* **Why this is not a field on {@link Inspection}.** It walks `archive/`, which
|
|
128
|
+
* most callers of {@link inspectProject} have no use for, and a reader should
|
|
129
|
+
* not pay a second tree walk to be told about requirements.
|
|
130
|
+
*
|
|
131
|
+
* **Why it returns rows rather than the records.** The record is a nested map
|
|
132
|
+
* keyed by requirement ids, paths and scenario names read off disk, and reading
|
|
133
|
+
* it correctly means never answering from an inherited key — the whole of the
|
|
134
|
+
* prototype argument `red-record.ts` carries. Handing that container to a caller
|
|
135
|
+
* would export the hazard along with the data. What is exported is the answer,
|
|
136
|
+
* at the granularity the question is asked.
|
|
137
|
+
*
|
|
138
|
+
* **What the granularity is, and why it is not the requirement.** The gate
|
|
139
|
+
* blocks a change unless *every* scenario of every id it ADDs was observed
|
|
140
|
+
* failing, so "did this requirement ever have red evidence" comes back yes for
|
|
141
|
+
* every archived id and answers nothing. Where the gate stops is one level down:
|
|
142
|
+
* a scenario grown onto a requirement after it archived, and a requirement whose
|
|
143
|
+
* statement a later change MODIFIED, which carries no such obligation at all.
|
|
144
|
+
*/
|
|
145
|
+
export declare function inspectRedEvidence(root: string): Promise<RedEvidenceInspection>;
|
|
54
146
|
//# sourceMappingURL=inspect.d.ts.map
|
package/dist/inspect.js
CHANGED
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
// `tests/import-boundary.spec.ts` gates that rather than leaving it to review —
|
|
25
25
|
// this module is on the short list of entry points whose closure is walked.
|
|
26
26
|
import { resolve } from 'node:path';
|
|
27
|
+
import { readArchivedEvidence } from './core/archive.js';
|
|
27
28
|
import { loadRegistry, parseSpecs, scanProject, staticReader } from './core/locate.js';
|
|
29
|
+
import { recordedOutcome } from './core/red-record.js';
|
|
28
30
|
/**
|
|
29
31
|
* Read the requirements and scenarios declared under `root`, executing nothing.
|
|
30
32
|
*
|
|
@@ -65,4 +67,57 @@ export async function inspectProject(root) {
|
|
|
65
67
|
issues: [...loaded.issues, ...parsed.issues],
|
|
66
68
|
};
|
|
67
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Join what a project declares against what its archive recorded, executing
|
|
72
|
+
* nothing.
|
|
73
|
+
*
|
|
74
|
+
* **Why this is not a field on {@link Inspection}.** It walks `archive/`, which
|
|
75
|
+
* most callers of {@link inspectProject} have no use for, and a reader should
|
|
76
|
+
* not pay a second tree walk to be told about requirements.
|
|
77
|
+
*
|
|
78
|
+
* **Why it returns rows rather than the records.** The record is a nested map
|
|
79
|
+
* keyed by requirement ids, paths and scenario names read off disk, and reading
|
|
80
|
+
* it correctly means never answering from an inherited key — the whole of the
|
|
81
|
+
* prototype argument `red-record.ts` carries. Handing that container to a caller
|
|
82
|
+
* would export the hazard along with the data. What is exported is the answer,
|
|
83
|
+
* at the granularity the question is asked.
|
|
84
|
+
*
|
|
85
|
+
* **What the granularity is, and why it is not the requirement.** The gate
|
|
86
|
+
* blocks a change unless *every* scenario of every id it ADDs was observed
|
|
87
|
+
* failing, so "did this requirement ever have red evidence" comes back yes for
|
|
88
|
+
* every archived id and answers nothing. Where the gate stops is one level down:
|
|
89
|
+
* a scenario grown onto a requirement after it archived, and a requirement whose
|
|
90
|
+
* statement a later change MODIFIED, which carries no such obligation at all.
|
|
91
|
+
*/
|
|
92
|
+
export async function inspectRedEvidence(root) {
|
|
93
|
+
const projectRoot = resolve(root);
|
|
94
|
+
// Sequential rather than concurrent, unlike the two reads inside
|
|
95
|
+
// `inspectProject`: this is one tree walk after another over the same disk,
|
|
96
|
+
// and the archive holds only the folders of changes already merged — so
|
|
97
|
+
// overlapping them buys nothing worth a second fan-out.
|
|
98
|
+
const inspection = await inspectProject(projectRoot);
|
|
99
|
+
const { changes, firstRun } = await readArchivedEvidence(projectRoot);
|
|
100
|
+
const scenarios = [];
|
|
101
|
+
for (const [reqId, declared] of inspection.scenarios) {
|
|
102
|
+
// Own property only, and the distinction the two absences rest on: an id
|
|
103
|
+
// the archive never mentions is a different fact from an id it mentions
|
|
104
|
+
// without this scenario.
|
|
105
|
+
const archived = Object.hasOwn(firstRun, reqId);
|
|
106
|
+
for (const s of declared) {
|
|
107
|
+
const outcome = recordedOutcome(firstRun, { reqId, file: s.file, name: s.name });
|
|
108
|
+
scenarios.push({
|
|
109
|
+
reqId,
|
|
110
|
+
file: s.file,
|
|
111
|
+
name: s.name,
|
|
112
|
+
line: s.line,
|
|
113
|
+
evidence: outcome ?? (archived ? 'unobserved' : 'unarchived'),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
scenarios,
|
|
119
|
+
archived: changes.map((c) => ({ name: c.name, path: c.path })),
|
|
120
|
+
issues: inspection.issues,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
68
123
|
//# sourceMappingURL=inspect.js.map
|
package/package.json
CHANGED