@kitschpatrol/shared-config 8.12.1 → 8.13.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.
@@ -1,9 +1,43 @@
1
- # Consumer verification recipes
1
+ # Verification recipes
2
2
 
3
3
  Use these recipes during Setup and step 5. Save commands, versions, raw logs,
4
- and results under `node_modules/.cache/ksc-update/`. Run pnpm commands as separate
5
- bare invocations; save their captured output in a later operation so sandbox
6
- command exclusions still match.
4
+ and results in the run directory, `${RUNNER_TEMP:-${TMPDIR:-/tmp}}/ksc-update/`,
5
+ under `baseline/` and `updated/`. Capture output without losing exit codes and
6
+ use the runner's normal permissions.
7
+
8
+ ## Baselines
9
+
10
+ Keep these states distinct:
11
+
12
+ - **Original checkout:** the starting manifests, lockfile, and policy settings
13
+ copied before any Setup edit. Identify the original locked tree without
14
+ claiming it passed checks that only ran later.
15
+ - **Setup baseline:** every install attempt and build, test, lint, inventory,
16
+ and advisory output under `baseline/`, with the actual pnpm version, policy
17
+ changes, commands, environment, and lockfile diff. Label results after a
18
+ lockfile repair as repaired. A failure here predates the dependency updates
19
+ but may have been introduced by Setup; call it pre-existing only when evidence
20
+ shows it.
21
+ - **Updated result:** compared with the Setup baseline, with every change
22
+ explained.
23
+ - **Published release:** a fresh install resolving the currently allowed
24
+ versions is not the historical dependency tree; that needs a known lockfile or
25
+ preserved installation. A registry tarball alone does not recover it, so
26
+ report missing historical evidence rather than relabeling a fresh install.
27
+
28
+ For build-producing repositories, save the baseline artifacts such as `dist/`,
29
+ disable nondeterministic steps such as encryption or timestamps through
30
+ repository switches, and record the matching commands and environment for later
31
+ comparison.
32
+
33
+ For each published package, record declared `engines.node`, the effective
34
+ consumer minimum and full supported range, and separate development
35
+ requirements. Inspect published dependency trees and artifacts, not just the
36
+ root or the current CI runtime. When support is undeclared, derive the existing
37
+ requirement from published code and dependencies rather than assuming the
38
+ developer runtime is the consumer floor. Capture the production inventory,
39
+ advisory report, isolated consumer installs, and representative fixture results
40
+ below before updates, and repeat them for the updated result.
7
41
 
8
42
  ## Production-tree engines
9
43
 
@@ -13,156 +47,243 @@ For each published package, run in its directory before and after updates:
13
47
  pnpm list --prod --depth Infinity --json
14
48
  ```
15
49
 
16
- Save stdout as `production-tree.json` in the corresponding baseline or updated
17
- results folder. If stdout contains lines beginning with `[WARN]`, preserve those
18
- warnings separately and remove only those lines before parsing. Require a
19
- successful command and valid JSON; do not extract an arbitrary JSON-looking
20
- substring from failed output. In a monorepo, include every published package,
21
- not only a private root with no production dependencies.
50
+ Save stdout as `production-tree.json` in the matching results folder. If stdout
51
+ contains lines beginning with `[WARN]`, keep those warnings separately and strip
52
+ only those lines before parsing. Require a successful command and valid JSON;
53
+ never extract a JSON-looking substring from failed output. In a monorepo include
54
+ every published package, not only a private root.
22
55
 
23
- The list supplies installed paths; read each installed `package.json` for its
24
- actual engine declaration. Save this helper in the run's cache as
25
- `production-engines.mjs` and run it with the saved JSON path as its argument:
56
+ The list supplies installed paths, and each installed `package.json` holds the
57
+ actual engine declaration. The skill's `scripts/production-engines.mjs` walks
58
+ the tree and prints one sorted row of name, version, and declared `engines.node`
59
+ per installed package:
26
60
 
27
- ```js
28
- import { readFileSync } from 'node:fs'
29
- import { join } from 'node:path'
61
+ ```bash
62
+ node "<skill-directory>/scripts/production-engines.mjs" production-tree.json > production-engines.txt
63
+ ```
30
64
 
31
- const roots = JSON.parse(readFileSync(process.argv[2], 'utf8'))
32
- if (!Array.isArray(roots)) {
33
- throw new TypeError('Expected pnpm list JSON array')
34
- }
65
+ Save the inventory and diff it after resolution and deduplication. A row with
66
+ `installed: false` is a dependency this host did not install, usually a
67
+ platform-specific optional dependency; review its engines from registry
68
+ metadata, because one host's installed tree cannot prove it. An omitted `node`
69
+ field means undeclared support, not compatibility. Check full semver ranges
70
+ against every supported consumer Node.js line, and check shipped or bundled code
71
+ separately. This inventory does not replace the newest-in-range registry review.
35
72
 
36
- const manifests = new Map()
37
- const rows = new Set()
73
+ ## Security advisories
38
74
 
39
- function visit(pkg) {
40
- if (!pkg.path) {
41
- throw new Error(`Missing installed path: ${pkg.name ?? pkg.version}`)
42
- }
75
+ Before and after updates, in each workspace root:
43
76
 
44
- if (!manifests.has(pkg.path)) {
45
- manifests.set(pkg.path, JSON.parse(readFileSync(join(pkg.path, 'package.json'), 'utf8')))
46
- }
77
+ ```bash
78
+ pnpm audit --prod --json > audit.json
79
+ echo $? > audit.exit
80
+ ```
47
81
 
48
- const manifest = manifests.get(pkg.path)
49
- rows.add(
50
- JSON.stringify({
51
- name: manifest.name,
52
- node: manifest.engines?.node,
53
- version: manifest.version,
54
- }),
55
- )
56
- for (const group of ['dependencies', 'optionalDependencies']) {
57
- const children = Object.values(pkg[group] ?? {})
58
- for (const child of children) {
59
- visit(child)
60
- }
61
- }
62
- }
82
+ Diff the two reports and list advisories that are new, resolved, or still open,
83
+ with their advisory identifiers and dependency paths. Never run `pnpm audit
84
+ --fix`: it writes overrides and release-age exclusions outside the override and
85
+ exemption policies. Resolve an open advisory through an accepted update or a
86
+ reviewed override under the configuration policy, and otherwise report it as a
87
+ gap.
63
88
 
64
- for (const root of roots) {
65
- visit(root)
66
- }
89
+ ## Packed builds
67
90
 
68
- process.stdout.write(`${[...rows].toSorted().join('\n')}\n`)
69
- ```
91
+ Use this repository's tests and CLI fixtures for baseline comparisons.
70
92
 
71
- Save the resulting inventory and diff it after resolution and deduplication.
72
- Investigate missing installed paths instead of omitting them. An omitted `node`
73
- field means undeclared support, not proof of compatibility. Check full semver ranges
74
- against every supported consumer Node.js line; check platform-specific optional
75
- dependencies and shipped/bundled code separately because one host's installed
76
- tree cannot prove their compatibility. This inventory does not replace the
77
- newest-in-range registry review.
78
-
79
- ## Known consumers and packed builds
80
-
81
- Read confirmed consumers from the repository's existing `CLAUDE.md` or
82
- `AGENTS.md`. A useful `Known consumers` entry contains the consumer's name and
83
- path, which package/imports/CLI it uses, its test command, and representative
84
- CLI commands and fixture inputs. Preserve existing instructions; add only facts
85
- confirmed by the user or repository evidence.
86
-
87
- 1. Create a temporary directory under `${TMPDIR:-/tmp}`. Clone each local
88
- consumer into it with `git clone --no-hardlinks <consumer-path> <temp-copy>`.
89
- Record the commit. A clone excludes uncommitted changes; if those matter,
90
- explicitly copy the needed working-tree files and record that baseline.
91
- Do not modify the consumer's original checkout.
92
- 2. Install and test the copied consumer with its starting lockfile. Save
93
- its tests' results and representative CLI stdout, stderr, and exit codes.
94
- Preserve this copy for baseline comparison.
95
- 3. Build the updated package with its development Node.js version and run
96
- `pnpm pack` as a bare command in that package. Inspect the archive's contents
97
- and manifest, including exported entrypoints, engines, and dependency ranges.
98
- 4. In a second consumer copy, replace the consumed dependency with a `file:`
99
- specifier pointing to that tarball, using a direct manifest edit. Install
100
- and rerun the same tests and CLI fixtures. If a workspace-local dependency
101
- or alias is involved, replace the actual consuming edge and verify the
102
- resolved path/version; do not assume a root-only edit reaches it. A linked
103
- built package is useful for iteration, but the tarball verifies what ships
104
- and avoids a link silently borrowing the producer's `node_modules`.
105
- 5. Diff CLI output and fixture results. Inspect semantic differences, including
106
- behavior JSON may hide, such as object prototypes, missing versus undefined
107
- values, ordering, and error handling. Explain each change against accepted
108
- migrations and consumer expectations.
93
+ 1. Build the package with its development Node.js version and run `pnpm pack` in
94
+ that package. Inspect the archive's contents and manifest, including exported
95
+ entrypoints, engines, and dependency ranges.
96
+ 2. Create an isolated fixture under the run directory with a `file:` dependency
97
+ on that tarball. Install it and run representative API and CLI checks with
98
+ the repository's fixtures. Verify the resolved path and version; the tarball
99
+ checks what ships without borrowing the producer's `node_modules`.
100
+ 3. Diff CLI output and fixture results against the Setup baseline. Inspect
101
+ semantic differences that JSON can hide, such as object prototypes, missing
102
+ versus undefined values, ordering, and error handling, and explain each
103
+ change against accepted migrations and consumer expectations.
109
104
 
110
105
  Also test a fresh isolated install of the published release with the newest
111
- dependencies its ranges allow. Keep this distinct from the published release
112
- running on its old dependencies: reproduce the latter from a known historical
113
- lockfile or preserved installation where possible. A registry tarball alone
114
- does not recover the old transitive tree. If it cannot be reconstructed, report
115
- that gap instead of labeling a fresh install the historical baseline.
106
+ dependencies its ranges allow, kept distinct from the historical state as the
107
+ Baselines section describes.
108
+
109
+ `scripts/packed-fixture.mjs <state>` performs steps 1 and 2 up to the install:
110
+ it packs every published workspace package, records each tarball's manifest and
111
+ file list in `packed-manifests.json`, and installs a fixture under `<run
112
+ directory>/<state>/fixture`. With `--published name@version` it installs the
113
+ registry release instead, for the fresh-install check. Run the representative
114
+ checks inside the printed fixture directory yourself. Pitfalls it handles, which
115
+ a hand-built fixture must handle too:
116
+
117
+ - Packing rewrites `workspace:` ranges to registry versions, so a fixture that
118
+ depends only on one tarball silently installs its siblings from the registry.
119
+ Override every packed sibling to its tarball.
120
+ - Some consumer setup must exist before installing, for example an init command
121
+ that writes `publicHoistPattern` so bins and plugins resolve. Follow the
122
+ documented consumer installation order; `--before-install` runs such a
123
+ command, with `FIXTURE_TARBALLS` naming a JSON map of package to tarball for
124
+ `dlx` against a packed package.
125
+ - Exported JSON or JSONC files, such as tsconfig presets, cannot be imported as
126
+ ES modules; check them with the consuming tool, for example `tsc
127
+ --showConfig`.
128
+ - Fixers mutate files in the fixture, including untracked probe scripts. Restore
129
+ the tree (`git checkout -- .` after committing the setup, and regenerate
130
+ untracked probes) before every run that should be comparable.
131
+ - pnpm may append `minimumReleaseAgeExclude` entries to the fixture's workspace
132
+ file when it falls back to young versions; that is expected in a fixture and
133
+ shows which young versions consumers resolve.
116
134
 
117
135
  ## Exact Node.js floor
118
136
 
119
- Use the exact minimum supported release, not just the same major or the active
120
- developer runtime. For disjoint supported Node.js lines, check each relevant
121
- minimum. For an approved engine bump, compare the old release at its old floor
122
- with the updated package at its new floor and on an overlapping supported
123
- runtime where available.
124
-
125
- If the version manager has no suitable installed binary, download the exact
126
- official archive from `https://nodejs.org/dist/v<version>/` for the host's OS and
127
- architecture into a temporary folder, alongside `SHASUMS256.txt`. Verify the
128
- archive's SHA-256 against that file, then extract it there. Use the resulting
129
- Node binary by absolute path; do not install globally or change the user's
130
- default runtime. Record `<absolute-node-path> --version` with the results.
131
-
132
- Install/build with the development runtime if the tooling requires it, then
133
- invoke the packed package's built JavaScript entrypoint and runtime fixtures
134
- with the exact-floor binary. For subprocesses that use `env node`, put that
135
- binary's directory first in the test process's `PATH` only. Confirm which
136
- binary the child process uses. If the test runner itself requires newer Node,
137
- run a small fixture harness or the built CLI directly at the floor; running
138
- only the newer test runner is not floor validation. Report when installation
139
- or execution on the exact runtime could not be verified.
137
+ Validate at the exact minimum supported release, not the same major or the
138
+ active developer runtime. For disjoint supported lines check each relevant
139
+ minimum. For an approved engine change compare the old release at its old floor
140
+ with the updated package at its new floor, and on an overlapping supported
141
+ runtime where one exists.
142
+
143
+ Obtain the exact runtime without changing the user's default. In the isolated
144
+ fixture declare the floor version:
145
+
146
+ ```json
147
+ {
148
+ "devEngines": {
149
+ "runtime": { "name": "node", "version": "24.16.0", "onFail": "download" }
150
+ }
151
+ }
152
+ ```
153
+
154
+ Run `pnpm install` there and confirm `pnpm exec node --version`; pnpm downloads
155
+ the release, verifies it, and records the checksum in the fixture's lockfile. If
156
+ that is unavailable, download the official archive for the host from
157
+ `https://nodejs.org/dist/v<version>/` together with `SHASUMS256.txt`, verify the
158
+ SHA-256, extract it in the run directory, and use the binary by absolute path.
159
+ Record the binary's path and `--version` output with the results either way.
160
+
161
+ Install and build with the development runtime when the tooling requires it,
162
+ then invoke the packed package's built entrypoint and runtime fixtures with the
163
+ floor binary. For subprocesses that use `env node`, put the floor binary's
164
+ directory first in the test process's `PATH` only, and confirm which binary the
165
+ child used. If the test runner itself needs newer Node.js, run a small fixture
166
+ harness or the built CLI directly at the floor; running only the newer runner is
167
+ not floor validation. Report when installation or execution at the exact runtime
168
+ could not be verified.
169
+
170
+ `scripts/run-at-node.mjs <version> --cwd <fixture> -- <command>` does all of
171
+ this: it downloads and verifies the release through `devEngines.runtime` in the
172
+ run directory, puts a logging `node` shim first in `PATH`, runs the command, and
173
+ reports how many `node` processes started and with which versions. A count that
174
+ is implausibly low for the command means children bypassed `PATH`; treat that as
175
+ unverified rather than as a pass.
140
176
 
141
177
  Record stdout, stderr, exit codes, and relevant API results for identical
142
178
  fixtures across runtimes and dependency baselines. Normalize only known
143
- nondeterministic fields using the same documented transformation for all runs.
144
- Save SHA-256 digests of normalized fixture output for quick comparison, retain
145
- the actual output for reviewing differences, and add targeted assertions for
146
- behavior that serialization omits.
179
+ nondeterministic fields with the same documented transformation for every run.
180
+ Save SHA-256 digests of normalized output for quick comparison, keep the actual
181
+ output for reviewing differences, and add targeted assertions for behavior that
182
+ serialization omits.
183
+
184
+ `scripts/compare-outputs.mjs <dir-a> <dir-b>` compares two result directories
185
+ with the standard normalization: state names only as path segments (`--token-a`,
186
+ `--token-b`), pnpm store directory names, git blob ids, and content-hashed
187
+ filenames. Never replace a state name as a bare word: `baseline` also occurs in
188
+ CSS properties and rule names. Use `--sort-lines` only for output whose parallel
189
+ tools interleave differently on each run, such as `ksc lint`.
190
+
191
+ ## Updated validation
192
+
193
+ After resolution and deduplication, run:
194
+
195
+ ```bash
196
+ pnpm build
197
+ pnpm fix
198
+ pnpm build # if fix changed source files or build configuration
199
+ pnpm test
200
+ pnpm lint
201
+ ```
202
+
203
+ Skip a script the repository does not define and say so. Compare every check
204
+ with the recorded baseline, and use these procedures where applicable; later
205
+ cleanup steps reuse them for affected checks:
206
+
207
+ - **Published consumers:** diff the production-engine inventories; inspect
208
+ packed manifests and artifacts; test isolated installations and representative
209
+ fixtures at the exact supported floors above, separately from
210
+ development-runtime checks. Review fixer edits to `engines.node` and
211
+ `devEngines.runtime`. Passing on newer Node.js does not establish consumer
212
+ compatibility. Validate new contracts and record changed baselines for
213
+ authorized breaks.
214
+ - **Advisories:** diff the audit reports as described above.
215
+ - **Warnings:** trace and report new warnings, deprecations, and peer issues
216
+ with the configuration policy's procedure, applying only its permitted
217
+ suppressions or explicitly authorized exceptions.
218
+ - **Build output:** when a baseline exists, rebuild with its command and
219
+ environment and diff the trees with content-hashed filenames normalized
220
+ (`name.[hash].js`). Explain every remaining difference through an upstream
221
+ changelog, issue, or reviewed migration or configuration change, and
222
+ investigate the rest. Record effective targets before and after: unless
223
+ configured explicitly, tsdown derives its target from `engines.node`. Changed
224
+ syntax can be expected, but syntax lowering does not polyfill runtime APIs.
225
+ - **Fixer output:** review the diff and compare a required rebuild with the
226
+ pre-fix build. Formatting changes must produce identical output; handle
227
+ behavioral or browser-compatibility changes under the configuration policy's
228
+ lint-fix rules and recheck the behavior after correction.
229
+
230
+ On failure, make the smallest change the authorization table allows and rerun
231
+ from the failing step, using the baseline logs to separate new failures, subject
232
+ to the baseline qualifications above. `git stash` does not restore
233
+ `node_modules`. To isolate an update, reset half of the accepted specifiers, run
234
+ `pnpm update -r --no-save`, and repeat the failing check; defer the culprit
235
+ unless an authorized migration resolves it. For a runtime release that published
236
+ ranges already allow, follow the consumer-range decision; resetting a specifier
237
+ or the lockfile may not exclude it.
238
+
239
+ ## Tool behavior notes
240
+
241
+ Known behaviors that look like failures, or hide them:
242
+
243
+ - `ksc fix` prints nothing when everything was fixed; only remaining problems
244
+ produce output. A silent exit code of 0 is success.
245
+ - `ksc lint` and `ksc fix` use tool caches. Before trusting a pass on files a
246
+ fix or migration touched, rerun ESLint with `--no-cache` and `ksc-prettier
247
+ lint --no-cache`.
248
+ - Output from `ksc lint` interleaves its parallel tools differently on each run;
249
+ compare it with sorted lines.
250
+ - mdat expands rules from the `package.json` nearest to where it runs. Never run
251
+ `mdat expand` on a package readme from a monorepo root; it fills the title,
252
+ description, and other rules from the private root manifest. Regenerate
253
+ through the repository's own script.
254
+ - Use `pnpm -r ls --depth -1 --json` to enumerate workspace packages instead of
255
+ globbing `packages/*`, which also matches stray entries such as `.DS_Store`.
256
+ - Recent pnpm lockfiles contain several YAML documents, the first describing the
257
+ package manager's own dependencies; a lockfile parser must read every
258
+ `packages:` section.
147
259
 
148
260
  ## Tool references
149
261
 
150
262
  Check these primary sources or the installed tool's help when behavior differs:
151
263
 
152
- - [GitHub Actions variables](https://docs.github.com/en/actions/reference/workflows-and-actions/variables):
153
- `GITHUB_ACTIONS` and `CI` identify unattended execution in Actions.
154
- - [pnpm update](https://pnpm.io/cli/update): manifest/catalog writes and selectors.
155
- This workflow uses a bare update for its full dependency refresh; do not
156
- assume exclusion syntax works identically in every pnpm implementation.
157
- - [pnpm dedupe](https://pnpm.io/cli/dedupe) and
158
- [pnpm list](https://pnpm.io/cli/list): deduplication checks and tree inventories.
159
- - [pnpm settings](https://pnpm.io/settings) and
160
- [peer rules](https://pnpm.io/settings/peer-dependencies#peerdependencyrulesallowedversions):
161
- current configuration locations and scoped warning allowances. Current pnpm
162
- uses `.npmrc` for registry/auth settings, not all policy settings.
163
- - [Taze](https://github.com/antfu-collective/taze): candidate filters and maturity.
164
- - [Actions Up](https://github.com/azat-io/actions-up#update-style): SHA pinning.
165
- - [tsdown target](https://tsdown.dev/options/target): implicit engine-derived
166
- targets affect emitted syntax and do not provide API polyfills.
167
- - [Node.js release archives](https://nodejs.org/dist/): exact runtime downloads
168
- and checksum manifests.
264
+ - [GitHub Actions
265
+ variables](https://docs.github.com/en/actions/reference/workflows-and-actions/variables):
266
+ `GITHUB_ACTIONS` and `CI` identify unattended execution.
267
+ - [pnpm update](https://pnpm.io/cli/update): `--no-save` and what an update
268
+ writes. The full refresh here uses no package selectors; do not assume
269
+ exclusion syntax is identical in every pnpm version.
270
+ - [pnpm dedupe](https://pnpm.io/cli/dedupe),
271
+ [pnpm list](https://pnpm.io/cli/list), and [pnpm audit](https://pnpm.io/cli/audit).
272
+ - [pnpm settings](https://pnpm.io/settings), including
273
+ [minimumReleaseAge](https://pnpm.io/settings/dependency-resolution#minimumreleaseage),
274
+ [trustPolicy](https://pnpm.io/settings/dependency-resolution#trustpolicy),
275
+ [allowBuilds](https://pnpm.io/settings/build#allowbuilds), and
276
+ [peer rules](https://pnpm.io/settings/peer-dependencies#peerdependencyrulesallowedversions).
277
+ Current pnpm reads only registry and auth settings from `.npmrc`.
278
+ - [devEngines.runtime](https://pnpm.io/package_json#devenginesruntime): exact
279
+ runtime downloads for fixtures.
280
+ - [Taze](https://github.com/antfu-collective/taze): candidate filters, maturity,
281
+ and the pnpm settings it infers.
282
+ - [Actions Up](https://github.com/azat-io/actions-up): SHA pinning, cool-down,
283
+ and `--min-age-exclude`.
284
+ - [Node.js release schedule](https://github.com/nodejs/Release): LTS lines and
285
+ the annual all-LTS cycle from Node 27.
286
+ - [tsdown target](https://tsdown.dev/options/target): engine-derived targets
287
+ affect emitted syntax and do not polyfill APIs.
288
+ - [Node.js release archives](https://nodejs.org/dist/): fallback runtime
289
+ downloads and checksum manifests.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Compares two directories of captured outputs, such as baseline and updated
3
+ * fixture results or build trees, after normalizing only known nondeterministic
4
+ * text, and reports each file as identical, different, or present on one side.
5
+ *
6
+ * Normalization:
7
+ *
8
+ * - `--token-a` and `--token-b` replace state names such as `baseline` and
9
+ * `updated` with `<STATE>`, but only where they appear as a path segment
10
+ * (bounded by `/`, `\`, or `+`). Bare-word replacement would also rewrite
11
+ * content such as the CSS property `alignment-baseline`.
12
+ * - Store directory names that pnpm ends with `_` plus 32 hex characters become
13
+ * `<STORE-DIR>`. pnpm truncates long names before the hash, so the whole
14
+ * segment varies with the state name's length, not just the hash.
15
+ * - Git blob ids in captured diffs (`index abc123..def456`) become `index
16
+ * <BLOBS>`.
17
+ * - Content-hashed filenames (`name.<8+ characters>.js`) become `name.[hash].js`.
18
+ * - `--sort-lines` sorts lines first, for output that parallel tools interleave
19
+ * differently on each run. Never use it where order is meaningful.
20
+ *
21
+ * Usage:
22
+ *
23
+ * ```
24
+ * node compare-outputs.mjs <dir-a> <dir-b> [--token-a <name>] [--token-b <name>] [--sort-lines]
25
+ * ```
26
+ *
27
+ * Prints a short `git diff --no-index` of each differing file and exits 1 when
28
+ * anything differs.
29
+ */
30
+ import { spawnSync } from 'node:child_process'
31
+ import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
32
+ import { join, relative } from 'node:path'
33
+ import process from 'node:process'
34
+ import { runDirectory } from './lib.mjs'
35
+
36
+ /** @type {string[]} */
37
+ const positional = []
38
+ /** @type {Record<string, string>} */
39
+ const options = {}
40
+ let sortLines = false
41
+ const argv = process.argv.slice(2)
42
+ for (let index = 0; index < argv.length; index++) {
43
+ const argument = argv[index] ?? ''
44
+ if (argument === '--sort-lines') {
45
+ sortLines = true
46
+ } else if (argument.startsWith('--')) {
47
+ options[argument.slice(2)] = argv[++index] ?? ''
48
+ } else {
49
+ positional.push(argument)
50
+ }
51
+ }
52
+
53
+ const [directoryA, directoryB] = positional
54
+ if (directoryA === undefined || directoryB === undefined) {
55
+ throw new Error(
56
+ 'Usage: node compare-outputs.mjs <dir-a> <dir-b> [--token-a x] [--token-b y] [--sort-lines]',
57
+ )
58
+ }
59
+
60
+ /**
61
+ * Lists files under a directory recursively, as paths relative to it.
62
+ *
63
+ * @param {string} root - Directory to walk.
64
+ *
65
+ * @returns {string[]} Relative file paths.
66
+ */
67
+ function listFiles(root) {
68
+ return readdirSync(root, { recursive: true, withFileTypes: true })
69
+ .filter((entry) => entry.isFile())
70
+ .map((entry) => relative(root, join(entry.parentPath, entry.name)))
71
+ .toSorted()
72
+ }
73
+
74
+ /**
75
+ * Normalizes one file's text for comparison.
76
+ *
77
+ * @param {string} text - File contents.
78
+ * @param {string | undefined} token - State name to replace on this side.
79
+ *
80
+ * @returns {string} Normalized text.
81
+ */
82
+ function normalize(text, token) {
83
+ let result = text
84
+ if (token !== undefined && token !== '') {
85
+ const escaped = token.replaceAll(/[$\(\)*+.?\[\\\]^\{\|\}]/gv, String.raw`\$&`)
86
+ result = result.replaceAll(
87
+ new RegExp(String.raw`(?<=[\/\\+])${escaped}(?=[\/\\+])`, 'gv'),
88
+ '<STATE>',
89
+ )
90
+ }
91
+
92
+ result = result
93
+ .replaceAll(/[^\s"'\/\\]*_[\da-f]{32}/gv, '<STORE-DIR>')
94
+ .replaceAll(/^index [\da-f]+\.\.[\da-f]+/gmv, 'index <BLOBS>')
95
+ .replaceAll(/\.[\w\-]{8,}\.(c?js|mjs|css)\b/gv, '.[hash].$1')
96
+ return sortLines ? result.split('\n').toSorted().join('\n') : result
97
+ }
98
+
99
+ const filesA = new Set(listFiles(directoryA))
100
+ const filesB = new Set(listFiles(directoryB))
101
+ const scratch = mkdtempSync(join(runDirectory(), 'compare-'))
102
+ let differences = 0
103
+
104
+ for (const file of [...filesA.union(filesB)].toSorted()) {
105
+ if (!filesA.has(file) || !filesB.has(file)) {
106
+ differences++
107
+ process.stdout.write(`only in ${filesA.has(file) ? 'A' : 'B'}: ${file}\n`)
108
+ continue
109
+ }
110
+
111
+ const pathA = join(directoryA, file)
112
+ const pathB = join(directoryB, file)
113
+ if (statSync(pathA).size === 0 && statSync(pathB).size === 0) {
114
+ continue
115
+ }
116
+
117
+ const textA = normalize(readFileSync(pathA, 'utf8'), options['token-a'])
118
+ const textB = normalize(readFileSync(pathB, 'utf8'), options['token-b'])
119
+ if (textA === textB) {
120
+ continue
121
+ }
122
+
123
+ differences++
124
+ const safeName = file.replaceAll(/[\/\\]/gv, '__')
125
+ writeFileSync(join(scratch, `a-${safeName}`), textA)
126
+ writeFileSync(join(scratch, `b-${safeName}`), textB)
127
+ const diff = spawnSync(
128
+ 'git',
129
+ ['diff', '--no-index', '--no-color', '-U1', `a-${safeName}`, `b-${safeName}`],
130
+ { cwd: scratch, encoding: 'utf8' },
131
+ )
132
+ const lines = diff.stdout.split('\n')
133
+ process.stdout.write(`differs: ${file}\n${lines.slice(4, 44).join('\n')}\n`)
134
+ if (lines.length > 44) {
135
+ process.stdout.write(` ... ${lines.length - 44} more diff lines\n`)
136
+ }
137
+ }
138
+
139
+ process.stdout.write(
140
+ `${differences === 0 ? 'no differences' : `${differences} file(s) differ`} (normalized copies in ${scratch})\n`,
141
+ )
142
+ if (differences > 0) {
143
+ process.exitCode = 1
144
+ }