@descent-vtt/spec-brief 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +269 -0
  4. package/bin/spec-brief.js +19 -0
  5. package/dist/apply.d.ts +26 -0
  6. package/dist/apply.js +71 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/archive.d.ts +81 -0
  9. package/dist/archive.js +333 -0
  10. package/dist/archive.js.map +1 -0
  11. package/dist/brief.d.ts +60 -0
  12. package/dist/brief.js +152 -0
  13. package/dist/brief.js.map +1 -0
  14. package/dist/cli.d.ts +35 -0
  15. package/dist/cli.js +411 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/collisions.d.ts +50 -0
  18. package/dist/collisions.js +127 -0
  19. package/dist/collisions.js.map +1 -0
  20. package/dist/config.d.ts +94 -0
  21. package/dist/config.js +353 -0
  22. package/dist/config.js.map +1 -0
  23. package/dist/corpus.d.ts +41 -0
  24. package/dist/corpus.js +154 -0
  25. package/dist/corpus.js.map +1 -0
  26. package/dist/engine.d.ts +121 -0
  27. package/dist/engine.js +276 -0
  28. package/dist/engine.js.map +1 -0
  29. package/dist/frontmatter.d.ts +68 -0
  30. package/dist/frontmatter.js +311 -0
  31. package/dist/frontmatter.js.map +1 -0
  32. package/dist/fs.d.ts +59 -0
  33. package/dist/fs.js +189 -0
  34. package/dist/fs.js.map +1 -0
  35. package/dist/git.d.ts +59 -0
  36. package/dist/git.js +131 -0
  37. package/dist/git.js.map +1 -0
  38. package/dist/glob.d.ts +79 -0
  39. package/dist/glob.js +465 -0
  40. package/dist/glob.js.map +1 -0
  41. package/dist/index.d.ts +24 -0
  42. package/dist/index.js +26 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/integrity.d.ts +11 -0
  45. package/dist/integrity.js +20 -0
  46. package/dist/integrity.js.map +1 -0
  47. package/dist/links.d.ts +38 -0
  48. package/dist/links.js +142 -0
  49. package/dist/links.js.map +1 -0
  50. package/dist/lint.d.ts +38 -0
  51. package/dist/lint.js +90 -0
  52. package/dist/lint.js.map +1 -0
  53. package/dist/markdown.d.ts +65 -0
  54. package/dist/markdown.js +274 -0
  55. package/dist/markdown.js.map +1 -0
  56. package/dist/plugins.d.ts +16 -0
  57. package/dist/plugins.js +77 -0
  58. package/dist/plugins.js.map +1 -0
  59. package/dist/report.d.ts +38 -0
  60. package/dist/report.js +244 -0
  61. package/dist/report.js.map +1 -0
  62. package/dist/rules.d.ts +58 -0
  63. package/dist/rules.js +448 -0
  64. package/dist/rules.js.map +1 -0
  65. package/dist/scaffold.d.ts +25 -0
  66. package/dist/scaffold.js +81 -0
  67. package/dist/scaffold.js.map +1 -0
  68. package/dist/schema.d.ts +47 -0
  69. package/dist/schema.js +195 -0
  70. package/dist/schema.js.map +1 -0
  71. package/dist/text.d.ts +40 -0
  72. package/dist/text.js +95 -0
  73. package/dist/text.js.map +1 -0
  74. package/dist/types.d.ts +30 -0
  75. package/dist/types.js +5 -0
  76. package/dist/types.js.map +1 -0
  77. package/package.json +76 -0
  78. package/schema.json +321 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ Notable changes, newest first. Versions follow [semver](https://semver.org).
4
+
5
+ ## 0.1.0
6
+
7
+ The first release.
8
+
9
+ - `init`, `new`, `lint`, `list`, `matrix`, `archive` and `unarchive`.
10
+ - Configurable conventions: directories, file names, ids, status words,
11
+ sections with aliases, order and required text, brief types, archival
12
+ dispositions and the banner template. A configuration that does not load
13
+ stops the run with exit 2.
14
+ - 22 lint rules and 3 collision rules, with severities set in configuration.
15
+ - Scope collisions by glob intersection, with a path both scopes cover.
16
+ - Archival as a planned transaction: preflight, banner, link rewriting in both
17
+ directions, freeze hash, rollback on failure. Git is read, never written.
18
+ - Pretty, JSON, SARIF 2.1.0 and GitHub workflow-command output.
19
+ - Rule plugins, and a library API over a real or an in-memory filesystem.
20
+ - No runtime dependencies.
21
+ - Mutation testing in two sweeps: the core measured at 95.75% and gated at 93
22
+ (ADR-0009); coverage 100% of lines.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 spec-brief contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,269 @@
1
+ # spec-brief
2
+
3
+ **Lint the contract a round of work runs under, map which rounds can run side by side, and archive a finished one in one atomic command.**
4
+
5
+ A *brief* is the input to one round of work - for a coding agent or a person: what done looks like, what the round must not touch, and the checks that prove it finished. spec-brief manages briefs as files in your repository. It has no runtime dependencies, reads and writes nothing but Markdown in two directories, and never writes to git.
6
+
7
+ ```bash
8
+ npm install --save-dev @descent-vtt/spec-brief
9
+ npx spec-brief init
10
+ npx spec-brief new "Rotate session tokens on privilege change" --wave 1
11
+ npx spec-brief lint
12
+ ```
13
+
14
+ ## Why
15
+
16
+ Repositories that run agents on briefs end up doing the same ceremony by hand, and getting it wrong in the same places:
17
+
18
+ - **A brief that is incomplete.** A missing negative scope or an empty invariant list is how a round wanders. `lint` checks the sections your repository requires, in the order it requires them, and that each one says something.
19
+ - **Two rounds that write the same files.** Run in parallel, they collide at merge time. `matrix` compares the scopes of briefs scheduled in the same wave *as globs*, and names a file both would write.
20
+ - **Archival.** Move the file, set its status, write a frozen banner with the date, the pull request and the commit, disposition every open box, add `../` to every relative link so they still resolve, fix the links other briefs hold to it. `archive` does all of it as one transaction, or none of it.
21
+
22
+ The conventions are not invented here. They were measured against two repositories that already keep briefs - one with front matter and eight ordered sections, one with none at all - and everything they disagree on is configuration.
23
+
24
+ ## A brief
25
+
26
+ ```markdown
27
+ ---
28
+ status: active
29
+ wave: 2
30
+ dependsOn: [007]
31
+ affectedFiles: [src/auth/**, tests/auth/**]
32
+ protectedFiles: [src/db/schema.ts]
33
+ ---
34
+
35
+ # 012 - Rotate session tokens on privilege change
36
+
37
+ ## Intent
38
+
39
+ Every privilege change issues a new session token and invalidates the old one.
40
+
41
+ ## Negative Scope
42
+
43
+ - No change to the login UI or the token format.
44
+
45
+ ## Invariants
46
+
47
+ - [ ] `npm test` passes
48
+ - [ ] a rotated token is rejected by every endpoint
49
+ ```
50
+
51
+ The id comes from the file name (`012_rotate-session-tokens.md`) or from an `id` field. `affectedFiles` is the scope the round may write; `protectedFiles` is what it is not empowered to change, and archival refuses a round that changed it.
52
+
53
+ ## Commands
54
+
55
+ ### `spec-brief init`
56
+
57
+ Writes `.spec-brief.json` with every default spelled out, and creates the brief and archive directories. `--briefs <dir>` and `--archive <dir>` choose them.
58
+
59
+ ### `spec-brief new <title>`
60
+
61
+ Scaffolds a brief with the next free number - one more than the highest id, live or archived, since ids are never reused - and every required section, each holding a hint in a comment. A comment is not content, so a fresh brief reads as unwritten until someone writes it. `--id`, `--type`, `--wave`, `--depends-on 7,8` and `--date` fill the front matter.
62
+
63
+ ### `spec-brief lint [brief...]`
64
+
65
+ Checks every brief, or the ones named. A draft (`status: draft`) gets its unwritten sections as warnings; an active brief gets them as errors. Archived briefs are frozen records: they are checked for identity and for the freeze, never against today's schema.
66
+
67
+ ```text
68
+ briefs/035_the-background-side.md
69
+ 5 error has no "Report" section missing-section
70
+ add a "## Report" heading
71
+ 48 error "Context" comes before "Standing directives" section-order
72
+ the order is Mission, Standing directives, Context, Deliverables, ...
73
+
74
+ 2 errors, 0 warnings, 0 notes in 35 brief(s)
75
+ ```
76
+
77
+ ### `spec-brief list`
78
+
79
+ Live briefs with their status, wave, task count and readiness. A brief is *ready* when every brief it depends on is archived. `--ready` shows only those, which is the question an orchestrator asks; `--archived` includes the archive; `--format json` gives an orchestrator the whole table.
80
+
81
+ ### `spec-brief matrix`
82
+
83
+ Compares the `affectedFiles` of every pair of live briefs in the same wave, `--all-waves` for every pair.
84
+
85
+ ```text
86
+ wave 1 · 3 briefs
87
+ 001 002 003
88
+ 001 · X ·
89
+ 002 X · ·
90
+ 003 · · ·
91
+ X 001 "src/auth/**" and 002 "src/**/session.ts" both cover src/auth/session.ts
92
+ ? 003 declares no affectedFiles and cannot be checked
93
+ ```
94
+
95
+ Scopes are intersected as globs, not compared as strings: the two above share no prefix and still meet, and the file named is one both patterns match. A brief with no scope is reported as unscoped rather than counted as safe. Exit 1 on a collision.
96
+
97
+ ### `spec-brief archive <brief>`
98
+
99
+ Closes a round. Refused, with every reason, when:
100
+
101
+ - the brief has lint errors, is a draft, or depends on a brief that is still live;
102
+ - a task item is neither ticked nor dispositioned - an open box counts as closed when a note under it starts with one of the `dispositions` (`**Delegated`, `**Accepted debt`, `**Rejected` by default);
103
+ - the working tree holds uncommitted work outside the brief directories (`--allow-dirty` to proceed);
104
+ - the round's commit changed a file in `protectedFiles`.
105
+
106
+ Changes outside `affectedFiles` are a warning, and an error under `--strict`. Otherwise it writes the archived brief with its status set, a frozen banner under the front matter, every relative link rewritten for the archive directory, and an `integrity` hash; rewrites the links other live briefs hold to it; and removes the original. `--commit <rev>` records the commit and the files it changed; `--base <rev>` measures from the merge base instead, for a branch of several commits; `--pr <n>` links the pull request; `--summary <text>` is what the round did, in the banner. `--dry-run` prints the plan and the banner and writes nothing. Archiving an archived brief does nothing and exits 0.
107
+
108
+ spec-brief reads git and never writes it. Review the change and commit it with the round.
109
+
110
+ ### `spec-brief unarchive <brief>`
111
+
112
+ Reopens an archived brief: the banner and the hash come off, the status goes back to the live word, and the links are rewritten again. A brief archived and reopened is the brief it was, blank lines and final newline included, with two exceptions: the status line is written in its plain spelling, and a relative link that had to be rewritten comes back in its shortest form (`./b.md` returns as `b.md`).
113
+
114
+ ### Every command
115
+
116
+ `--root <dir>` runs from another directory. `--config <file>` names the configuration; `--no-config` uses the defaults. `--format` is `pretty`, `json`, `sarif` or `github` where the command reports findings. `--strict` makes warnings fail the run. `--no-git` leaves git out even inside a repository: the tree is read from disk, and `archive` records no commit. `--color` and `--no-color` override `NO_COLOR`, `FORCE_COLOR` and the terminal check. `--help` and `--version` do what they say.
117
+
118
+ **Exit codes:** `0` clean, `1` findings, a collision, or a refused action, `2` the run could not be trusted - a bad flag, a configuration that does not load, a brief that does not exist. A run over a briefs directory that does not exist exits 2, because a check over nothing looks exactly like a clean one.
119
+
120
+ ## Configuration
121
+
122
+ `.spec-brief.json` (or `spec-brief.json`) at the root; the nearest one above the working directory is used, and its directory is the root. It is JSON, validated against [`schema.json`](schema.json): an unknown key, a misspelt rule or a value of the wrong type stops the run with exit 2 rather than falling back to defaults and reporting on the wrong rules.
123
+
124
+ | Key | Default | What it says |
125
+ | --- | --- | --- |
126
+ | `briefs` | `"briefs"` | Directory of live briefs. |
127
+ | `archive` | `"<briefs>/archive"` | Directory of archived briefs. |
128
+ | `files` | `"[0-9]*.md"` | Glob a file name must match to be a brief. |
129
+ | `exclude` | `[]` | File-name globs that are never briefs, such as an index. |
130
+ | `template` | `null` | A template file for `new`, with `{id}`, `{title}`, `{date}`, `{type}`, `{wave}`, `{status}`. |
131
+ | `id` | `{ "source": "filename", "separator": "_", "digits": 3 }` | Where an id comes from, and how a new one is written. |
132
+ | `status` | `{ "field": "status", "draft": "draft", "active": "active", "archived": "archived" }` | The words a repository uses. `field: null` reads status from location alone. |
133
+ | `sections` | Intent, Negative Scope, Not Empowered (optional), Invariants (checklist) | Sections every live brief carries: a name, or `{ name, aliases, mustContain, checklist, optional }`. |
134
+ | `sectionOrder` | `false` | Sections must appear in the listed order. |
135
+ | `types` | `feature`, `defect`, `refactor`, `chore` | Brief types and the sections each adds. |
136
+ | `placeholders` | `TBD`, `TODO`, `FIXME`, ... | Words that mark a section as unwritten. |
137
+ | `fields` | `[]` | Front-matter keys the repository uses beyond the built-in ones. |
138
+ | `archiving.tasks` | `"all"` | `"all"`, or the sections whose boxes must be closed. |
139
+ | `archiving.dispositions` | `**Delegated`, `**Accepted debt`, `**Rejected` | Text under an open box that closes it. |
140
+ | `archiving.banner` | see [`src/config.ts`](src/config.ts) | Banner lines, with `{date}`, `{summary}`, `{pr}`, `{commit}`, `{diffstat}`, `{links}`, `{id}`, `{title}`, `{author}`. A line with an empty placeholder is left out. |
141
+ | `archiving.rewriteLinks` | `true` | Rewrite relative links when a brief moves. |
142
+ | `archiving.freeze` | `true` | Write an integrity hash, so a later edit is caught. |
143
+ | `archiving.base` | `null` | Branch the diff is measured from, such as `"main"`. |
144
+ | `rules` | `{}` | Severity per rule: `off`, `note`, `warning` or `error`. |
145
+ | `plugins` | `[]` | Modules that contribute rules: a path or package, or `{ module, options }`. |
146
+
147
+ A repository whose briefs have eight ordered sections, `proposed` and `archived` for words, and a banner of its own:
148
+
149
+ ```json
150
+ {
151
+ "$schema": "./node_modules/@descent-vtt/spec-brief/schema.json",
152
+ "files": "[0-9][0-9][0-9]_*.md",
153
+ "status": { "draft": null, "active": "proposed", "archived": "archived" },
154
+ "sections": [
155
+ "Mission",
156
+ { "name": "Standing directives", "mustContain": ["Latest ≠ Newest"] },
157
+ "Context",
158
+ { "name": "Deliverables", "checklist": true },
159
+ "Not empowered",
160
+ "Architectural empowerment",
161
+ "Verification",
162
+ "Report"
163
+ ],
164
+ "sectionOrder": true,
165
+ "types": {},
166
+ "archiving": {
167
+ "tasks": ["Deliverables"],
168
+ "banner": ["**Executed {date} in pull request {pr}.** {summary} The body below describes the tree before execution and is not maintained."]
169
+ }
170
+ }
171
+ ```
172
+
173
+ Section names compare without case, typographic quotes, emphasis, a leading number or a trailing colon, and a heading may add a qualifier after a separator: `## 2. Commander’s Intent:` fills `Commander's Intent`, and `## Invariants (must hold)` fills `Invariants`.
174
+
175
+ ## Rules
176
+
177
+ <!-- rules:start -->
178
+ | Rule | Default | Reports |
179
+ | --- | --- | --- |
180
+ | `front-matter` | error | Front matter that does not parse, a duplicate key, YAML beyond the flat subset. |
181
+ | `field` | error | A field spec-brief reads with a value of the wrong shape: a wave that is not a whole number, a list where one value belongs. |
182
+ | `unknown-field` | warning | A front-matter key nobody declared, with the one probably meant. |
183
+ | `status` | error | No status, a word the configuration does not use, or a status that disagrees with the directory. |
184
+ | `id` | error | No id, or one with whitespace or a slash. |
185
+ | `duplicate-id` | error | Two briefs, live or archived, with one id. |
186
+ | `title` | warning | A live brief with no title. |
187
+ | `unknown-type` | error | A type the configuration does not define. |
188
+ | `missing-section` | error | A required section that is absent. |
189
+ | `duplicate-section` | warning | A section that appears twice. |
190
+ | `empty-section` | error | A section with no content; a comment is not content. A warning in a draft. |
191
+ | `placeholder` | error | A section holding only `TBD`, `TODO` or an empty box. A warning in a draft. |
192
+ | `missing-checklist` | error | A checklist section with no task item. A warning in a draft. |
193
+ | `must-contain` | error | A section without the text the configuration requires of it. |
194
+ | `section-order` | error | Sections out of the configured order, when one is configured. |
195
+ | `dependency` | error | A dependency on itself, or on a brief that does not exist. |
196
+ | `dependency-cycle` | error | Live briefs that depend on each other in a cycle, reported once, as a path. |
197
+ | `wave-order` | error | A live dependency that does not run in an earlier wave. |
198
+ | `glob` | error | A scope pattern spec-brief cannot read. |
199
+ | `scope-contradiction` | error | A file that is both in `affectedFiles` and in `protectedFiles`. |
200
+ | `glob-matches-nothing` | note | A scope pattern that matches no file git sees, tracked or untracked - expected when the round creates it. |
201
+ | `archive-freeze` | error | An archived brief that changed after it was archived. |
202
+ | `collision` | error | Two briefs in one wave whose scopes can name the same file (`matrix`). |
203
+ | `unscoped` | note | A brief sharing a wave that declares no scope (`matrix`). |
204
+ | `shared-directory` | off | Two briefs in one wave writing into the same directory (`matrix`). |
205
+ <!-- rules:end -->
206
+
207
+ `archive` refuses with its own reasons - `open-task`, `archive-draft`, `dependency-open`, `dirty-tree`, `protected-file`, `out-of-scope`, `archive-exists` - which are not lint rules: they are about whether this round is done, not whether the brief is well written.
208
+
209
+ ## In CI
210
+
211
+ ```yaml
212
+ - run: npx spec-brief lint --format github
213
+ - run: npx spec-brief matrix --format github
214
+ ```
215
+
216
+ `github` writes workflow commands, which annotate the pull request with no upload and no permission. `sarif` writes SARIF 2.1.0 for code-scanning upload. `json` is a versioned document (`schemaVersion`) for anything else.
217
+
218
+ ## As a library
219
+
220
+ ```ts
221
+ import { BriefEngine } from '@descent-vtt/spec-brief';
222
+
223
+ const engine = await BriefEngine.open({ cwd: process.cwd() });
224
+ const findings = await engine.lint();
225
+ const ready = engine.ready(); // what can run now
226
+ const { report } = await engine.collisions(); // who collides with whom
227
+ const plan = await engine.planArchive('012', { commit: 'HEAD', pr: 41 });
228
+ if (plan.blocking.length === 0) await engine.apply(plan);
229
+ ```
230
+
231
+ Everything below the engine is a pure function of text: `parseBrief`, `lint`, `collisions`, `intersectGlobs`, `planArchive`. `MemoryFileSystem` runs an engine over files that were never written, which is how a harness can ask what archiving a brief would do.
232
+
233
+ ## Plugins
234
+
235
+ A plugin is a module that exports `{ name, rules }`, or a function of its configured options that returns one. Its rules run beside the built-in ones as `<name>/<rule>`, and configuration sets their severity like any other.
236
+
237
+ ```js
238
+ // tools/departures.mjs
239
+ export default (options) => ({
240
+ name: 'departures',
241
+ rules: [{
242
+ id: 'signed',
243
+ description: 'every departure is signed',
244
+ severity: 'error',
245
+ check: ({ brief }) => brief.text.includes(options.marker) ? [] : [{ line: 1, message: 'has an unsigned departure' }],
246
+ }],
247
+ });
248
+ ```
249
+
250
+ ```json
251
+ { "plugins": [{ "module": "./tools/departures.mjs", "options": { "marker": "Signed-off-by" } }] }
252
+ ```
253
+
254
+ This is where integrations belong. A tool that defines a format - signed departures, recorded reproducers, a code graph's blast radius - is the one that can check it, and spec-brief carries no copy of formats it does not own. Loading a plugin runs its code, exactly as loading a linter configuration does.
255
+
256
+ ## What it does not do
257
+
258
+ - **No status file.** There is no `manifest.json` to keep in step. A brief and the directory it sits in are the record; `list --format json` is the index, computed when asked. A committed index is also the one file every round merging in parallel edits.
259
+ - **No roadmap editing.** A roadmap is a document people write. spec-brief reports on briefs; it does not rewrite prose around them.
260
+ - **No git writes.** It reads commits, diffs and the working tree, and leaves staging and committing to whoever does them.
261
+ - **No labels as sections, yet.** Sections are headings. A repository that marks its sections with bold or bracketed labels instead, `**Your Mission:**` or `[STANDING DIRECTIVES]`, is not described by this version.
262
+
263
+ ## Design
264
+
265
+ The decisions and what they cost are in [`docs/adr/`](docs/adr/README.md).
266
+
267
+ ## License
268
+
269
+ MIT
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Thin launcher. All logic lives in dist/cli.js so the published binary stays a
4
+ * short shim that is trivially auditable.
5
+ */
6
+ const entry = new URL('../dist/cli.js', import.meta.url);
7
+
8
+ let cli;
9
+ try {
10
+ cli = await import(entry.href);
11
+ } catch (error) {
12
+ if (error?.code === 'ERR_MODULE_NOT_FOUND' && String(error.message).includes('dist')) {
13
+ process.stderr.write('spec-brief: build output is missing; run "npm run build" first, or install the published package.\n');
14
+ process.exit(2);
15
+ }
16
+ throw error;
17
+ }
18
+
19
+ process.exitCode = await cli.main();
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Executing a plan as a transaction over files.
3
+ *
4
+ * Three phases. First every file is read back and compared with what the plan
5
+ * saw, so a file somebody edited in the meantime stops the transaction before
6
+ * anything changes. Then the operations run in order - the new file first, the
7
+ * old one removed last - and each one that completes is recorded. If any
8
+ * fails, the recorded ones are undone in reverse from the contents the plan
9
+ * already holds. Each write is itself atomic (a rename over the target), so
10
+ * the only window in which the tree shows both copies is between the first
11
+ * operation and the last, and a crash inside it leaves two briefs with one id,
12
+ * which `lint` reports by name.
13
+ */
14
+ import type { FileOp } from './archive.js';
15
+ import type { FileSystem } from './fs.js';
16
+ export declare class ConflictError extends Error {
17
+ readonly path: string;
18
+ constructor(path: string);
19
+ }
20
+ export declare class TransactionError extends Error {
21
+ /** Whether every completed operation was undone. */
22
+ readonly rolledBack: boolean;
23
+ readonly failures: readonly string[];
24
+ constructor(cause: unknown, rolledBack: boolean, failures: readonly string[]);
25
+ }
26
+ export declare function applyPlan(fs: FileSystem, ops: readonly FileOp[]): Promise<void>;
package/dist/apply.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Executing a plan as a transaction over files.
3
+ *
4
+ * Three phases. First every file is read back and compared with what the plan
5
+ * saw, so a file somebody edited in the meantime stops the transaction before
6
+ * anything changes. Then the operations run in order - the new file first, the
7
+ * old one removed last - and each one that completes is recorded. If any
8
+ * fails, the recorded ones are undone in reverse from the contents the plan
9
+ * already holds. Each write is itself atomic (a rename over the target), so
10
+ * the only window in which the tree shows both copies is between the first
11
+ * operation and the last, and a crash inside it leaves two briefs with one id,
12
+ * which `lint` reports by name.
13
+ */
14
+ export class ConflictError extends Error {
15
+ path;
16
+ constructor(path) {
17
+ super(`${path} changed after the plan was made; nothing was written. Run the command again.`);
18
+ this.name = 'ConflictError';
19
+ this.path = path;
20
+ }
21
+ }
22
+ export class TransactionError extends Error {
23
+ /** Whether every completed operation was undone. */
24
+ rolledBack;
25
+ failures;
26
+ constructor(cause, rolledBack, failures) {
27
+ const reason = cause instanceof Error ? cause.message : String(cause);
28
+ super(rolledBack
29
+ ? `${reason}; every change was rolled back`
30
+ : `${reason}; the rollback also failed (${failures.join('; ')}), so check these files by hand`);
31
+ this.name = 'TransactionError';
32
+ this.rolledBack = rolledBack;
33
+ this.failures = failures;
34
+ }
35
+ }
36
+ export async function applyPlan(fs, ops) {
37
+ for (const op of ops) {
38
+ const current = await fs.read(op.path);
39
+ if (current !== op.before)
40
+ throw new ConflictError(op.path);
41
+ }
42
+ const done = [];
43
+ try {
44
+ for (const op of ops) {
45
+ if (op.kind === 'write')
46
+ await fs.write(op.path, op.content);
47
+ else
48
+ await fs.remove(op.path);
49
+ done.push(op);
50
+ }
51
+ }
52
+ catch (error) {
53
+ const failures = [];
54
+ for (const op of [...done].reverse()) {
55
+ // Undoing is putting back what was there: a file that was absent is
56
+ // removed, and one that was present - written over or removed - is
57
+ // written again.
58
+ try {
59
+ if (op.before === null)
60
+ await fs.remove(op.path);
61
+ else
62
+ await fs.write(op.path, op.before);
63
+ }
64
+ catch (undo) {
65
+ failures.push(`${op.path}: ${undo instanceof Error ? undo.message : String(undo)}`);
66
+ }
67
+ }
68
+ throw new TransactionError(error, failures.length === 0, failures);
69
+ }
70
+ }
71
+ //# sourceMappingURL=apply.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apply.js","sourceRoot":"","sources":["../src/apply.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,MAAM,OAAO,aAAc,SAAQ,KAAK;IAC7B,IAAI,CAAS;IAEtB,YAAY,IAAY;QACtB,KAAK,CAAC,GAAG,IAAI,+EAA+E,CAAC,CAAC;QAC9F,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;QAC5B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF;AAED,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IACzC,oDAAoD;IAC3C,UAAU,CAAU;IACpB,QAAQ,CAAoB;IAErC,YAAY,KAAc,EAAE,UAAmB,EAAE,QAA2B;QAC1E,MAAM,MAAM,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACtE,KAAK,CACH,UAAU;YACR,CAAC,CAAC,GAAG,MAAM,gCAAgC;YAC3C,CAAC,CAAC,GAAG,MAAM,+BAA+B,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,iCAAiC,CACjG,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC;QAC/B,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;QAC7B,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;IAC3B,CAAC;CACF;AAED,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,EAAc,EAAE,GAAsB;IACpE,KAAK,MAAM,EAAE,IAAI,GAAG,EAAE,CAAC;QACrB,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;QACvC,IAAI,OAAO,KAAK,EAAE,CAAC,MAAM;YAAE,MAAM,IAAI,aAAa,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;IAC9D,CAAC;IAED,MAAM,IAAI,GAAa,EAAE,CAAC;IAC1B,IAAI,CAAC;QACH,KAAK,MAAM,EAAE,IAAI,GAAG,EAAE,CAAC;YACrB,IAAI,EAAE,CAAC,IAAI,KAAK,OAAO;gBAAE,MAAM,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC;;gBACxD,MAAM,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;YAC9B,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAChB,CAAC;IACH,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,QAAQ,GAAa,EAAE,CAAC;QAC9B,KAAK,MAAM,EAAE,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC;YACrC,oEAAoE;YACpE,mEAAmE;YACnE,iBAAiB;YACjB,IAAI,CAAC;gBACH,IAAI,EAAE,CAAC,MAAM,KAAK,IAAI;oBAAE,MAAM,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;;oBAC5C,MAAM,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC;YAC1C,CAAC;YAAC,OAAO,IAAI,EAAE,CAAC;gBACd,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,KAAK,IAAI,YAAY,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YACtF,CAAC;QACH,CAAC;QACD,MAAM,IAAI,gBAAgB,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,QAAQ,CAAC,CAAC;IACrE,CAAC;AACH,CAAC","sourcesContent":["/**\n * Executing a plan as a transaction over files.\n *\n * Three phases. First every file is read back and compared with what the plan\n * saw, so a file somebody edited in the meantime stops the transaction before\n * anything changes. Then the operations run in order - the new file first, the\n * old one removed last - and each one that completes is recorded. If any\n * fails, the recorded ones are undone in reverse from the contents the plan\n * already holds. Each write is itself atomic (a rename over the target), so\n * the only window in which the tree shows both copies is between the first\n * operation and the last, and a crash inside it leaves two briefs with one id,\n * which `lint` reports by name.\n */\n\nimport type { FileOp } from './archive.js';\nimport type { FileSystem } from './fs.js';\n\nexport class ConflictError extends Error {\n readonly path: string;\n\n constructor(path: string) {\n super(`${path} changed after the plan was made; nothing was written. Run the command again.`);\n this.name = 'ConflictError';\n this.path = path;\n }\n}\n\nexport class TransactionError extends Error {\n /** Whether every completed operation was undone. */\n readonly rolledBack: boolean;\n readonly failures: readonly string[];\n\n constructor(cause: unknown, rolledBack: boolean, failures: readonly string[]) {\n const reason = cause instanceof Error ? cause.message : String(cause);\n super(\n rolledBack\n ? `${reason}; every change was rolled back`\n : `${reason}; the rollback also failed (${failures.join('; ')}), so check these files by hand`,\n );\n this.name = 'TransactionError';\n this.rolledBack = rolledBack;\n this.failures = failures;\n }\n}\n\nexport async function applyPlan(fs: FileSystem, ops: readonly FileOp[]): Promise<void> {\n for (const op of ops) {\n const current = await fs.read(op.path);\n if (current !== op.before) throw new ConflictError(op.path);\n }\n\n const done: FileOp[] = [];\n try {\n for (const op of ops) {\n if (op.kind === 'write') await fs.write(op.path, op.content);\n else await fs.remove(op.path);\n done.push(op);\n }\n } catch (error) {\n const failures: string[] = [];\n for (const op of [...done].reverse()) {\n // Undoing is putting back what was there: a file that was absent is\n // removed, and one that was present - written over or removed - is\n // written again.\n try {\n if (op.before === null) await fs.remove(op.path);\n else await fs.write(op.path, op.before);\n } catch (undo) {\n failures.push(`${op.path}: ${undo instanceof Error ? undo.message : String(undo)}`);\n }\n }\n throw new TransactionError(error, failures.length === 0, failures);\n }\n}\n"]}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Archival and its reverse, planned.
3
+ *
4
+ * A plan is a pure function of the corpus and a request: what would be
5
+ * refused and why, the text every touched file would hold afterwards, and the
6
+ * operations that get there. Nothing is written here. `--dry-run` prints a
7
+ * plan; `apply.ts` executes one as a transaction. Splitting the two is what
8
+ * makes the ceremony testable without a disk and previewable without risk.
9
+ */
10
+ import { type Brief } from './brief.js';
11
+ import { type Corpus } from './corpus.js';
12
+ import type { CommitInfo, FileChange } from './git.js';
13
+ import type { Finding } from './types.js';
14
+ export interface PullRequest {
15
+ readonly number: number;
16
+ readonly url: string | null;
17
+ }
18
+ export interface ArchiveRequest {
19
+ /** `YYYY-MM-DD`. */
20
+ readonly date: string;
21
+ readonly summary?: string | undefined;
22
+ readonly pr?: PullRequest | undefined;
23
+ readonly commit?: CommitInfo | undefined;
24
+ /** The files the round changed, when a commit is known. */
25
+ readonly changes?: readonly FileChange[] | undefined;
26
+ /** Uncommitted paths, when the working tree was read. */
27
+ readonly dirty?: readonly string[] | undefined;
28
+ readonly allowDirty?: boolean | undefined;
29
+ /** Out-of-scope changes refuse the archival instead of warning. */
30
+ readonly strict?: boolean | undefined;
31
+ /** The corpus's lint findings; errors on the brief refuse the archival. */
32
+ readonly findings?: readonly Finding[] | undefined;
33
+ }
34
+ export type FileOp = {
35
+ readonly kind: 'write';
36
+ readonly path: string;
37
+ readonly content: string;
38
+ /** What the file holds when the plan is made; `null` for a file that must not exist yet. */
39
+ readonly before: string | null;
40
+ } | {
41
+ readonly kind: 'remove';
42
+ readonly path: string;
43
+ readonly before: string;
44
+ };
45
+ export interface Plan {
46
+ readonly action: 'archive' | 'unarchive';
47
+ readonly brief: Brief;
48
+ readonly from: string;
49
+ readonly to: string;
50
+ /** The brief is already where this action would put it. */
51
+ readonly done: boolean;
52
+ readonly ops: readonly FileOp[];
53
+ /** Why the action is refused; empty when it may proceed. */
54
+ readonly blocking: readonly Finding[];
55
+ readonly warnings: readonly Finding[];
56
+ readonly linksRewritten: number;
57
+ readonly inboundRewritten: readonly string[];
58
+ /** Links archived briefs hold to the old path. They are frozen and left as they are. */
59
+ readonly inboundFrozen: readonly {
60
+ readonly file: string;
61
+ readonly line: number;
62
+ }[];
63
+ readonly banner: readonly string[];
64
+ readonly changes: readonly FileChange[];
65
+ }
66
+ /** Task items that must be closed before a brief can be archived. */
67
+ export declare function openTasks(brief: Brief, corpus: Corpus): Brief['tasks'];
68
+ /**
69
+ * The banner's lines, markers included. A template line whose placeholders
70
+ * are not all filled is left out, so a round with no pull request has no
71
+ * pull-request sentence rather than a sentence with a hole in it. A template
72
+ * with nothing left writes no banner at all, markers included.
73
+ */
74
+ export declare function renderBanner(template: readonly string[], values: Readonly<Record<string, string>>): string[];
75
+ export declare function planArchive(corpus: Corpus, brief: Brief, request: ArchiveRequest): Plan;
76
+ /**
77
+ * Reopens an archived brief: the banner and the freeze come off, the status
78
+ * goes back to the live word, and the links are rewritten for the directory
79
+ * it returns to. What the round wrote stays in history, where it belongs.
80
+ */
81
+ export declare function planUnarchive(corpus: Corpus, brief: Brief): Plan;