game5 / client / scripts / patch-npm.mjs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
// Rewrites, in place inside node_modules, the two ES-module forms shadow-cljs
// mishandles — before every shadow invocation (scripts/dev.mjs, build.mjs).
//
// node_modules is generated and gitignored, so nothing tracked changes; every
// build re-applies this after an `npm ci`. Both rewrites are semantics-
// preserving and idempotent (a patched file no longer matches), and both are
// no-ops under a real ES-module loader — which is why they are safe to make at
// the SOURCE rather than on shadow's output.
//
// ---------------------------------------------------------------------------
// RULE 1 — `export-star-as`
//
// shadow's npm inspector uses Closure's ES-module parser, which rejects
//
//     export * as NS from "./mod.js";
//
// with `{:message "'from' expected", :line 1, :column 9}` and ABORTS the whole
// app build. Three files in this dependency tree are written that way —
// `@libp2p/crypto/ciphers` (a direct dep of libp2p and @chainsafe/libp2p-noise;
// no helia/keychain needed to pull it in) and two in `@peculiar/utils` (via
// @libp2p/webrtc -> @peculiar/webcrypto). Today's build graph happens not to
// reach them, so this rule is prophylactic here — but the files are real, the
// coverage gate below counts them, and the first dep bump that reaches one
// would abort the build without this desugaring. Vite/rollup and esbuild both
// accept the form, which is why the TypeScript build never saw this.
//
// The rewrite is the exact desugaring — a namespace import plus a named
// re-export.
//
// Alternatives rejected: aliasing the specifier to a local shim (shadow's
// `:target :file` resources have no package context, so the shim cannot reach
// the leaf module — the package's export map does not name it), and pinning an
// older @libp2p/crypto (5.1.8, the version this app already pins exactly, is
// written the same way).
//
// ---------------------------------------------------------------------------
// RULE 2 — `namespace-spread-esmodule`
//
// shadow's CommonJS conversion of an npm module marks its exports object with
// an ENUMERABLE `__esModule` (shadow-cljs's own ShadowESModuleRewriter emits
// `Object.defineProperties(exports, {__esModule:{enumerable:true,value:true},…})`
// — a babel-interop convention Closure itself never emits). A real ES-module
// namespace object has no such property at all. So wherever a package builds a
// DATA object by spreading namespaces —
//
//     import * as base32 from './bases/base32.js'          // …and nine more
//     export const bases = { ...identityBase, ...base2, ...base32, … }
//
// — the marker is copied in as a first-class entry, and every consumer that
// ENUMERATES that object trips over it. `multiformats` does exactly the above,
// and `@multiformats/multiaddr/dist/src/utils.js` then does
//
//     const decoders = Object.values(bases).map((c) => c.decoder)
//     let acc = decoders[0].or(decoders[1])
//
// where `decoders[0]` is `true.decoder` === undefined, so module init throws
//   TypeError: Cannot read properties of undefined (reading 'or')
// which aborts @multiformats/multiaddr and, with it, @libp2p/webrtc and
// gossipsub — i.e. the whole p2p layer: the lobby paints (it is in :main) but
// creating or joining a room can never bring a network stack up.
//
// The rewrite drops the marker from the built object. Under a real ESM loader
// there is no `__esModule` key, so the `delete` is a no-op: dev and release run
// the same source either way. Interop READS of `mod.__esModule` on the module
// itself are untouched — only the copy inside the data object goes.
//
// This lives HERE, at the source, because it is the only place that reaches
// BOTH pipelines. build.mjs step 2b flips the marker non-enumerable across the
// emitted release bundle, which is correct and more general — but it is a
// one-shot post-process over a finished dist, and `shadow-cljs watch` writes
// dist/assets/cljs-runtime/*.js continuously, so dev can never have that step.
// That asymmetry is exactly how this bug shipped: release was fine, dev was a
// blank screen, and `npx shadow-cljs release app` (no build.mjs) was broken too
// while still compiling clean.
//
// ---------------------------------------------------------------------------
// THE GATES
//
// A rewriting script that quietly matches nothing is a gate that passes by not
// looking — it prints "0 file(s)", the build goes green, and the browser dies.
// So each rule asserts its own postcondition and this script EXITS NON-ZERO on
// violation:
//
//   coverage — after the pass, at least one file in the tree must carry the
//     rule's patched signature. Zero means the packages moved, the upstream
//     form changed, or the pattern drifted: the rule is no longer doing the job
//     it exists for, and someone must look. (Note this is NOT "did we change a
//     file": the rewrites are idempotent, so a second run legitimately changes
//     nothing while coverage stays satisfied.)
//
//   residual — after the pass, a deliberately LOOSER detector than the
//     rewriter re-scans the tree. Anything it finds that the rewriter did not
//     fix is reported by path and fails the run. This is what catches a form
//     that drifted just past the rewriting regex — the failure mode where a
//     tighter check would silently skip the file.
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";

const CLIENT = dirname(dirname(fileURLToPath(import.meta.url)));
const NM = join(CLIENT, "node_modules");

// ---- rule 1 ---------------------------------------------------------------

const STAR_AS = /^export \* as ([A-Za-z_$][\w$]*) from (['"][^'"]+['"]);?[ \t]*$/gm;
const STAR_AS_DONE = /^import \* as ([A-Za-z_$][\w$]*) from ['"][^'"]+['"];\nexport \{ \1 \};$/m;
// Looser than STAR_AS on purpose: any indentation, any internal spacing, any
// specifier, and no requirement that the statement end the line. Still anchored
// to the start of a line, or every build tool in the tree that DISCUSSES the
// form in a `// export * as …` comment (rollup, terser, hermes-parser all do)
// reports as an unfixed hazard.
const STAR_AS_ANY = /^[ \t]*export[ \t]*\*[ \t]*as[ \t]+[A-Za-z_$][\w$]*[ \t]+from\b/m;

// ---- rule 2 ---------------------------------------------------------------

const NS_IMPORT = /^import \* as ([A-Za-z_$][\w$]*) from /gm;
// an object literal made up of NOTHING BUT identifier spreads, bound to a name
const SPREAD_DECL =
  /^([ \t]*)(export )?(const|let|var) ([A-Za-z_$][\w$]*) = (\{ *(?:\.\.\.[A-Za-z_$][\w$]* *, *)*\.\.\.[A-Za-z_$][\w$]* *\}) *;?[ \t]*$/gm;
const SPREAD_DONE = /^[ \t]*delete __esm_[A-Za-z_$][\w$]*\.__esModule;$/m;

// The residual detector for rule 2. Looser than SPREAD_DECL in every direction
// that matters: the literal may span lines, may hold members other than
// spreads, and the declaration keyword need not start the line. It still
// requires the target to be a plain identifier, because
// `const { a, b } = { ...ns }` reads named keys and can never see the marker.
function spreadHazards(src, ns) {
  const out = [];
  for (const name of ns) {
    const re = new RegExp(
      String.raw`(?:^|[;{}\n])[ \t]*(?:export[ \t]+)?(?:const|let|var)[ \t]+[A-Za-z_$][\w$]*[ \t]*=[ \t]*\{[\s\S]{0,400}?\.\.\.${name}\b`,
      "m",
    );
    if (re.test(src)) out.push(name);
  }
  return out;
}

const RULES = [
  {
    id: "export-star-as",
    what: "`export * as NS from …` (shadow's npm inspector cannot parse it)",
    rewrite(src) {
      return src.replace(STAR_AS, (_m, nsName, spec) => `import * as ${nsName} from ${spec};\nexport { ${nsName} };`);
    },
    patched: (src) => STAR_AS_DONE.test(src),
    residual: (src) => (STAR_AS_ANY.test(src) ? ["export * as"] : []),
  },
  {
    id: "namespace-spread-esmodule",
    what: "an ESM namespace spread into a data object (shadow's enumerable `__esModule` leaks in)",
    rewrite(src) {
      const ns = new Set([...src.matchAll(NS_IMPORT)].map((m) => m[1]));
      if (ns.size === 0) return src;
      return src.replace(SPREAD_DECL, (m, indent, exported, kw, name, literal) => {
        // idempotence: the temporary this rule introduces is itself a
        // spread-literal declaration and would otherwise be rewritten again on
        // every run, nesting `__esm___esm_…` one layer deeper each time
        if (name.startsWith("__esm_")) return m;
        // only when at least one spread member really is a namespace import
        if (![...ns].some((n) => literal.includes(`...${n}`))) return m;
        const tmp = `__esm_${name}`;
        return (
          `${indent}const ${tmp} = ${literal};\n` +
          `${indent}delete ${tmp}.__esModule;\n` +
          `${indent}${exported ?? ""}${kw} ${name} = ${tmp};`
        );
      });
    },
    patched: (src) => SPREAD_DONE.test(src),
    residual(src) {
      const ns = [...src.matchAll(NS_IMPORT)].map((m) => m[1]);
      return ns.length === 0 ? [] : spreadHazards(src, ns);
    },
  },
];

// ---------------------------------------------------------------------------

function walk(dir, out = []) {
  let entries;
  try {
    entries = readdirSync(dir);
  } catch {
    return out;
  }
  for (const name of entries) {
    if (name === ".bin" || name === ".cache") continue;
    const p = join(dir, name);
    let st;
    try {
      st = statSync(p);
    } catch {
      continue;
    }
    if (st.isDirectory()) walk(p, out);
    else if (name.endsWith(".js") || name.endsWith(".mjs")) out.push(p);
  }
  return out;
}

const files = walk(NM);
const changed = new Map(RULES.map((r) => [r.id, 0]));
const covered = new Map(RULES.map((r) => [r.id, 0]));
const leftover = new Map(RULES.map((r) => [r.id, []]));

for (const file of files) {
  let src;
  try {
    src = readFileSync(file, "utf8");
  } catch {
    continue;
  }
  const before = src;
  for (const rule of RULES) {
    const out = rule.rewrite(src);
    if (out !== src) {
      changed.set(rule.id, changed.get(rule.id) + 1);
      src = out;
    }
  }
  if (src !== before) writeFileSync(file, src);
  for (const rule of RULES) {
    if (rule.patched(src)) covered.set(rule.id, covered.get(rule.id) + 1);
    else {
      const hits = rule.residual(src);
      if (hits.length) leftover.get(rule.id).push(`${relative(CLIENT, file)} (${hits.join(", ")})`);
    }
  }
}

let failed = false;
for (const rule of RULES) {
  console.log(
    `patch-npm: ${rule.id} — rewrote ${changed.get(rule.id)} file(s), ${covered.get(rule.id)} now carry the fix`,
  );
  if (covered.get(rule.id) === 0) {
    failed = true;
    console.error(
      `patch-npm GATE FAILED: rule '${rule.id}' matched NOTHING in node_modules.\n` +
        `  it exists to fix ${rule.what}.\n` +
        `  zero coverage means the packages moved, the upstream form changed, or this\n` +
        `  pattern drifted — the build will look clean and die in the browser. Look.`,
    );
  }
  const rest = leftover.get(rule.id);
  if (rest.length) {
    failed = true;
    console.error(
      `patch-npm GATE FAILED: rule '${rule.id}' left ${rest.length} file(s) unfixed —\n` +
        `  the hazard is present in a form the rewrite does not match:\n` +
        rest.map((f) => `    ${f}`).join("\n"),
    );
  }
}
if (failed) process.exit(1);

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/game5.git