ts-node-pack 0.1.3 → 0.2.1

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/README.md CHANGED
@@ -25,18 +25,19 @@ Requires Node ≥ 20 and TypeScript ≥ 5.7 available in the package being packe
25
25
  ## Usage
26
26
 
27
27
  ```sh
28
- ts-node-pack <packageDir> [--tsconfig <path>] [--emit-only] [--keep-temp] [--verbose]
28
+ ts-node-pack <packageDir> [--tsconfig <path>] [--stage-to <dir>] [--skip-pack] [--force] [--verbose]
29
29
  ```
30
30
 
31
31
  | Flag | Description |
32
32
  | ------------------- | -------------------------------------------------------------------------------------------- |
33
33
  | `--tsconfig <path>` | tsconfig to extend. Defaults to `tsconfig.build.json` if present, otherwise `tsconfig.json`. |
34
- | `--emit-only` | Run emit + rewrites + validation, but skip `npm pack`. Prints the staging directory. |
35
- | `--keep-temp` | Do not delete the temporary staging directory on exit. |
34
+ | `--stage-to <dir>` | Stage into `<dir>` instead of an auto-created temp dir. Caller owns its lifecycle. |
35
+ | `--skip-pack` | Skip the final `npm pack` step. Requires `--stage-to`. |
36
+ | `--force` | With `--stage-to`, clear `<dir>` if it already has contents. |
36
37
  | `-v`, `--verbose` | Log each pipeline phase to stderr. |
37
38
  | `-h`, `--help` | Show help. |
38
39
 
39
- The resulting `<name>-<version>.tgz` is written to the current working directory.
40
+ The resulting `<name>-<version>.tgz` is written to the current working directory (unless `--skip-pack` is set).
40
41
 
41
42
  ### Example
42
43
 
@@ -46,20 +47,35 @@ ts-node-pack ./packages/core --verbose
46
47
  npm install ./my-core-1.2.3.tgz
47
48
  ```
48
49
 
50
+ ### `--stage-to` and `--skip-pack`
51
+
52
+ By default `ts-node-pack` stages into an auto-created `mkdtemp()` directory, runs `npm pack` against it, copies the resulting `.tgz` to the current working directory, and removes the temp dir.
53
+
54
+ Pass `--stage-to <dir>` when you want to keep the staged contents — for example, to let another tool pack from that directory instead (`lerna publish --contents <dir>`, an alternate tarball builder, etc.). `<dir>` must either not exist, be empty, or be opted-in for clearing via `--force`.
55
+
56
+ Combine with `--skip-pack` to stop after staging and never run `npm pack` at all. `--skip-pack` is only valid with `--stage-to` (otherwise the staged contents would have no accessible location).
57
+
58
+ | Invocation | Behavior |
59
+ | ------------------------------------------------- | ------------------------------------------------------------------- |
60
+ | `ts-node-pack <pkg>` | Stage to temp dir, pack, copy `.tgz` to CWD, delete temp dir. |
61
+ | `ts-node-pack <pkg> --stage-to <dir>` | Stage to `<dir>`, pack, copy `.tgz` to CWD, leave `<dir>` in place. |
62
+ | `ts-node-pack <pkg> --stage-to <dir> --skip-pack` | Stage to `<dir>`, skip pack, leave `<dir>` in place. |
63
+ | `ts-node-pack <pkg> --skip-pack` | Error: `skipPack requires stageTo`. |
64
+
49
65
  ## Pipeline
50
66
 
51
67
  1. **Resolve package** — read `package.json`, pick tsconfig.
52
- 2. **Stage** — create `mkdtemp()/package/`.
53
- 3. **Derived tsconfig** — write `tsconfig.emit.json` _inside the temp dir_ that `extends` the chosen tsconfig (by absolute path) and forces `outDir`, `declaration`, `rewriteRelativeImportExtensions: true`, `noEmit: false`. If the base tsconfig enables `sourceMap`, `inlineSourceMap`, or `declarationMap`, `inlineSources: true` is also set so debuggers get full source-level fidelity without any `.ts` files in the tarball.
68
+ 2. **Stage** — use `--stage-to <dir>` if given, otherwise create `mkdtemp()/package/`. A small separate `mkdtemp()` work dir always holds auxiliary files (e.g. the derived tsconfig) so they never land in the packed contents.
69
+ 3. **Derived tsconfig** — write `tsconfig.emit.json` _inside the work dir_ that `extends` the chosen tsconfig (by absolute path) and forces `outDir`, `declaration`, `rewriteRelativeImportExtensions: true`, `noEmit: false`. If the base tsconfig enables `sourceMap`, `inlineSourceMap`, or `declarationMap`, `inlineSources: true` is also set so debuggers get full source-level fidelity without any `.ts` files in the tarball.
54
70
  4. **Emit** — run `tsc -p` against the derived config.
55
71
  5. **Rewrite `.d.ts`** — for each emitted `.d.ts`, rewrite `./foo.ts` → `./foo.js` in `import` / `export from` / dynamic `import()` specifiers. Non-relative specifiers are left alone.
56
72
  6. **Rewrite `package.json`** — rewrite `.ts` → `.js` (and → `.d.ts` under `types` conditions) in `main`, `module`, `types`, `typings`, `bin`, `exports`, and the `files` array. Strip `devDependencies` and `scripts`.
57
73
  7. **Copy assets** — `README*`, `LICENSE*`, `CHANGELOG*`, `NOTICE*`. Source `.ts` files are never copied.
58
74
  8. **Validate** — fail if any `.ts` specifier remains in emitted `.js` / `.d.ts` / `package.json`, or if a referenced entry point does not exist.
59
- 9. **Pack** — `npm pack` in the staging directory; move the tarball to the original CWD.
60
- 10. **Cleanup** — remove `.ts-node-pack/` and the temp directory (unless `--keep-temp`).
75
+ 9. **Pack** — unless `--skip-pack`: `npm pack` in the staging directory and move the tarball to the original CWD.
76
+ 10. **Cleanup** — always remove the work dir. In default mode this also removes the staging dir (which is nested inside). In `--stage-to` mode the staging dir is the caller's, and survives.
61
77
 
62
- The source tree is never mutated. All intermediate artifacts (derived tsconfig, staging dir, tarball) live under a single `mkdtemp()` directory that is removed on exit.
78
+ The source tree is never mutated.
63
79
 
64
80
  ### Sourcemaps
65
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-node-pack",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "Pack a TypeScript package into a Node-compatible npm tarball without modifying the source tree",
5
5
  "keywords": [
6
6
  "cli",
package/src/cli.js CHANGED
@@ -8,8 +8,9 @@ const { values, positionals } = parseArgs({
8
8
  allowPositionals: true,
9
9
  options: {
10
10
  tsconfig: { type: "string" },
11
- "emit-only": { type: "boolean", default: false },
12
- "keep-temp": { type: "boolean", default: false },
11
+ "skip-pack": { type: "boolean", default: false },
12
+ "stage-to": { type: "string" },
13
+ force: { type: "boolean", default: false },
13
14
  verbose: { type: "boolean", short: "v", default: false },
14
15
  help: { type: "boolean", short: "h", default: false },
15
16
  },
@@ -20,10 +21,12 @@ if (values.help || positionals.length === 0) {
20
21
 
21
22
  Options:
22
23
  --tsconfig <path> Path to tsconfig (default: tsconfig.build.json or tsconfig.json)
23
- --emit-only Emit compiled files without packing
24
- --keep-temp Keep temporary staging directory
25
- -v, --verbose Verbose output
26
- -h, --help Show this help message
24
+ --stage-to <dir> Stage into <dir> instead of an auto-created temp dir.
25
+ Caller owns cleanup. Errors if <dir> is non-empty unless --force.
26
+ --skip-pack Skip the final \`npm pack\` step. Requires --stage-to.
27
+ --force With --stage-to, clear <dir> if it already has contents.
28
+ -v, --verbose Log each pipeline phase to stderr.
29
+ -h, --help Show this help message.
27
30
  `);
28
31
  process.exit(values.help ? 0 : 1);
29
32
  }
@@ -33,8 +36,9 @@ const packageDir = resolve(positionals[0]);
33
36
  try {
34
37
  const result = await tsNodePack(packageDir, {
35
38
  tsconfig: values.tsconfig,
36
- emitOnly: values["emit-only"],
37
- keepTemp: values["keep-temp"],
39
+ skipPack: values["skip-pack"],
40
+ stageTo: values["stage-to"],
41
+ force: values.force,
38
42
  verbose: values.verbose,
39
43
  });
40
44
  console.log(result);
package/src/index.d.ts CHANGED
@@ -1,12 +1,24 @@
1
1
  export interface TsNodePackOptions {
2
2
  tsconfig?: string;
3
- emitOnly?: boolean;
4
- keepTemp?: boolean;
3
+ /**
4
+ * Skip the final `npm pack` step. Requires `stageTo` (otherwise there
5
+ * is no way for the caller to access the staged contents).
6
+ */
7
+ skipPack?: boolean;
8
+ /**
9
+ * Stage directly into this directory instead of an auto-created temp
10
+ * dir. Caller owns cleanup. Errors if the directory already has
11
+ * contents, unless `force` is set.
12
+ */
13
+ stageTo?: string;
14
+ /** With `stageTo`, clear the target directory if it already has contents. */
15
+ force?: boolean;
5
16
  verbose?: boolean;
6
17
  }
7
18
  /**
8
19
  * Main pipeline: pack a TypeScript package into a Node-compatible tarball.
9
- * Returns the path to the .tgz file (or the staging dir if --emit-only).
20
+ * Returns the path to the .tgz file, or the staging directory when
21
+ * `skipPack` is set.
10
22
  */
11
23
  export declare function tsNodePack(packageDir: string, options?: TsNodePackOptions): Promise<string>;
12
24
  /**
package/src/index.js CHANGED
@@ -15,28 +15,47 @@ import { basename, dirname, join, relative, resolve } from "node:path";
15
15
  import { promisify } from "node:util";
16
16
  import packlist from "npm-packlist";
17
17
  import tsBlankSpace from "ts-blank-space";
18
- import { TS_SPECIFIER_PATTERNS, rewriteTsSpecifiers } from "./rewrite-specifiers.js";
18
+ import { rewriteTsSpecifiers } from "./rewrite-specifiers.js";
19
+ import { validate } from "./validation.js";
19
20
 
20
21
  const execFileAsync = promisify(execFile);
21
22
 
22
23
 
23
24
 
25
+
26
+
27
+
28
+
24
29
 
25
-
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
26
38
 
27
39
 
28
40
 
29
41
  /**
30
42
  * Main pipeline: pack a TypeScript package into a Node-compatible tarball.
31
- * Returns the path to the .tgz file (or the staging dir if --emit-only).
43
+ * Returns the path to the .tgz file, or the staging directory when
44
+ * `skipPack` is set.
32
45
  */
33
46
  export async function tsNodePack(
34
47
  packageDir ,
35
48
  options = {},
36
49
  ) {
37
- const { tsconfig, emitOnly, keepTemp, verbose } = options;
50
+ const { tsconfig, skipPack, stageTo, force, verbose } = options;
38
51
  const log = verbose ? (...args ) => console.error("[ts-node-pack]", ...args) : () => {};
39
52
 
53
+ if (skipPack && !stageTo) {
54
+ throw new Error(
55
+ "skipPack requires stageTo: caller must specify where to put the staged contents",
56
+ );
57
+ }
58
+
40
59
  packageDir = resolve(packageDir);
41
60
 
42
61
  // ── Phase 1: Resolve package ──────────────────────────────────────────
@@ -48,11 +67,34 @@ export async function tsNodePack(
48
67
  const tsconfigPath = resolveTsconfig(packageDir, tsconfig);
49
68
  if (tsconfigPath) log(`Found tsconfig: ${tsconfigPath}`);
50
69
 
51
- // ── Phase 2: Create staging directory ─────────────────────────────────
52
- log("Phase 2: Creating staging directory...");
53
- const tmpDir = await mkdtemp(join(tmpdir(), "ts-node-pack-"));
54
- const stagingDir = join(tmpDir, "package");
55
- await mkdir(stagingDir, { recursive: true });
70
+ // ── Phase 2: Create work and staging directories ─────────────────────
71
+ // We always need a private work dir for auxiliary files (e.g.
72
+ // tsconfig.emit.json) that MUST NOT land inside the packed contents.
73
+ // In default mode the staging dir is nested inside the work dir, so
74
+ // cleaning up the work dir cleans up everything. In stageTo mode the
75
+ // staging dir is the caller's directory — we still create a small
76
+ // separate work dir for auxiliary files and rm only that in Phase 10.
77
+ log("Phase 2: Creating work and staging directories...");
78
+ const workDir = await mkdtemp(join(tmpdir(), "ts-node-pack-"));
79
+ let stagingDir ;
80
+ if (stageTo) {
81
+ stagingDir = resolve(stageTo);
82
+ if (existsSync(stagingDir)) {
83
+ const entries = await readdir(stagingDir);
84
+ if (entries.length > 0) {
85
+ if (!force) {
86
+ throw new Error(
87
+ `stageTo directory is not empty: ${stagingDir}. Pass force: true to clear it.`,
88
+ );
89
+ }
90
+ await rm(stagingDir, { recursive: true, force: true });
91
+ }
92
+ }
93
+ await mkdir(stagingDir, { recursive: true });
94
+ } else {
95
+ stagingDir = join(workDir, "package");
96
+ await mkdir(stagingDir, { recursive: true });
97
+ }
56
98
  log(`Staging: ${stagingDir}`);
57
99
 
58
100
  try {
@@ -100,7 +142,7 @@ export async function tsNodePack(
100
142
 
101
143
  if (shouldRunTsc) {
102
144
  log("Phase 4: Generating derived tsconfig...");
103
- const emitConfigPath = join(tmpDir, "tsconfig.emit.json");
145
+ const emitConfigPath = join(workDir, "tsconfig.emit.json");
104
146
  // tsc's typeRoots walk-up starts at the config's directory, so it
105
147
  // can't reach packageDir/node_modules/@types from our temp-dir
106
148
  // location. Pin typeRoots when that directory exists. (TS 6.0
@@ -187,26 +229,31 @@ export async function tsNodePack(
187
229
  await validate(stagingDir, rewrittenPkg, log);
188
230
 
189
231
  // ── Phase 9: Pack ─────────────────────────────────────────────────────
190
- if (!emitOnly) {
232
+ if (!skipPack) {
191
233
  log("Phase 9: Packing...");
192
234
  const tgzPath = await pack(stagingDir);
193
235
  const tgzName = basename(tgzPath);
194
236
  const dest = join(process.cwd(), tgzName);
195
237
  await copyFile(tgzPath, dest);
238
+ // In stageTo mode the whole staging dir survives into the caller's
239
+ // filesystem, so the intermediate .tgz that `npm pack` wrote in
240
+ // there would stick around as visible clutter. Remove it. (In
241
+ // default mode the entire workDir is rm'd in Phase 10, so this
242
+ // unlink is redundant but harmless.)
243
+ if (stageTo) await unlink(tgzPath);
196
244
  log(`Created: ${dest}`);
197
245
  return dest;
198
246
  }
199
247
 
200
- log(`Emit-only mode. Staging directory: ${stagingDir}`);
248
+ log(`Skip-pack mode. Staging directory: ${stagingDir}`);
201
249
  return stagingDir;
202
250
  } finally {
203
251
  // ── Phase 10: Cleanup ───────────────────────────────────────────────
204
- if (!emitOnly && !keepTemp) {
205
- log("Phase 10: Cleaning up temp directory...");
206
- await rm(tmpDir, { recursive: true, force: true });
207
- } else if (keepTemp) {
208
- log(`Keeping temp directory: ${tmpDir}`);
209
- }
252
+ // Always rm the work dir. In default mode this also removes the
253
+ // staging dir (nested inside). In stageTo mode the staging dir is
254
+ // the caller's directory and survives.
255
+ log("Phase 10: Cleaning up work directory...");
256
+ await rm(workDir, { recursive: true, force: true });
210
257
  }
211
258
  }
212
259
 
@@ -549,7 +596,7 @@ export function rewritePackageJson(pkg) {
549
596
  } else if (result.typings) {
550
597
  result.typings = rewriteTsToDts(result.typings);
551
598
  } else if (mainWasTs) {
552
- result.types = result.main.replace(/\.js$/, ".d.ts");
599
+ result.types = rewriteTsToDts(originalMain);
553
600
  }
554
601
 
555
602
  // Rewrite bin
@@ -571,7 +618,7 @@ export function rewritePackageJson(pkg) {
571
618
  // Rewrite files array
572
619
  if (Array.isArray(result.files)) {
573
620
  result.files = result.files.flatMap((f) => {
574
- if (typeof f === "string" && /\.tsx?$/.test(f)) {
621
+ if (typeof f === "string" && TS_SOURCE_EXT_RE.test(f)) {
575
622
  return [rewriteTsToJs(f), rewriteTsToDts(f)];
576
623
  }
577
624
  return [f];
@@ -581,14 +628,22 @@ export function rewritePackageJson(pkg) {
581
628
  return result;
582
629
  }
583
630
 
631
+ // `.ts`/`.tsx` → `.js`; `.mts` → `.mjs`. Matches what `ts-blank-space`
632
+ // writes in Phase 6 and the specifier-rewrite pass in rewrite-specifiers.ts.
584
633
  function rewriteTsToJs(p) {
585
- return typeof p === "string" ? p.replace(/\.tsx?$/, ".js") : p;
634
+ if (typeof p !== "string") return p;
635
+ return p.replace(/\.mts$/, ".mjs").replace(/\.tsx?$/, ".js");
586
636
  }
587
637
 
638
+ // `.ts`/`.tsx` → `.d.ts`; `.mts` → `.d.mts`. Matches the declaration files
639
+ // tsc emits from an .mts source.
588
640
  function rewriteTsToDts(p) {
589
- return typeof p === "string" ? p.replace(/\.tsx?$/, ".d.ts") : p;
641
+ if (typeof p !== "string") return p;
642
+ return p.replace(/\.mts$/, ".d.mts").replace(/\.tsx?$/, ".d.ts");
590
643
  }
591
644
 
645
+ const TS_SOURCE_EXT_RE = /\.(tsx?|mts)$/;
646
+
592
647
  function rewriteExportsValue(value, isTypesKey) {
593
648
  if (typeof value === "string") {
594
649
  return isTypesKey ? rewriteTsToDts(value) : rewriteTsToJs(value);
@@ -606,98 +661,6 @@ function rewriteExportsValue(value, isTypesKey) {
606
661
  return value;
607
662
  }
608
663
 
609
- async function validate(stagingDir, pkg, log) {
610
- const errors = [];
611
-
612
- // Check .js and .d.ts files for remaining .ts specifiers
613
- const allFiles = await findFiles(
614
- stagingDir,
615
- (name) => name.endsWith(".js") || name.endsWith(".d.ts"),
616
- );
617
-
618
- for (const filePath of allFiles) {
619
- const raw = await readFile(filePath, "utf8");
620
- // Strip block and line comments before scanning so JSDoc examples that
621
- // literally contain strings like `import './foo.js'` don't register as
622
- // false positives. Comment stripping here is intentionally coarse
623
- // (doesn't understand strings-containing-comment-markers) — good enough
624
- // for tsc-emitted output, where comments are well-behaved.
625
- const content = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
626
- const relPath = relative(stagingDir, filePath);
627
-
628
- for (const pattern of TS_SPECIFIER_PATTERNS) {
629
- for (const m of content.matchAll(pattern)) {
630
- errors.push(`${relPath}: remaining .ts specifier: ${m[0]}`);
631
- }
632
- }
633
- }
634
-
635
- // Check package.json for .ts references in entry points
636
- const entryFields = ["main", "module", "types", "typings"];
637
- for (const field of entryFields) {
638
- if (typeof pkg[field] === "string" && /(?<!\.d)\.tsx?$/.test(pkg[field])) {
639
- errors.push(`package.json "${field}" still references .ts: ${pkg[field]}`);
640
- }
641
- }
642
-
643
- // Non-fatal: yarn/npm tolerate `bin`/`main` pointing at missing
644
- // files. Real transformation bugs are caught by the specifier
645
- // checks above.
646
- const referencedFiles = collectReferencedFiles(pkg);
647
- for (const ref of referencedFiles) {
648
- if (!existsSync(join(stagingDir, ref))) {
649
- log(` Warning: referenced file missing from tarball: ${ref}`);
650
- }
651
- }
652
-
653
- if (errors.length > 0) {
654
- const msg = "Validation failed:\n " + errors.join("\n ");
655
- throw new Error(msg);
656
- }
657
-
658
- log(`Validated ${allFiles.length} file(s), no issues found`);
659
- }
660
-
661
- function collectReferencedFiles(pkg) {
662
- const refs = new Set ();
663
-
664
- for (const field of ["main", "module", "types", "typings"]) {
665
- if (typeof pkg[field] === "string") {
666
- refs.add(pkg[field].replace(/^\.\//, ""));
667
- }
668
- }
669
-
670
- if (typeof pkg.bin === "string") {
671
- refs.add(pkg.bin.replace(/^\.\//, ""));
672
- } else if (typeof pkg.bin === "object" && pkg.bin !== null) {
673
- for (const v of Object.values(pkg.bin)) {
674
- if (typeof v === "string") refs.add(v.replace(/^\.\//, ""));
675
- }
676
- }
677
-
678
- if (pkg.exports) {
679
- collectExportsRefs(pkg.exports, refs);
680
- }
681
-
682
- return refs;
683
- }
684
-
685
- function collectExportsRefs(value, refs) {
686
- if (typeof value === "string") {
687
- refs.add(value.replace(/^\.\//, ""));
688
- return;
689
- }
690
- if (Array.isArray(value)) {
691
- for (const v of value) collectExportsRefs(v, refs);
692
- return;
693
- }
694
- if (typeof value === "object" && value !== null) {
695
- for (const v of Object.values(value)) {
696
- collectExportsRefs(v, refs);
697
- }
698
- }
699
- }
700
-
701
664
  async function pack(stagingDir) {
702
665
  const { stdout } = await execFileAsync("npm", ["pack"], {
703
666
  cwd: stagingDir,
@@ -0,0 +1,21 @@
1
+ export declare function validate(stagingDir: any, pkg: any, log?: (...args: unknown[]) => void): Promise<void>;
2
+ /**
3
+ * Collect the relative file paths referenced by path-bearing fields of a
4
+ * package.json manifest, partitioned by how strictly a missing target
5
+ * should be treated.
6
+ *
7
+ * - `strict`: `main`, `module`, `types`, `typings`, and every leaf of
8
+ * `exports`. A missing target here is a bug in the tarball we produced —
9
+ * Node's module resolution will fail and `npm publish` warns on
10
+ * dangling `types`.
11
+ * - `lenient`: `bin` entries. yarn and npm both tolerate a `bin` pointing
12
+ * at a missing file (see the `bin-missing` fixture, which mirrors
13
+ * agoric/portfolio-api's intentional shape).
14
+ *
15
+ * Exported for direct unit-testing of the policy; the only runtime caller
16
+ * is `validate()` above.
17
+ */
18
+ export declare function collectReferencedFiles(pkg: any): {
19
+ strict: Set<string>;
20
+ lenient: Set<string>;
21
+ };
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Post-rewrite validation of a staged package. `validate()` is the one
3
+ * caller of `collectReferencedFiles`; both live here because the
4
+ * strict-vs-lenient partition is a policy decision owned by validation.
5
+ */
6
+ import { existsSync } from "node:fs";
7
+ import { readdir, readFile } from "node:fs/promises";
8
+ import { join, relative } from "node:path";
9
+ import { TS_SPECIFIER_PATTERNS } from "./rewrite-specifiers.js";
10
+
11
+ export async function validate(
12
+ stagingDir,
13
+ pkg,
14
+ log = () => {},
15
+ ) {
16
+ const errors = [];
17
+
18
+ // Check .js/.mjs and .d.ts/.d.mts files for remaining .ts specifiers
19
+ const allFiles = await findFiles(
20
+ stagingDir,
21
+ (name) =>
22
+ name.endsWith(".js") ||
23
+ name.endsWith(".mjs") ||
24
+ name.endsWith(".d.ts") ||
25
+ name.endsWith(".d.mts"),
26
+ );
27
+
28
+ for (const filePath of allFiles) {
29
+ const raw = await readFile(filePath, "utf8");
30
+ // Strip block and line comments before scanning so JSDoc examples that
31
+ // literally contain strings like `import './foo.js'` don't register as
32
+ // false positives. Comment stripping here is intentionally coarse
33
+ // (doesn't understand strings-containing-comment-markers) — good enough
34
+ // for tsc-emitted output, where comments are well-behaved.
35
+ const content = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
36
+ const relPath = relative(stagingDir, filePath);
37
+
38
+ for (const pattern of TS_SPECIFIER_PATTERNS) {
39
+ for (const m of content.matchAll(pattern)) {
40
+ errors.push(`${relPath}: remaining .ts specifier: ${m[0]}`);
41
+ }
42
+ }
43
+ }
44
+
45
+ // Check package.json for .ts references in entry points
46
+ const entryFields = ["main", "module", "types", "typings"];
47
+ for (const field of entryFields) {
48
+ if (typeof pkg[field] === "string" && /(?<!\.d)\.(tsx?|mts)$/.test(pkg[field])) {
49
+ errors.push(`package.json "${field}" still references .ts: ${pkg[field]}`);
50
+ }
51
+ }
52
+
53
+ // main/module/types/typings/exports must resolve to a file that is
54
+ // actually in the staging dir. This catches rewrite bugs that specifier
55
+ // scanning can't — e.g. the @endo/ses-ava regression where a synthesized
56
+ // `types: ./index.d.ts` pointed at a .d.ts ts-node-pack never emitted.
57
+ // `bin` is deliberately excluded: yarn/npm both tolerate a missing bin
58
+ // target (see the bin-missing fixture — agoric/portfolio-api ships that
59
+ // way intentionally), so we only warn.
60
+ const { strict: strictRefs, lenient: lenientRefs } = collectReferencedFiles(pkg);
61
+ for (const ref of strictRefs) {
62
+ if (!existsSync(join(stagingDir, ref))) {
63
+ errors.push(`referenced file missing from tarball: ${ref}`);
64
+ }
65
+ }
66
+ for (const ref of lenientRefs) {
67
+ if (!existsSync(join(stagingDir, ref))) {
68
+ log(` Warning: referenced file missing from tarball: ${ref}`);
69
+ }
70
+ }
71
+
72
+ if (errors.length > 0) {
73
+ const msg = "Validation failed:\n " + errors.join("\n ");
74
+ throw new Error(msg);
75
+ }
76
+
77
+ log(`Validated ${allFiles.length} file(s), no issues found`);
78
+ }
79
+
80
+ /**
81
+ * Collect the relative file paths referenced by path-bearing fields of a
82
+ * package.json manifest, partitioned by how strictly a missing target
83
+ * should be treated.
84
+ *
85
+ * - `strict`: `main`, `module`, `types`, `typings`, and every leaf of
86
+ * `exports`. A missing target here is a bug in the tarball we produced —
87
+ * Node's module resolution will fail and `npm publish` warns on
88
+ * dangling `types`.
89
+ * - `lenient`: `bin` entries. yarn and npm both tolerate a `bin` pointing
90
+ * at a missing file (see the `bin-missing` fixture, which mirrors
91
+ * agoric/portfolio-api's intentional shape).
92
+ *
93
+ * Exported for direct unit-testing of the policy; the only runtime caller
94
+ * is `validate()` above.
95
+ */
96
+ export function collectReferencedFiles(pkg) {
97
+ const strict = new Set ();
98
+ const lenient = new Set ();
99
+ const addStrict = (p) => strict.add(stripDotSlash(p));
100
+ const addLenient = (p) => lenient.add(stripDotSlash(p));
101
+
102
+ for (const field of ["main", "module", "types", "typings"]) {
103
+ if (typeof pkg[field] === "string") addStrict(pkg[field]);
104
+ }
105
+
106
+ if (typeof pkg.bin === "string") {
107
+ addLenient(pkg.bin);
108
+ } else if (typeof pkg.bin === "object" && pkg.bin !== null) {
109
+ for (const v of Object.values(pkg.bin)) {
110
+ if (typeof v === "string") addLenient(v);
111
+ }
112
+ }
113
+
114
+ if (pkg.exports) collectExportsRefs(pkg.exports, strict);
115
+
116
+ return { strict, lenient };
117
+ }
118
+
119
+ function collectExportsRefs(value, refs) {
120
+ if (typeof value === "string") {
121
+ refs.add(stripDotSlash(value));
122
+ return;
123
+ }
124
+ if (Array.isArray(value)) {
125
+ for (const v of value) collectExportsRefs(v, refs);
126
+ return;
127
+ }
128
+ if (typeof value === "object" && value !== null) {
129
+ for (const v of Object.values(value)) collectExportsRefs(v, refs);
130
+ }
131
+ }
132
+
133
+ function stripDotSlash(p) {
134
+ return p.replace(/^\.\//, "");
135
+ }
136
+
137
+ async function findFiles(dir, test) {
138
+ const results = [];
139
+ const entries = await readdir(dir, { withFileTypes: true, recursive: true });
140
+ for (const entry of entries) {
141
+ if (entry.isFile() && test(entry.name)) {
142
+ results.push(join(entry.parentPath, entry.name));
143
+ }
144
+ }
145
+ return results;
146
+ }