@orkestrel/scaffold 0.0.101 → 0.0.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/agents/skills/orkestrel-dispatch/scripts/brief.js +6 -14
  2. package/dist/agents/skills/orkestrel-dispatch/scripts/cite.js +41 -28
  3. package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +5 -12
  4. package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +25 -13
  5. package/dist/agents/skills/orkestrel-dispatch/scripts/login.js +18 -25
  6. package/dist/agents/skills/orkestrel-dispatch/scripts/result.js +11 -9
  7. package/dist/agents/skills/orkestrel-harden/scripts/discovery.js +233 -232
  8. package/dist/agents/skills/orkestrel-publish/scripts/compare.js +95 -89
  9. package/dist/agents/skills/orkestrel-publish/scripts/pins.js +5 -7
  10. package/dist/agents/skills/orkestrel-publish/scripts/swap.js +1273 -0
  11. package/dist/agents/skills/orkestrel-publish/scripts/wave.js +99 -89
  12. package/dist/agents/skills/orkestrel-publish/scripts/window.js +54 -52
  13. package/dist/agents/skills/orkestrel-scout/scripts/map.js +160 -161
  14. package/dist/bin/main.js +483 -444
  15. package/dist/bin/main.js.map +1 -1
  16. package/dist/host/AGENTS.md +1 -0
  17. package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +10 -10
  18. package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +8 -11
  19. package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +58 -28
  20. package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +5 -10
  21. package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +31 -12
  22. package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +28 -23
  23. package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +12 -10
  24. package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +318 -237
  25. package/dist/host/agents/skills/orkestrel-journey/references/statechart.md +2 -5
  26. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +7 -6
  27. package/dist/host/agents/skills/orkestrel-publish/references/release.md +12 -10
  28. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +12 -5
  29. package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +124 -82
  30. package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +5 -7
  31. package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1655 -0
  32. package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +128 -97
  33. package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +70 -55
  34. package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +215 -155
  35. package/dist/host/claude/rules/architecture.md +2 -2
  36. package/dist/host/claude/rules/tests.md +8 -2
  37. package/dist/host/claude/rules/typescript.md +130 -0
  38. package/dist/host/claude/rules/workspace.md +30 -12
  39. package/dist/host/configs/helpers.ts +206 -133
  40. package/dist/host/configs/policy.ts +110 -105
  41. package/dist/host/guides/scaffold.md +244 -170
  42. package/dist/host/manifest.json +35 -29
  43. package/dist/host/tests/config.test.ts +1220 -633
  44. package/dist/host/tests/policy.test.ts +19 -0
  45. package/dist/host/tests/setupPolicy.ts +1669 -985
  46. package/dist/src/core/index.cjs +1595 -1686
  47. package/dist/src/core/index.cjs.map +1 -1
  48. package/dist/src/core/index.d.cts +202 -18
  49. package/dist/src/core/index.d.ts +202 -18
  50. package/dist/src/core/index.js +1590 -1687
  51. package/dist/src/core/index.js.map +1 -1
  52. package/dist/src/server/index.cjs +448 -392
  53. package/dist/src/server/index.cjs.map +1 -1
  54. package/dist/src/server/index.d.cts +1182 -1052
  55. package/dist/src/server/index.d.ts +1182 -1052
  56. package/dist/src/server/index.js +447 -395
  57. package/dist/src/server/index.js.map +1 -1
  58. package/package.json +2 -2
@@ -64,18 +64,15 @@ interface DisclosureContext {
64
64
 
65
65
  // A journey verb resolves its own target by accessible name, so a second mounted disclosure called
66
66
  // "Advanced" is an ambiguity rather than a second fixture. Each build takes the previous one out.
67
- let mounted: HTMLElement | undefined
68
-
69
67
  function buildDisclosure(): DisclosureContext {
70
- mounted?.remove()
68
+ for (const stale of document.querySelectorAll('[data-disclosure]')) stale.remove()
71
69
  const container = render(
72
- '<details><summary>Advanced</summary><p>Every setting.</p></details><button type="button">Dismiss</button>',
70
+ '<div data-disclosure><details><summary>Advanced</summary><p>Every setting.</p></details><button type="button">Dismiss</button></div>',
73
71
  )
74
72
  const details = requireValue(container.querySelector('details'))
75
73
  requireValue(container.querySelector('button')).addEventListener('click', () => {
76
74
  details.open = false
77
75
  })
78
- mounted = container
79
76
  return { summary: requireValue(container.querySelector('summary')) }
80
77
  }
81
78
 
@@ -20,12 +20,13 @@ description: Run an Orkestrel release from layer order to registry confirmation.
20
20
 
21
21
  ## Scripts
22
22
 
23
- | Script | Does |
24
- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
- | `scripts/compare.ts` | `[--package NAME] [--version V] [--tarball PATH] [--dist dist] [--json]`, run from the package root after its build: fetches the published tarball and lists the material differences from the rebuilt `dist/` (sourcemaps excluded, whitespace-only differences ignored). Exit 0 when nothing differs, 3 when something does, 2 when the tarball cannot be read, 64 on usage (no package name, no `dist/`, or `--tarball` with no path). |
26
- | `scripts/pins.ts` | `--version PRIOR [--range PRIOR_RANGE ...] [--paths src,tests] [--json]`, run from the package root: lists every line under the paths carrying the prior version literal or a prior range literal, once per line, sweeping a one-comparator range such as `^8.3.0` for its bare version in any form. Exit 0 with no hits, 3 with hits, 64 on usage (no literal, or a flag with no value). |
27
- | `scripts/wave.ts` | `--visit [--target DIR] [--offline] [--from STEP] [--to STEP] [--prior VERSION] [--dry-run] [--json] [--out FILE]` runs one repository's visit in the order [wave.md](references/wave.md) § Visit a repository fixes (pin, commit, overwrite, verify, install, pins, format, gates, compare), stops at the first failing step, and prints each step's exit (a failed step names the `code: message` refusal or the `note` a JSON verb writes on stdout, else the last stderr lines), the bump ruling (the rebuilt dist against the published tarball, and the final runtime dependency set against the published manifest), and a row for `.orkestrel/release.md`; `--prior` names the version a pre-ruled bump replaced so the self-pin sweep finds it; `--plan` prints the packages by layer from the catalog table. Exit 0 when every step passed, 1 when one failed, 2 when a reading failed, 64 on usage. |
28
- | `scripts/window.ts` | `--whoami [--wait SECONDS]` reads or polls `npm whoami`; `--login` prints the operator's login command and polls `whoami` until it answers; `--publish DIR... --otp CODE` refuses a batch whose nearest `.git` entry is a file, including ancestor entries, with exit 3 before contacting npm; it names a linked worktree's primary clone or identifies a submodule, then uploads back to back with `npm publish --ignore-scripts --otp`, journals each upload under `tmp/units/`, stops at the first refusal naming where to resume, and confirms each accepted version against the registry; `--confirm NAME@VERSION...` re-reads the registry until it serves the version. Exit 0 on success, 3 when not. |
23
+ | Script | Does |
24
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
+ | `scripts/compare.ts` | `[--package NAME] [--version V] [--tarball PATH] [--dist dist] [--json]`, run from the package root after its build: fetches the published tarball and lists the material differences from the rebuilt `dist/` (sourcemaps excluded, whitespace-only differences ignored). Exit 0 when nothing differs, 3 when something does, 2 when the tarball cannot be read, 64 on usage (no package name, no `dist/`, or `--tarball` with no path). |
26
+ | `scripts/pins.ts` | `--version PRIOR [--range PRIOR_RANGE ...] [--paths src,tests] [--json]`, run from the package root: lists every line under the paths carrying the prior version literal or a prior range literal, once per line, sweeping a one-comparator range such as `^8.3.0` for its bare version in any form. Exit 0 with no hits, 3 with hits, 64 on usage (no literal, or a flag with no value). |
27
+ | `scripts/swap.ts` | One mode per run, from any directory: `--pin --consumer DIR --source DIR [--source DIR ...] [--override NAME ...] [--no-build] [--dry-run] [--json]` refuses a source whose packed manifest names a local dependency spec in any dependency field (a `file:` or `link:` prefix, a path start of `.`, `~/`, `/`, `\`, or a drive letter, or a protocol-free archive name ending in `.tgz`, `.tar.gz`, or `.tar` that is not a `user/repo` shorthand), builds and packs each source into `<consumer>/tmp/tarballs/` under a name carrying its SHA-256, records each replaced range in the ledger `pins.json` there, the record of which build the consumer runs, writes each tarball's absolute `file:` spec and each `--override` (repeatable, like `--source`) where oxfmt sorts it, installs, and proves that every pinned source, an override-only one included, is installed with the source's bytes; `--restore --consumer DIR [--package NAME ...] [--json]` puts back each recorded range and override, keeps an `overrides` block that existed before the pin, installs, proves the restored copy, and lists the re-pin arguments, and refuses with 64, before it writes anything, a ledger whose package or override keys are not package names or whose removal would leave `node_modules`; `--status --consumer DIR [--json]` writes nothing and reports drift between the ledger, the manifest, the tarballs, the installed bytes, and the source commits, a manifest spec holding its tarball only when it names the ledger archive's location. `--pin` and `--restore` write a receipt under `tmp/units/`. Exit 0 on success, 1 when a command or a proof failed, 3 when `--status` reports drift, 64 on usage. |
28
+ | `scripts/wave.ts` | `--visit [--target DIR] [--offline] [--from STEP] [--to STEP] [--prior VERSION] [--dry-run] [--json] [--out FILE]` runs one repository's visit in the order [wave.md](references/wave.md) § Visit a repository fixes (pin, commit, overwrite, verify, install, pins, format, gates, compare), stops at the first failing step, and prints each step's exit (a failed step names the `code: message` refusal or the `note` a JSON verb writes on stdout, else the last stderr lines), the bump ruling (the rebuilt dist against the published tarball, and the final runtime dependency set against the published manifest), and a row for `.orkestrel/release.md`; `--prior` names the version a pre-ruled bump replaced so the self-pin sweep finds it; `--plan` prints the packages by layer from the catalog table. Exit 0 when every step passed, 1 when one failed, 2 when a reading failed, 64 on usage. |
29
+ | `scripts/window.ts` | `--whoami [--wait SECONDS]` reads or polls `npm whoami`; `--login` prints the operator's login command and polls `whoami` until it answers; `--publish DIR... --otp CODE` refuses a batch whose nearest `.git` entry is a file, including ancestor entries, with exit 3 before contacting npm; it names a linked worktree's primary clone or identifies a submodule, then uploads back to back with `npm publish --ignore-scripts --otp`, journals each upload under `tmp/units/`, stops at the first refusal naming where to resume, and confirms each accepted version against the registry; `--confirm NAME@VERSION...` re-reads the registry until it serves the version. Exit 0 on success, 3 when not. |
29
30
 
30
31
  The user's current instruction wins. `references/release.md` owns the credential
31
32
  and authorization law; nothing here weakens it.
@@ -10,17 +10,19 @@
10
10
 
11
11
  ## Fix a dependency before it publishes
12
12
 
13
- When a consumer meets a defect that lives in a package it only has from the registry, build the dependency from source, pack it, and install the tarball into the consumer. Do not wait for the release and do not work around it in the consumer.
14
-
15
- - Install the tarball; never link it. A link skips packing, the `files` list, and the exports map.
16
- - Write the build, pack, and install to a script and run the file.
17
- - Record the range you replaced in the same step that replaces it.
18
- - Rebuild and repack whenever the dependency's source moves; a stale tarball is a stale `dist/`.
19
- - Delete the consumer's `node_modules/.vite` directory after every tarball install, or install into a fresh worktree; Vite keys its pre-bundle on the installed version.
13
+ When a consumer needs a dependency's unpublished source, to meet a defect that lives in a package it only has from the registry or to work on fresh local builds, pin the consumer to a tarball of that source. Do not wait for the release and do not work around it in the consumer.
14
+
15
+ - Pin with `node .agents/skills/orkestrel-publish/scripts/swap.ts --pin --consumer <consumer> --source <dependency>`, repeating `--source` for each dependency. The script refuses a source whose packed manifest names a local dependency in any dependency field, as npm reads one: a `file:` or `link:` prefix, a spec that starts like a path (`.`, `~/`, `/`, `\`, or a drive letter), or an archive name ending in `.tgz`, `.tar.gz`, or `.tar` that carries no protocol and is not a `user/repo` shorthand. A registry range, a dist-tag, an `npm:` alias, a `user/repo` shorthand, a git spec, and a URL stay remote. The script then builds and packs each source, installs the tarball, and proves the installed bytes. Never link a source instead: a link skips packing, the `files` list, and the exports map.
16
+ - Read the ledger `<consumer>/tmp/tarballs/pins.json` as the record of which build the consumer runs. Each row names the tarball and its SHA-256, the range the pin replaced, the source with its commit and dirty flag, the digest of the proved install, and the time; the ledger also records whether the manifest held an `overrides` block before the first pin. The script writes the replaced range before it writes the manifest and keeps the first pin's range across re-pins, so edit neither the ledger nor a pinned spec by hand.
17
+ - Keep the absolute `file:` spec the script writes. npm resolves a relative spec against the installed package's own directory, so a packed consumer installed elsewhere, as the `distribution` proof installs it, cannot find a relative tarball.
18
+ - Pass `--override <name>` for each pinned package that another installed package also requires. The override is `$<name>` when the consumer declares the package and the tarball spec when it does not. The proof fails a pin that leaves a pinned package, an override-only one included, uninstalled at the top level, that leaves a second copy of a pinned package, or that moves an unpinned `@orkestrel` package; an override that selects no installed dependency is such a failure and exits 1.
19
+ - Run `scripts/swap.ts --status --consumer <consumer>` before relying on any gate run against a pinned consumer. Exit 3 names each drift: a manifest entry that no longer names the full location of its ledger tarball (a relative spec that resolves to that location holds it), a missing or changed tarball, installed bytes that differ from the proved pin, a pin with no proved install, or a source whose commit moved. Re-pin to clear it.
20
+ - Re-pin whenever the dependency's source moves; a stale tarball is a stale `dist/`. Fetch and merge the dependency's default branch before packing it.
21
+ - Delete the consumer's `node_modules/.vite` directory after every tarball install the script does not run, or install into a fresh worktree; Vite keys its pre-bundle on the installed version, and the script deletes it after its own installs.
20
22
  - Run one unit per checkout, at that checkout's catalog layer.
21
- - Fetch and merge the dependency's default branch before packing it.
22
- - Restore the registry copy before any gate that must prove the published artifact, and before publishing anything. The release still follows layer order: the dependency publishes first, then the consumer re-pins and re-runs its gates.
23
- - Keep tarballs under `tmp/`; sweep them at acceptance; never commit them.
23
+ - Restore with `scripts/swap.ts --restore --consumer <consumer>` before any `scaffold` verb, before any gate that must see a registry range or prove the published artifact, and before publishing anything. The restore puts back each recorded range, keeps an `overrides` block that existed before the pin and removes one the pin created, removes the lock entry and installed copy of each restored package and dropped override so the install resolves them again from the registry, and proves the registry source in the lockfile. The restore refuses with exit 64, before it writes anything, a ledger whose package or override keys are not package names or whose removal would leave `node_modules`. [wave.md](wave.md) § Visit a repository orders a verb run around the pins.
24
+ - Re-pin after the verb or gate with the arguments the restore receipt's `repin` member lists, then run `--status`. The release still follows layer order: the dependency publishes first, then the consumer takes the registry range and re-runs its gates.
25
+ - Keep pins uncommitted: commit neither a pinned `package.json` nor its `package-lock.json`. Keep tarballs, the ledger, and receipts under `tmp/`; sweep them at acceptance; never commit them.
24
26
 
25
27
  ## What a bump obliges
26
28
 
@@ -45,11 +45,18 @@ by hand only to repair it, then resume with `--from <step>`.
45
45
  against the published tarball for material content. An exit of 2 is an unanswered comparison,
46
46
  never a moved dist: fetch and re-run rather than ruling a bump owed.
47
47
 
48
- Restore any unpublished tarball the target is holding before the quality gates run, per
49
- `release.md` § Fix a dependency before it publishes. A distribution proof run
50
- against a local tarball proves the local tarball. Stage an unpublished tarball with
51
- `npm install --no-save`, because a `file:` pin refuses the blueprint and the manifest keeps a
52
- registry range.
48
+ Restore every pin the target holds with `scripts/swap.ts --restore` before the visit's first step, per
49
+ [release.md](release.md) § Fix a dependency before it publishes, because the visit reads and
50
+ proves registry ranges. A distribution proof run against a local tarball proves the local tarball.
51
+ Where a `scaffold` verb must run against a target that works on local builds, run it in this order:
52
+
53
+ 1. Restore the pins with `scripts/swap.ts --restore --consumer <target>`.
54
+ 2. Stage the unpublished scaffold tarball with `npm install --no-save`, which leaves the
55
+ manifest's registry range in place. The blueprint refuses an `@orkestrel` `file:` spec under
56
+ `dependencies`, so `--no-save` is the staging route a verb admits.
57
+ 3. Run the verb.
58
+ 4. Re-pin with the arguments the restore receipt's `repin` member lists, then run
59
+ `scripts/swap.ts --status --consumer <target>` and clear any drift it reports before the gates.
53
60
 
54
61
  Where a visit runs before scaffold itself publishes, run the visit with `--offline`; the runner
55
62
  performs the offline overwrite and proves the sweep with `scaffold audit --offline`. The online verbs read vendored bytes from the published
@@ -13,6 +13,8 @@ import {
13
13
  listFiles,
14
14
  readMissingFlags,
15
15
  readOption,
16
+ parseJsonRecord,
17
+ readString,
16
18
  runNpm,
17
19
  } from '../../orkestrel-dispatch/scripts/helpers.ts'
18
20
 
@@ -24,20 +26,27 @@ interface Manifest {
24
26
  readonly version: string | undefined
25
27
  }
26
28
 
29
+ interface Source {
30
+ readonly archive: Buffer | undefined
31
+ readonly version: string | undefined
32
+ readonly failure: string | undefined
33
+ }
34
+
35
+ interface TarState {
36
+ readonly next: number
37
+ readonly name: string | undefined
38
+ readonly files: ReadonlyArray<readonly [string, Buffer]>
39
+ readonly done: boolean
40
+ }
41
+
27
42
  function readManifest(): Manifest {
28
43
  if (!existsSync('package.json')) return { name: undefined, version: undefined }
29
- let parsed: unknown
30
44
  try {
31
- parsed = JSON.parse(readFileSync('package.json', 'utf8'))
45
+ const record = parseJsonRecord(readFileSync('package.json', 'utf8'))
46
+ return { name: readString(record, 'name'), version: readString(record, 'version') }
32
47
  } catch {
33
48
  return { name: undefined, version: undefined }
34
49
  }
35
- if (typeof parsed !== 'object' || parsed === null) return { name: undefined, version: undefined }
36
- const record = Object.fromEntries(Object.entries(parsed))
37
- return {
38
- name: typeof record.name === 'string' ? record.name : undefined,
39
- version: typeof record.version === 'string' ? record.version : undefined,
40
- }
41
50
  }
42
51
 
43
52
  function readField(text: string): string | undefined {
@@ -51,41 +60,96 @@ function readHeaderText(header: Buffer, start: number, length: number): string {
51
60
  return slice.subarray(0, end === -1 ? slice.length : end).toString('utf8')
52
61
  }
53
62
 
54
- function readPaxPath(data: Buffer): string | undefined {
55
- let offset = 0
56
- while (offset < data.length) {
57
- const space = data.indexOf(0x20, offset)
58
- if (space === -1) return undefined
59
- const length = Number(data.subarray(offset, space).toString('utf8'))
60
- if (!Number.isFinite(length) || length <= 0) return undefined
61
- const record = data.subarray(space + 1, offset + length - 1).toString('utf8')
62
- if (record.startsWith('path=')) return record.slice('path='.length)
63
- offset += length
63
+ function readPaxPath(data: Buffer, offset = 0): string | undefined {
64
+ if (offset >= data.length) return undefined
65
+ const space = data.indexOf(0x20, offset)
66
+ if (space === -1) return undefined
67
+ const length = Number(data.subarray(offset, space).toString('utf8'))
68
+ if (!Number.isFinite(length) || length <= 0) return undefined
69
+ const record = data.subarray(space + 1, offset + length - 1).toString('utf8')
70
+ return record.startsWith('path=')
71
+ ? record.slice('path='.length)
72
+ : readPaxPath(data, offset + length)
73
+ }
74
+
75
+ // A tar entry occupies a header block and the data blocks its size names, so a step ignores every
76
+ // block offset an earlier entry already consumed.
77
+ function stepTar(archive: Buffer, state: TarState, offset: number): TarState {
78
+ if (state.done || offset < state.next) return state
79
+ const header = archive.subarray(offset, offset + BLOCK)
80
+ if (header.every((byte) => byte === 0)) return { ...state, done: true }
81
+ const size = Number.parseInt(readHeaderText(header, 124, 12).trim() || '0', 8)
82
+ const type = String.fromCharCode(header[156] ?? 0)
83
+ const magic = readHeaderText(header, 257, 6)
84
+ const prefix = magic.startsWith('ustar') ? readHeaderText(header, 345, 155) : ''
85
+ const shortName = readHeaderText(header, 0, 100)
86
+ const name = state.name ?? (prefix === '' ? shortName : `${prefix}/${shortName}`)
87
+ const data = archive.subarray(offset + BLOCK, offset + BLOCK + size)
88
+ const next = offset + BLOCK + Math.ceil(size / BLOCK) * BLOCK
89
+ return {
90
+ next,
91
+ name:
92
+ type === 'x'
93
+ ? readPaxPath(data)
94
+ : type === 'L'
95
+ ? readHeaderText(data, 0, data.length)
96
+ : undefined,
97
+ files:
98
+ type === '0' || type === '\0' ? [...state.files, [name, Buffer.from(data)]] : state.files,
99
+ done: !Number.isFinite(next),
64
100
  }
65
- return undefined
66
101
  }
67
102
 
68
103
  function readTar(archive: Buffer): ReadonlyMap<string, Buffer> {
69
- const files = new Map<string, Buffer>()
70
- let offset = 0
71
- let overrideName: string | undefined
72
- while (offset + BLOCK <= archive.length) {
73
- const header = archive.subarray(offset, offset + BLOCK)
74
- if (header.every((byte) => byte === 0)) break
75
- const size = Number.parseInt(readHeaderText(header, 124, 12).trim() || '0', 8)
76
- const type = String.fromCharCode(header[156] ?? 0)
77
- const magic = readHeaderText(header, 257, 6)
78
- const prefix = magic.startsWith('ustar') ? readHeaderText(header, 345, 155) : ''
79
- const shortName = readHeaderText(header, 0, 100)
80
- const name = overrideName ?? (prefix === '' ? shortName : `${prefix}/${shortName}`)
81
- const data = archive.subarray(offset + BLOCK, offset + BLOCK + size)
82
- overrideName = undefined
83
- if (type === 'x') overrideName = readPaxPath(data)
84
- else if (type === 'L') overrideName = readHeaderText(data, 0, data.length)
85
- else if (type === '0' || type === '\0') files.set(name, Buffer.from(data))
86
- offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK
104
+ const offsets = Array.from(
105
+ { length: Math.floor(archive.length / BLOCK) },
106
+ (_, index) => index * BLOCK,
107
+ )
108
+ // A later entry of the same name replaces an earlier one at the earlier position, as a map write does.
109
+ return new Map(
110
+ offsets.reduce<TarState>((state, offset) => stepTar(archive, state, offset), {
111
+ next: 0,
112
+ name: undefined,
113
+ files: [],
114
+ done: false,
115
+ }).files,
116
+ )
117
+ }
118
+
119
+ function unpackTar(archive: Buffer): ReadonlyMap<string, Buffer> | Error {
120
+ try {
121
+ return readTar(gunzipSync(archive))
122
+ } catch (error) {
123
+ return error instanceof Error ? error : new Error(String(error))
124
+ }
125
+ }
126
+
127
+ async function readSource(
128
+ name: string,
129
+ requested: string | undefined,
130
+ tarball: string | undefined,
131
+ ): Promise<Source> {
132
+ if (tarball !== undefined) {
133
+ return existsSync(tarball)
134
+ ? { archive: readFileSync(tarball), version: requested, failure: undefined }
135
+ : { archive: undefined, version: requested, failure: `${tarball} does not exist` }
136
+ }
137
+ const version = requested ?? readNpmValue(['view', name, 'version', '--json'])
138
+ const url =
139
+ version === undefined
140
+ ? undefined
141
+ : readNpmValue(['view', `${name}@${version}`, 'dist.tarball', '--json'])
142
+ if (version === undefined || url === undefined) {
143
+ return {
144
+ archive: undefined,
145
+ version,
146
+ failure: `the registry served no tarball for ${name}${version === undefined ? '' : `@${version}`}`,
147
+ }
87
148
  }
88
- return files
149
+ const fetched = await fetchTarball(url)
150
+ return fetched === undefined
151
+ ? { archive: undefined, version, failure: `fetching ${url} failed` }
152
+ : { archive: fetched, version, failure: undefined }
89
153
  }
90
154
 
91
155
  // A dist tree holds nothing a walk skips, so every emitted file counts.
@@ -125,53 +189,31 @@ async function main(argv: readonly string[]): Promise<number> {
125
189
  )
126
190
  return 64
127
191
  }
128
- let version = readOption(argv, '--version')
129
- let archive: Buffer
130
- if (tarball !== undefined) {
131
- if (!existsSync(tarball)) {
132
- console.error(`compare: ${tarball} does not exist`)
133
- return 2
134
- }
135
- archive = readFileSync(tarball)
136
- } else {
137
- version ??= readNpmValue(['view', name, 'version', '--json'])
138
- const url =
139
- version === undefined
140
- ? undefined
141
- : readNpmValue(['view', `${name}@${version}`, 'dist.tarball', '--json'])
142
- if (version === undefined || url === undefined) {
143
- console.error(
144
- `compare: the registry served no tarball for ${name}${version === undefined ? '' : `@${version}`}`,
145
- )
146
- return 2
147
- }
148
- const fetched = await fetchTarball(url)
149
- if (fetched === undefined) {
150
- console.error(`compare: fetching ${url} failed`)
151
- return 2
152
- }
153
- archive = fetched
154
- }
155
- const published = new Map<string, Buffer>()
156
- let entries: ReadonlyMap<string, Buffer>
157
- try {
158
- entries = readTar(gunzipSync(archive))
159
- } catch (error) {
160
- console.error(
161
- `compare: the tarball could not be read: ${error instanceof Error ? error.message : String(error)}`,
162
- )
192
+ const source = await readSource(name, readOption(argv, '--version'), tarball)
193
+ const { archive, version } = source
194
+ if (archive === undefined) {
195
+ console.error(`compare: ${source.failure}`)
163
196
  return 2
164
197
  }
165
- for (const [path, content] of entries) {
166
- const prefixed = `${PACKAGE_PREFIX}${dist}/`
167
- if (path.startsWith(prefixed) && isMaterial(path))
168
- published.set(path.slice(prefixed.length), content)
169
- }
170
- const local = new Map<string, Buffer>()
171
- for (const { path } of listFiles(dist, NOTHING_SKIPPED)) {
172
- const key = relative(dist, path).split('\\').join('/')
173
- if (isMaterial(key)) local.set(key, readFileSync(path))
198
+ const entries = unpackTar(archive)
199
+ if (entries instanceof Error) {
200
+ console.error(`compare: the tarball could not be read: ${entries.message}`)
201
+ return 2
174
202
  }
203
+ const prefixed = `${PACKAGE_PREFIX}${dist}/`
204
+ const published = new Map(
205
+ [...entries].flatMap(([path, content]) =>
206
+ path.startsWith(prefixed) && isMaterial(path)
207
+ ? [[path.slice(prefixed.length), content] as const]
208
+ : [],
209
+ ),
210
+ )
211
+ const local = new Map(
212
+ listFiles(dist, NOTHING_SKIPPED).flatMap(({ path }) => {
213
+ const key = relative(dist, path).split('\\').join('/')
214
+ return isMaterial(key) ? [[key, readFileSync(path)] as const] : []
215
+ }),
216
+ )
175
217
  const added = [...local.keys()].filter((key) => !published.has(key)).sort()
176
218
  const removed = [...published.keys()].filter((key) => !local.has(key)).sort()
177
219
  const changed = [...local.keys()]
@@ -56,17 +56,15 @@ function buildRangeLiteral(literal: string): Literal {
56
56
  function sweepFile(path: string, literals: readonly Literal[]): readonly Hit[] {
57
57
  const content = readFileSync(path)
58
58
  if (content.includes(0)) return []
59
- const hits: Hit[] = []
60
- content
59
+ return content
61
60
  .toString('utf8')
62
61
  .split(/\r\n|\n/)
63
- .forEach((text, index) => {
62
+ .flatMap((text, index) => {
64
63
  const match = literals.find((entry) => entry.pattern.test(text))
65
- if (match !== undefined) {
66
- hits.push({ path, line: index + 1, literal: match.literal, text: text.trim() })
67
- }
64
+ return match === undefined
65
+ ? []
66
+ : [{ path, line: index + 1, literal: match.literal, text: text.trim() }]
68
67
  })
69
- return hits
70
68
  }
71
69
 
72
70
  function main(argv: readonly string[]): number {