@descent-vtt/spec-guard 0.10.3 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +410 -0
- package/README.md +385 -27
- package/dist/cites.d.ts +192 -0
- package/dist/cites.d.ts.map +1 -0
- package/dist/cites.js +599 -0
- package/dist/cites.js.map +1 -0
- package/dist/cli.d.ts +14 -6
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +315 -22
- package/dist/cli.js.map +1 -1
- package/dist/comments.d.ts +51 -2
- package/dist/comments.d.ts.map +1 -1
- package/dist/comments.js +353 -57
- package/dist/comments.js.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +46 -5
- package/dist/config.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +38 -14
- package/dist/engine.js.map +1 -1
- package/dist/glob.d.ts +128 -24
- package/dist/glob.d.ts.map +1 -1
- package/dist/glob.js +304 -61
- package/dist/glob.js.map +1 -1
- package/dist/impact.d.ts +179 -0
- package/dist/impact.d.ts.map +1 -0
- package/dist/impact.js +360 -0
- package/dist/impact.js.map +1 -0
- package/dist/imports.d.ts.map +1 -1
- package/dist/imports.js +5 -58
- package/dist/imports.js.map +1 -1
- package/dist/index.d.ts +13 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +16 -75
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +198 -370
- package/dist/mcp.js.map +1 -1
- package/dist/overlay.d.ts +39 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +133 -0
- package/dist/overlay.js.map +1 -0
- package/dist/parser.d.ts +41 -24
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +217 -160
- package/dist/parser.js.map +1 -1
- package/dist/prove.d.ts +46 -0
- package/dist/prove.d.ts.map +1 -0
- package/dist/prove.js +549 -0
- package/dist/prove.js.map +1 -0
- package/dist/query.d.ts +11 -0
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +9 -3
- package/dist/query.js.map +1 -1
- package/dist/reporter.d.ts +139 -1
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +593 -21
- package/dist/reporter.js.map +1 -1
- package/dist/rules.js +3 -3
- package/dist/rules.js.map +1 -1
- package/dist/runner.d.ts +24 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +84 -21
- package/dist/runner.js.map +1 -1
- package/dist/specs.d.ts +5 -1
- package/dist/specs.d.ts.map +1 -1
- package/dist/specs.js +5 -1
- package/dist/specs.js.map +1 -1
- package/dist/structure.d.ts.map +1 -1
- package/dist/structure.js +7 -6
- package/dist/structure.js.map +1 -1
- package/dist/types.d.ts +193 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vendor/spec-core/jsonrpc/index.d.ts +3 -0
- package/dist/vendor/spec-core/jsonrpc/index.d.ts.map +1 -0
- package/dist/vendor/spec-core/jsonrpc/index.js +2 -0
- package/dist/vendor/spec-core/jsonrpc/index.js.map +1 -0
- package/dist/vendor/spec-core/jsonrpc/mcp.d.ts +158 -0
- package/dist/vendor/spec-core/jsonrpc/mcp.d.ts.map +1 -0
- package/dist/vendor/spec-core/jsonrpc/mcp.js +353 -0
- package/dist/vendor/spec-core/jsonrpc/mcp.js.map +1 -0
- package/dist/vendor/spec-core/markdown/frontmatter.d.ts +104 -0
- package/dist/vendor/spec-core/markdown/frontmatter.d.ts.map +1 -0
- package/dist/vendor/spec-core/markdown/frontmatter.js +459 -0
- package/dist/vendor/spec-core/markdown/frontmatter.js.map +1 -0
- package/dist/vendor/spec-core/markdown/headings.d.ts +46 -0
- package/dist/vendor/spec-core/markdown/headings.d.ts.map +1 -0
- package/dist/vendor/spec-core/markdown/headings.js +199 -0
- package/dist/vendor/spec-core/markdown/headings.js.map +1 -0
- package/dist/vendor/spec-core/markdown/index.d.ts +6 -0
- package/dist/vendor/spec-core/markdown/index.d.ts.map +1 -0
- package/dist/vendor/spec-core/markdown/index.js +4 -0
- package/dist/vendor/spec-core/markdown/index.js.map +1 -0
- package/dist/vendor/spec-core/markdown/layout.d.ts +40 -0
- package/dist/vendor/spec-core/markdown/layout.d.ts.map +1 -0
- package/dist/vendor/spec-core/markdown/layout.js +28 -0
- package/dist/vendor/spec-core/markdown/layout.js.map +1 -0
- package/dist/vendor/spec-core/markdown/links.d.ts +23 -0
- package/dist/vendor/spec-core/markdown/links.d.ts.map +1 -0
- package/dist/vendor/spec-core/markdown/links.js +364 -0
- package/dist/vendor/spec-core/markdown/links.js.map +1 -0
- package/dist/vendor/spec-core/markdown/lists.d.ts +23 -0
- package/dist/vendor/spec-core/markdown/lists.d.ts.map +1 -0
- package/dist/vendor/spec-core/markdown/lists.js +127 -0
- package/dist/vendor/spec-core/markdown/lists.js.map +1 -0
- package/dist/vendor/spec-core/markdown/scan.d.ts +37 -0
- package/dist/vendor/spec-core/markdown/scan.d.ts.map +1 -0
- package/dist/vendor/spec-core/markdown/scan.js +439 -0
- package/dist/vendor/spec-core/markdown/scan.js.map +1 -0
- package/dist/vendor/spec-core/markdown/syntax.d.ts +76 -0
- package/dist/vendor/spec-core/markdown/syntax.d.ts.map +1 -0
- package/dist/vendor/spec-core/markdown/syntax.js +181 -0
- package/dist/vendor/spec-core/markdown/syntax.js.map +1 -0
- package/dist/vendor/spec-core/markdown/tables.d.ts +19 -0
- package/dist/vendor/spec-core/markdown/tables.d.ts.map +1 -0
- package/dist/vendor/spec-core/markdown/tables.js +86 -0
- package/dist/vendor/spec-core/markdown/tables.js.map +1 -0
- package/dist/vendor/spec-core/markdown/types.d.ts +288 -0
- package/dist/vendor/spec-core/markdown/types.d.ts.map +1 -0
- package/dist/vendor/spec-core/markdown/types.js +9 -0
- package/dist/vendor/spec-core/markdown/types.js.map +1 -0
- package/dist/vendor/spec-core/path/index.d.ts +2 -0
- package/dist/vendor/spec-core/path/index.d.ts.map +1 -0
- package/dist/vendor/spec-core/path/index.js +2 -0
- package/dist/vendor/spec-core/path/index.js.map +1 -0
- package/dist/vendor/spec-core/path/posix.d.ts +84 -0
- package/dist/vendor/spec-core/path/posix.d.ts.map +1 -0
- package/dist/vendor/spec-core/path/posix.js +150 -0
- package/dist/vendor/spec-core/path/posix.js.map +1 -0
- package/dist/vendor/spec-core/pattern/automaton.d.ts +130 -0
- package/dist/vendor/spec-core/pattern/automaton.d.ts.map +1 -0
- package/dist/vendor/spec-core/pattern/automaton.js +340 -0
- package/dist/vendor/spec-core/pattern/automaton.js.map +1 -0
- package/dist/vendor/spec-core/pattern/charset.d.ts +59 -0
- package/dist/vendor/spec-core/pattern/charset.d.ts.map +1 -0
- package/dist/vendor/spec-core/pattern/charset.js +116 -0
- package/dist/vendor/spec-core/pattern/charset.js.map +1 -0
- package/dist/vendor/spec-core/pattern/glob.d.ts +122 -0
- package/dist/vendor/spec-core/pattern/glob.d.ts.map +1 -0
- package/dist/vendor/spec-core/pattern/glob.js +481 -0
- package/dist/vendor/spec-core/pattern/glob.js.map +1 -0
- package/dist/vendor/spec-core/pattern/index.d.ts +7 -0
- package/dist/vendor/spec-core/pattern/index.d.ts.map +1 -0
- package/dist/vendor/spec-core/pattern/index.js +4 -0
- package/dist/vendor/spec-core/pattern/index.js.map +1 -0
- package/dist/vendor/spec-core/pattern/regex.d.ts +68 -0
- package/dist/vendor/spec-core/pattern/regex.d.ts.map +1 -0
- package/dist/vendor/spec-core/pattern/regex.js +813 -0
- package/dist/vendor/spec-core/pattern/regex.js.map +1 -0
- package/dist/vendor/spec-core/text/index.d.ts +3 -0
- package/dist/vendor/spec-core/text/index.d.ts.map +1 -0
- package/dist/vendor/spec-core/text/index.js +2 -0
- package/dist/vendor/spec-core/text/index.js.map +1 -0
- package/dist/vendor/spec-core/text/lines.d.ts +73 -0
- package/dist/vendor/spec-core/text/lines.d.ts.map +1 -0
- package/dist/vendor/spec-core/text/lines.js +156 -0
- package/dist/vendor/spec-core/text/lines.js.map +1 -0
- package/package.json +8 -1
- package/src/vendor/spec-core/LICENSE +21 -0
- package/src/vendor/spec-core/README.md +8 -0
package/README.md
CHANGED
|
@@ -158,8 +158,21 @@ can never exclude anything:
|
|
|
158
158
|
- **a drive path** such as `C:/repo/dist`.
|
|
159
159
|
- **`.` or `/`**, the root itself.
|
|
160
160
|
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
So is any pattern the glob engine cannot read - an unclosed `[` or `{`, an
|
|
162
|
+
extended glob such as `+(a|b)`, a range that runs backwards, `**` inside a
|
|
163
|
+
name - rather than being read as a literal that excludes nothing. The same goes
|
|
164
|
+
for `glob`, `module`, `order` and the structure rules' patterns, and for a spec
|
|
165
|
+
pattern.
|
|
166
|
+
|
|
167
|
+
`**` means any number of directories only as a whole segment. `docs/**.md` is
|
|
168
|
+
refused, with the two ways to say what was meant: `docs/**/*.md` for any depth,
|
|
169
|
+
`*.md` for one level. The tools it could have come from read it three ways,
|
|
170
|
+
and a spec pattern read the narrow way drops every nested document without a
|
|
171
|
+
word.
|
|
172
|
+
|
|
173
|
+
In a directive a refused pattern is an invalid directive, whether or not its
|
|
174
|
+
document is in force; in the configuration's `specs` or `exclude`, a spec
|
|
175
|
+
pattern on the command line or `--spec`, or `--exclude`, it is exit 2.
|
|
163
176
|
|
|
164
177
|
List attributes accept commas or whitespace, so both of these work:
|
|
165
178
|
|
|
@@ -169,7 +182,10 @@ List attributes accept commas or whitespace, so both of these work:
|
|
|
169
182
|
```
|
|
170
183
|
|
|
171
184
|
(A path containing a space therefore cannot be written; there is no quoting
|
|
172
|
-
inside an attribute value.
|
|
185
|
+
inside an attribute value. Nor can a brace group with a comma in it:
|
|
186
|
+
`glob="*.{ts,tsx}"` is the two patterns `*.{ts` and `tsx}`, and is refused with a
|
|
187
|
+
message that says so. Write `glob="*.ts, *.tsx"`. A configuration's `exclude` is
|
|
188
|
+
a JSON array, where a brace group keeps its commas.)
|
|
173
189
|
|
|
174
190
|
### `comments` - the note about a deletion is not the deletion
|
|
175
191
|
|
|
@@ -206,13 +222,16 @@ touching code, a run that passed this way says so:
|
|
|
206
222
|
✔ every spec assertion holds
|
|
207
223
|
```
|
|
208
224
|
|
|
209
|
-
Comment syntax is known for around 68 extensions across
|
|
210
|
-
C#, Rust, Go, Python-style `#`, shell
|
|
225
|
+
Comment syntax is known for around 68 extensions across 11 families (JS/TS, C,
|
|
226
|
+
C#, Rust, Go, Python-style `#`, shell `#`, YAML, SQL-style `--`, markup -
|
|
211
227
|
MSBuild project files included - and formats with no comments at all). Strings
|
|
212
228
|
are tracked too, because `//` inside a URL is not a comment and reading it as
|
|
213
|
-
one would hide real code. So
|
|
214
|
-
|
|
215
|
-
|
|
229
|
+
one would hide real code. So are JavaScript's regular expressions, because the
|
|
230
|
+
quote in `/["']/` is not a quote. And so is what only looks like either: a Rust
|
|
231
|
+
lifetime (`&'static str`), a C++ digit separator (`100'000`), a `#` inside a
|
|
232
|
+
shell word (`${#items[@]}`), an apostrophe in a YAML value (`name: it's fine`)
|
|
233
|
+
and a `/*` in JSX text (`<div>/*</div>`) all open nothing. Where spec-guard is
|
|
234
|
+
unsure —
|
|
216
235
|
an unknown extension, an unterminated literal — the text counts as code, and the
|
|
217
236
|
report says which files it could not classify. A match wrongly kept is a visible
|
|
218
237
|
failure you can argue with; a match wrongly dropped is a lie.
|
|
@@ -733,8 +752,11 @@ long before anyone deletes it - because deleting it deletes the reason a
|
|
|
733
752
|
decision was made.
|
|
734
753
|
|
|
735
754
|
spec-guard reads that status from the document and honours it. A document whose
|
|
736
|
-
status is `draft`, `proposed`, `rejected`, `deprecated
|
|
737
|
-
parsed, validated, reported - and not executed.
|
|
755
|
+
status is `draft`, `proposed`, `rejected`, `deprecated`, `superseded` or
|
|
756
|
+
`archived` is parsed, validated, reported - and not executed. `archived` is what
|
|
757
|
+
`spec-brief` writes when it closes a task brief's round: the brief is kept as
|
|
758
|
+
the record, and its directives, which stated the round's premises and goals,
|
|
759
|
+
are history.
|
|
738
760
|
|
|
739
761
|
Three spellings are recognised, because three are in use:
|
|
740
762
|
|
|
@@ -757,7 +779,13 @@ Accepted (0.3.0).
|
|
|
757
779
|
```
|
|
758
780
|
|
|
759
781
|
Front-matter wins, then the `## Status` section, then the bold label (which is
|
|
760
|
-
only read above the first
|
|
782
|
+
only read above the first section heading). A front-matter `status` wins even
|
|
783
|
+
when its value cannot be read - `status: "accepted" (2024-05-01)`, text after
|
|
784
|
+
a closing quote - and then the document stays in force, the section and the
|
|
785
|
+
label are not read in its place, and the report warns on the key's line with
|
|
786
|
+
the reason. Headings are read as CommonMark reads them, so an underlined
|
|
787
|
+
`Status` is the section and a `## Status` kept in a comment is not. Anything
|
|
788
|
+
else - `Provisional`, `In review`, a
|
|
761
789
|
misspelled `Supersedded`, or no status at all - keeps enforcing. That asymmetry
|
|
762
790
|
is deliberate: an unanticipated word that keeps enforcing is a visible failure
|
|
763
791
|
with an obvious fix, while one that stops enforcing is a green build over a rule
|
|
@@ -869,6 +897,12 @@ to its checks.
|
|
|
869
897
|
| --- | --- |
|
|
870
898
|
| `get_architectural_rules(path, include_inactive?)` | The query above, as text and as structured content |
|
|
871
899
|
| `check_architecture(paths?)` | A run. Given paths, only the rules that govern them, each over its whole scope, with every violation marked as in those paths or not |
|
|
900
|
+
| `get_dependents(paths, depth?, include_inactive?)` | [`impact`](#who-depends-on-this---impact): the files that import each path, however far away, with the import that leads there, the imports that could not be followed, and the rules in play. The structured content is the document `impact --json` writes |
|
|
901
|
+
|
|
902
|
+
All three are marked read-only, idempotent and closed-world. The server's
|
|
903
|
+
instructions tell an agent when to call each: the rules before creating or
|
|
904
|
+
changing a file, the dependents before changing a file others import, and the
|
|
905
|
+
check after changing files.
|
|
872
906
|
|
|
873
907
|
| Resource | What it holds |
|
|
874
908
|
| --- | --- |
|
|
@@ -881,6 +915,174 @@ version on every request and probe with `server/discover`. Nothing is cached, so
|
|
|
881
915
|
an ADR edited mid-session is read as edited. ADR-0012 has the shapes, the
|
|
882
916
|
sources they follow, and what is deliberately not implemented.
|
|
883
917
|
|
|
918
|
+
## Can each rule fail? - `prove`
|
|
919
|
+
|
|
920
|
+
A passing rule says one of two things, and the report cannot tell which: the
|
|
921
|
+
code holds, or the rule cannot see the code. A `glob="*.js"` over a codebase
|
|
922
|
+
written in TypeScript passes, and would pass whatever the code did.
|
|
923
|
+
|
|
924
|
+
```bash
|
|
925
|
+
spec-guard prove
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
`prove` shows each rule in force a violation of itself and runs the rule over
|
|
929
|
+
it: a file holding the forbidden text beside the files the rule reads, a file
|
|
930
|
+
importing the forbidden module, two files importing each other, a file in a
|
|
931
|
+
lower layer importing a higher one, a misnamed file, a required entry or a
|
|
932
|
+
partner taken away, a file the rule requires removed. The violation is made **in
|
|
933
|
+
memory**, through the same door every read goes through. Nothing is written to
|
|
934
|
+
disk, and nothing touches git.
|
|
935
|
+
|
|
936
|
+
```text
|
|
937
|
+
spec-guard prove 3 specs · 12 rules
|
|
938
|
+
|
|
939
|
+
✖ docs/adr/0004-legacy.md:12 @assert-absence passed with a violation in place
|
|
940
|
+
"LegacyClient" must not appear in src
|
|
941
|
+
maximum: added src/spec-guard-prove.ts holding "LegacyClient", beside the code under src, none of which the rule reads, and it still passed: expected no matches, found 0
|
|
942
|
+
|
|
943
|
+
○ docs/adr/0009-queue.md:8 @assert-absence no violation could be made
|
|
944
|
+
"enqueueRaw" must not appear in src/queue
|
|
945
|
+
it fails on the tree as it stands (expected no matches, found 1), so no change can be shown to be what fails it
|
|
946
|
+
|
|
947
|
+
10 seen to fail · 1 survived · 1 unprovable · 1.10s
|
|
948
|
+
✖ 1 rule passed with a violation of itself in place
|
|
949
|
+
```
|
|
950
|
+
|
|
951
|
+
Each rule is **killed** (it failed on the violation), **survived** (it still
|
|
952
|
+
passed, and the violation is shown), or **unprovable** (no violation could be
|
|
953
|
+
made, and the reason is given). Both bounds of a count are probed. `prove`
|
|
954
|
+
exits 1 when a rule survived, and under `--strict` when one is unprovable. It
|
|
955
|
+
takes the run's `--root`, `--spec`, `--exclude`, `--ignore-status`, `--json`
|
|
956
|
+
and `--format sarif|github|gitlab`, and the configuration's specs, exclusions
|
|
957
|
+
and strictness.
|
|
958
|
+
|
|
959
|
+
A kill means the rule can fail, not that it catches the way the code is
|
|
960
|
+
written: `module="src/db.ts"` is killed by `import 'src/db.ts'` though every
|
|
961
|
+
file imports `./db.js`. [ADR-0016](docs/adr/0016-rules-seen-to-fail.md) has the
|
|
962
|
+
violations for each kind of rule, and what `unprovable` means.
|
|
963
|
+
|
|
964
|
+
## Do the comments cite decisions in force? - `cites`
|
|
965
|
+
|
|
966
|
+
Code comments cite decisions - `// ADR-0011: the domain imports no
|
|
967
|
+
infrastructure`, `# see ADR-7`, `/* Q-172 */` - and the claim goes stale the
|
|
968
|
+
way code does: the document was never written, or it has since been superseded.
|
|
969
|
+
`spec-guard cites` reads the comment text of every source file for the ids a
|
|
970
|
+
project names, and reports each citation of a document that does not exist or
|
|
971
|
+
is no longer in force:
|
|
972
|
+
|
|
973
|
+
```bash
|
|
974
|
+
spec-guard cites
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
```text
|
|
978
|
+
spec-guard cites ADR-{n} (10 documents matching docs/adr/{n}-*.md); RFC-{n} (2 documents matching docs/rfcs/rfc-{n}.md)
|
|
979
|
+
|
|
980
|
+
⚠ crates/ledger/src/journal.rs:1 cites ADR-0007, which is superseded - cite ADR-0009 instead stale-citation
|
|
981
|
+
hint: docs/adr/0007-async-journal.md says "superseded by ADR-0009"
|
|
982
|
+
✖ crates/ledger/src/lib.rs:28 cites ADR-0011, which no document defines ghost-citation
|
|
983
|
+
hint: no document matching docs/adr/{n}-*.md has the number 11; the nearest is ADR-0010
|
|
984
|
+
|
|
985
|
+
3 citations in 2 files · 1 ghost · 1 stale · 22ms
|
|
986
|
+
✖ 1 citation names a document that does not exist
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
The families of documents are named in the configuration, one entry each - an
|
|
990
|
+
id with `{n}` for the number, and a path glob naming the documents with `{n}`
|
|
991
|
+
in the file name:
|
|
992
|
+
|
|
993
|
+
```json
|
|
994
|
+
{
|
|
995
|
+
"cites": [
|
|
996
|
+
{ "id": "ADR-{n}", "files": "docs/adr/{n}-*.md" },
|
|
997
|
+
{ "id": "RFC-{n}", "files": "docs/rfcs/rfc-{n}.md" }
|
|
998
|
+
]
|
|
999
|
+
}
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
- **The number is a number**: `ADR-7`, `ADR-007` and `docs/adr/0007-x.md` are
|
|
1003
|
+
one document. An id has a word boundary on each side, so `XADR-1` and
|
|
1004
|
+
`ADR-12a` are not citations, and one id on one line is one finding however
|
|
1005
|
+
often it is written there.
|
|
1006
|
+
- **Without `cites`**, a directory of specs whose names begin with a number, and
|
|
1007
|
+
whose titles all begin with one id and that number (`# ADR-0007: ...` in
|
|
1008
|
+
`0007-x.md`), is read as a family. Anything less is not guessed at: the
|
|
1009
|
+
command exits 0 with a note saying to name the documents.
|
|
1010
|
+
- **Comment text only**, as the comment classifier reads it: an id in a string,
|
|
1011
|
+
a raw string or a YAML value is not a citation. Markdown is spec-graph's and
|
|
1012
|
+
is not read, nor are the spec files, the project's `exclude`, and the default
|
|
1013
|
+
skips. Files in a language whose comments spec-guard does not know are
|
|
1014
|
+
counted by extension, and a file read in part - binary, unreadable, or a scan
|
|
1015
|
+
that lost its place - is named.
|
|
1016
|
+
- **Another project's ids are not checked**: `spec-core's ADR-0005`,
|
|
1017
|
+
`spec-graph ADR-0017` or `its ADR-12` name a document in another repository,
|
|
1018
|
+
and are counted instead.
|
|
1019
|
+
- **Stale** is `superseded`, `deprecated`, `rejected` or `archived`; a draft or
|
|
1020
|
+
proposal cited from the code implementing it is not. The successor is read
|
|
1021
|
+
from the document's status line - `Superseded by ADR-0014` - and followed to
|
|
1022
|
+
the one in force.
|
|
1023
|
+
|
|
1024
|
+
A ghost exits 1. A stale citation is a warning, and exits 1 under `--strict`,
|
|
1025
|
+
as does a file read in part or a check that looked for nothing. A family whose
|
|
1026
|
+
files match no document exits 2. `--json` (versioned by `formatVersion`),
|
|
1027
|
+
`--format sarif`, `--format github` and `--format gitlab` carry the same
|
|
1028
|
+
findings; `spec-guard cites src lib/a.ts` reads only those paths.
|
|
1029
|
+
[ADR-0017](docs/adr/0017-citations-in-comments.md) has the design, and what is
|
|
1030
|
+
out of scope: links to symbols, which need a parser the family does not ship.
|
|
1031
|
+
|
|
1032
|
+
## Who depends on this? - `impact`
|
|
1033
|
+
|
|
1034
|
+
Before changing a file, the question after "which rules govern it" is "what
|
|
1035
|
+
else does it reach". `spec-guard impact` reads the import graph backwards: for
|
|
1036
|
+
each path, the files that import it, and the files that import those, with how
|
|
1037
|
+
far each is and the import that takes it one step closer; then the rules in
|
|
1038
|
+
force that govern the path or any of them:
|
|
1039
|
+
|
|
1040
|
+
```bash
|
|
1041
|
+
spec-guard impact src/db/client.ts --depth 3
|
|
1042
|
+
```
|
|
1043
|
+
|
|
1044
|
+
```text
|
|
1045
|
+
src/db/client.ts
|
|
1046
|
+
4 files depend on it, 2 directly, up to 3 imports away
|
|
1047
|
+
1 src/app/service.ts imports src/db/client.ts (line 2)
|
|
1048
|
+
1 src/db/index.ts imports src/db/client.ts (line 1)
|
|
1049
|
+
2 src/app/cache.ts imports src/app/service.ts (line 1)
|
|
1050
|
+
3 src/ui/view.tsx imports src/app/cache.ts (line 1)
|
|
1051
|
+
|
|
1052
|
+
3 rules govern these files, from 1 document
|
|
1053
|
+
|
|
1054
|
+
ADR-0001: Layers (docs/adr/0001-layers.md)
|
|
1055
|
+
:3 @assert-layers src must keep its layers in order, src/db < src/app < src/ui
|
|
1056
|
+
governs: src/db/client.ts, src/app/service.ts, src/db/index.ts, src/app/cache.ts, src/ui/view.tsx
|
|
1057
|
+
...
|
|
1058
|
+
|
|
1059
|
+
3 imports could not be resolved, and may depend on these paths:
|
|
1060
|
+
src/ui/view.tsx:3 ./gone.js (names no file)
|
|
1061
|
+
src/ui/view.tsx:4 @/db (names no file)
|
|
1062
|
+
src/ui/view.tsx:7 import(name)
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
- **Followed**: relative JavaScript and TypeScript imports, by the table the
|
|
1066
|
+
cycle rule uses ([ADR-0011](docs/adr/0011-layers-and-cycles.md)), and relative
|
|
1067
|
+
Python imports, which name a module beside the importing file.
|
|
1068
|
+
- **Counted, not followed**: Go imports, Rust `use`s, C# `using`s and absolute
|
|
1069
|
+
Python imports name modules, and which file a module is depends on `go.mod`,
|
|
1070
|
+
the crate's module tree, the compiler or `sys.path`. A path in one of those
|
|
1071
|
+
languages says its dependents are not computed, rather than showing an empty
|
|
1072
|
+
list.
|
|
1073
|
+
- **Listed, never guessed**: an import naming no file, an alias such as `@/db`,
|
|
1074
|
+
a dynamic `import(name)`. Any of them may depend on the path.
|
|
1075
|
+
|
|
1076
|
+
A directory asks about every file under it. `--depth <n>` stops `n` imports
|
|
1077
|
+
away; cycles are safe. `--ignore-status` lists rules from documents not in
|
|
1078
|
+
force, which are otherwise counted, as `query` does. `--json` is versioned by
|
|
1079
|
+
`formatVersion`. A path that does not exist exits 2, and so does a run of
|
|
1080
|
+
`impact` in which no spec matched, as `query` does: the dependents are still
|
|
1081
|
+
written, but "no rules" over no specs is nothing measured. `--allow-empty`
|
|
1082
|
+
asks for the dependents alone, and exits 0. The MCP server answers the same
|
|
1083
|
+
question as `get_dependents`, with the same document.
|
|
1084
|
+
[ADR-0018](docs/adr/0018-impact.md) has the table and what it leaves out.
|
|
1085
|
+
|
|
884
1086
|
## CLI
|
|
885
1087
|
|
|
886
1088
|
```bash
|
|
@@ -888,6 +1090,9 @@ spec-guard [patterns...] [options] # execute the directives
|
|
|
888
1090
|
spec-guard --watch [patterns...] # execute them again whenever the tree changes
|
|
889
1091
|
spec-guard query <paths...> [options] # the rules in force for files or directories
|
|
890
1092
|
spec-guard mcp [options] # serve the rules over MCP on stdio
|
|
1093
|
+
spec-guard prove [patterns...] # show each rule a violation of itself, in memory
|
|
1094
|
+
spec-guard cites [paths...] # check each spec a code comment cites exists and is in force
|
|
1095
|
+
spec-guard impact <paths...> # the files that depend on each path, and the rules in play there
|
|
891
1096
|
```
|
|
892
1097
|
|
|
893
1098
|
| Option | Description |
|
|
@@ -897,8 +1102,8 @@ spec-guard mcp [options] # serve the rules over MCP on stdio
|
|
|
897
1102
|
| `-v, --verbose` | Print passing assertions too |
|
|
898
1103
|
| `--watch` | Report, then report again as the tree changes, until Ctrl+C |
|
|
899
1104
|
| `--fail-fast` | Stop at the first failing assertion |
|
|
900
|
-
| `--json` | Machine-readable report on stdout (same as `--format json`) |
|
|
901
|
-
| `--format <human\|json\|sarif>` | Output format. `sarif` uploads to GitHub code scanning |
|
|
1105
|
+
| `--json` | Machine-readable report on stdout (same as `--format json`), versioned by `formatVersion` |
|
|
1106
|
+
| `--format <human\|json\|sarif\|github\|gitlab>` | Output format. `sarif` uploads to GitHub code scanning, `github` annotates a pull request from the job's log, `gitlab` is a GitLab Code Quality report |
|
|
902
1107
|
| `--engine <auto\|rg\|js>` | Search engine (default `auto`: scanner for small trees, ripgrep for big ones) |
|
|
903
1108
|
| `--strict` / `--no-strict` | Treat analysis that could not be completed as a failure |
|
|
904
1109
|
| `--allow-missing-targets` / `--no-allow-missing-targets` | Warn instead of failing when a `target` path does not exist |
|
|
@@ -906,11 +1111,12 @@ spec-guard mcp [options] # serve the rules over MCP on stdio
|
|
|
906
1111
|
| `--print-baseline` | Print the `baseline="..."` that would exempt today's violations, and exit |
|
|
907
1112
|
| `--no-default-skips` / `--default-skips` | Search `.git`, `.hg`, `.svn` and `node_modules` too |
|
|
908
1113
|
| `--exclude <globs>` | Paths no assertion looks at, beside each directive's own `exclude`. Repeatable; replaces the configuration's list, and `--exclude=` clears it |
|
|
909
|
-
| `--ignore-status` / `--no-ignore-status` | Execute directives in draft, proposed
|
|
1114
|
+
| `--ignore-status` / `--no-ignore-status` | Execute directives in documents not in force too: draft, proposed, rejected, deprecated, superseded or archived |
|
|
910
1115
|
| `--include-specs` / `--no-include-specs` | Also count matches inside the spec files themselves |
|
|
911
1116
|
| `--max-snippets <n>` | Failure snippets per assertion (default 5) |
|
|
912
1117
|
| `--concurrency <n>` | Search passes in flight at once (default 8) |
|
|
913
|
-
| `--
|
|
1118
|
+
| `--depth <n>` | `impact` only: follow dependents at most `n` imports away (default: all) |
|
|
1119
|
+
| `--allow-empty` | Exit 0 when no spec file matched the patterns, for a run, `prove` or `impact` (about the run, not an assertion) |
|
|
914
1120
|
| `--color` / `--no-color` | Force colour on or off (`NO_COLOR` honoured) |
|
|
915
1121
|
|
|
916
1122
|
Patterns are expanded by spec-guard itself, so quoted globs behave identically
|
|
@@ -955,6 +1161,7 @@ same options, at the top level, in `.spec-guard.json`:
|
|
|
955
1161
|
| `strict`, `allowMissingTargets`, `allowEmptyScope`, `ignoreStatus`, `includeSpecs`, `defaultSkips` | the flag of that name | `true` or `false` |
|
|
956
1162
|
| `maxSnippets` | `--max-snippets` | an integer, 0 or more |
|
|
957
1163
|
| `concurrency` | `--concurrency` | an integer, 1 or more |
|
|
1164
|
+
| `cites` | none; `spec-guard cites` reads it | a non-empty array of `{ "id": "ADR-{n}", "files": "docs/adr/{n}-*.md" }`, and no other key |
|
|
958
1165
|
|
|
959
1166
|
- **Only the root's files** are read: the `--root` directory, or the working
|
|
960
1167
|
directory. Nothing is inherited from a parent directory.
|
|
@@ -1042,8 +1249,8 @@ symbolic link, and anything outside the root. CI stays the authority.
|
|
|
1042
1249
|
| Code | Meaning |
|
|
1043
1250
|
| --- | --- |
|
|
1044
1251
|
| `0` | Every assertion held |
|
|
1045
|
-
| `1` | An assertion failed, or a directive was malformed |
|
|
1046
|
-
| `2` | spec-guard could not run: bad usage, a malformed configuration, no spec files matched, `--engine rg` with no ripgrep, a watch that could not start |
|
|
1252
|
+
| `1` | An assertion failed, or a directive was malformed; for `prove`, a rule survived; for `cites`, a comment cites a document that does not exist |
|
|
1253
|
+
| `2` | spec-guard could not run: bad usage, a malformed configuration, no spec files matched, `--engine rg` with no ripgrep, a watch that could not start, a `cites` family whose files match no document |
|
|
1047
1254
|
| `130` | A `--watch` session was stopped |
|
|
1048
1255
|
|
|
1049
1256
|
## CI integration
|
|
@@ -1101,6 +1308,43 @@ where the fix goes. Alerts carry a fingerprint derived from what the assertion
|
|
|
1101
1308
|
is about rather than where its matches landed, so inserting a line above a
|
|
1102
1309
|
violation does not close the alert and open a new one.
|
|
1103
1310
|
|
|
1311
|
+
Two formats need no upload step. `--format github` writes one GitHub Actions
|
|
1312
|
+
workflow command per finding, which the job's log turns into an annotation on
|
|
1313
|
+
the diff:
|
|
1314
|
+
|
|
1315
|
+
```text
|
|
1316
|
+
::error file=src/a.ts,line=2,title=assert-absence::"Legacy" must not appear in src: expected no matches, found 2 (docs/adr/0004-legacy.md:12)
|
|
1317
|
+
```
|
|
1318
|
+
|
|
1319
|
+
`--format gitlab` writes a
|
|
1320
|
+
[GitLab Code Quality](https://docs.gitlab.com/ci/testing/code_quality/) report,
|
|
1321
|
+
which a merge request shows beside its changes:
|
|
1322
|
+
|
|
1323
|
+
```yaml
|
|
1324
|
+
spec-guard:
|
|
1325
|
+
script:
|
|
1326
|
+
- npx @descent-vtt/spec-guard --format gitlab > gl-code-quality-report.json || true
|
|
1327
|
+
artifacts:
|
|
1328
|
+
reports:
|
|
1329
|
+
codequality: gl-code-quality-report.json
|
|
1330
|
+
```
|
|
1331
|
+
|
|
1332
|
+
Every format places the same findings, and `prove` and `cites` write all three:
|
|
1333
|
+
|
|
1334
|
+
| Finding | GitHub | GitLab severity |
|
|
1335
|
+
| --- | --- | --- |
|
|
1336
|
+
| a failing assertion, on its first offending line or its directive | `error` | `critical` |
|
|
1337
|
+
| a rule that survived `prove`, on its directive | `error` | `critical` |
|
|
1338
|
+
| a ghost citation (`cites`), on its comment | `error` | `critical` |
|
|
1339
|
+
| a directive that could not be read | `error` | `major` |
|
|
1340
|
+
| a stale citation (`cites`) | `warning`; `error` under `--strict` | `minor`; `critical` under `--strict` |
|
|
1341
|
+
| a rule `prove` could make no violation for | `notice` | `minor` |
|
|
1342
|
+
| a file `cites` could read only in part | `notice` | `info` |
|
|
1343
|
+
| a document not in force, on its first line | `notice` | `info` |
|
|
1344
|
+
|
|
1345
|
+
A GitLab issue's `fingerprint` is the SHA-256 of its rule, file and message,
|
|
1346
|
+
which GitLab compares across pipelines to tell a new finding from an old one.
|
|
1347
|
+
|
|
1104
1348
|
**There is no language server, and that is a decision rather than a gap.**
|
|
1105
1349
|
spec-guard's claims are about a whole repository - "this symbol appears nowhere
|
|
1106
1350
|
in `src`" - and a language server is handed one buffer at a time. Answering a
|
|
@@ -1118,9 +1362,11 @@ parser ──▶ Directive[] ──▶ runner ──▶ Assertion[] ──▶ e
|
|
|
1118
1362
|
(pure) (I/O) (rg | js) (pure)
|
|
1119
1363
|
```
|
|
1120
1364
|
|
|
1121
|
-
1. **Parse.** Markdown is scanned for `<!-- @assert-* -->` comments.
|
|
1122
|
-
|
|
1123
|
-
|
|
1365
|
+
1. **Parse.** Markdown is scanned for `<!-- @assert-* -->` comments. Code -
|
|
1366
|
+
fenced and indented blocks, code spans, and the HTML elements whose content
|
|
1367
|
+
is not Markdown - and front matter are masked first, by the Markdown scanner
|
|
1368
|
+
every spec-* tool shares, so documentation that shows the syntax (like this
|
|
1369
|
+
README) never executes it.
|
|
1124
1370
|
2. **Resolve.** Each directive becomes a typed assertion. Bad numbers, unknown
|
|
1125
1371
|
booleans, unknown attributes and paths escaping `--root` are rejected here,
|
|
1126
1372
|
before any I/O.
|
|
@@ -1235,6 +1481,39 @@ observed count, match locations, warnings and timings. The parser
|
|
|
1235
1481
|
(`parseDirectives`) and reporter (`formatReport`, `formatJson`) are pure
|
|
1236
1482
|
functions you can use on their own.
|
|
1237
1483
|
|
|
1484
|
+
A run reads the tree through one interface, `Io` - list a directory, `stat` a
|
|
1485
|
+
path, read a file, resolve a real path - and `nodeIo` is the filesystem. Pass
|
|
1486
|
+
your own as `io` and every read the run makes goes through it: finding and
|
|
1487
|
+
reading the specs, checking targets exist, walking them, scanning files, reading
|
|
1488
|
+
imports and listing directories. That is how to run the rules over a tree that
|
|
1489
|
+
is not on disk, or not as the disk has it:
|
|
1490
|
+
|
|
1491
|
+
```ts
|
|
1492
|
+
import { nodeIo, runSpecGuard, type Io } from '@descent-vtt/spec-guard';
|
|
1493
|
+
|
|
1494
|
+
// The disk, except for one file edited in memory.
|
|
1495
|
+
const edited: Io = {
|
|
1496
|
+
...nodeIo,
|
|
1497
|
+
readFile: async (file) =>
|
|
1498
|
+
file === '/repo/src/pay.ts' ? Buffer.from('new LegacyGateway();\n') : nodeIo.readFile(file),
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1501
|
+
const report = await runSpecGuard({ patterns: ['docs/**/*.md'], root: '/repo', io: edited });
|
|
1502
|
+
```
|
|
1503
|
+
|
|
1504
|
+
ripgrep reads the disk itself, in a process of its own, so no `io` can reach
|
|
1505
|
+
it: `engine: 'ripgrep'` with an `io` is an error, and `auto` with one searches
|
|
1506
|
+
with the built-in scanner.
|
|
1507
|
+
|
|
1508
|
+
`proveSpecGuard` is `spec-guard prove` for a caller: it takes the options that
|
|
1509
|
+
decide whether a rule passes, and an `io` too, and returns each rule's outcome
|
|
1510
|
+
with the violations it was shown. `overlayIo` is the door it makes them behind,
|
|
1511
|
+
and is there for a caller who wants to try a change of its own.
|
|
1512
|
+
|
|
1513
|
+
`findCitations` and `impactOf` are `spec-guard cites` and `spec-guard impact`,
|
|
1514
|
+
and take an `io` as well; so do `loadRuleSet` and `queryRules`. Each command's
|
|
1515
|
+
JSON is the report its function returns, with a `formatVersion` in front.
|
|
1516
|
+
|
|
1238
1517
|
## Design decisions
|
|
1239
1518
|
|
|
1240
1519
|
This tool was specified loosely and built opinionatedly. Where the
|
|
@@ -1249,11 +1528,27 @@ themselves stay uncounted, because a directive is an HTML comment and a rule
|
|
|
1249
1528
|
whose own text trips it can never be satisfied. `comments="include"` counts even
|
|
1250
1529
|
those.
|
|
1251
1530
|
|
|
1252
|
-
**
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1531
|
+
**Code is masked before parsing.** A README documenting the syntax must not
|
|
1532
|
+
execute it. What is code is what CommonMark says it is, decided by
|
|
1533
|
+
[spec-core](https://github.com/DescentVTT/spec-core)'s Markdown scanner, which
|
|
1534
|
+
every spec-* tool reads documents with and which is copied into
|
|
1535
|
+
`src/vendor/spec-core` and verified by hash: fenced and indented code, code
|
|
1536
|
+
spans, `<script>`, `<pre>`, `<style>` and `<textarea>` blocks, and front matter.
|
|
1537
|
+
Comments and code spans are resolved left to right in one pass, so a backtick
|
|
1538
|
+
inside a comment is a character and `<!--` inside a code span is code, and a
|
|
1539
|
+
code span ends with its paragraph, so a stray backtick cannot hide the
|
|
1540
|
+
directives after it. A fence may be indented with the list item it sits in.
|
|
1541
|
+
Masking preserves every offset and line terminator, so reported line numbers
|
|
1542
|
+
stay exact. [ADR-0002](docs/adr/0002-directive-format.md)'s amendment lists
|
|
1543
|
+
where this differs from 0.11.0.
|
|
1544
|
+
|
|
1545
|
+
What is masked is still counted. A comment shaped like a directive -
|
|
1546
|
+
`<!-- @assert-...` - in code, raw HTML or front matter is not run, and the
|
|
1547
|
+
report says how many there were and where, on one line (every one under
|
|
1548
|
+
`--verbose`, and in `maskedDirectives` in JSON), so text read as code by
|
|
1549
|
+
accident is not a rule gone quiet without a word. A code fence or raw-text
|
|
1550
|
+
block that is never closed, which makes the rest of its document code, is a
|
|
1551
|
+
warning on its opening line.
|
|
1257
1552
|
|
|
1258
1553
|
**A document's lifecycle status is read; a directive's is not.** Status is
|
|
1259
1554
|
document-level and visible in every rendered Markdown view. A per-directive
|
|
@@ -1287,6 +1582,13 @@ anything - it answers only *which files contain this text*, and the scanner
|
|
|
1287
1582
|
counts each pattern separately over the shared file contents. The batching
|
|
1288
1583
|
checks were deleted along with the risk they guarded.
|
|
1289
1584
|
|
|
1585
|
+
**Globs are matched by an automaton, not a regular expression.** Every
|
|
1586
|
+
pattern - `glob`, `exclude`, `module`, a layer, a spec pattern - is read by the
|
|
1587
|
+
glob engine the spec-* tools share, copied from spec-core and checked by hash.
|
|
1588
|
+
A `RegExp` backtracks: `*-*-*-*-*-*x` took 55 seconds to fail one long file
|
|
1589
|
+
name. The automaton cannot, and ripgrep is handed each pattern spelled as the
|
|
1590
|
+
automaton reads it ([ADR-0015](docs/adr/0015-globs-from-spec-core.md)).
|
|
1591
|
+
|
|
1290
1592
|
**Exit code 2 exists.** "Your specs failed" and "spec-guard could not run" are
|
|
1291
1593
|
different facts, and CI should be able to tell them apart.
|
|
1292
1594
|
|
|
@@ -1339,7 +1641,9 @@ and [`docs/adr/0002-directive-format.md`](docs/adr/0002-directive-format.md) are
|
|
|
1339
1641
|
executed against this repository on every CI run. The CLI never calls
|
|
1340
1642
|
`console.log`, nothing outside the engine spawns a process, and the parser and
|
|
1341
1643
|
reporter never touch the filesystem - because those documents say so, and the
|
|
1342
|
-
build fails if they stop being true.
|
|
1644
|
+
build fails if they stop being true. CI proves every one of those rules can
|
|
1645
|
+
fail, and `spec-guard cites` holds every ADR a comment in the code cites to
|
|
1646
|
+
existing and being in force.
|
|
1343
1647
|
|
|
1344
1648
|
This README is executable too:
|
|
1345
1649
|
|
|
@@ -1367,7 +1671,7 @@ prebuilt binary and is never a runtime dependency.
|
|
|
1367
1671
|
|
|
1368
1672
|
Coverage says a line ran. It does not say an assertion would notice if the line
|
|
1369
1673
|
behaved differently. This repository measures the difference: line coverage is
|
|
1370
|
-
**100%**, and **99.
|
|
1674
|
+
**100%**, and **99.20%** of 11,537 mutants are killed in CI, with 89 survivors and
|
|
1371
1675
|
no file below 95%. The second number is the one worth reading, and what was done
|
|
1372
1676
|
about the survivors matters more than the score. At 0.5.1 every surviving mutant
|
|
1373
1677
|
was checked individually, and the 101 left then produced byte-identical output.
|
|
@@ -1443,6 +1747,60 @@ mutants" sounded like judgement rather than an unchecked assumption - along with
|
|
|
1443
1747
|
the real bugs that chasing the gate uncovered, and a class of mutant the runner
|
|
1444
1748
|
reports as survived while the suite is in fact killing it.
|
|
1445
1749
|
|
|
1750
|
+
### Releasing
|
|
1751
|
+
|
|
1752
|
+
A release is a tag push. Nothing is published from a laptop, and there is no
|
|
1753
|
+
npm token in this repository or in its secrets.
|
|
1754
|
+
|
|
1755
|
+
```bash
|
|
1756
|
+
npm version minor # or patch / major - writes package.json and makes the tag
|
|
1757
|
+
git push origin main --follow-tags
|
|
1758
|
+
```
|
|
1759
|
+
|
|
1760
|
+
[`release.yml`](.github/workflows/release.yml) then:
|
|
1761
|
+
|
|
1762
|
+
1. refuses the release unless the tag, `package.json` and a `## <version>`
|
|
1763
|
+
heading in `CHANGELOG.md` all agree;
|
|
1764
|
+
2. type checks, builds, runs the suite and executes this repository's own ADRs.
|
|
1765
|
+
A tag push does not run CI, so the release job runs those steps itself;
|
|
1766
|
+
3. `npm pack`s the tarball, prints its file list and its SHA-256, and hands that
|
|
1767
|
+
exact file to the publishing job. Nothing is rebuilt in between, so what
|
|
1768
|
+
reaches the registry is the artifact the tests ran against;
|
|
1769
|
+
4. stages it with `npm stage publish --provenance`. The job authenticates with a
|
|
1770
|
+
short-lived OIDC token that GitHub mints for `release.yml` running in the
|
|
1771
|
+
`npm` environment, and npmjs.com accepts it only for that combination - npm's
|
|
1772
|
+
trusted publishing, so there is no credential to leak or to rotate.
|
|
1773
|
+
|
|
1774
|
+
The publishing job installs no dependencies and checks nothing out. Everything
|
|
1775
|
+
that runs third-party code - `npm ci`, a postinstall that downloads a ripgrep
|
|
1776
|
+
binary, the test suite - happens in the earlier job, which has no token.
|
|
1777
|
+
|
|
1778
|
+
Staging is not publishing. The version sits on npmjs.com visible to maintainers
|
|
1779
|
+
and installable by nobody until:
|
|
1780
|
+
|
|
1781
|
+
```bash
|
|
1782
|
+
npm stage list @descent-vtt/spec-guard
|
|
1783
|
+
npm stage view <stage-id>
|
|
1784
|
+
npm stage approve <stage-id> # asks for a second factor
|
|
1785
|
+
```
|
|
1786
|
+
|
|
1787
|
+
`npm stage reject <stage-id>` discards it instead, and the tag can be deleted
|
|
1788
|
+
and remade. Approving needs 2FA and therefore a person: CI can build a release,
|
|
1789
|
+
but it cannot decide to publish one.
|
|
1790
|
+
|
|
1791
|
+
Two repository settings hold the other end of this. The `npm` environment
|
|
1792
|
+
accepts deployments only from `main` and from `v*` tags, so no branch can reach
|
|
1793
|
+
the publishing job. A `Release tags` ruleset lets only repository admins create,
|
|
1794
|
+
move or delete a `v*` tag - under this workflow, pushing one is the release, and
|
|
1795
|
+
an accidental push should not be able to start it.
|
|
1796
|
+
|
|
1797
|
+
`Actions -> Release -> Run workflow` with `dry_run` left on verifies, packs and
|
|
1798
|
+
hands the tarball to npm without spending a version number. It stops at the
|
|
1799
|
+
registry's version check - a dispatch runs from `main`, whose version is already
|
|
1800
|
+
published - and it does not exercise authentication, because the OIDC exchange
|
|
1801
|
+
happens only on a publish that intends to write. The first real tag is what
|
|
1802
|
+
proves that end.
|
|
1803
|
+
|
|
1446
1804
|
## Requirements
|
|
1447
1805
|
|
|
1448
1806
|
- Node.js 22 or newer (native ESM)
|