@cosyte/script-utils 0.0.2 → 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.
- package/CHANGELOG.md +82 -2
- package/README.md +365 -30
- package/attw.d.ts +36 -0
- package/attw.js +1679 -0
- package/check-no-emdash.sh +874 -0
- package/internal-refs.d.ts +223 -0
- package/internal-refs.js +1363 -0
- package/package.json +21 -1
- package/phi-scan.d.ts +59 -12
- package/phi-scan.js +500 -107
package/attw.js
ADDED
|
@@ -0,0 +1,1679 @@
|
|
|
1
|
+
// packages/script-utils/attw.js
|
|
2
|
+
/**
|
|
3
|
+
* `@cosyte/script-utils/attw`: the `attw` publish gate, made to report its own
|
|
4
|
+
* failure. ONE BODY, CONSUMED BY EVERY REPO, NEVER COPIED INTO ONE.
|
|
5
|
+
*
|
|
6
|
+
* THIS DOCBLOCK IS THE ONE AUTHORITATIVE DESCRIPTION OF THE GATE. The CHANGELOG
|
|
7
|
+
* entry and the scaffolded repo's `CLAUDE.md` point here rather than restating
|
|
8
|
+
* the rules, deliberately: the previous shape of this guard was described in
|
|
9
|
+
* several committed files at once, and every drift between those copies was a
|
|
10
|
+
* claim that had been edited in some of them and not the others. Edit this
|
|
11
|
+
* docblock; leave the pointers alone.
|
|
12
|
+
*
|
|
13
|
+
* WHAT A CALLER IS, AND WHAT IT OWNS. Every consuming repo keeps a
|
|
14
|
+
* `scripts/attw.mjs` and an `attw` script whose body is exactly
|
|
15
|
+
* `node scripts/attw.mjs`: three independent things pin that spelling and none
|
|
16
|
+
* of them is this file's to change. That file is a CALLER of this body rather
|
|
17
|
+
* than a copy of it, and the whole of what it owns is one line:
|
|
18
|
+
*
|
|
19
|
+
* process.exit(runAttwGate({ callerUrl: import.meta.url }));
|
|
20
|
+
*
|
|
21
|
+
* `callerUrl` IS REQUIRED AND HAS NO DEFAULT, because the default would be
|
|
22
|
+
* wrong in the one way that matters. The gate spawns the CONSUMING package's
|
|
23
|
+
* own `node_modules/.bin/attw`, resolved as `../node_modules/.bin/attw` from
|
|
24
|
+
* the CALLER's URL. Resolved from THIS file's URL instead it would land in
|
|
25
|
+
* `node_modules/@cosyte/`, where a consuming package has no attw at all, and a
|
|
26
|
+
* gate that ran some other package's binary would be analysing the wrong
|
|
27
|
+
* dependency tree. A call with no `callerUrl` is refused rather than defaulted.
|
|
28
|
+
*
|
|
29
|
+
* NOTHING HERE CALLS `process.exit`. `runAttwGate` RETURNS the exit code a
|
|
30
|
+
* caller should exit with: 0 when every net passed, attw's own status when attw
|
|
31
|
+
* judged the package, and 1 for every refusal below. A library that exits the
|
|
32
|
+
* process cannot be tested in one, and this one is graded by
|
|
33
|
+
* `packages/script-utils/test/attw-shared.test.ts`.
|
|
34
|
+
*
|
|
35
|
+
* WHY THIS WRAPPER EXISTS. `attw` PRINTS "This package does not contain types."
|
|
36
|
+
* AND EXITS 0. That is not a bug in `attw`: an untyped package is a legitimate
|
|
37
|
+
* npm package, so the CLI treats "no types at all" as a *description*, not a
|
|
38
|
+
* problem. From `@arethetypeswrong/cli@0.18.4`,
|
|
39
|
+
* `node_modules/@arethetypeswrong/cli/dist/getExitCode.js`, first statement:
|
|
40
|
+
*
|
|
41
|
+
* export function getExitCode(analysis, opts) {
|
|
42
|
+
* if (!analysis.types) {
|
|
43
|
+
* return 0;
|
|
44
|
+
* }
|
|
45
|
+
*
|
|
46
|
+
* The problem list is consulted only *after* that early return, so no
|
|
47
|
+
* `--profile`, `--ignore-rules` or config setting can reach it. For a package
|
|
48
|
+
* that ships types, "does not contain types" does not mean "fine, untyped": it
|
|
49
|
+
* means THE TYPES WERE NOT IN THE TARBALL, which is a broken publish. The gate
|
|
50
|
+
* says nothing, and its caller reads the 0. A false red costs an hour. A FALSE
|
|
51
|
+
* GREEN MERGES.
|
|
52
|
+
*
|
|
53
|
+
* REPRODUCED HERE, ON THIS REPO'S OWN PACKAGE, WITH ZERO CONCURRENCY. Against
|
|
54
|
+
* `@cosyte/test-utils` at `0.0.2`, on a quiet box, both states print the untyped
|
|
55
|
+
* sentence and exit 0:
|
|
56
|
+
*
|
|
57
|
+
* rm -f dist/index.d.ts dist/index.d.cts && attw --pack . -> exit 0
|
|
58
|
+
* rm -rf dist && attw --pack . -> exit 0
|
|
59
|
+
*
|
|
60
|
+
* CONCURRENCY SUPPLIES THE CONDITION AND IS NOT THE DEFECT, WHICH IS WHY THE
|
|
61
|
+
* ANSWER IS NOT A LOCK, A LEASE OR A BUILD QUEUE. `tsup` emits JS in one pass
|
|
62
|
+
* and the declaration files in a later pass, so there is a window in every build
|
|
63
|
+
* where `dist/` holds `.mjs`/`.cjs` and no `.d.ts`. Polling clean `tsup` runs on
|
|
64
|
+
* this package, the JS landed first in EVERY run measured (12 of 12, across two
|
|
65
|
+
* independent sets).
|
|
66
|
+
*
|
|
67
|
+
* DO NOT PIN A WIDTH HERE, NOT EVEN A RANGE. Two measurement sets on the same
|
|
68
|
+
* idle box disagreed about the spread, and an earlier draft of this comment
|
|
69
|
+
* quoted a range that the next set did not reproduce. The load-bearing fact is
|
|
70
|
+
* the ORDER (JS, then declarations); the width is whatever the box was doing.
|
|
71
|
+
* Anything that lands `attw` inside that window (a concurrent build, a
|
|
72
|
+
* `pnpm clean`, a half-finished build) gets the false green, and a gate has to be
|
|
73
|
+
* able to say its own inputs were missing, whatever removed them.
|
|
74
|
+
*
|
|
75
|
+
* FOUR NETS, AND THEY CATCH DIFFERENT THINGS. Keep all four.
|
|
76
|
+
*
|
|
77
|
+
* 1. PREFLIGHT (structural, no string matching). Every relative artifact path
|
|
78
|
+
* `package.json` promises (the field set is enumerated once, under "THE
|
|
79
|
+
* FIELDS THIS READS" below) must exist and be non-empty before `attw` runs.
|
|
80
|
+
* This is the one that catches the window above, and it names the missing
|
|
81
|
+
* file instead of leaving the reader to infer it.
|
|
82
|
+
*
|
|
83
|
+
* A MANIFEST THAT DECLARES NOTHING IS REFUSED RATHER THAN REPORTED AS
|
|
84
|
+
* CHECKED, and this half arrived from the 297-line variant `hl7` carried,
|
|
85
|
+
* which had it where the longer body did not. A preflight with no inputs is
|
|
86
|
+
* not a preflight that passed: nets 1, 3 and 4 all grade the set
|
|
87
|
+
* `declaredArtifacts()` returns, so an empty set makes three of the four
|
|
88
|
+
* nets vacuous at once and leaves the run standing on attw's own exit code,
|
|
89
|
+
* which is 0 on an untyped package. The gate said "declares no relative
|
|
90
|
+
* artifact paths, so net 3 had none to check" and exited 0; it now says the
|
|
91
|
+
* preflight CHECKED NOTHING and exits 1. It is the same discipline net 2
|
|
92
|
+
* applies to an empty transcript and net 3 applies to an unreadable pack
|
|
93
|
+
* listing: this gate does not report from a check that read nothing.
|
|
94
|
+
*
|
|
95
|
+
* TWO THINGS IT USED TO WALK PAST, both closed here. (a) `bin` was never
|
|
96
|
+
* read, so a package could ship a manifest promising a command that is not
|
|
97
|
+
* in the tarball and this gate would say nothing: `attw` never looks at
|
|
98
|
+
* `bin` at all. The template declares no `bin` today; the half is here
|
|
99
|
+
* because this file is the shape every scaffolded parser inherits, and a
|
|
100
|
+
* parser that grows a CLI entry point grows the hole with it. (b) A path
|
|
101
|
+
* written WITHOUT a leading `./` was skipped, silently.
|
|
102
|
+
* `"types": "dist/index.d.ts"` is legal and is the spelling npm's own
|
|
103
|
+
* documentation uses, so that dropped a real promise while the gate still
|
|
104
|
+
* reported it had checked. `exports` leaves are different and are left
|
|
105
|
+
* alone: Node requires `./` there, so a leaf without it is not a path of
|
|
106
|
+
* ours.
|
|
107
|
+
*
|
|
108
|
+
* 2. POST-CHECK, ON attw's STRUCTURED OUTPUT AND NEVER ON ITS PROSE. The
|
|
109
|
+
* preflight cannot see this case: the declaration files can be present on
|
|
110
|
+
* disk and still be absent from the tarball, because `files` (or
|
|
111
|
+
* `.npmignore`) left them out. That is the case `attw --pack` exists to
|
|
112
|
+
* catch, and the whole point here is that it catches it silently.
|
|
113
|
+
*
|
|
114
|
+
* SO THIS GATE FORCES `--format json` ONTO THE CHILD AND PARSES ITS STDOUT,
|
|
115
|
+
* then asserts one thing: `analysis.types.kind === "included"`, i.e. the
|
|
116
|
+
* types came out of THIS package's own tarball. See MEASURED SHAPES below
|
|
117
|
+
* for the three values that field takes and why each is the answer it is.
|
|
118
|
+
*
|
|
119
|
+
* WHY STRUCTURE, AND NOT THE SENTENCE IT USED TO MATCH. The post-check used to
|
|
120
|
+
* look for `attw`'s untyped sentence, a plain un-chalked string from
|
|
121
|
+
* `dist/render/untyped.js`. Anything that suppressed or reformatted output
|
|
122
|
+
* deleted that string, and the gate read its ABSENCE as a pass, so every such
|
|
123
|
+
* route had to be refused BY NAME, one spelling at a time, on the argv side and
|
|
124
|
+
* the config side both. Parsing the JSON inverts the default: a route that hides
|
|
125
|
+
* or reformats output leaves stdout unparseable, and UNPARSEABLE FAILS CLOSED.
|
|
126
|
+
* Routes nobody has enumerated fail closed too, which is the property a
|
|
127
|
+
* name-scoped refusal could never have.
|
|
128
|
+
*
|
|
129
|
+
* 3. THE TARBALL CHECK (structural, and independent of attw entirely). LIKE NET
|
|
130
|
+
* 1 it never consults attw, so no `.attw.json` reaches either of them; UNLIKE
|
|
131
|
+
* net 1 it reads the TARBALL rather than the working tree, which is the whole
|
|
132
|
+
* of what it adds. It runs LAST, after attw has exited 0 and net 2 has
|
|
133
|
+
* passed, and it asks NET 1's question one level further out, over NET 1's
|
|
134
|
+
* OWN SET so the two cannot disagree: every relative artifact path
|
|
135
|
+
* `package.json` promises must be in the TARBALL npm would publish, not
|
|
136
|
+
* merely on disk. That set is what `declaredArtifacts()` returns, so it
|
|
137
|
+
* excludes wildcard subpaths (they name a set, not a file), absolute paths
|
|
138
|
+
* (not ours to promise), `browser` map KEYS (see below), `package.json`
|
|
139
|
+
* itself (always in the tarball by definition), and, IN THE FIELDS WHOSE
|
|
140
|
+
* GRAMMAR REQUIRES A RELATIVE TARGET (`exports`, `imports`, `browser` maps),
|
|
141
|
+
* every leaf that DOES NOT BEGIN WITH A DOT, because there such a leaf is a
|
|
142
|
+
* package specifier. Read the test literally: it is a leading DOT, not a
|
|
143
|
+
* leading `./`, so `.hidden.js` and `../outside.js` are kept and reported.
|
|
144
|
+
* Both are invalid `exports`/`imports` targets to Node itself, so reporting
|
|
145
|
+
* them is right; what would be wrong is a sentence here implying they are
|
|
146
|
+
* dropped. That exclusion is field-conditional and the pass line says so: in
|
|
147
|
+
* `main`, `module`, `types`, `typings`, `bin`, `man`, `unpkg`, `jsdelivr`,
|
|
148
|
+
* `typesVersions`, the STRING form of `browser` and the `SEE LICEN[CS]E IN`
|
|
149
|
+
* remainder of `license` the prefix is optional, so a bare leaf there IS read
|
|
150
|
+
* as a path. The pass line names those exclusions rather
|
|
151
|
+
* than printing a count that reads like a total. The list comes from
|
|
152
|
+
* `npm pack --dry-run --json` run in this directory, so nothing a committed
|
|
153
|
+
* `.attw.json` sets can change the answer.
|
|
154
|
+
*
|
|
155
|
+
* THE COST, MEASURED, BECAUSE IT IS NOT FREE AND WAS NOT OBVIOUS: THIS RUNS
|
|
156
|
+
* EVERY LIFECYCLE SCRIPT `npm pack` FIRES, A SECOND TIME. `npm pack
|
|
157
|
+
* --dry-run` still runs them; only the tarball write is suppressed. Measured
|
|
158
|
+
* on a well-formed fixture with `ignore-scripts` off: `prepack` fired ONCE
|
|
159
|
+
* through the base gate (attw's own nested `npm pack`) and TWICE through this
|
|
160
|
+
* one, and `prepare` and `postpack` double the same way. `prepublishOnly` does
|
|
161
|
+
* not run under `npm pack` AT ALL (measured: 0 through either gate), which is
|
|
162
|
+
* why this cannot recurse into the gate that runs it. Of the three that do
|
|
163
|
+
* double, every repo here defines only `prepare`, and defines it as the same
|
|
164
|
+
* `command -v simple-git-hooks … || true` one-liner, so
|
|
165
|
+
* nothing reds today. IT IS NOT FIXED WITH `--ignore-scripts`, AND THAT IS
|
|
166
|
+
* DELIBERATE: a `prepack` may GENERATE files that belong in the tarball, so
|
|
167
|
+
* suppressing it would make this net read a listing the real publish would not
|
|
168
|
+
* produce, which is a correctness bug wearing a performance fix.
|
|
169
|
+
*
|
|
170
|
+
* WHY IT IS A NET AND NOT A WIDER READING OF NET 2. Net 2's
|
|
171
|
+
* `kind === "included"` is `containsTypes()`, which is "SOME
|
|
172
|
+
* TypeScript-extension file is in the tarball" and NOT "the DECLARED
|
|
173
|
+
* declarations are". A package that packs one stray `.d.ts` and loses every
|
|
174
|
+
* declared one satisfies it. Net 1 does not see that either, because net 1
|
|
175
|
+
* reads the WORKING TREE and the loss is in the `files` field. Until this
|
|
176
|
+
* net the only thing catching it was attw's own EXIT CODE, and a committed
|
|
177
|
+
* config relaxes exactly that.
|
|
178
|
+
*
|
|
179
|
+
* MEASURED on the pinned `@arethetypeswrong/cli@0.18.4`, on a package whose
|
|
180
|
+
* declared `./dist/index.d.ts` is left out of `files` while an undeclared
|
|
181
|
+
* `./dist/internal.d.ts` is packed. Bare attw exits 1 there. Through this
|
|
182
|
+
* gate, WITHOUT net 3, each of these committed configs made it exit 0:
|
|
183
|
+
*
|
|
184
|
+
* {"ignoreRules": [every rule attw fired]} -> exit 0
|
|
185
|
+
* {"ignoreResolutions": [all four resolutions]} -> exit 0
|
|
186
|
+
* {"entrypoints": []} -> exit 0, and attw
|
|
187
|
+
* reported NO
|
|
188
|
+
* problems at all
|
|
189
|
+
*
|
|
190
|
+
* THE THIRD ROW IS WHY THIS NET DOES NOT READ attw's UNFILTERED PROBLEM LIST
|
|
191
|
+
* INSTEAD. That list is in the document and net 2 already prints it, so
|
|
192
|
+
* gating on it would close the first two rows. It closes nothing on the
|
|
193
|
+
* third: an empty `entrypoints` means attw analysed nothing, so there is no
|
|
194
|
+
* finding to read. AND IT WOULD RED HEALTHY PACKAGES THAT SHIP TODAY, which
|
|
195
|
+
* is the half that settled it. Measured across the sibling repos that pass
|
|
196
|
+
* `--profile node16` (`mllp`, `deid`, `synth` and `cli`), ALL FOUR carry a
|
|
197
|
+
* non-empty unfiltered problem list (`NoResolution`) that their profile
|
|
198
|
+
* suppresses on purpose, so all four would have gone red on a net reading
|
|
199
|
+
* that list, against 0 that go red on this one. A net that has to read what
|
|
200
|
+
* attw was configured to look at sits inside the blast radius of that
|
|
201
|
+
* configuration.
|
|
202
|
+
*
|
|
203
|
+
* THIS IS NOT THE KEY LIST THIS FILE HAS RETIRED TWICE, AND IT CANNOT DECAY
|
|
204
|
+
* INTO ONE. It never reads `.attw.json`, so a key nobody has enumerated is
|
|
205
|
+
* not a hole in it. The set it checks is derived from THIS package's own
|
|
206
|
+
* manifest, which is bounded by the manifest rather than by attw's option
|
|
207
|
+
* surface, and a new attw release cannot add to it.
|
|
208
|
+
*
|
|
209
|
+
* Measured against `profile`, which the item that asked for this net named
|
|
210
|
+
* alongside the three above: on that same package `{"profile": "strict"}`,
|
|
211
|
+
* `{"profile": "node16"}` and `{"profile": "esm-only"}` each still exited 1,
|
|
212
|
+
* as did `{"entrypoints": ["."]}`. `profile` is therefore recorded here as
|
|
213
|
+
* NOT MEASURED TO BLIND THIS CASE rather than as a fourth route; the config
|
|
214
|
+
* was confirmed to be read at all by `{"profile": "bogus-profile"}`, which
|
|
215
|
+
* attw rejects outright.
|
|
216
|
+
*
|
|
217
|
+
* 4. THE MANIFEST pnpm WOULD PUBLISH (structural, and it does not consult attw
|
|
218
|
+
* either). NETS 1 AND 3 BOTH GRADE THE MANIFEST ON DISK, AND THAT IS NOT THE
|
|
219
|
+
* MANIFEST THIS ORG PUBLISHES. pnpm honours `publishConfig` as a set of
|
|
220
|
+
* PUBLISH-TIME OVERRIDES and rewrites the manifest inside the tarball;
|
|
221
|
+
* `npm pack`, which is net 3's authority, leaves it alone. Re-measured here on
|
|
222
|
+
* BOTH pnpm versions this repo can run, `10.34.5` (its own `packageManager`
|
|
223
|
+
* pin) and `11.20.0` (the `mise` shim), one package, both packers:
|
|
224
|
+
*
|
|
225
|
+
* manifest: main "./index.js", publishConfig.main "./absent-override.js"
|
|
226
|
+
* npm pack -> tarball manifest main = "./index.js" (NOT applied)
|
|
227
|
+
* pnpm pack -> tarball manifest main = "./absent-override.js" (APPLIED, and
|
|
228
|
+
* the tarball does not carry that path)
|
|
229
|
+
*
|
|
230
|
+
* So before this net a package could pass the whole gate green and publish,
|
|
231
|
+
* THROUGH pnpm, a manifest whose `main`, `exports` and `bin` all name a path
|
|
232
|
+
* the tarball does not carry. Re-measured directly on this gate before the net
|
|
233
|
+
* was written: that fixture exited 0.
|
|
234
|
+
*
|
|
235
|
+
* SO THIS NET ASKS pnpm RATHER THAN MODELLING IT. It runs a real `pnpm pack`
|
|
236
|
+
* into a temporary directory outside the package, reads `package/package.json`
|
|
237
|
+
* AND the entry list out of the tarball pnpm just wrote, and runs
|
|
238
|
+
* `declaredArtifacts()` over that manifest against that entry list. Both
|
|
239
|
+
* documents are pnpm's own bytes, so nothing here has to predict which keys
|
|
240
|
+
* pnpm merges, in which order, or what it does to them.
|
|
241
|
+
*
|
|
242
|
+
* THAT CHOICE IS THE WHOLE DESIGN, AND SYNTHESISING THE MERGE WAS THE
|
|
243
|
+
* ALTERNATIVE. Applying `publishConfig` over the manifest in this file would be
|
|
244
|
+
* shorter, and `publishConfig.directory` is the measurement that says it would
|
|
245
|
+
* also have been wrong. TWO SEPARATE RUNS, AND THE SECOND ONE CORRECTED THE
|
|
246
|
+
* FIRST:
|
|
247
|
+
*
|
|
248
|
+
* - The ROOT's other overrides are NOT applied. On
|
|
249
|
+
* `publishConfig: { directory: "dist", main: "./absent-in-dist.js" }` over a
|
|
250
|
+
* `dist/package.json` saying `main: "./built.js"`, the published manifest
|
|
251
|
+
* reads `./built.js` and `./absent-in-dist.js` never appears.
|
|
252
|
+
* - But the SUBTREE's own `publishConfig` IS applied, and an earlier draft of
|
|
253
|
+
* this paragraph said the subtree manifest ships "verbatim", which is FALSE.
|
|
254
|
+
* On a `sub/package.json` carrying `main: "./on-disk.js"` and
|
|
255
|
+
* `publishConfig: { main: "./overridden-by-subtree.js" }`, the published
|
|
256
|
+
* manifest reads `./overridden-by-subtree.js` and carries no
|
|
257
|
+
* `publishConfig`.
|
|
258
|
+
*
|
|
259
|
+
* So the rule is TWO-LEVEL, and reading the tarball gets both levels for free
|
|
260
|
+
* while a merge written here would have had to know each of them. The gate
|
|
261
|
+
* never states which level produced a red, because it did not measure that.
|
|
262
|
+
*
|
|
263
|
+
* IT RUNS ONLY WHEN `publishConfig` IS PRESENT, and that is a cost decision
|
|
264
|
+
* with a measured floor, not an optimisation. A real `pnpm pack` costs
|
|
265
|
+
* 1.01-1.10 s on a throwaway fixture here and 1.48-2.01 s on this repo's own
|
|
266
|
+
* package, which lands as +0.9 s on the fixture's whole gate run and +1.7 s on
|
|
267
|
+
* this package's; without a `publishConfig` there is no override to grade. What
|
|
268
|
+
* the skip is NOT is a claim that pnpm and npm would otherwise pack
|
|
269
|
+
* identically: see WHAT NET 4 DOES NOT COVER below.
|
|
270
|
+
*
|
|
271
|
+
* THE COST IN LIFECYCLE SCRIPTS, MEASURED THE SAME WAY NET 3's WAS: this is a
|
|
272
|
+
* THIRD round of them. On a fixture logging each hook, with `ignore-scripts`
|
|
273
|
+
* off, `prepack`/`prepare`/`postpack` each fired TWICE through the gate before
|
|
274
|
+
* this net (attw's own nested `npm pack`, then net 3's) and THREE times after,
|
|
275
|
+
* when `publishConfig` is present. `prepublishOnly` fired 0 times through
|
|
276
|
+
* `pnpm pack`, which is what keeps this net from recursing into the gate that
|
|
277
|
+
* `prepublishOnly` runs. THAT ARGUMENT COVERS `prepublishOnly` AND NOTHING
|
|
278
|
+
* ELSE: `prepack`, `prepare` and `postpack` DO fire, so a repo that wired this
|
|
279
|
+
* gate into one of those would recurse without bound. No repo here does, and
|
|
280
|
+
* the sentence says which hook it settled rather than implying it settled the
|
|
281
|
+
* question. `pnpm pack --dry-run` was measured and REJECTED for
|
|
282
|
+
* two reasons: it prints no manifest, only a file list, and it fires `prepack`
|
|
283
|
+
* and `prepare` while SKIPPING `postpack`, so it leaves a package's own
|
|
284
|
+
* bookkeeping half-run. It is not cheaper either, within the spread measured on
|
|
285
|
+
* one fixture: 0.92-1.08 s dry, 1.01-1.10 s real.
|
|
286
|
+
*
|
|
287
|
+
* WHAT NET 4 DOES NOT COVER, AND NONE OF THIS MAY BE READ AS COVERED.
|
|
288
|
+
* (a) IT IS PRESENCE, NOT RESOLUTION, exactly like net 3.
|
|
289
|
+
* (b) IT GRADES WHAT pnpm WOULD WRITE ON THE BOX IT RUNS ON. The merge rule is
|
|
290
|
+
* read out of the pnpm on `PATH`; a publish driven by a different pnpm, or
|
|
291
|
+
* by npm or yarn, is a different document. Net 3 is the one that grades
|
|
292
|
+
* npm's.
|
|
293
|
+
* (c) IT DOES NOT WIDEN THE FIELD SET. The published manifest goes through the
|
|
294
|
+
* SAME `declaredArtifacts()`, so `directories` is unread in it too, and
|
|
295
|
+
* `KNOWN_UNREAD_FIELDS` covers both manifests at once.
|
|
296
|
+
* (d) IT DOES NOT RUN WITHOUT A `publishConfig`, so a difference between what
|
|
297
|
+
* npm packs and what pnpm packs that `publishConfig` did not cause is
|
|
298
|
+
* outside it. The suite measures the one case that matters here: on a
|
|
299
|
+
* package with no `publishConfig`, the manifest in a real `pnpm pack`
|
|
300
|
+
* tarball declares exactly what the manifest on disk declares.
|
|
301
|
+
* (e) NETS 1 AND 3 STILL GRADE THE ON-DISK MANIFEST AGAINST THE ROOT TREE, so
|
|
302
|
+
* under `publishConfig.directory` they are answering about a package that
|
|
303
|
+
* is not the one published. Net 4 is the net that grades the published one.
|
|
304
|
+
*
|
|
305
|
+
* THE FIELDS THIS READS. Nets 1, 3 and 4 all ask their question of ONE set, the one
|
|
306
|
+
* `declaredArtifacts()` returns, so a declaring field missing from that set is a
|
|
307
|
+
* hole in ALL of them at once and none says anything. Until this was measured
|
|
308
|
+
* the set was `main`, `module`, `types`, `typings`, `bin` and `exports`, and
|
|
309
|
+
* further fields that name files were walked past. No count is written here: this
|
|
310
|
+
* list has grown twice and a total in the prose is the thing that goes stale.
|
|
311
|
+
*
|
|
312
|
+
* `typesVersions` `{ <range>: { <subpath>: [ <path>, ... ] } }`. The values are
|
|
313
|
+
* paths with an OPTIONAL `./`, so they are read the lenient way
|
|
314
|
+
* `types` is, not the strict way an `exports` target is.
|
|
315
|
+
* `imports` Same target grammar as `exports` (`./`-relative or a bare
|
|
316
|
+
* specifier). A `#foo` that a SHIPPED file imports and that
|
|
317
|
+
* resolves into an unpacked file breaks the package's own
|
|
318
|
+
* runtime resolution once installed. Node resolves a `#`
|
|
319
|
+
* specifier only when something imports it, so this is read
|
|
320
|
+
* STRICTLY on purpose: a `#test-helpers` pointing into an
|
|
321
|
+
* unpacked `test/` reds here and is not a broken publish. That
|
|
322
|
+
* is a known false red, taken deliberately, because deciding
|
|
323
|
+
* the other way means deciding WHICH declarations are load
|
|
324
|
+
* bearing, and this gate does not resolve anything.
|
|
325
|
+
* `browser` A string entry point, or a replacement map.
|
|
326
|
+
* `man` A bare string, or an ARRAY of them. `bin`'s own sibling in
|
|
327
|
+
* the npm spec, with the same lenient path grammar, so it is
|
|
328
|
+
* read exactly the way `bin` is.
|
|
329
|
+
* `unpkg` A string, same grammar as `main`. CDN conventions rather than
|
|
330
|
+
* `jsdelivr` npm ones, but a path this manifest promises either way, and
|
|
331
|
+
* one the CDN 404s on if the tarball does not carry it.
|
|
332
|
+
* `license` NAMES A FILE IN EXACTLY ONE FORM, and that form is
|
|
333
|
+
* `SEE LICEN[CS]E IN <filename>`, whose remainder is a file
|
|
334
|
+
* inside this package with `main`'s lenient grammar. It was
|
|
335
|
+
* SILENTLY blind here, which is a DIFFERENT state from
|
|
336
|
+
* `directories`: it was in no disclosure list and had no probe.
|
|
337
|
+
* The machinery that keeps `KNOWN_UNREAD_FIELDS` honest could
|
|
338
|
+
* not have caught that, and the reason is structural rather than
|
|
339
|
+
* an oversight: it proves every NAMED field really is unread and
|
|
340
|
+
* says nothing about a field nobody named. THIS IS NOT A
|
|
341
|
+
* COMPLETENESS CLAIM AND NO SUPERLATIVE IS MEANT: it is not
|
|
342
|
+
* "the last" such field, because nothing establishes that.
|
|
343
|
+
* `style`, `svelte`, `react-native`, `source` and the array form
|
|
344
|
+
* of `sideEffects` all name files inside a package on the same
|
|
345
|
+
* tool-convention ground `unpkg` and `jsdelivr` are read on;
|
|
346
|
+
* none is read by `declaredArtifacts()` and none is on
|
|
347
|
+
* `KNOWN_UNREAD_FIELDS`. Naming them is not a plan to read them:
|
|
348
|
+
* see "AND THIS IS NOT AN ENUMERATION" below, which is the shape
|
|
349
|
+
* this file has retired twice.
|
|
350
|
+
*
|
|
351
|
+
* MEASURED, NOT PREDICTED, on one throwaway package per field, each otherwise
|
|
352
|
+
* identical to the well-formed dual ESM/CJS fixture: one path on disk, left out of
|
|
353
|
+
* `files`, and declared ONLY through the field under test. Every one of them passed
|
|
354
|
+
* the whole gate WITHOUT its field being read: nets 1, 2 and 3 green, attw exited 0
|
|
355
|
+
* and reported nothing. That is the same false-green shape net 3 was built for,
|
|
356
|
+
* arriving through a field net 3 did not read.
|
|
357
|
+
*
|
|
358
|
+
* SAY EXACTLY WHAT PRODUCED THAT RED-BEFORE, BECAUSE IT IS NOT A BASE COMMIT.
|
|
359
|
+
* `attw-gate.test.ts` reconstructs the gate by deleting the marked block below out
|
|
360
|
+
* of THIS file at test time and runs the fixtures against that, so the
|
|
361
|
+
* counterfactual is derived from the shipped source rather than pasted beside it.
|
|
362
|
+
* It rebuilds the pre-`#59` field set, which for these fixtures is equivalent to
|
|
363
|
+
* any later base, because each declares its one path through one field and nothing
|
|
364
|
+
* through the others. The literal-base reading is separate and coarser: at
|
|
365
|
+
* `fe2f427` a fixture naming absent paths through all four disclosed fields passed
|
|
366
|
+
* the whole gate.
|
|
367
|
+
*
|
|
368
|
+
* ▶ WHAT IT DID NOT CHANGE, AND THIS IS THE HONEST HALF: nothing in the org moves.
|
|
369
|
+
* `typesVersions` is the only one that any cosyte manifest uses today
|
|
370
|
+
* (`ncpdp` and `@cosyte/test-utils`), and in BOTH of them every `typesVersions`
|
|
371
|
+
* target is already declared through `exports`, so the derived set is byte-for-byte
|
|
372
|
+
* what it was. `imports`, `browser`, `man`, `unpkg` and `jsdelivr` have no users
|
|
373
|
+
* here at all, re-derived over every manifest in the org rather than assumed. This closes
|
|
374
|
+
* a LATENT hole, exactly like the `.attw.json` class above, and the claim is not
|
|
375
|
+
* that anything shipped broken.
|
|
376
|
+
*
|
|
377
|
+
* `license` IS THE FIRST FIELD READ HERE THAT IS PRESENT ALMOST EVERYWHERE, so the
|
|
378
|
+
* "no users" sentence above must NOT be extended to it and the distinction is the
|
|
379
|
+
* point: the FIELD has users, the file-naming FORM has none. Re-derived over every
|
|
380
|
+
* `package.json` in every submodule rather than assumed: what `license` holds here
|
|
381
|
+
* is an ordinary SPDX expression, npm's reserved `UNLICENSED` (which is NOT an SPDX
|
|
382
|
+
* expression), or nothing at all, and NOT ONE manifest carries `SEE LICEN[CS]E IN`,
|
|
383
|
+
* so the derived set does not move anywhere in the org.
|
|
384
|
+
* NO TALLY IS WRITTEN DOWN, deliberately: a count here would be reader-dependent
|
|
385
|
+
* (a stray worktree under the umbrella changes it) and would go stale, and the
|
|
386
|
+
* load-bearing half is the ZERO, which needs no numbers. Re-derive it, which is one
|
|
387
|
+
* `grep` over the tracked manifests.
|
|
388
|
+
*
|
|
389
|
+
* ▶ `browser` MAP KEYS ARE DELIBERATELY NOT READ, and it is the only exclusion here
|
|
390
|
+
* that is a judgement rather than a grammar. A value is what a browser build LOADS;
|
|
391
|
+
* a key is what it stops loading. Reading keys would red a package that maps a file
|
|
392
|
+
* away precisely because that file is not shipped to browsers, which is a false red
|
|
393
|
+
* bought for no catch. `false` values fall out of the same rule with no case of
|
|
394
|
+
* their own, because `false` is not a `./`-relative string.
|
|
395
|
+
*
|
|
396
|
+
* ▶ TWO MORE `browser` EDGES, MEASURED AND NAMED RATHER THAN CODED AROUND. (a) The
|
|
397
|
+
* STRING form goes through the lenient reading, so `"browser": "some-shim-pkg"`
|
|
398
|
+
* is read as `./some-shim-pkg` and reds net 1. That is the same reading `main`
|
|
399
|
+
* gets and the same risk `main` has always carried; the string form is an entry
|
|
400
|
+
* point by convention, not a specifier slot. (b) Net 1 requires NON-EMPTY, so a
|
|
401
|
+
* 0-byte browser shim (browserify's `_empty.js` convention) reds even when it is
|
|
402
|
+
* packed. Both are new false reds, both were measured, and neither has a user in
|
|
403
|
+
* this org. They are written down here instead of being special-cased, because a
|
|
404
|
+
* per-field exception to net 1's non-empty rule is a bigger surface than the two
|
|
405
|
+
* cases it would buy. THE SAME NON-EMPTY RULE NOW REACHES `man`, `unpkg`,
|
|
406
|
+
* `jsdelivr` AND the `license` remainder, on the same terms and for the same
|
|
407
|
+
* reason: a 0-byte man page, CDN bundle or licence file reds, and that stays one
|
|
408
|
+
* rule rather than becoming four exceptions.
|
|
409
|
+
*
|
|
410
|
+
* ▶ AND THIS IS NOT AN ENUMERATION THAT BUYS ONE EVASION PER ROUND, which is the
|
|
411
|
+
* shape this file has retired twice: the question is schema-sized and bounded by
|
|
412
|
+
* `package.json` rather than by attw's option surface, so nothing a caller passes
|
|
413
|
+
* and nothing an attw release adds can extend it. BUT IT IS NOT COMPLETE, AND AN
|
|
414
|
+
* EARLIER DRAFT OF THIS PARAGRAPH SAID IT WAS. That draft was corrected to disclose
|
|
415
|
+
* four fields it did not read (`man`, `directories`, `unpkg`, `jsdelivr`), with
|
|
416
|
+
* the reason given as "none has a user in this org, and `man` is a LINK-TIME
|
|
417
|
+
* promise rather than a resolution-time one". THAT REASON DOES NOT SURVIVE BEING
|
|
418
|
+
* READ NEXT TO `bin`, WHICH IS ALSO LINK-TIME AND IS READ, so three of the four are
|
|
419
|
+
* now read and the reason is retired rather than restated.
|
|
420
|
+
*
|
|
421
|
+
* ▶ `directories` STAYS UNREAD, AND ON A GRAMMAR GROUND THAT IS MEASURED RATHER
|
|
422
|
+
* THAN A POPULARITY ONE. Its values name DIRECTORIES, and every net grades FILES.
|
|
423
|
+
* Measured on a package whose `directories.bin`/`directories.man` trees are fully
|
|
424
|
+
* packed: `npm pack --dry-run --json` lists `binscripts/tool.js` and
|
|
425
|
+
* `mandir/page.1` and NO directory entry at all, so net 3's `packed.files.has()`
|
|
426
|
+
* would miss on every user of the field: a false red for the correctly-packed
|
|
427
|
+
* case, which is the worst kind. Net 1 is no better in the other direction:
|
|
428
|
+
* `statSync("./mandir").size` is 60 here, non-zero, so its "missing or empty" test
|
|
429
|
+
* passes a directory without looking inside it. Reading `directories` therefore
|
|
430
|
+
* needs a prefix test against the packed list, which is a second grading rule, not
|
|
431
|
+
* a wider field set. It is out of this slice deliberately, and it is what the
|
|
432
|
+
* KNOWN-UNREAD disclosure below now names.
|
|
433
|
+
*
|
|
434
|
+
* ▶ `license` IS READ, IN EXACTLY ONE FORM, AND THE CONTRAST WITH `directories` IS
|
|
435
|
+
* THE WHOLE ARGUMENT FOR READING IT. `directories` needs a SECOND GRADING RULE (a
|
|
436
|
+
* prefix test, because it names directories and every net grades files). The
|
|
437
|
+
* `SEE LICEN[CS]E IN <filename>` remainder needs a second PARSING rule and then
|
|
438
|
+
* grades as a FILE against the same `packed.files.has()` every other field uses, so
|
|
439
|
+
* it costs one prefix match and adds no grading surface. Those are different costs
|
|
440
|
+
* and they get different answers.
|
|
441
|
+
*
|
|
442
|
+
* THE GRAMMAR IS NPM'S, READ OFF NPM ON THIS BOX, NOT RECALLED:
|
|
443
|
+
* `@npmcli/package-json/lib/license.js` accepts `/^SEE LICEN[CS]E IN ./`. Both
|
|
444
|
+
* spellings are npm's. The match here is CASE-SENSITIVE because npm's is: a
|
|
445
|
+
* lowercase `see license in x` is not this form, npm rejects it as an invalid
|
|
446
|
+
* license, and reading it as a path would be inventing a grammar npm does not have.
|
|
447
|
+
*
|
|
448
|
+
* WHY IT IS NOT VACUOUS, AND WHERE IT STOPS, MEASURED WITH
|
|
449
|
+
* `npm pack --dry-run --json` ON ONE THROWAWAY PACKAGE. npm force-packs `/readme`,
|
|
450
|
+
* `/copying`, `/license` and `/licence` with an optional extension AT THE ROOT,
|
|
451
|
+
* whatever `files` says. So `SEE LICENSE IN LICENSE.md` and `SEE LICENCE IN
|
|
452
|
+
* licence.txt` ship on their own and stay green here, which is what keeps this from
|
|
453
|
+
* reddening packages that rely on npm's auto-include. `EULA.txt`,
|
|
454
|
+
* `LICENSE-COMMERCIAL.md` and `legal/terms.md` were all left OUT of the same
|
|
455
|
+
* tarball, and npm printed no warning about the manifest naming them. THAT is the
|
|
456
|
+
* catch: a published package whose stated license points at a file the tarball does
|
|
457
|
+
* not carry, with every net green, which is this gate's own false-green shape
|
|
458
|
+
* arriving on a consumer-facing legal promise rather than a types one.
|
|
459
|
+
*
|
|
460
|
+
* THE REMAINDER IS TAKEN VERBATIM, NOT TRIMMED. npm defines no trimming and
|
|
461
|
+
* resolves the filename nowhere, so trimming would be a grammar of OURS. A trailing
|
|
462
|
+
* space makes the declaration malformed rather than making it name a different
|
|
463
|
+
* file, and naming the path it actually spells is more use to a reader than quietly
|
|
464
|
+
* repairing it. NO TEST PINS THIS, and that is disclosed rather than fixed: adding
|
|
465
|
+
* `.trim()` here keeps the whole suite green, so it is a stated decision and not a
|
|
466
|
+
* guarded one. It stays disclosed because the only input that separates the two is
|
|
467
|
+
* a filename with trailing whitespace, and a fixture whose entire content is that
|
|
468
|
+
* is a worse thing to maintain than this sentence.
|
|
469
|
+
*
|
|
470
|
+
* ▶ `publishConfig` IS NO LONGER ON THAT LIST, AND IT DID NOT GET THERE BY BECOMING
|
|
471
|
+
* A KEY IN `declaredArtifacts()`. It is read by NET 4, which is a SECOND SOURCE OF
|
|
472
|
+
* TRUTH rather than a wider field set, and the reasoning for that shape is worth
|
|
473
|
+
* keeping because the short version of it is not true of every key. For a plain
|
|
474
|
+
* override (`publishConfig.main` and its friends) the target has to be packed
|
|
475
|
+
* anyway, so a `packed.files.has()` on it would be a TRUE red rather than a false
|
|
476
|
+
* one; what widening `declaredArtifacts()` gets wrong THERE is the DOCUMENT it
|
|
477
|
+
* grades, since the published manifest is not the one on disk. For
|
|
478
|
+
* `publishConfig.directory` it is worse: pnpm packs a different subtree entirely and
|
|
479
|
+
* publishes that subtree's own manifest, so every path net 3 holds goes wrong at
|
|
480
|
+
* once, and THAT one really would be false reds. Net 4 answers both by reading
|
|
481
|
+
* pnpm's tarball instead of predicting it. See net 4 above for the measurements and
|
|
482
|
+
* for the five things it does not cover.
|
|
483
|
+
*
|
|
484
|
+
* ▶ THE DISCLOSURE IS NOW ONE STRING, READ BY THE PASS LINE AND BY THE SUITE.
|
|
485
|
+
* `KNOWN_UNREAD_FIELDS` is the single copy; the pass line prints it, and
|
|
486
|
+
* `attw-gate.test.ts` parses it out of this file and builds a fixture declaring an
|
|
487
|
+
* absent path through each name, proving each really is unread. The sentence has
|
|
488
|
+
* drifted ahead of the behaviour three rounds running because every guard on it so
|
|
489
|
+
* far compared one copy of the prose to another copy of the prose. This one
|
|
490
|
+
* compares the prose to the gate. It still is NOT a completeness proof: the list
|
|
491
|
+
* is KNOWN-INCOMPLETE and says so, but a name on it can no longer be false.
|
|
492
|
+
*
|
|
493
|
+
* WHAT THE PREFLIGHT CANNOT CONCLUDE, AND WHY IT NO LONGER TRIES. This script
|
|
494
|
+
* used to end its preflight failure with a sentence naming the exit code `attw`
|
|
495
|
+
* "would have" produced. It is gone rather than reworded, because THE PREFLIGHT
|
|
496
|
+
* READS THE MANIFEST AND NEVER THE TARBALL, and the tarball is what decides.
|
|
497
|
+
* `analysis.types` comes from `containsTypes()` in `@arethetypeswrong/core`'s
|
|
498
|
+
* `createPackage.js`, which is `listFiles(directory).some(ts.hasTSFileExtension)`:
|
|
499
|
+
* ANY TypeScript-extension file in the PACKED TARBALL, not the set `exports`
|
|
500
|
+
* declares, and computed before any entrypoint is resolved. So a package whose
|
|
501
|
+
* `files` packs a whole `dist/` can lose every DECLARED declaration and still
|
|
502
|
+
* hand `attw` an undeclared chunk declaration to find, at which point it exits 1
|
|
503
|
+
* and any "would have exited 0" sentence here is false. A partial loss `attw`
|
|
504
|
+
* catches by itself; only a total one is the false green. A gate that reds
|
|
505
|
+
* correctly and then explains itself with a falsehood teaches the next reader
|
|
506
|
+
* the wider, wrong story, and this file gets copied into every new parser.
|
|
507
|
+
*
|
|
508
|
+
* BLINDING, AND WHY THE ARGUMENT GUARD IS AN ALLOW-LIST RATHER THAN A DENY-LIST.
|
|
509
|
+
* Each of these was measured against the pinned `@arethetypeswrong/cli@0.18.4`
|
|
510
|
+
* on a package whose tarball carries no types. Each restored the exact false
|
|
511
|
+
* green AGAINST THE SENTENCE-MATCHING NET 2 THIS FILE NO LONGER HAS, by making
|
|
512
|
+
* the untyped sentence absent from what this script can read, while `attw`
|
|
513
|
+
* exits 0:
|
|
514
|
+
*
|
|
515
|
+
* --quiet / -q output empty, exit 0
|
|
516
|
+
* --format json / -f json sentence absent, output NOT empty, exit 0
|
|
517
|
+
* -fjson / -Pf json / -Pfjson
|
|
518
|
+
* same, exit 0: a value fused to a short flag, and
|
|
519
|
+
* a short flag inside a cluster
|
|
520
|
+
* --config-path <file setting quiet or format>
|
|
521
|
+
* sentence absent, exit 0
|
|
522
|
+
* .attw.json {"quiet":true} or {"format":"json"}
|
|
523
|
+
* sentence absent, exit 0 (readConfig() applies it
|
|
524
|
+
* after argv, so the file beats the flag)
|
|
525
|
+
* --help / -h / --version / -V
|
|
526
|
+
* exit 0, output NOT empty, no sentence: the gate
|
|
527
|
+
* cannot tell either from a pass
|
|
528
|
+
*
|
|
529
|
+
* EVERY ROW ABOVE NOW FAILS CLOSED ON THE PARSE, because none of them puts
|
|
530
|
+
* parseable JSON on stdout. THE ALLOW-LIST STAYS ANYWAY, and not as ceremony: it
|
|
531
|
+
* refuses these at the door with a sentence that says which argument was wrong,
|
|
532
|
+
* where the parse would only say the output was not JSON. A gate that reds for a
|
|
533
|
+
* legible reason costs the next reader minutes; one that reds for an illegible
|
|
534
|
+
* reason costs an hour. It also keeps this script forwarding only what it can
|
|
535
|
+
* vouch for, which is a smaller promise than "whatever survives the parse".
|
|
536
|
+
*
|
|
537
|
+
* A DENY-LIST DOES NOT HOLD HERE, AND EACH ROUND OF IT BOUGHT EXACTLY ONE MORE
|
|
538
|
+
* EVASION. The first shape refused a fixed set of tokens by `arg.split("=")[0]`,
|
|
539
|
+
* which is token equality rather than option-name matching, so `-fjson` was
|
|
540
|
+
* neither `-f` nor `--format` and walked straight through. The second shape
|
|
541
|
+
* added per-character matching over short clusters, which closed `-fjson`,
|
|
542
|
+
* `-Pfjson` and `-Pf json` and closed nothing else: measured against this gate
|
|
543
|
+
* on an untyped pack, `--help`, `-h`, `--version` and `-V` each still exited 0
|
|
544
|
+
* with the sentence absent and a non-empty transcript, so the empty-output net
|
|
545
|
+
* below could not backstop them either. Enumerating spellings is a ceiling, not
|
|
546
|
+
* a fix.
|
|
547
|
+
*
|
|
548
|
+
* So the guard is total instead: an ALLOW-LIST of the two arguments this gate's
|
|
549
|
+
* own callers pass. Everything else is refused, including a
|
|
550
|
+
* `--format table-flipped` that was measured to still print the sentence and so
|
|
551
|
+
* blinds nothing. "Harmless" is a judgement this script cannot make from an
|
|
552
|
+
* option name, and being over-strict about an argument nobody passes to a repo's
|
|
553
|
+
* own publish gate costs less than a route back to a false green. `-h`,
|
|
554
|
+
* `--version`, `--config-path` and every future spelling fall out of this for
|
|
555
|
+
* free rather than needing a line each. Widening the set is a deliberate
|
|
556
|
+
* one-line edit.
|
|
557
|
+
*
|
|
558
|
+
* NEITHER ALLOWED ARGUMENT IS PASSED BY THIS PACKAGE'S OWN `attw` SCRIPT.
|
|
559
|
+
* `--profile` selects the resolution profile. The manifest passes none, so the
|
|
560
|
+
* gate runs `attw`'s default `strict`; several sibling manifests DO pass
|
|
561
|
+
* `--profile node16`, and the value is forwarded rather than dropped. Its
|
|
562
|
+
* value is bounded by `attw` itself, which rejects anything outside its own
|
|
563
|
+
* choices.
|
|
564
|
+
* `--no-definitely-typed` suppresses the DefinitelyTyped lookup. It is allowed
|
|
565
|
+
* because the test suites here pass it (it keeps a gate run off the network),
|
|
566
|
+
* and nothing else does.
|
|
567
|
+
*
|
|
568
|
+
* THE `.attw.json` DENY-LIST IS GONE, AND NOTHING REPLACED IT BY NAME. It refused
|
|
569
|
+
* exactly two keys, `quiet` and `format`: the same shape this file had just
|
|
570
|
+
* retired on the argument side, and one that could never have been widened
|
|
571
|
+
* honestly. `readConfig()` calls `setOptionValueWithSource` for EVERY key except
|
|
572
|
+
* `configPath`/`help`/`version`, and applies them AFTER argv, so a committed
|
|
573
|
+
* config beats any argument this gate passes and reaches options no list here
|
|
574
|
+
* names. Enumerating keys would have bought exactly one more evasion per round,
|
|
575
|
+
* the way enumerating spellings did.
|
|
576
|
+
*
|
|
577
|
+
* NET 2's STRUCTURAL FORM CLOSES THE THREE CONFIG KEYS THAT BLIND *NET 2*, AND THAT
|
|
578
|
+
* IS ALL IT CLOSES. IT DOES NOT CLOSE "THE CONFIG ROUTE" AND NOTHING HERE MAY SAY
|
|
579
|
+
* SO. See WHAT THIS NET DOES NOT CLAIM below, which names the keys still open and
|
|
580
|
+
* the case they still pass. Measured on the pinned `@arethetypeswrong/cli@0.18.4`,
|
|
581
|
+
* against a package whose tarball carries no types at all:
|
|
582
|
+
*
|
|
583
|
+
* {"quiet": true} stdout empty -> unparseable -> RED
|
|
584
|
+
* {"format": "table"} beats the `--format json` this gate passes, and
|
|
585
|
+
* prints prose -> unparseable -> RED
|
|
586
|
+
* {"definitelyTyped": "./x.tgz"}
|
|
587
|
+
* parses fine, attw exits 0, AND THE PACKAGE ANALYSES AS
|
|
588
|
+
* TYPED. THE PARSE ALONE DOES NOT CATCH THIS ONE; the
|
|
589
|
+
* `kind` assertion is what does.
|
|
590
|
+
*
|
|
591
|
+
* THE THIRD ROW IS WHY THE ASSERTION IS ABOUT `kind` AND NOT ABOUT `types` MERELY
|
|
592
|
+
* BEING TRUTHY. `checkPackage` in `@arethetypeswrong/core` sets its `types` from
|
|
593
|
+
* `pkg.typesPackage` ALONE (it never re-reads the tarball once a DefinitelyTyped
|
|
594
|
+
* package has been merged in), so a tarball containing no declaration file
|
|
595
|
+
* anywhere reports `{"kind": "@types"}` and a clean bill of health. Reproduced end
|
|
596
|
+
* to end here: an untyped package plus a committed `.attw.json` naming a
|
|
597
|
+
* `@types/<name>` tarball EXITED 0 through the sentence-matching gate, and reds
|
|
598
|
+
* through this one.
|
|
599
|
+
*
|
|
600
|
+
* MEASURED SHAPES of `analysis.types`, all three read off real runs:
|
|
601
|
+
*
|
|
602
|
+
* false no TS-extension file in the
|
|
603
|
+
* tarball -> RED, the false green
|
|
604
|
+
* {"kind": "included"} SOME TS-extension file is in
|
|
605
|
+
* THIS tarball -> the only pass
|
|
606
|
+
* {"kind": "@types"} types were merged in from a
|
|
607
|
+
* DefinitelyTyped tarball -> RED
|
|
608
|
+
*
|
|
609
|
+
* `kind` CANNOT BE `"@types"` BY ACCIDENT ON THIS GATE'S OWN INVOCATION, so the
|
|
610
|
+
* assertion costs no false reds. Under `--pack`, the merge happens only when
|
|
611
|
+
* `opts.definitelyTyped` is a STRING that looks like a path; the option defaults to
|
|
612
|
+
* the boolean `true`, `--definitely-typed` is refused by the allow-list, and
|
|
613
|
+
* `--no-definitely-typed` sets it false. A committed config file is the only way in.
|
|
614
|
+
*
|
|
615
|
+
* WHAT THESE NETS DO NOT CLAIM, AND MUST NOT BE READ AS CLAIMING. READ THIS BEFORE
|
|
616
|
+
* WRITING ANYWHERE THAT THE CONFIG ROUTE IS CLOSED, BECAUSE IT STILL IS NOT.
|
|
617
|
+
*
|
|
618
|
+
* (a) `"included"` IS STILL NOT "THE DECLARED DECLARATIONS ARE PRESENT", AND NET
|
|
619
|
+
* 2 STILL DOES NOT SAY IT IS. It is `containsTypes()`, which is
|
|
620
|
+
* `listFiles("/").some(ts.hasTSFileExtension)`: ANY TypeScript-extension file
|
|
621
|
+
* anywhere in the tarball. A package that packs one stray `.d.ts` and loses
|
|
622
|
+
* every DECLARED one satisfies net 2. What refuses it now is NET 3, which
|
|
623
|
+
* does not ask attw anything. It is NOT a wider reading of `kind`, which has not
|
|
624
|
+
* changed and cannot be given one.
|
|
625
|
+
*
|
|
626
|
+
* (b) SO THE CONFIG ROUTE IS NARROWED, NOT CLOSED, AND THE PART STILL OPEN IS
|
|
627
|
+
* RESOLUTION. `readConfig()` still applies a committed `.attw.json` after
|
|
628
|
+
* argv, and `ignoreRules`, `ignoreResolutions` (not even validated by
|
|
629
|
+
* `readConfig()`) and an empty `entrypoints` still relax attw's exit code.
|
|
630
|
+
* Net 3 makes that stop mattering for one specific failure, a DECLARED path
|
|
631
|
+
* missing from the tarball, because net 3 never consults that exit code.
|
|
632
|
+
* Everything else attw judges is still behind it.
|
|
633
|
+
*
|
|
634
|
+
* MEASURED, so the residue is a fact and not a hedge. A package that declares
|
|
635
|
+
* `./dist/index.js` + `./dist/index.d.ts`, PACKS BOTH, and ships ESM under a
|
|
636
|
+
* CJS-default `main` reds on bare attw (exit 1, `UnexpectedModuleSyntax`) and
|
|
637
|
+
* PASSES THIS GATE, net 3 included, under
|
|
638
|
+
* `{"ignoreRules": ["unexpected-module-syntax", ...]}`. Net 3 is satisfied
|
|
639
|
+
* there and is right to be: both declared paths really are in the tarball.
|
|
640
|
+
* The gate PRINTS the suppressed problem kind rather than swallowing it, but
|
|
641
|
+
* printing is not gating and this file must never describe it as if it were.
|
|
642
|
+
*
|
|
643
|
+
* Do not answer that residue by adding keys here. That is the deny-list this
|
|
644
|
+
* file has retired twice, and net 3 is what it looks like to answer one of
|
|
645
|
+
* these structurally instead: read something attw's configuration cannot
|
|
646
|
+
* reach, derived from a set the manifest bounds.
|
|
647
|
+
*
|
|
648
|
+
* (c) THE PASS LINE IS WORDED TO MATCH (a) AND (b), and must stay that way. Its
|
|
649
|
+
* net 2 half claims only what `kind === "included"` proves: SOME
|
|
650
|
+
* TypeScript-extension file is in the tarball, and it was not merged in from
|
|
651
|
+
* `@types`. IT MUST NOT SAY "the declarations" or "this package's types":
|
|
652
|
+
* both assert (a)'s missing half, and two earlier drafts of this line did,
|
|
653
|
+
* the second more strongly than the first. Its net 3 half is bounded twice
|
|
654
|
+
* over in the same breath, BECAUSE A COUNT READS LIKE A TOTAL: it says
|
|
655
|
+
* "presence, not resolution", which is (b), and it NAMES THE THREE THINGS
|
|
656
|
+
* `declaredArtifacts()` LEAVES OUT of the set it counted (wildcard `exports`
|
|
657
|
+
* subpaths, absolute paths, and `package.json` itself). Without that clause a
|
|
658
|
+
* reader takes "all N paths package.json declares" for "everything the
|
|
659
|
+
* manifest declares", and for a manifest with wildcard subpaths that is
|
|
660
|
+
* false. It never says "no problems"
|
|
661
|
+
* unless the document really is empty, and it PRINTS any problem kinds attw
|
|
662
|
+
* reported but did not gate.
|
|
663
|
+
*
|
|
664
|
+
* No repo in the org ships a `.attw.json` today, so everything above, closed and
|
|
665
|
+
* open alike, is LATENT rather than live.
|
|
666
|
+
*
|
|
667
|
+
* THE NESTED `npm pack`, AND THE ONE THING IN THE ENVIRONMENT THAT BREAKS IT.
|
|
668
|
+
* `attw --pack .` shells out to a real `npm pack` and then opens the tarball at a
|
|
669
|
+
* path it COMPUTED from the manifest (`<dir>/<name>-<version>.tgz`, see
|
|
670
|
+
* `dist/index.js`). It never asks npm where the file went. So any inherited npm
|
|
671
|
+
* config that stops that file being written, or writes it somewhere else, turns
|
|
672
|
+
* this gate into `ENOENT: no such file or directory, open '<name>-<version>.tgz'`.
|
|
673
|
+
*
|
|
674
|
+
* MEASURED, NOT PREDICTED, and it is the whole of `CONFIG-PREPUBLISH-ATTW-ENOENT`:
|
|
675
|
+
* `pnpm publish --dry-run` exports `npm_config_dry_run=true` into every lifecycle
|
|
676
|
+
* script it runs, and `npm pack` under that variable prints its listing and writes
|
|
677
|
+
* NOTHING. That is why the failure only ever appears on a version bump, and it is
|
|
678
|
+
* the real mechanism behind the earlier `pnpm attw`-in-`prepublishOnly` incident
|
|
679
|
+
* (#40) that `RELEASING.md` records: `publish --dry-run` SKIPS a version already on
|
|
680
|
+
* npm, so the `prepublishOnly` chain runs on nothing else. A REAL publish does not
|
|
681
|
+
* set the variable (measured on a non-dry-run `pnpm publish`: the lifecycle
|
|
682
|
+
* environment carries `registry`, `cache`, `user_agent` and no `dry_run`), so this
|
|
683
|
+
* class has never broken a release, only the dry run that exists to prove one.
|
|
684
|
+
* `pack-destination` is the same mechanism through the other half: it moves the
|
|
685
|
+
* tarball away from the path attw computed.
|
|
686
|
+
*
|
|
687
|
+
* SO THE attw CHILD DOES NOT INHERIT THOSE TWO KEYS. THE UNDERSCORE SPELLINGS ARE
|
|
688
|
+
* THE ONES THAT ALWAYS ARRIVE, in either case: `npm_config_dry_run`,
|
|
689
|
+
* `NPM_CONFIG_DRY_RUN`, and the same two for `pack-destination`. npm ALSO honours a
|
|
690
|
+
* hyphenated key, and whether that one can reach npm here depends on something
|
|
691
|
+
* outside npm entirely. attw packs with `execSync("npm pack")`, which runs through
|
|
692
|
+
* `/bin/sh`, and `npm_config_dry-run` is not a valid shell variable name: DASH
|
|
693
|
+
* (what Debian and Ubuntu ship as `/bin/sh`, so the runner too) refuses to export
|
|
694
|
+
* it, while BASH, including bash invoked as `sh`, forwards it unchanged. Measured in
|
|
695
|
+
* both directions. So the hyphen is dead on CI and live on a bash-as-`sh` box, and
|
|
696
|
+
* an earlier draft of this paragraph stated dash's answer as though it were a
|
|
697
|
+
* property of shells.
|
|
698
|
+
*
|
|
699
|
+
* THAT IS EXACTLY WHY THE MATCH BELOW IS A SUPERSET rather than the pair that was
|
|
700
|
+
* measured arriving. It costs one character, it is right on either shell, and it
|
|
701
|
+
* stays right if anything ever spawns npm without one. What it must not be read as
|
|
702
|
+
* is evidence that the hyphen is a live route on any given box: the suite MEASURES
|
|
703
|
+
* the shell rather than assuming it, and asserts the counterfactual that shell
|
|
704
|
+
* actually produces.
|
|
705
|
+
*
|
|
706
|
+
* THIS IS NOT THE DENY-LIST THE ARGUMENT GUARD RETIRED, AND THE DIFFERENCE IS THAT
|
|
707
|
+
* THIS SET IS BOUNDED. Argv spellings were unbounded because the option parser
|
|
708
|
+
* accepts fused, clustered and `=`-joined forms, so each round of enumeration
|
|
709
|
+
* bought exactly one more evasion. Here the question is a schema-sized one with a
|
|
710
|
+
* two-key answer: which npm config decides WHETHER and WHERE `npm pack` writes its
|
|
711
|
+
* tarball? Every other npm setting changes what goes INSIDE the tarball, which is
|
|
712
|
+
* the thing this gate exists to read, so stripping more would change what attw
|
|
713
|
+
* analyses rather than fix where npm wrote. `npm_config_registry` in particular is
|
|
714
|
+
* left alone on purpose: dropping it would move where attw RESOLVES from.
|
|
715
|
+
*
|
|
716
|
+
* IT DOES NOT OVERRIDE AN OPERATOR'S `--dry-run`, and it cannot: the pack it
|
|
717
|
+
* un-suppresses is attw's own analysis input, in the directory being checked, and
|
|
718
|
+
* attw deletes it when it is done. Nothing about the outer dry run's "publishes
|
|
719
|
+
* nothing" guarantee is touched. What is restored is only that a gate asked to run
|
|
720
|
+
* gets to read a tarball instead of dying on a file npm was told not to write.
|
|
721
|
+
*
|
|
722
|
+
* THIS BODY USED TO BE KEPT BYTE-IDENTICAL IN TWO PLACES HERE AND HAND-COPIED
|
|
723
|
+
* INTO EVERY OTHER REPO, and that is what this file replaces.
|
|
724
|
+
* `packages/test-utils/scripts/attw.mjs` and
|
|
725
|
+
* `scripts/parser-template/scripts/attw.mjs` each carried the whole gate, a
|
|
726
|
+
* drift test pinned them equal, and every repo outside this one carried its own
|
|
727
|
+
* variant that no test here could see. A defect found in one was fixed in one.
|
|
728
|
+
* Both of those files are now callers of this module, the template's copy
|
|
729
|
+
* travels into a new parser as a caller, and a fix is one pull request and a
|
|
730
|
+
* version bump rather than one pull request per repo.
|
|
731
|
+
*/
|
|
732
|
+
|
|
733
|
+
import { spawnSync } from "node:child_process";
|
|
734
|
+
import {
|
|
735
|
+
accessSync,
|
|
736
|
+
constants,
|
|
737
|
+
mkdtempSync,
|
|
738
|
+
readdirSync,
|
|
739
|
+
readFileSync,
|
|
740
|
+
rmSync,
|
|
741
|
+
statSync,
|
|
742
|
+
} from "node:fs";
|
|
743
|
+
import { tmpdir } from "node:os";
|
|
744
|
+
import { join } from "node:path";
|
|
745
|
+
import { fileURLToPath } from "node:url";
|
|
746
|
+
import { gunzipSync } from "node:zlib";
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* A refusal this gate raised, carrying the code the caller should exit with.
|
|
750
|
+
*
|
|
751
|
+
* `die()` throws one rather than calling `process.exit`, so the whole gate is
|
|
752
|
+
* runnable inside a test process. `runAttwGate` is the only thing that catches
|
|
753
|
+
* it; anything else thrown is a defect here and is left to propagate.
|
|
754
|
+
*/
|
|
755
|
+
class AttwGateFailure extends Error {
|
|
756
|
+
/** @param {number} code The exit code this failure asks its caller for. */
|
|
757
|
+
constructor(code) {
|
|
758
|
+
super(`attw gate failed with exit code ${code}`);
|
|
759
|
+
this.name = "AttwGateFailure";
|
|
760
|
+
this.code = code;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const die = (msg) => {
|
|
765
|
+
process.stderr.write(`\n✗ attw gate: ${msg}\n`);
|
|
766
|
+
throw new AttwGateFailure(1);
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
// ---- Only the arguments this gate can vouch for are forwarded ---------------
|
|
770
|
+
// ALLOW-LIST, NOT A DENY-LIST, AND THAT IS THE WHOLE POINT. See BLINDING above.
|
|
771
|
+
const ALLOWED = new Set(["--profile", "--no-definitely-typed"]);
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* The arguments this gate will hand attw, or a refusal.
|
|
775
|
+
*
|
|
776
|
+
* @param {string[]} args The caller's own argv tail.
|
|
777
|
+
* @returns {string[]} The allow-listed arguments, in the order given.
|
|
778
|
+
*/
|
|
779
|
+
function forwardable(args) {
|
|
780
|
+
const forwarded = [];
|
|
781
|
+
for (let i = 0; i < args.length; i++) {
|
|
782
|
+
const arg = args[i];
|
|
783
|
+
const name = arg.split("=")[0];
|
|
784
|
+
if (!ALLOWED.has(name)) {
|
|
785
|
+
die(
|
|
786
|
+
`${arg} is not an argument this gate accepts.\n` +
|
|
787
|
+
` It forwards an ALLOW-LIST (${[...ALLOWED].join(", ")}) rather than refusing a\n` +
|
|
788
|
+
` list of spellings. This gate reads attw's printed output and attw exits 0 on an\n` +
|
|
789
|
+
` untyped package, so anything that changes what attw prints can hide the one\n` +
|
|
790
|
+
` sentence net 2 reads. Widening this set is a deliberate one-line edit; check\n` +
|
|
791
|
+
` first that the option cannot suppress or reformat attw's output.`,
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
forwarded.push(arg);
|
|
795
|
+
// `--profile` takes a value. A fused `--profile=node16` carries its own; a
|
|
796
|
+
// separated one must claim the next argument, or that value would be read as
|
|
797
|
+
// an option on the next turn of this loop and refused.
|
|
798
|
+
if (name === "--profile" && !arg.includes("=")) {
|
|
799
|
+
const value = args[++i];
|
|
800
|
+
if (value === undefined) die(`--profile was given with no value.`);
|
|
801
|
+
forwarded.push(value);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return forwarded;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* The two npm settings that decide whether and where the nested `npm pack`
|
|
809
|
+
* writes its tarball, in every spelling npm honours. attw opens a path it
|
|
810
|
+
* computed, so either one leaves this gate dying on ENOENT instead of checking
|
|
811
|
+
* anything. See "THE NESTED `npm pack`" above; `pnpm publish --dry-run` sets the
|
|
812
|
+
* first of them in every lifecycle script it runs.
|
|
813
|
+
*/
|
|
814
|
+
const PACK_PLACEMENT_CONFIG = /^npm_config_(dry[_-]run|pack[_-]destination)$/i;
|
|
815
|
+
|
|
816
|
+
// MAXBUFFER IS SET BECAUSE `--format json` IS 20 TO 50 TIMES THE TABLE'S SIZE, and
|
|
817
|
+
// spawnSync's default is 1 MiB. Measured here: ~56 kB for this repo's own
|
|
818
|
+
// two-entrypoint package, and ~245 kB for ONE entrypoint over an unbundled
|
|
819
|
+
// declaration tree. That puts the default's ceiling at a few dozen entrypoints, at
|
|
820
|
+
// which point a package the previous gate passed dies on ENOBUFS instead. A gate
|
|
821
|
+
// that reds because its own reader was too small is a false red, so the buffer is
|
|
822
|
+
// sized past anything a package here will produce and the failure is named below
|
|
823
|
+
// rather than left as `spawnSync ... ENOBUFS`.
|
|
824
|
+
const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* The fields that CAN name a file and that `declaredArtifacts()` deliberately does
|
|
828
|
+
* not read. THIS IS THE ONLY COPY OF THAT CLAIM: the pass line prints it, and
|
|
829
|
+
* `attw-gate.test.ts` parses this literal out of this file and proves, per name,
|
|
830
|
+
* that the gate really is blind to a path declared only through it.
|
|
831
|
+
*
|
|
832
|
+
* That is deliberate machinery for one recurring defect. The disclosure sentence
|
|
833
|
+
* has drifted ahead of `declaredArtifacts()` three rounds running, and every guard
|
|
834
|
+
* on it so far compared one copy of the prose to another copy of the prose. A name
|
|
835
|
+
* added here without the behaviour to match it now reds. It is still not a
|
|
836
|
+
* completeness claim: see "AND THIS IS NOT AN ENUMERATION" in the docblock.
|
|
837
|
+
*
|
|
838
|
+
* `publishConfig` CAME OFF THIS LIST when net 4 was added, and the probe came off
|
|
839
|
+
* with it: the probe asserts a path declared through the name goes UNSEEN, so
|
|
840
|
+
* leaving the name here after the gate learned to see it would have reddened the
|
|
841
|
+
* suite. That is the machinery working, not a special case.
|
|
842
|
+
*/
|
|
843
|
+
const KNOWN_UNREAD_FIELDS = ["directories"];
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Every relative path `package.json` promises to ship, deduped and normalized to
|
|
847
|
+
* a leading `./` so two spellings of one promise are not checked twice.
|
|
848
|
+
*
|
|
849
|
+
* THE FIELD SET IS THE WHOLE OF WHAT NETS 1, 3 AND 4 CAN SEE, so a declaring field
|
|
850
|
+
* missing from it is a hole in all three at once, silently. See "THE FIELDS THIS
|
|
851
|
+
* READS" in the docblock for which fields are here, which are deliberately not, and
|
|
852
|
+
* the measurement behind each.
|
|
853
|
+
*
|
|
854
|
+
* NET 4 CALLS THIS WITH A DIFFERENT MANIFEST: the one pnpm writes into the tarball,
|
|
855
|
+
* with `publishConfig` already applied. That is why `publishConfig` is not a key
|
|
856
|
+
* here even though the gate now reads it.
|
|
857
|
+
*/
|
|
858
|
+
function declaredArtifacts(pkg) {
|
|
859
|
+
const found = new Set();
|
|
860
|
+
// `main`, `module`, `types`, `typings`, `bin`, `man`, `unpkg`, `jsdelivr`, the
|
|
861
|
+
// string form of `browser`, every `typesVersions` target and the `SEE LICEN[CS]E
|
|
862
|
+
// IN` remainder of `license` are ALWAYS paths,
|
|
863
|
+
// never package specifiers, and the `./` prefix is optional on all of them. Only an absolute path (not ours to
|
|
864
|
+
// promise) or a pattern is skipped.
|
|
865
|
+
const addPath = (v) => {
|
|
866
|
+
if (typeof v !== "string" || v === "") return;
|
|
867
|
+
if (v.startsWith("/") || v.includes("*")) return;
|
|
868
|
+
const rel = v.startsWith(".") ? v : `./${v}`;
|
|
869
|
+
if (rel === "./package.json") return;
|
|
870
|
+
found.add(rel);
|
|
871
|
+
};
|
|
872
|
+
// An `exports` or `imports` target is required by spec to be `./`-relative, so a
|
|
873
|
+
// leaf that is not one is a package specifier or a pattern, and is not a file of
|
|
874
|
+
// ours. A `browser` map VALUE follows the same convention and is read the same
|
|
875
|
+
// way, which is also what makes `false` (the "stub this out" form) fall out here
|
|
876
|
+
// rather than needing a case of its own.
|
|
877
|
+
//
|
|
878
|
+
// THE TEST IS A LEADING DOT, NOT A LEADING `./`, AND THAT IS DELIBERATE: it keeps
|
|
879
|
+
// `../outside.js` and `.hidden.js`, which are INVALID targets to Node and which a
|
|
880
|
+
// reader is better off seeing named than silently dropped. Do not tighten it to
|
|
881
|
+
// `"./"` without deciding what should happen to those, and do not describe it as
|
|
882
|
+
// `./` anywhere: that sentence has already been wrong once.
|
|
883
|
+
const addTarget = (v) => {
|
|
884
|
+
if (typeof v !== "string") return;
|
|
885
|
+
// Skip wildcard subpath patterns (they name a set, not a file) and the
|
|
886
|
+
// manifest itself, which is always in the tarball by definition.
|
|
887
|
+
if (!v.startsWith(".") || v.includes("*") || v === "./package.json") return;
|
|
888
|
+
found.add(v);
|
|
889
|
+
};
|
|
890
|
+
for (const key of ["main", "module", "types", "typings"]) addPath(pkg[key]);
|
|
891
|
+
// `bin` is a bare string, or a flat map of command name to path.
|
|
892
|
+
if (typeof pkg.bin === "string") addPath(pkg.bin);
|
|
893
|
+
else if (pkg.bin && typeof pkg.bin === "object")
|
|
894
|
+
for (const v of Object.values(pkg.bin)) addPath(v);
|
|
895
|
+
// Conditions nest arbitrarily and fallback ARRAYS are legal in both maps, so the
|
|
896
|
+
// leaves are reached generically rather than by walking a shape this has to
|
|
897
|
+
// predict. `null` targets (the "block this subpath" form) are objects to
|
|
898
|
+
// `typeof` and are excluded by the truthiness test, not by a case.
|
|
899
|
+
const walk = (node, add) => {
|
|
900
|
+
if (typeof node === "string") add(node);
|
|
901
|
+
else if (node && typeof node === "object") for (const v of Object.values(node)) walk(v, add);
|
|
902
|
+
};
|
|
903
|
+
walk(pkg.exports, addTarget);
|
|
904
|
+
// ---- BEYOND `exports`: the fields net 3 was blind to -------------------------
|
|
905
|
+
// COUNTERFACTUAL MARKER. `attw-gate.test.ts` rebuilds the pre-fix field set by
|
|
906
|
+
// deleting from here to the closing marker, so the RED-BEFORE half of that suite
|
|
907
|
+
// is derived from this file rather than pasted beside it. Keep both markers; the
|
|
908
|
+
// suite reds if either stops matching.
|
|
909
|
+
walk(pkg.imports, addTarget);
|
|
910
|
+
// `typesVersions` is `{ <range>: { <subpath>: [ <path>, ... ] } }`. Its targets
|
|
911
|
+
// are paths with an OPTIONAL `./` (TypeScript's own documented example writes
|
|
912
|
+
// `["ts3.1/*"]`), so they take `addPath`, and the `*` forms drop out there.
|
|
913
|
+
walk(pkg.typesVersions, addPath);
|
|
914
|
+
// `browser` is a bundler convention, not a Node one: a string entry point, or a
|
|
915
|
+
// replacement map. Only the VALUES are read. A key is the module being replaced
|
|
916
|
+
// rather than a promise about the tarball, and reading keys would red a package
|
|
917
|
+
// that maps a file away precisely because it does not ship it to browsers.
|
|
918
|
+
if (typeof pkg.browser === "string") addPath(pkg.browser);
|
|
919
|
+
else if (pkg.browser && typeof pkg.browser === "object")
|
|
920
|
+
for (const v of Object.values(pkg.browser)) addTarget(v);
|
|
921
|
+
// `man` is `bin`'s own SIBLING in the npm spec: a bare string, or an array of
|
|
922
|
+
// them, with the same lenient `./`-optional path grammar and never a specifier.
|
|
923
|
+
// It is read exactly the way `bin` is, because the "it is only a LINK-TIME
|
|
924
|
+
// promise" reason for skipping it is equally true of `bin`, which is read.
|
|
925
|
+
if (typeof pkg.man === "string") addPath(pkg.man);
|
|
926
|
+
else if (Array.isArray(pkg.man)) for (const v of pkg.man) addPath(v);
|
|
927
|
+
// `unpkg` and `jsdelivr` are CDN conventions rather than npm ones, but the value
|
|
928
|
+
// is a path into this tarball with `main`'s grammar, and a CDN serves a 404 for a
|
|
929
|
+
// path the tarball does not carry. Same reading `main` gets.
|
|
930
|
+
for (const key of ["unpkg", "jsdelivr"]) addPath(pkg[key]);
|
|
931
|
+
// `license` NAMES A FILE IN EXACTLY ONE FORM, npm's own
|
|
932
|
+
// `SEE LICEN[CS]E IN <filename>`. No other value names a path, and the forms that
|
|
933
|
+
// do not are deliberately NOT enumerated here: npm does not enforce the value set,
|
|
934
|
+
// so an enumeration would be a claim this gate cannot keep and does not need.
|
|
935
|
+
// The remainder of the one form is a file in this package
|
|
936
|
+
// with `main`'s lenient grammar, so it takes `addPath`. The regex mirrors npm's
|
|
937
|
+
// (`@npmcli/package-json/lib/license.js`, `/^SEE LICEN[CS]E IN ./`) including its
|
|
938
|
+
// CASE SENSITIVITY: a lowercase spelling is not this form to npm, so reading it
|
|
939
|
+
// here would be a grammar of ours. The remainder is NOT trimmed, for the same
|
|
940
|
+
// reason. See "`license` IS READ, IN EXACTLY ONE FORM" in the docblock for the
|
|
941
|
+
// npm-pack measurement that says where this stops being vacuous.
|
|
942
|
+
if (typeof pkg.license === "string") {
|
|
943
|
+
const seeLicenseIn = /^SEE LICEN[CS]E IN (.+)$/.exec(pkg.license);
|
|
944
|
+
if (seeLicenseIn) addPath(seeLicenseIn[1]);
|
|
945
|
+
}
|
|
946
|
+
// `directories` and `publishConfig` are NOT here, and neither is skipped on a
|
|
947
|
+
// popularity ground. `directories` names DIRECTORIES while every net grades FILES,
|
|
948
|
+
// so reading it needs a PREFIX test against the packed list: a second grading rule.
|
|
949
|
+
// It is what KNOWN_UNREAD_FIELDS, declared above this function, now names on its
|
|
950
|
+
// own. `publishConfig` names publish-time OVERRIDES that pnpm applies and
|
|
951
|
+
// `npm pack` does not, so its targets are promises about a tarball net 3 never
|
|
952
|
+
// reads; NET 4 reads that tarball and hands the manifest out of it back to THIS
|
|
953
|
+
// function, which is why the answer was a second source of truth and not a key
|
|
954
|
+
// here. See "`directories` STAYS UNREAD" and "`publishConfig` IS NO LONGER ON THAT
|
|
955
|
+
// LIST" in the docblock.
|
|
956
|
+
// ---- END BEYOND `exports` ---------------------------------------------------
|
|
957
|
+
return [...found];
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* A readable digest of attw's JSON problem map, or `null` if there is not one.
|
|
962
|
+
*
|
|
963
|
+
* The gate asks for `--format json` because net 2 reads structure, but that costs
|
|
964
|
+
* the human the emoji table attw prints by default, and this file's whole point is
|
|
965
|
+
* that a gate must report its own failure legibly. So the failure path renders the
|
|
966
|
+
* problem KINDS and the subpaths they hit. Returns `null` (caller falls back to the
|
|
967
|
+
* raw bytes) when the document cannot be read at all, which on the failure path
|
|
968
|
+
* includes attw exiting mid-write and truncating its own JSON.
|
|
969
|
+
*
|
|
970
|
+
* @param {string} out attw's stdout.
|
|
971
|
+
* @returns {string | null} A digest, or null if none could be derived.
|
|
972
|
+
*/
|
|
973
|
+
function summarizeProblems(out) {
|
|
974
|
+
let doc;
|
|
975
|
+
try {
|
|
976
|
+
doc = JSON.parse(out);
|
|
977
|
+
} catch {
|
|
978
|
+
return null;
|
|
979
|
+
}
|
|
980
|
+
const problems = doc?.problems;
|
|
981
|
+
if (!problems || typeof problems !== "object") return null;
|
|
982
|
+
const lines = [];
|
|
983
|
+
for (const [kind, entries] of Object.entries(problems)) {
|
|
984
|
+
const where = (Array.isArray(entries) ? entries : [])
|
|
985
|
+
.map((p) => p?.entrypoint ?? p?.subpath ?? p?.resolutionKind)
|
|
986
|
+
.filter((s) => typeof s === "string");
|
|
987
|
+
lines.push(` ${kind}${where.length > 0 ? `: ${[...new Set(where)].join(", ")}` : ``}`);
|
|
988
|
+
}
|
|
989
|
+
if (lines.length === 0) return null;
|
|
990
|
+
return [`attw reported ${lines.length} problem kind(s):`, ...lines].join("\n");
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* The paths a published tarball of this directory would carry, read from npm
|
|
995
|
+
* rather than recomputed from `files`/`.npmignore` here.
|
|
996
|
+
*
|
|
997
|
+
* `--dry-run` and `--json` are PASSED ON ARGV, NEVER LEFT TO THE ENVIRONMENT.
|
|
998
|
+
* `json` is an ordinary npm config, so an ambient `npm_config_json` /
|
|
999
|
+
* `NPM_CONFIG_JSON` picks npm's output format for any caller that does not pin
|
|
1000
|
+
* it, and a sibling test in this package was reading `]` as a tarball name for
|
|
1001
|
+
* exactly that reason. Measured here: the flag beats the variable in both
|
|
1002
|
+
* spellings. `--dry-run` is what keeps this net from writing a tarball into a
|
|
1003
|
+
* directory someone may be about to publish; measured, no `.tgz` appears.
|
|
1004
|
+
*
|
|
1005
|
+
* Every shape this cannot read comes back as an error rather than as a short
|
|
1006
|
+
* file list, because a partial answer graded as a whole one is the false green
|
|
1007
|
+
* this whole file exists to refuse.
|
|
1008
|
+
*
|
|
1009
|
+
* @param {{ name?: string }} pkg The parsed manifest.
|
|
1010
|
+
* @param {NodeJS.ProcessEnv} childEnv The environment to hand npm.
|
|
1011
|
+
* @returns {{ files: Set<string>, error?: undefined } | { error: string, files?: undefined }}
|
|
1012
|
+
*/
|
|
1013
|
+
function packedFiles(pkg, childEnv) {
|
|
1014
|
+
const res = spawnSync("npm", ["pack", "--dry-run", "--json"], {
|
|
1015
|
+
encoding: "utf8",
|
|
1016
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
1017
|
+
env: childEnv,
|
|
1018
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
1019
|
+
});
|
|
1020
|
+
if (res.error) return { error: `could not run \`npm pack\`: ${res.error.message}` };
|
|
1021
|
+
if (res.status !== 0) {
|
|
1022
|
+
const why = (res.stderr ?? "").trim();
|
|
1023
|
+
return {
|
|
1024
|
+
error: `\`npm pack --dry-run --json\` exited ${res.status}.${why ? `\n${why}` : ``}`,
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
let doc;
|
|
1028
|
+
try {
|
|
1029
|
+
doc = JSON.parse(res.stdout ?? "");
|
|
1030
|
+
} catch {
|
|
1031
|
+
return {
|
|
1032
|
+
error: `\`npm pack --dry-run --json\` did not print JSON. What it printed:\n\n${res.stdout ?? ""}`,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
// npm reports an ARRAY of tarballs, because the same command run at a workspace
|
|
1036
|
+
// root describes several. Only this package's own entry is gradeable here, and
|
|
1037
|
+
// anything other than exactly one of them means npm was asked a different
|
|
1038
|
+
// question than this net thinks it asked.
|
|
1039
|
+
const mine = (Array.isArray(doc) ? doc : []).filter((entry) => entry?.name === pkg.name);
|
|
1040
|
+
if (mine.length !== 1) {
|
|
1041
|
+
return {
|
|
1042
|
+
error:
|
|
1043
|
+
`npm described ${mine.length} tarball(s) named ${pkg.name}, and this net can only\n` +
|
|
1044
|
+
` grade exactly one. Something changed what \`npm pack\` was asked to pack.`,
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
const listed = mine[0]?.files;
|
|
1048
|
+
if (!Array.isArray(listed)) {
|
|
1049
|
+
return { error: `npm's pack report for ${pkg.name} carries no "files" array.` };
|
|
1050
|
+
}
|
|
1051
|
+
const files = new Set();
|
|
1052
|
+
for (const entry of listed) {
|
|
1053
|
+
if (typeof entry?.path !== "string") {
|
|
1054
|
+
return {
|
|
1055
|
+
error: `npm listed a packed file with no "path" string, so the list is unreadable.`,
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
files.add(entry.path);
|
|
1059
|
+
}
|
|
1060
|
+
return { files };
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* The `path` a pax extended header sets for the entry after it, or `null`.
|
|
1065
|
+
*
|
|
1066
|
+
* A pax body is a run of `<byte-length> <key>=<value>\n` records. The length is
|
|
1067
|
+
* counted in BYTES and includes itself, so the walk is done over the Buffer rather
|
|
1068
|
+
* than over a decoded string: a multi-byte character in an earlier record would put
|
|
1069
|
+
* a string-indexed walk one or more positions out and silently return the wrong
|
|
1070
|
+
* path. Anything it cannot read returns `null`, and the caller then falls back to
|
|
1071
|
+
* the header fields rather than inventing a path.
|
|
1072
|
+
*
|
|
1073
|
+
* @param {Buffer} body The pax record block.
|
|
1074
|
+
* @returns {string | null}
|
|
1075
|
+
*/
|
|
1076
|
+
function paxPath(body) {
|
|
1077
|
+
let at = 0;
|
|
1078
|
+
while (at < body.length) {
|
|
1079
|
+
const space = body.indexOf(0x20, at);
|
|
1080
|
+
if (space < 0) return null;
|
|
1081
|
+
const length = Number.parseInt(body.subarray(at, space).toString("ascii"), 10);
|
|
1082
|
+
if (!Number.isSafeInteger(length) || length <= 0 || at + length > body.length) return null;
|
|
1083
|
+
const record = body.subarray(space + 1, at + length).toString("utf8");
|
|
1084
|
+
const equals = record.indexOf("=");
|
|
1085
|
+
if (equals > 0 && record.slice(0, equals) === "path") {
|
|
1086
|
+
return record.slice(equals + 1).replace(/\n$/, "");
|
|
1087
|
+
}
|
|
1088
|
+
at += length;
|
|
1089
|
+
}
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* The REGULAR FILE entries of a gzipped tar archive, as a map of path to body.
|
|
1095
|
+
*
|
|
1096
|
+
* Net 4 needs two things out of the tarball pnpm wrote: the manifest inside it and
|
|
1097
|
+
* the list of what is beside that manifest. Both come from the same read, so this
|
|
1098
|
+
* walks the archive once rather than shelling out to `tar` twice: a system `tar` is
|
|
1099
|
+
* one more thing that has to be on the box for a Node-only gate to work, and this
|
|
1100
|
+
* gate is copied into every new parser repo.
|
|
1101
|
+
*
|
|
1102
|
+
* ONLY REGULAR FILES ARE KEPT. Directory records and symlinks are not files a
|
|
1103
|
+
* package promises, and net 3's authority does not list directories either, so the
|
|
1104
|
+
* two nets agree.
|
|
1105
|
+
*
|
|
1106
|
+
* A LONG PATH IS NOT IN THE HEADER'S `name` FIELD, AND BOTH ESCAPES pnpm USES ARE
|
|
1107
|
+
* MEASURED RATHER THAN ASSUMED. `name` is 100 bytes, and a path longer than that is
|
|
1108
|
+
* carried one of two ways. Both were reproduced against `pnpm pack` here, and
|
|
1109
|
+
* MISHANDLING EITHER IS A FALSE RED ON A CORRECTLY PACKED FILE, which is the worst
|
|
1110
|
+
* kind:
|
|
1111
|
+
*
|
|
1112
|
+
* ustar `prefix` A path that SPLITS at a `/` inside the last 155 bytes goes in
|
|
1113
|
+
* two fields. Measured on the suite's own fixture, whose entry
|
|
1114
|
+
* path is 120 bytes: `prefix` held `package/aaaa...` (28 bytes)
|
|
1115
|
+
* and `name` held the remaining 91. Read `name` alone and the
|
|
1116
|
+
* entry lands under a path no manifest declares.
|
|
1117
|
+
* pax `x` record A path that cannot be split that way gets an extended header
|
|
1118
|
+
* INSTEAD, and the real entry that follows is literally named
|
|
1119
|
+
* `PaxHeader`. Measured on the suite's own fixture, a 123-byte
|
|
1120
|
+
* single filename (131 with the `package/` prefix): two entries,
|
|
1121
|
+
* `x` then a regular file, both named `PaxHeader`, with
|
|
1122
|
+
* `path=package/llll....js` in the `x` record's body. Skip the
|
|
1123
|
+
* `x` record without reading it and the file is invisible.
|
|
1124
|
+
*
|
|
1125
|
+
* GNU's `L` (LongName) record is handled on the same terms, and `K` (LongLink) is
|
|
1126
|
+
* ignored WITHOUT clearing a pending name, because `K` names a link TARGET and GNU
|
|
1127
|
+
* may emit it beside an `L` for the same entry. Neither was measured coming out of
|
|
1128
|
+
* `pnpm pack`, which packs through `tar-stream` and uses pax, so both are
|
|
1129
|
+
* safeguards rather than reproductions and this sentence says so rather than
|
|
1130
|
+
* claiming a measurement.
|
|
1131
|
+
*
|
|
1132
|
+
* @param {Buffer} gz The gzipped archive.
|
|
1133
|
+
* @returns {{ entries: Map<string, Buffer>, error?: undefined } | { error: string, entries?: undefined }}
|
|
1134
|
+
*/
|
|
1135
|
+
function tarEntries(gz) {
|
|
1136
|
+
let buf;
|
|
1137
|
+
try {
|
|
1138
|
+
buf = gunzipSync(gz);
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
return { error: `the tarball pnpm wrote could not be decompressed: ${err.message}` };
|
|
1141
|
+
}
|
|
1142
|
+
const entries = new Map();
|
|
1143
|
+
const field = (header, start, end) =>
|
|
1144
|
+
header
|
|
1145
|
+
.subarray(start, end)
|
|
1146
|
+
.toString("utf8")
|
|
1147
|
+
.replace(/\0[\s\S]*$/, "");
|
|
1148
|
+
/** The path a pax or GNU record set for the entry that follows it, if any. */
|
|
1149
|
+
let longPath = null;
|
|
1150
|
+
let off = 0;
|
|
1151
|
+
while (off + 512 <= buf.length) {
|
|
1152
|
+
const header = buf.subarray(off, off + 512);
|
|
1153
|
+
// A zero-filled block is the end-of-archive marker.
|
|
1154
|
+
if (header.every((b) => b === 0)) break;
|
|
1155
|
+
const size = Number.parseInt(field(header, 124, 136).trim() || "0", 8);
|
|
1156
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
1157
|
+
return {
|
|
1158
|
+
error:
|
|
1159
|
+
`the tarball pnpm wrote has a header at byte ${off} whose size field this gate\n` +
|
|
1160
|
+
` could not read, so the archive could not be walked.`,
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
const name = field(header, 0, 100);
|
|
1164
|
+
const prefix = field(header, 345, 500);
|
|
1165
|
+
const body = buf.subarray(off + 512, off + 512 + size);
|
|
1166
|
+
// Typeflags are compared as BYTES rather than as decoded characters, so no
|
|
1167
|
+
// string escape for a NUL has to survive being copied between two files.
|
|
1168
|
+
// 0x30 "0" and 0x00 (the historical spelling) are a regular file; 0x78 "x" and
|
|
1169
|
+
// 0x58 "X" are a pax extended header for the NEXT entry; 0x4c "L" is GNU's
|
|
1170
|
+
// LongName, whose whole body is that next entry's PATH.
|
|
1171
|
+
const type = header[156];
|
|
1172
|
+
if (type === 0x78 || type === 0x58) {
|
|
1173
|
+
longPath = paxPath(body) ?? longPath;
|
|
1174
|
+
} else if (type === 0x4c) {
|
|
1175
|
+
longPath = body.toString("utf8").replace(/\0[\s\S]*$/, "");
|
|
1176
|
+
} else if (type === 0x4b) {
|
|
1177
|
+
// 0x4b "K" is GNU's LongLink, which carries a link TARGET rather than a path.
|
|
1178
|
+
// It is skipped WITHOUT clearing `longPath`: GNU may emit `K` beside an `L`
|
|
1179
|
+
// for the same entry, and clearing here would hand that entry its truncated
|
|
1180
|
+
// 100-byte name instead. Nothing it carries is a file this net grades.
|
|
1181
|
+
} else {
|
|
1182
|
+
if (type === 0x30 || type === 0x00) {
|
|
1183
|
+
entries.set(longPath ?? (prefix === "" ? name : `${prefix}/${name}`), body);
|
|
1184
|
+
}
|
|
1185
|
+
// An extended header applies to ONE entry. Cleared after any other record so
|
|
1186
|
+
// it can never be read as belonging to a later one.
|
|
1187
|
+
longPath = null;
|
|
1188
|
+
}
|
|
1189
|
+
off += 512 + Math.ceil(size / 512) * 512;
|
|
1190
|
+
}
|
|
1191
|
+
return { entries };
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* The manifest pnpm WOULD PUBLISH and the paths beside it, read out of a tarball
|
|
1196
|
+
* pnpm actually wrote.
|
|
1197
|
+
*
|
|
1198
|
+
* THIS ASKS pnpm RATHER THAN MODELLING IT, and the docblock's net 4 section records
|
|
1199
|
+
* why: `publishConfig.directory` makes pnpm publish a DIFFERENT subtree's own
|
|
1200
|
+
* manifest and drop the root's other overrides, which a merge written here would
|
|
1201
|
+
* have had to know. Reading the tarball knows it for free.
|
|
1202
|
+
*
|
|
1203
|
+
* The tarball is written into a temporary directory OUTSIDE the package, so nothing
|
|
1204
|
+
* lands in a tree someone may be about to publish and nothing a later pack could
|
|
1205
|
+
* pick up; it is removed again whatever happens. `--pack-destination` is passed on
|
|
1206
|
+
* ARGV for the same reason net 3 passes its flags there, and it is ABSOLUTE because
|
|
1207
|
+
* pnpm resolves a relative one against the PUBLISH directory, which under
|
|
1208
|
+
* `publishConfig.directory` is not the directory this gate is standing in
|
|
1209
|
+
* (measured: `--pack-destination ./tb` wrote into `dist/tb`).
|
|
1210
|
+
*
|
|
1211
|
+
* Every shape this cannot read comes back as an error rather than as an empty set,
|
|
1212
|
+
* because a partial answer graded as a whole one is the false green this whole file
|
|
1213
|
+
* exists to refuse.
|
|
1214
|
+
*
|
|
1215
|
+
* @param {NodeJS.ProcessEnv} childEnv The environment to hand pnpm.
|
|
1216
|
+
* @returns {{ manifest: Record<string, unknown>, files: Set<string>, error?: undefined } | { error: string, manifest?: undefined, files?: undefined }}
|
|
1217
|
+
*/
|
|
1218
|
+
function pnpmPublished(childEnv) {
|
|
1219
|
+
let dest;
|
|
1220
|
+
try {
|
|
1221
|
+
dest = mkdtempSync(join(tmpdir(), "attw-gate-pnpm-"));
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
return { error: `could not create a temporary directory for \`pnpm pack\`: ${err.message}` };
|
|
1224
|
+
}
|
|
1225
|
+
try {
|
|
1226
|
+
const res = spawnSync("pnpm", ["pack", "--pack-destination", dest], {
|
|
1227
|
+
encoding: "utf8",
|
|
1228
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
1229
|
+
env: childEnv,
|
|
1230
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
1231
|
+
});
|
|
1232
|
+
if (res.error) {
|
|
1233
|
+
return {
|
|
1234
|
+
error:
|
|
1235
|
+
`could not run \`pnpm pack\`: ${res.error.message}\n` +
|
|
1236
|
+
` This net grades the manifest pnpm would publish, so it needs pnpm on PATH.\n` +
|
|
1237
|
+
` Refused rather than skipped: a check that cannot run is not a pass.`,
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
if (res.status !== 0) {
|
|
1241
|
+
const why = (res.stderr ?? "").trim();
|
|
1242
|
+
return { error: `\`pnpm pack\` exited ${res.status}.${why ? `\n${why}` : ``}` };
|
|
1243
|
+
}
|
|
1244
|
+
const written = readdirSync(dest).filter((entry) => entry.endsWith(".tgz"));
|
|
1245
|
+
if (written.length !== 1) {
|
|
1246
|
+
return {
|
|
1247
|
+
error:
|
|
1248
|
+
`\`pnpm pack\` wrote ${written.length} tarball(s) where this net can only grade\n` +
|
|
1249
|
+
` exactly one. Something changed what pnpm was asked to pack.`,
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
const read = tarEntries(readFileSync(join(dest, written[0])));
|
|
1253
|
+
if (read.error) return { error: read.error };
|
|
1254
|
+
const manifestEntry = read.entries.get("package/package.json");
|
|
1255
|
+
if (manifestEntry === undefined) {
|
|
1256
|
+
return { error: `the tarball pnpm wrote carries no package/package.json to grade.` };
|
|
1257
|
+
}
|
|
1258
|
+
let manifest;
|
|
1259
|
+
try {
|
|
1260
|
+
manifest = JSON.parse(manifestEntry.toString("utf8"));
|
|
1261
|
+
} catch (err) {
|
|
1262
|
+
return {
|
|
1263
|
+
error: `the package.json inside the tarball pnpm wrote is not readable JSON: ${err.message}`,
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
const files = new Set();
|
|
1267
|
+
for (const path of read.entries.keys()) {
|
|
1268
|
+
if (path.startsWith("package/")) files.add(path.slice("package/".length));
|
|
1269
|
+
}
|
|
1270
|
+
return { manifest, files };
|
|
1271
|
+
} catch (err) {
|
|
1272
|
+
return { error: `could not read the tarball \`pnpm pack\` wrote: ${err.message}` };
|
|
1273
|
+
} finally {
|
|
1274
|
+
rmSync(dest, { recursive: true, force: true });
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Run the gate in the current working directory, and return the exit code a
|
|
1280
|
+
* caller should exit with.
|
|
1281
|
+
*
|
|
1282
|
+
* @param {object} options
|
|
1283
|
+
* @param {string} options.callerUrl The CALLER's `import.meta.url`. REQUIRED:
|
|
1284
|
+
* `attw` is resolved as `../node_modules/.bin/attw` from it, so that the
|
|
1285
|
+
* binary run is the CONSUMING package's own. See the docblock's "WHAT A
|
|
1286
|
+
* CALLER IS".
|
|
1287
|
+
* @param {string[]} [options.argv] The arguments to grade against the
|
|
1288
|
+
* allow-list and forward. Defaults to this process's own.
|
|
1289
|
+
* @returns {number} 0 when every net passed, attw's own status when attw judged
|
|
1290
|
+
* the package, 1 for every refusal.
|
|
1291
|
+
*/
|
|
1292
|
+
export function runAttwGate(options) {
|
|
1293
|
+
try {
|
|
1294
|
+
return gate(options ?? {});
|
|
1295
|
+
} catch (err) {
|
|
1296
|
+
if (err instanceof AttwGateFailure) return err.code;
|
|
1297
|
+
throw err;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* The gate itself. Raises `AttwGateFailure` rather than returning on a refusal;
|
|
1303
|
+
* `runAttwGate` is what turns that back into an exit code.
|
|
1304
|
+
*
|
|
1305
|
+
* @param {{ callerUrl?: string, argv?: string[] }} options
|
|
1306
|
+
* @returns {number}
|
|
1307
|
+
*/
|
|
1308
|
+
function gate({ callerUrl, argv = process.argv.slice(2) }) {
|
|
1309
|
+
// REFUSED, NEVER DEFAULTED. A default of `import.meta.url` would resolve attw
|
|
1310
|
+
// relative to THIS module, which inside a consumer is
|
|
1311
|
+
// `node_modules/@cosyte/script-utils/`, so the gate would look for
|
|
1312
|
+
// `node_modules/@cosyte/node_modules/.bin/attw` and find nothing, or, worse,
|
|
1313
|
+
// find some other package's binary. The caller knows where it lives; this
|
|
1314
|
+
// module cannot.
|
|
1315
|
+
if (typeof callerUrl !== "string" || callerUrl === "") {
|
|
1316
|
+
die(
|
|
1317
|
+
`runAttwGate was called with no callerUrl.\n` +
|
|
1318
|
+
` Pass \`{ callerUrl: import.meta.url }\` from the consuming package's own\n` +
|
|
1319
|
+
` scripts/attw.mjs. This gate runs THAT package's node_modules/.bin/attw, and a\n` +
|
|
1320
|
+
` default here would resolve the binary relative to @cosyte/script-utils inside\n` +
|
|
1321
|
+
` node_modules, which is not the package being checked.`,
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
const ATTW_BIN = fileURLToPath(new URL("../node_modules/.bin/attw", callerUrl));
|
|
1325
|
+
// THE BINARY IS CHECKED BEFORE IT IS SPAWNED, so the refusal NAMES THE PATH it
|
|
1326
|
+
// looked for. spawnSync reports ENOENT and EACCES the same way, and a message
|
|
1327
|
+
// built out of `res.error.message` is about a syscall rather than about which
|
|
1328
|
+
// dependency tree this gate was pointed at. A gate whose own tool is missing
|
|
1329
|
+
// has checked nothing, and reporting that as a pass is the defect this whole
|
|
1330
|
+
// file exists to refuse.
|
|
1331
|
+
try {
|
|
1332
|
+
accessSync(ATTW_BIN, constants.X_OK);
|
|
1333
|
+
} catch (err) {
|
|
1334
|
+
die(
|
|
1335
|
+
`this package's own attw binary is not runnable:\n` +
|
|
1336
|
+
` ${ATTW_BIN}\n` +
|
|
1337
|
+
` (${err.message})\n\n` +
|
|
1338
|
+
` That path is \`../node_modules/.bin/attw\` from the caller that invoked this\n` +
|
|
1339
|
+
` gate, so it is the CONSUMING package's binary and never one belonging to\n` +
|
|
1340
|
+
` whichever package this gate itself was installed under. Run the install\n` +
|
|
1341
|
+
` first. Refused rather than skipped, and no other attw on this box is\n` +
|
|
1342
|
+
` substituted for it: a gate that analysed a different dependency tree would\n` +
|
|
1343
|
+
` report on a package nobody asked about.`,
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
const forwarded = forwardable(argv);
|
|
1347
|
+
|
|
1348
|
+
let pkg;
|
|
1349
|
+
try {
|
|
1350
|
+
pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
|
1351
|
+
} catch (err) {
|
|
1352
|
+
die(`cannot read ./package.json from ${process.cwd()}: ${err.message}`);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// ---- Net 1: preflight -------------------------------------------------------
|
|
1356
|
+
// Net 3 asks this same set of paths a second question, of the tarball rather than
|
|
1357
|
+
// the working tree, so the set is derived once and shared.
|
|
1358
|
+
const declared = declaredArtifacts(pkg);
|
|
1359
|
+
// A PREFLIGHT WITH NOTHING TO CHECK IS NOT A PREFLIGHT THAT PASSED, and this
|
|
1360
|
+
// refusal is the one thing the 297-line variant had that the long one did not.
|
|
1361
|
+
// The same set feeds nets 3 and 4, so an empty one makes three nets vacuous at
|
|
1362
|
+
// once and leaves the verdict standing on attw's own exit code, which is 0 on
|
|
1363
|
+
// an untyped package. See net 1 in the docblock.
|
|
1364
|
+
if (declared.length === 0) {
|
|
1365
|
+
die(
|
|
1366
|
+
`package.json declares no relative artifact paths, so the preflight checked nothing.\n` +
|
|
1367
|
+
` main, module, types and typings are absent or unusable, and no other field this\n` +
|
|
1368
|
+
` gate reads carries a usable relative path either (the field set is in this\n` +
|
|
1369
|
+
` file's docblock, under "THE FIELDS THIS READS").\n` +
|
|
1370
|
+
` Nets 1, 3 and 4 all grade that one set, so an empty set leaves this gate\n` +
|
|
1371
|
+
` standing on attw's own exit code, which is 0 on an untyped package. Declare\n` +
|
|
1372
|
+
` the package's entry points, or do not run a publish gate over it.\n`,
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
const broken = [];
|
|
1376
|
+
for (const rel of declared) {
|
|
1377
|
+
let size;
|
|
1378
|
+
try {
|
|
1379
|
+
size = statSync(rel).size;
|
|
1380
|
+
} catch {
|
|
1381
|
+
broken.push({ rel, why: "missing" });
|
|
1382
|
+
continue;
|
|
1383
|
+
}
|
|
1384
|
+
if (size === 0) broken.push({ rel, why: "empty" });
|
|
1385
|
+
}
|
|
1386
|
+
if (broken.length > 0) {
|
|
1387
|
+
// No counterfactual about attw's exit code here, on purpose. See "WHAT THE
|
|
1388
|
+
// PREFLIGHT CANNOT CONCLUDE" above before adding one back.
|
|
1389
|
+
die(
|
|
1390
|
+
`package.json promises files the build has not produced:\n` +
|
|
1391
|
+
broken.map(({ rel, why }) => ` ${rel} (${why})\n`).join("") +
|
|
1392
|
+
`\n Run the build first. If you DID build, something removed or truncated the\n` +
|
|
1393
|
+
` output underneath this run. A concurrent build or \`clean\` in the same\n` +
|
|
1394
|
+
` working tree will do it, and \`tsup\` writes JS before declarations, so there\n` +
|
|
1395
|
+
` is a window in every build here where the .d.ts files do not exist yet.\n` +
|
|
1396
|
+
` attw was not run: this check reads the manifest, and what attw would have\n` +
|
|
1397
|
+
` reported depends on what the packed tarball carries, which it cannot see.\n`,
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// ---- Run attw ---------------------------------------------------------------
|
|
1402
|
+
// The environment the child does NOT inherit, and the buffer it is read with, are
|
|
1403
|
+
// declared once at module scope above: see PACK_PLACEMENT_CONFIG and
|
|
1404
|
+
// MAX_OUTPUT_BYTES, which nets 3 and 4 share with this spawn.
|
|
1405
|
+
const env = Object.fromEntries(
|
|
1406
|
+
Object.entries(process.env).filter(([key]) => !PACK_PLACEMENT_CONFIG.test(key)),
|
|
1407
|
+
);
|
|
1408
|
+
// `--format json` is APPENDED BY THIS GATE, never accepted from a caller: net 2
|
|
1409
|
+
// reads structure, so the structure has to be there. A `.attw.json` can still
|
|
1410
|
+
// override it (config is applied after argv), and that is handled where it lands,
|
|
1411
|
+
// as an unparseable transcript, rather than by refusing the key by name.
|
|
1412
|
+
const res = spawnSync(ATTW_BIN, ["--pack", ".", ...forwarded, "--format", "json"], {
|
|
1413
|
+
encoding: "utf8",
|
|
1414
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
1415
|
+
env,
|
|
1416
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
1417
|
+
});
|
|
1418
|
+
if (res.error) {
|
|
1419
|
+
const why =
|
|
1420
|
+
/** @type {NodeJS.ErrnoException} */ (res.error).code === "ENOBUFS"
|
|
1421
|
+
? `attw's JSON output exceeded this gate's ${MAX_OUTPUT_BYTES}-byte read buffer, so the\n` +
|
|
1422
|
+
` gate could not read what it asked for. This is the GATE's limit, not a problem\n` +
|
|
1423
|
+
` with the package. Raise MAX_OUTPUT_BYTES in this file.`
|
|
1424
|
+
: `could not run ${ATTW_BIN}: ${res.error.message}`;
|
|
1425
|
+
die(why);
|
|
1426
|
+
}
|
|
1427
|
+
const stdout = res.stdout ?? "";
|
|
1428
|
+
process.stderr.write(res.stderr ?? "");
|
|
1429
|
+
if (res.status !== 0) {
|
|
1430
|
+
// attw judged the package itself. Its own red is a red: forward its status rather
|
|
1431
|
+
// than re-deciding it. But `--format json` means the transcript is a document and
|
|
1432
|
+
// not the table a human could read, so summarise the problems first and keep the
|
|
1433
|
+
// raw bytes as the fallback. attw calls process.exit() straight after writing on
|
|
1434
|
+
// this path, so a large document can arrive TRUNCATED and unparseable; that is
|
|
1435
|
+
// exactly when the raw dump is the only thing left.
|
|
1436
|
+
process.stdout.write(`${summarizeProblems(stdout) ?? stdout}\n`);
|
|
1437
|
+
// attw's OWN status, carried back to the caller unchanged. It is not a refusal
|
|
1438
|
+
// of this gate's, so it is not a `die`, and it must not be flattened to 1: the
|
|
1439
|
+
// corpora assert that a wrapped run exits with exactly the status bare attw
|
|
1440
|
+
// gave.
|
|
1441
|
+
throw new AttwGateFailure(res.status ?? 1);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// ---- Net 2: post-check, on the structure and never on the prose -------------
|
|
1445
|
+
// STDOUT ONLY. attw writes its JSON document to stdout and nothing else to it, so
|
|
1446
|
+
// folding stderr in here would let one warning line turn every run into a parse
|
|
1447
|
+
// failure. stderr is forwarded to the human above, unread.
|
|
1448
|
+
let report;
|
|
1449
|
+
try {
|
|
1450
|
+
report = JSON.parse(stdout);
|
|
1451
|
+
} catch {
|
|
1452
|
+
die(
|
|
1453
|
+
stdout.trim() === ""
|
|
1454
|
+
? `attw exited 0 and printed nothing to stdout, so nothing was checked.\n` +
|
|
1455
|
+
` This gate passes --format json and reads the document attw prints. An empty\n` +
|
|
1456
|
+
` transcript means something suppressed it. A .attw.json setting "quiet" is the\n` +
|
|
1457
|
+
` measured route. Refused rather than read as a pass: this gate is only as good\n` +
|
|
1458
|
+
` as the output it got to see.`
|
|
1459
|
+
: `attw exited 0 but stdout was not the JSON document this gate asked for.\n` +
|
|
1460
|
+
` It passes --format json and parses the result. Anything that reformats or\n` +
|
|
1461
|
+
` intercepts that output lands here. A .attw.json setting "format" beats the\n` +
|
|
1462
|
+
` flag, because readConfig() applies the file after argv. Refused rather than\n` +
|
|
1463
|
+
` read as a pass. What attw actually printed:\n\n` +
|
|
1464
|
+
stdout,
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
const types = report?.analysis?.types;
|
|
1469
|
+
if (types === undefined) {
|
|
1470
|
+
// Not a blinding route: attw's own document shape changed under us. Say so,
|
|
1471
|
+
// rather than reporting it as an untyped package.
|
|
1472
|
+
die(
|
|
1473
|
+
`attw's JSON document has no analysis.types field, so this gate could not tell\n` +
|
|
1474
|
+
` whether the tarball carried types. That is an attw shape change, not a\n` +
|
|
1475
|
+
` package problem. Re-read scripts/attw.mjs's docblock against the installed\n` +
|
|
1476
|
+
` @arethetypeswrong/cli before adjusting anything.`,
|
|
1477
|
+
);
|
|
1478
|
+
}
|
|
1479
|
+
if (types === false) {
|
|
1480
|
+
die(
|
|
1481
|
+
`attw analysed this package as UNTYPED and exited 0.\n` +
|
|
1482
|
+
` This package ships types, so that means the tarball did not carry them.\n` +
|
|
1483
|
+
` Check the "files" field and .npmignore. Reported as a failure here because\n` +
|
|
1484
|
+
` attw's own exit code cannot: getExitCode() returns 0 whenever the analysis\n` +
|
|
1485
|
+
` found no types at all, before it ever looks at the problem list.`,
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
if (types.kind !== "included") {
|
|
1489
|
+
die(
|
|
1490
|
+
`attw analysed this package as typed, but the types did NOT come from its own\n` +
|
|
1491
|
+
` tarball: analysis.types.kind is "${types.kind}"` +
|
|
1492
|
+
(types.packageName ? `, merged in from ${types.packageName}` : ``) +
|
|
1493
|
+
`.\n` +
|
|
1494
|
+
` checkPackage() sets its verdict from pkg.typesPackage alone, so a tarball with\n` +
|
|
1495
|
+
` no declaration file in it at all analyses as typed once a DefinitelyTyped\n` +
|
|
1496
|
+
` package is merged in. A committed .attw.json naming a .tgz is the only way to\n` +
|
|
1497
|
+
` reach this on --pack. This gate exists to prove the types it analysed came out\n` +
|
|
1498
|
+
` of THIS tarball rather than somewhere else, so only "included" passes. (That is\n` +
|
|
1499
|
+
` a claim about ORIGIN, not about the declared declarations being present: see\n` +
|
|
1500
|
+
` "WHAT THIS NET DOES NOT CLAIM" in the docblock.)`,
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// ---- Net 3: the DECLARED paths must be in the TARBALL -----------------------
|
|
1505
|
+
// Net 1 asked this of the WORKING TREE. This asks it of the tarball npm would
|
|
1506
|
+
// publish, which is the one place `files`/`.npmignore` can drop a declared path
|
|
1507
|
+
// while leaving it on disk for net 1 to find. It reads npm's own pack listing, so
|
|
1508
|
+
// nothing attw does, and nothing a committed `.attw.json` configures, changes the
|
|
1509
|
+
// answer. See "FOUR NETS" in the docblock for why that independence is the point.
|
|
1510
|
+
const packed = packedFiles(pkg, env);
|
|
1511
|
+
if (packed.error) {
|
|
1512
|
+
die(
|
|
1513
|
+
`this gate could not read which files a tarball of this package would carry, so\n` +
|
|
1514
|
+
` it could not check that the paths package.json declares are among them.\n` +
|
|
1515
|
+
` Refused rather than passed: an answer this net could not read is not a green\n` +
|
|
1516
|
+
` one. ${packed.error}`,
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
const notPacked = declared.filter((rel) => !packed.files.has(rel.replace(/^\.\//, "")));
|
|
1520
|
+
if (notPacked.length > 0) {
|
|
1521
|
+
die(
|
|
1522
|
+
`package.json declares paths the published tarball would NOT carry:\n` +
|
|
1523
|
+
notPacked.map((rel) => ` ${rel}\n`).join("") +
|
|
1524
|
+
`\n Net 1 found each of these on disk, so this is not an unfinished build. The\n` +
|
|
1525
|
+
` "files" field or .npmignore left them out, and an installer would get a\n` +
|
|
1526
|
+
` package whose own manifest points at paths that are not in it.\n` +
|
|
1527
|
+
` attw exited 0 over this, which is not by itself evidence of a blinding route:\n` +
|
|
1528
|
+
` it never reads "bin" at all, and a lost DECLARATION normally reds it on its own.\n` +
|
|
1529
|
+
` Before this net that exit code was the only thing catching a lost declaration,\n` +
|
|
1530
|
+
` and a committed .attw.json relaxes exactly it: "ignoreRules",\n` +
|
|
1531
|
+
` "ignoreResolutions" and an empty "entrypoints" were each measured to. That is\n` +
|
|
1532
|
+
` why this net reads npm's pack listing rather than attw's verdict.\n`,
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
// ---- END Net 3 --------------------------------------------------------------
|
|
1536
|
+
// THE CLOSING MARKER IS EXPLICIT, and it was not always. `attw-gate.test.ts` used to
|
|
1537
|
+
// slice net 3 out by cutting to the pass line's own comment, which meant anything
|
|
1538
|
+
// added between the two silently left the counterfactual. Net 4 was added there and
|
|
1539
|
+
// took `net4`'s declaration with it, and the three net 3 cases died on a
|
|
1540
|
+
// ReferenceError that read as a plain exit 1. A named marker cannot do that.
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* What net 4 concluded, as the line the pass message prints for it, in the three
|
|
1544
|
+
* states that message has to tell apart: EMPTY means the net is not in this build
|
|
1545
|
+
* of the gate at all (the suite's counterfactual deletes it, and a deleted net
|
|
1546
|
+
* must print NOTHING rather than a sentence about a check that did not happen);
|
|
1547
|
+
* the skip sentence means there was no `publishConfig` object for it to grade; a
|
|
1548
|
+
* count means it graded that many paths.
|
|
1549
|
+
*
|
|
1550
|
+
* It is declared OUTSIDE the marked block below so the counterfactual copy still
|
|
1551
|
+
* parses. It holds the SENTENCE rather than a verdict object because the pass line
|
|
1552
|
+
* then needs no three-way comparison, and the object form's `net4 === null` test
|
|
1553
|
+
* is what CodeQL's `js/comparison-between-incompatible-types` fires on: a warning
|
|
1554
|
+
* carried twice over on the two copies this file replaces.
|
|
1555
|
+
*
|
|
1556
|
+
* @type {string}
|
|
1557
|
+
*/
|
|
1558
|
+
let net4Line = "";
|
|
1559
|
+
|
|
1560
|
+
// ---- Net 4: the manifest PNPM would publish ---------------------------------
|
|
1561
|
+
// COUNTERFACTUAL MARKER. `attw-gate.test.ts` rebuilds the pre-net-4 gate by deleting
|
|
1562
|
+
// from here to the closing marker, so the RED-BEFORE half of that suite is derived
|
|
1563
|
+
// from this file rather than pasted beside it. Keep both markers; the suite reds if
|
|
1564
|
+
// either stops matching.
|
|
1565
|
+
//
|
|
1566
|
+
// Nets 1 and 3 both graded the manifest ON DISK. pnpm publishes a DIFFERENT one: it
|
|
1567
|
+
// applies `publishConfig` as publish-time overrides and rewrites the manifest inside
|
|
1568
|
+
// the tarball, and `npm pack` does not. This net reads the manifest AND the entry
|
|
1569
|
+
// list out of a tarball pnpm actually wrote, so it grades pnpm's own bytes rather
|
|
1570
|
+
// than a model of them. See net 4 in the docblock for what it does not cover.
|
|
1571
|
+
if (pkg.publishConfig !== null && typeof pkg.publishConfig === "object") {
|
|
1572
|
+
const published = pnpmPublished(env);
|
|
1573
|
+
if (published.error) {
|
|
1574
|
+
die(
|
|
1575
|
+
`this gate could not read the manifest pnpm would publish, so it could not check\n` +
|
|
1576
|
+
` the paths that manifest declares. package.json sets publishConfig, so the\n` +
|
|
1577
|
+
` manifest that would be published is not the one nets 1 and 3 graded. Refused\n` +
|
|
1578
|
+
` rather than passed: an answer this net could not read is not a green one.\n` +
|
|
1579
|
+
` ${published.error}`,
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
const publishedDeclared = declaredArtifacts(published.manifest);
|
|
1583
|
+
const missing = publishedDeclared.filter(
|
|
1584
|
+
(rel) => !published.files.has(rel.replace(/^\.\//, "")),
|
|
1585
|
+
);
|
|
1586
|
+
if (missing.length > 0) {
|
|
1587
|
+
// THIS MESSAGE SAYS ONLY WHAT WAS READ, AND IT COST TWO ROUNDS TO GET THERE.
|
|
1588
|
+
// It used to end by naming a cause: "pnpm applies publishConfig as overrides, so
|
|
1589
|
+
// this is not the manifest on disk - fix the override." A second shape was then
|
|
1590
|
+
// added for `publishConfig.directory` claiming pnpm ships that subtree's manifest
|
|
1591
|
+
// VERBATIM. BOTH were wrong somewhere, because the rule is two-level: the root's
|
|
1592
|
+
// other overrides are dropped in directory mode, AND the subtree's own
|
|
1593
|
+
// publishConfig is then applied. Whichever level caused a red, this net did not
|
|
1594
|
+
// measure which, so it names neither and points at the document instead. See net
|
|
1595
|
+
// 4 in the docblock for both measurements.
|
|
1596
|
+
die(
|
|
1597
|
+
`the manifest PNPM WOULD PUBLISH declares paths its tarball would NOT carry:\n` +
|
|
1598
|
+
missing.map((rel) => ` ${rel}\n`).join("") +
|
|
1599
|
+
`\n This is not net 3 repeating itself: package.json sets publishConfig, so the\n` +
|
|
1600
|
+
` manifest above is the one pnpm would publish, and net 3 graded the one at\n` +
|
|
1601
|
+
` ./package.json. Both the manifest and the file list were read out of a tarball\n` +
|
|
1602
|
+
` \`pnpm pack\` just wrote, so nothing here is predicted.\n` +
|
|
1603
|
+
` THIS ORG PUBLISHES WITH pnpm, so that is the document that would ship: an\n` +
|
|
1604
|
+
` installer would get a package whose own manifest points at paths that are not\n` +
|
|
1605
|
+
` in it.\n`,
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
// NET 4's HALF OF THE PASS LINE IS BOUNDED THE WAY NET 3's IS, AND IT NAMES THE
|
|
1609
|
+
// DOCUMENT IT READ, because "the manifest pnpm would publish" is a different
|
|
1610
|
+
// claim from net 3's and a reader must not take one for the other.
|
|
1611
|
+
net4Line =
|
|
1612
|
+
publishedDeclared.length === 0
|
|
1613
|
+
? ` the manifest pnpm would publish declares no relative artifact paths, so net 4\n` +
|
|
1614
|
+
` had none to check.\n`
|
|
1615
|
+
: ` all ${publishedDeclared.length} relative artifact path(s) the manifest PNPM WOULD PUBLISH declares\n` +
|
|
1616
|
+
` are in the tarball pnpm would write (net 4). Manifest and file list were both\n` +
|
|
1617
|
+
` read out of a tarball \`pnpm pack\` wrote, so publishConfig overrides are\n` +
|
|
1618
|
+
` applied; same exclusions as net 3, and it is presence, not resolution.\n`;
|
|
1619
|
+
} else {
|
|
1620
|
+
// "no publishConfig OBJECT", not "no publishConfig", and the difference is a
|
|
1621
|
+
// measured one rather than pedantry: a STRING `publishConfig` takes this branch
|
|
1622
|
+
// too, and pnpm ignores it (measured: the packed manifest is unchanged), so there
|
|
1623
|
+
// really is no override to grade. The earlier wording said "sets no
|
|
1624
|
+
// publishConfig", which that manifest contradicts. The sentence says only that
|
|
1625
|
+
// there was no override to grade: it must NOT say pnpm would publish the same
|
|
1626
|
+
// declarations, which is a claim this gate did not make on this run.
|
|
1627
|
+
net4Line =
|
|
1628
|
+
` package.json declares no publishConfig OBJECT, so there was no publish-time\n` +
|
|
1629
|
+
` override for net 4 to grade and it did not run pnpm.\n`;
|
|
1630
|
+
}
|
|
1631
|
+
// ---- END Net 4 --------------------------------------------------------------
|
|
1632
|
+
|
|
1633
|
+
// WHAT THIS LINE MAY AND MAY NOT SAY. `kind === "included"` is `containsTypes()`,
|
|
1634
|
+
// which is "some file in the tarball has a TypeScript extension" and NOT "the
|
|
1635
|
+
// declarations `package.json` declared are the ones present". So the pass claims
|
|
1636
|
+
// the former only. It also must not say "no problems": `getExitCode` filters
|
|
1637
|
+
// `analysis.problems` through `ignoreRules`/`ignoreResolutions` (and `--profile`
|
|
1638
|
+
// sets the latter), so attw can exit 0 with a non-empty problem list, and an
|
|
1639
|
+
// earlier draft of this line asserted "no problems" over exactly that case. The
|
|
1640
|
+
// document carries the UNFILTERED list, so the suppressed ones are printed instead
|
|
1641
|
+
// of being swallowed by the exit code.
|
|
1642
|
+
const kinds = Object.keys(report.problems ?? {});
|
|
1643
|
+
process.stdout.write(
|
|
1644
|
+
`✓ attw gate: ${report.analysis.packageName}@${report.analysis.packageVersion}\n` +
|
|
1645
|
+
` a TypeScript-extension file is present in this tarball, and no @types package\n` +
|
|
1646
|
+
` was merged in (kind=included).\n` +
|
|
1647
|
+
// Net 3's half of the pass line is bounded the same way: it says the declared
|
|
1648
|
+
// paths are IN the tarball, never that they resolve. Resolution is attw's
|
|
1649
|
+
// problem list, and a config can still relax the exit code that gates it.
|
|
1650
|
+
//
|
|
1651
|
+
// THERE IS NO ZERO-DECLARED BRANCH HERE ANY MORE, AND ITS DELETION IS THE
|
|
1652
|
+
// POINT OF THE NET 1 REFUSAL ABOVE. This line used to read "package.json
|
|
1653
|
+
// declares no relative artifact paths, so net 3 had none to check" and exit 0,
|
|
1654
|
+
// which is a gate reporting a pass from a check it never made. That state is
|
|
1655
|
+
// now refused by net 1, so this branch became unreachable and is gone rather
|
|
1656
|
+
// than left as dead prose a later reader would take for a live claim.
|
|
1657
|
+
` all ${declared.length} relative artifact path(s) package.json declares are in the\n` +
|
|
1658
|
+
` tarball npm would publish (net 3). That set excludes wildcard subpaths, absolute\n` +
|
|
1659
|
+
` paths, browser-map keys, package.json itself, and leaves of exports, imports and\n` +
|
|
1660
|
+
` browser maps that do not begin with a dot; and it is presence, not resolution.\n` +
|
|
1661
|
+
// THE DISCLOSURE SITS OUTSIDE EVERY BRANCH ABOVE, SO IT PRINTS ON EVERY RUN, and
|
|
1662
|
+
// that is not cosmetic. `directories` is unread in BOTH manifests nets 3 and 4
|
|
1663
|
+
// grade, so the sentence has work to do on a run where every other branch is the
|
|
1664
|
+
// happy one. It sat inside an else-branch for one commit while the docblock
|
|
1665
|
+
// claimed it printed every run.
|
|
1666
|
+
// NET 4's HALF IS THE SENTENCE THE NET ITSELF BUILT, above, and it is EMPTY when
|
|
1667
|
+
// the net is not in this build of the gate, so a deleted net prints nothing
|
|
1668
|
+
// rather than a sentence about a check that did not happen.
|
|
1669
|
+
net4Line +
|
|
1670
|
+
` The field set does NOT cover every field that can name a file. Known-unread: ` +
|
|
1671
|
+
`${KNOWN_UNREAD_FIELDS.join(", ")}.\n` +
|
|
1672
|
+
(kinds.length === 0
|
|
1673
|
+
? ` attw exited 0 and reported no problems.\n`
|
|
1674
|
+
: ` attw exited 0, but it REPORTED ${kinds.length} problem kind(s) that its exit code\n` +
|
|
1675
|
+
` did not gate (--profile / ignoreRules / ignoreResolutions suppress the status,\n` +
|
|
1676
|
+
` not the finding): ${kinds.join(", ")}.\n`),
|
|
1677
|
+
);
|
|
1678
|
+
return 0;
|
|
1679
|
+
}
|