@cosyte/script-utils 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.11
4
+
5
+ ### Patch Changes
6
+
7
+ - c854d59: `@cosyte/script-utils`: the attw gate (`cosyte-attw`, `runAttwGate` and `runAttwCli`) now starts on
8
+ Windows. It used to check `node_modules/.bin/attw` and start it without a shell; on Windows that file
9
+ is a POSIX shell script, so the start failed with ENOENT, and npm and pnpm, which the gate also
10
+ starts, are `.cmd` files there that Node will not start without a shell. On Windows the gate now
11
+ checks and starts `node_modules/.bin/attw.cmd`, and starts all three tools through `cmd.exe` with
12
+ every argument and every path on the command line in double quotes, a trailing run of backslashes
13
+ doubled so each argument arrives whole. Elsewhere nothing changes. Two new exports,
14
+ `attwBinaryPath(callerUrl, platform?)` and `toolCommand(file, args, platform?)`, say which binary and
15
+ which command line. The gate now refuses an argument carrying a double quote, a percent sign or a
16
+ line break, on every system: no argument it accepts needs one. A repository that skipped the attw
17
+ step on Windows can drop the skip once it takes this version. Callers change nothing.
18
+
19
+ `@cosyte/bench`: the README now says what the small-input memory budget gives up: a cost in fewer
20
+ than half of a small case's 20 calls is not bounded, the rule switches at a hard 64 KiB edge, and
21
+ the empty-call floor's highest call is not a fixed figure (usually about 29 KiB, once 369,888
22
+ bytes) while its median is stable at about 3.4 KiB. No behavior changes.
23
+
3
24
  ## 0.1.10
4
25
 
5
26
  ### Patch Changes
package/README.md CHANGED
@@ -807,6 +807,16 @@ Four nets, each catching something the others cannot:
807
807
  What each net does and does not claim, and every measurement behind them, is in `attw.js`'s own
808
808
  docblock. It is not restated here: a claim written down twice is a claim that drifts.
809
809
 
810
+ On Windows the gate checks and starts `node_modules/.bin/attw.cmd`, because the extensionless
811
+ `attw` there is a POSIX shell script, and it starts attw, npm and pnpm through `cmd.exe`, because
812
+ all three are `.cmd` files that Node will not start without a shell. Every argument and every path
813
+ on that command line is in double quotes (a bare name found on PATH, such as `npm`, is not: `cmd.exe`
814
+ gives a quoted one the wrong `%~dp0`), with a trailing run of backslashes doubled so node still
815
+ reads the closing quote as one, and an argument carrying a double quote, a percent sign or a line break is
816
+ refused on every system. Two exports say what the gate starts: `attwBinaryPath(callerUrl,
817
+ platform?)`, the binary's path, and `toolCommand(file, args, platform?)`, the command, its arguments
818
+ and whether a shell runs it.
819
+
810
820
  ## Compatibility
811
821
 
812
822
  Node `>=22.14`, ESM only, published as source (`index.js`, `phi-scan.js`, `internal-refs.js` and
@@ -814,7 +824,8 @@ Node `>=22.14`, ESM only, published as source (`index.js`, `phi-scan.js`, `inter
814
824
  relative path from a checkout as well as by specifier. `runPhiScan` shells out to `git` for the
815
825
  index-backed half of its sweep, so `all` mode needs git on the path, `runInternalRefsScan` shells
816
826
  out to `git` for its whole enumeration, and `runAttwGate` spawns the consuming package's own `attw`
817
- binary, `npm pack` and, where a `publishConfig` object is present, `pnpm pack`.
827
+ binary, `npm pack` and, where a `publishConfig` object is present, `pnpm pack`, through `cmd.exe`
828
+ on Windows.
818
829
 
819
830
  ## Contributing
820
831
 
package/attw.d.ts CHANGED
@@ -52,3 +52,31 @@ export declare function readAttwExclusions(text: string): {
52
52
  * `scripts/attw.exclude`, with no caller file. `node node_modules/@cosyte/script-utils/attw.js`.
53
53
  */
54
54
  export declare function runAttwCli(argv: string[]): number;
55
+
56
+ /**
57
+ * The consuming package's own attw binary, resolved as `../node_modules/.bin/attw` from
58
+ * `callerUrl`, or `../node_modules/.bin/attw.cmd` on Windows, where the extensionless file is a
59
+ * POSIX shell script that Windows cannot start.
60
+ *
61
+ * @param callerUrl The caller's `import.meta.url`.
62
+ * @param platform Defaults to `process.platform`.
63
+ */
64
+ export declare function attwBinaryPath(callerUrl: string, platform?: string): string;
65
+
66
+ /**
67
+ * How the gate starts attw, npm and pnpm. Off Windows: the file and its arguments as they are,
68
+ * with no shell. On Windows, where all three are `.cmd` files that Node will not start without
69
+ * one: a single command line, every part but a bare tool name in double quotes, run through `cmd.exe`. A part carrying
70
+ * a double quote, a percent sign or a line break throws there rather than being escaped.
71
+ * A trailing run of backslashes in an argument is doubled, so node, which halves backslashes
72
+ * before a quote, still reads the closing quote as one.
73
+ *
74
+ * @param file The tool: a path, or a name found on PATH.
75
+ * @param args Its arguments.
76
+ * @param platform Defaults to `process.platform`.
77
+ */
78
+ export declare function toolCommand(
79
+ file: string,
80
+ args: string[],
81
+ platform?: string,
82
+ ): { file: string; args: string[]; shell: boolean };
package/attw.js CHANGED
@@ -787,6 +787,18 @@ const ALLOWED = new Set(["--profile", "--no-definitely-typed"]);
787
787
  */
788
788
  function forwardable(args) {
789
789
  const forwarded = [];
790
+ // REFUSED ON EVERY PLATFORM, though only Windows needs it: there attw is started
791
+ // through cmd.exe, which cannot take these characters inside quotes (see
792
+ // `toolCommand`). No option or value this gate accepts carries one, and refusing
793
+ // them everywhere keeps one behaviour on every system.
794
+ const unsafe = args.find((arg) => /["%\r\n]/.test(arg));
795
+ if (unsafe !== undefined) {
796
+ die(
797
+ `${JSON.stringify(unsafe)} carries a double quote, a percent sign or a line break.\n` +
798
+ ` No argument this gate accepts needs one, and on Windows, where attw is started\n` +
799
+ ` through cmd.exe, none of them can be passed on safely.`,
800
+ );
801
+ }
790
802
  for (let i = 0; i < args.length; i++) {
791
803
  const arg = args[i];
792
804
  const name = arg.split("=")[0];
@@ -832,6 +844,92 @@ const PACK_PLACEMENT_CONFIG = /^npm_config_(dry[_-]run|pack[_-]destination)$/i;
832
844
  // rather than left as `spawnSync ... ENOBUFS`.
833
845
  const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
834
846
 
847
+ /**
848
+ * Where the consuming package's own attw binary is, on the platform given.
849
+ *
850
+ * ON WINDOWS THE RUNNABLE ONE IS `attw.cmd`. A package manager writes three files
851
+ * into `node_modules/.bin` there: `attw` (a POSIX shell script), `attw.cmd` and
852
+ * `attw.ps1`. The extensionless one exists, so a check for it passes, and then
853
+ * starting it fails with ENOENT, because Windows cannot start a shell script. That
854
+ * was the whole failure on the Windows legs that first ran this gate.
855
+ *
856
+ * @param {string} callerUrl The caller's `import.meta.url`.
857
+ * @param {NodeJS.Platform} [platform] Defaults to this process's own.
858
+ * @returns {string} The absolute path of the binary to check and start.
859
+ */
860
+ export function attwBinaryPath(callerUrl, platform = process.platform) {
861
+ const name = platform === "win32" ? "attw.cmd" : "attw";
862
+ return fileURLToPath(new URL(`../node_modules/.bin/${name}`, callerUrl));
863
+ }
864
+
865
+ /**
866
+ * How many backslashes end `text`. Counted by hand rather than by a regular
867
+ * expression anchored at the end, which backtracks over every run of backslashes
868
+ * that does not end the string and is slow on a long one.
869
+ *
870
+ * @param {string} text
871
+ * @returns {number}
872
+ */
873
+ function trailingBackslashes(text) {
874
+ let end = text.length;
875
+ while (end > 0 && text[end - 1] === "\\") end--;
876
+ return text.length - end;
877
+ }
878
+
879
+ /**
880
+ * How to start one of the three tools this gate runs (attw, npm and pnpm), on the
881
+ * platform given, with no shell wherever a shell is not needed.
882
+ *
883
+ * Off Windows it is the file and its arguments, as they are. ON WINDOWS a `.cmd`
884
+ * file cannot be started without `cmd.exe`: Node refuses it (EINVAL) since the
885
+ * April 2024 security releases, and npm and pnpm are `.cmd` files there too. So
886
+ * the command line is built here, every part in double quotes (but one, below),
887
+ * and handed to the
888
+ * shell whole, with no separate arguments for Node to join. Inside double quotes
889
+ * `cmd.exe` takes `&`, `|`, `<`, `>`, `^` and spaces literally. It does not take
890
+ * `"` (which ends the quoting) or `%` (which expands a variable) literally, and a
891
+ * line break ends the command, so a part carrying any of those THROWS rather than
892
+ * being escaped: an escape that is wrong runs a different command. Nothing this
893
+ * gate passes carries one, because `forwardable()` refuses them in a caller's
894
+ * arguments on every platform, so a throw here is a defect in this file and is
895
+ * left to propagate. A trailing run of backslashes in an argument is doubled, for
896
+ * node's own reading of the line after cmd.exe (see below).
897
+ *
898
+ * A BARE NAME FOUND ON PATH IS THE ONE PART LEFT UNQUOTED. `cmd.exe` starts a
899
+ * quoted batch file found through PATH with `%~dp0` pointing at the working
900
+ * directory instead of the file's own, and `npm.cmd` finds `npm-cli.js` through
901
+ * `%~dp0`. Measured on `windows-latest` with every part quoted: npm looked for
902
+ * `<package>\node_modules\npm\bin\npm-cli.js` and exited 1. A name made only of
903
+ * letters, digits, `.`, `_` and `-` needs no quotes; a path is quoted, and a
904
+ * batch file named by its full path gets the right `%~dp0` either way.
905
+ *
906
+ * @param {string} file The tool: a path, or a name found on PATH.
907
+ * @param {string[]} args Its arguments.
908
+ * @param {NodeJS.Platform} [platform] Defaults to this process's own.
909
+ * @returns {{ file: string, args: string[], shell: boolean }}
910
+ */
911
+ export function toolCommand(file, args, platform = process.platform) {
912
+ if (platform !== "win32") return { file, args, shell: false };
913
+ const parts = [file, ...args];
914
+ const unsafe = parts.find((part) => /["%\r\n]/.test(part));
915
+ if (unsafe !== undefined) {
916
+ throw new Error(
917
+ `${JSON.stringify(unsafe)} cannot be passed through cmd.exe safely: it carries a ` +
918
+ `double quote, a percent sign or a line break.`,
919
+ );
920
+ }
921
+ const command = /^[\w.-]+$/.test(file) ? file : `"${file}"`;
922
+ // A TRAILING RUN OF BACKSLASHES IS DOUBLED. cmd.exe passes the quotes on, and node then splits
923
+ // its command line by the Microsoft C rules, where backslashes before a quote are halved and an
924
+ // odd one makes the quote literal. Unescaped, `node16\` would arrive as `node16" --format json`,
925
+ // one argument where three were sent. A backslash anywhere else is literal and left alone.
926
+ const line = [
927
+ command,
928
+ ...args.map((arg) => `"${arg}${"\\".repeat(trailingBackslashes(arg))}"`),
929
+ ].join(" ");
930
+ return { file: line, args: [], shell: true };
931
+ }
932
+
835
933
  /**
836
934
  * The fields that CAN name a file and that `declaredArtifacts()` deliberately does
837
935
  * not read. THIS IS THE ONLY COPY OF THAT CLAIM: the pass line prints it, and
@@ -1050,7 +1148,9 @@ function packReportEntries(doc, name) {
1050
1148
  * @returns {{ files: Set<string>, error?: undefined } | { error: string, files?: undefined }}
1051
1149
  */
1052
1150
  function packedFiles(pkg, childEnv) {
1053
- const res = spawnSync("npm", ["pack", "--dry-run", "--json"], {
1151
+ const npm = toolCommand("npm", ["pack", "--dry-run", "--json"]);
1152
+ const res = spawnSync(npm.file, npm.args, {
1153
+ shell: npm.shell,
1054
1154
  encoding: "utf8",
1055
1155
  stdio: ["inherit", "pipe", "pipe"],
1056
1156
  env: childEnv,
@@ -1263,7 +1363,9 @@ function pnpmPublished(childEnv) {
1263
1363
  return { error: `could not create a temporary directory for \`pnpm pack\`: ${err.message}` };
1264
1364
  }
1265
1365
  try {
1266
- const res = spawnSync("pnpm", ["pack", "--pack-destination", dest], {
1366
+ const pnpm = toolCommand("pnpm", ["pack", "--pack-destination", dest]);
1367
+ const res = spawnSync(pnpm.file, pnpm.args, {
1368
+ shell: pnpm.shell,
1267
1369
  encoding: "utf8",
1268
1370
  stdio: ["inherit", "pipe", "pipe"],
1269
1371
  env: childEnv,
@@ -1361,7 +1463,8 @@ function gate({ callerUrl, argv = process.argv.slice(2) }) {
1361
1463
  ` node_modules, which is not the package being checked.`,
1362
1464
  );
1363
1465
  }
1364
- const ATTW_BIN = fileURLToPath(new URL("../node_modules/.bin/attw", callerUrl));
1466
+ // `attw.cmd` on Windows, `attw` everywhere else: see `attwBinaryPath`.
1467
+ const ATTW_BIN = attwBinaryPath(callerUrl);
1365
1468
  // THE BINARY IS CHECKED BEFORE IT IS SPAWNED, so the refusal NAMES THE PATH it
1366
1469
  // looked for. spawnSync reports ENOENT and EACCES the same way, and a message
1367
1470
  // built out of `res.error.message` is about a syscall rather than about which
@@ -1449,7 +1552,9 @@ function gate({ callerUrl, argv = process.argv.slice(2) }) {
1449
1552
  // reads structure, so the structure has to be there. A `.attw.json` can still
1450
1553
  // override it (config is applied after argv), and that is handled where it lands,
1451
1554
  // as an unparseable transcript, rather than by refusing the key by name.
1452
- const res = spawnSync(ATTW_BIN, ["--pack", ".", ...forwarded, "--format", "json"], {
1555
+ const attw = toolCommand(ATTW_BIN, ["--pack", ".", ...forwarded, "--format", "json"]);
1556
+ const res = spawnSync(attw.file, attw.args, {
1557
+ shell: attw.shell,
1453
1558
  encoding: "utf8",
1454
1559
  stdio: ["inherit", "pipe", "pipe"],
1455
1560
  env,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosyte/script-utils",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Zero-dependency helpers for the repo-local gate scripts in @cosyte/* repos.",
5
5
  "keywords": [
6
6
  "cli",