@cosyte/script-utils 0.0.1 → 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/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
+ }