@venizia/dev-configs 0.4.3 → 0.4.4

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 (70) hide show
  1. package/LICENSE.md +2 -5
  2. package/README.md +15 -2
  3. package/dist/checks/catalog.d.ts +39 -0
  4. package/dist/checks/catalog.d.ts.map +1 -0
  5. package/dist/checks/catalog.js +94 -0
  6. package/dist/checks/catalog.js.map +1 -0
  7. package/dist/checks/index.d.ts +11 -0
  8. package/dist/checks/index.d.ts.map +1 -0
  9. package/dist/checks/index.js +27 -0
  10. package/dist/checks/index.js.map +1 -0
  11. package/dist/checks/module-cycles.d.ts +22 -0
  12. package/dist/checks/module-cycles.d.ts.map +1 -0
  13. package/dist/checks/module-cycles.js +149 -0
  14. package/dist/checks/module-cycles.js.map +1 -0
  15. package/dist/checks/purity/manifest.d.ts +94 -0
  16. package/dist/checks/purity/manifest.d.ts.map +1 -0
  17. package/dist/checks/purity/manifest.js +177 -0
  18. package/dist/checks/purity/manifest.js.map +1 -0
  19. package/dist/checks/purity/probe.d.ts +68 -0
  20. package/dist/checks/purity/probe.d.ts.map +1 -0
  21. package/dist/checks/purity/probe.js +295 -0
  22. package/dist/checks/purity/probe.js.map +1 -0
  23. package/dist/checks/purity/run.d.ts +47 -0
  24. package/dist/checks/purity/run.d.ts.map +1 -0
  25. package/dist/checks/purity/run.js +121 -0
  26. package/dist/checks/purity/run.js.map +1 -0
  27. package/dist/checks/split-report.d.ts +19 -0
  28. package/dist/checks/split-report.d.ts.map +1 -0
  29. package/dist/checks/split-report.js +123 -0
  30. package/dist/checks/split-report.js.map +1 -0
  31. package/dist/cli/check.d.ts +10 -0
  32. package/dist/cli/check.d.ts.map +1 -0
  33. package/dist/cli/check.js +206 -0
  34. package/dist/cli/check.js.map +1 -0
  35. package/dist/cli/codemod/const-classes.d.ts +80 -0
  36. package/dist/cli/codemod/const-classes.d.ts.map +1 -0
  37. package/dist/cli/codemod/const-classes.js +603 -0
  38. package/dist/cli/codemod/const-classes.js.map +1 -0
  39. package/dist/cli/codemod/drop-redundant-casts.d.ts +112 -0
  40. package/dist/cli/codemod/drop-redundant-casts.d.ts.map +1 -0
  41. package/dist/cli/codemod/drop-redundant-casts.js +780 -0
  42. package/dist/cli/codemod/drop-redundant-casts.js.map +1 -0
  43. package/dist/cli/codemod/index.d.ts +2 -1
  44. package/dist/cli/codemod/index.d.ts.map +1 -1
  45. package/dist/cli/codemod/index.js +25 -7
  46. package/dist/cli/codemod/index.js.map +1 -1
  47. package/dist/cli/codemod/model-settings.d.ts.map +1 -1
  48. package/dist/cli/codemod/model-settings.js +39 -2
  49. package/dist/cli/codemod/model-settings.js.map +1 -1
  50. package/dist/cli/index.js +9 -1
  51. package/dist/cli/index.js.map +1 -1
  52. package/dist/testing/index.d.ts +2 -0
  53. package/dist/testing/index.d.ts.map +1 -1
  54. package/dist/testing/index.js +2 -0
  55. package/dist/testing/index.js.map +1 -1
  56. package/dist/testing/repo-tmp.d.ts +12 -0
  57. package/dist/testing/repo-tmp.d.ts.map +1 -0
  58. package/dist/testing/repo-tmp.js +67 -0
  59. package/dist/testing/repo-tmp.js.map +1 -0
  60. package/dist/testing/spawn.helper.d.ts +13 -0
  61. package/dist/testing/spawn.helper.d.ts.map +1 -0
  62. package/dist/testing/spawn.helper.js +22 -0
  63. package/dist/testing/spawn.helper.js.map +1 -0
  64. package/dist/testing/tmpdir-preload.d.ts +2 -0
  65. package/dist/testing/tmpdir-preload.d.ts.map +1 -0
  66. package/dist/testing/tmpdir-preload.js +9 -0
  67. package/dist/testing/tmpdir-preload.js.map +1 -0
  68. package/package.json +9 -1
  69. package/src/tsconfig/tsconfig.base.json +5 -2
  70. package/src/tsconfig/tsconfig.common.json +2 -2
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildManifest = exports.assertBrowserEsmEntry = exports.deriveEntries = exports.assertNoWorkspaceExternal = void 0;
4
+ const node_fs_1 = require("node:fs");
5
+ const node_path_1 = require("node:path");
6
+ const DEFAULT_WORKSPACE_SCOPE = '@venizia/';
7
+ /** The one place this engine builds an `Error`: `getError` would make dev-configs depend on inversion, which depends on dev-configs. */
8
+ // eslint-disable-next-line venizia/no-new-error -- getError would be a dev-configs -> inversion cycle
9
+ const purityError = (message) => new Error(message);
10
+ /** `types` describes the entry, it is not a runtime target; `./package.json` is not code. */
11
+ const NON_RUNTIME_CONDITION = 'types';
12
+ const NON_CODE_SUBPATH = './package.json';
13
+ /**
14
+ * A code-level guard, not just the JSDoc on `external`: it is an escape hatch from this gate, and a
15
+ * hatch with no lock is one bad edit away from hiding a real leak - `external: ['@venizia/ignis-helpers']`
16
+ * on a red entry would silently turn it green by removing the exact root-barrel import this gate
17
+ * exists to catch from the module graph, rather than proving it absent.
18
+ *
19
+ * A plain `Error`, not `getError`: the engine runs from the repository root through `devz`, outside
20
+ * every workspace package, and has no logger to resolve.
21
+ */
22
+ const assertNoWorkspaceExternal = (manifest, workspaceScope = DEFAULT_WORKSPACE_SCOPE) => {
23
+ for (const row of manifest) {
24
+ const specifiers = row.external ?? [];
25
+ for (const specifier of specifiers) {
26
+ if (!specifier.startsWith(workspaceScope)) {
27
+ continue;
28
+ }
29
+ throw purityError(`[purity][manifest] Entry '${row.label}' externalises '${specifier}', a workspace package ` +
30
+ `| \`external\` may only exempt THIRD-PARTY packaging, never the repository's own code - doing so ` +
31
+ `would hide the exact leak this gate exists to catch`);
32
+ }
33
+ }
34
+ return manifest;
35
+ };
36
+ exports.assertNoWorkspaceExternal = assertNoWorkspaceExternal;
37
+ const readExportsMap = (opts) => {
38
+ const manifestPath = (0, node_path_1.join)(opts.root, 'packages', opts.package, 'package.json');
39
+ const manifest = JSON.parse((0, node_fs_1.readFileSync)(manifestPath, 'utf8'));
40
+ if (!manifest.exports) {
41
+ throw purityError(`[purity][manifest] '${opts.package}' claims browser purity but publishes no \`exports\` map ` +
42
+ `| There is nothing to derive rows from - remove the claim or add the map`);
43
+ }
44
+ return manifest.exports;
45
+ };
46
+ /**
47
+ * `exports` may spell one sub-path as several conditions pointing at different builds - the dual
48
+ * CJS/ESM packages do exactly that - and each is a separately resolvable file that a consumer can
49
+ * end up importing, so each is probed. `default` normally repeats `require`, so files are deduped
50
+ * and the FIRST condition naming a file is the one that labels it.
51
+ */
52
+ const listConditionTargets = (target) => {
53
+ if (typeof target === 'string') {
54
+ return [{ condition: 'default', file: target }];
55
+ }
56
+ const seen = new Set();
57
+ const targets = [];
58
+ const targetEntries = Object.entries(target);
59
+ for (const [condition, file] of targetEntries) {
60
+ if (condition === NON_RUNTIME_CONDITION || seen.has(file)) {
61
+ continue;
62
+ }
63
+ seen.add(file);
64
+ targets.push({ condition, file });
65
+ }
66
+ return targets;
67
+ };
68
+ const buildLabel = (opts) => {
69
+ const { package: packageName, subpath, condition, isSoleTarget } = opts;
70
+ const suffix = subpath === '.' ? '' : subpath.slice(1);
71
+ const base = `${packageName}${suffix}`;
72
+ return isSoleTarget ? base : `${base} [${condition}]`;
73
+ };
74
+ /**
75
+ * Exported for the tests, which have to drive a claim that no longer matches its package's
76
+ * `exports` map - the staleness guard is the whole point of deriving, and it cannot be asserted
77
+ * through a built manifest, which by construction is never stale.
78
+ */
79
+ const deriveEntries = (opts) => {
80
+ const { claim, root } = opts;
81
+ const exportsMap = readExportsMap({ root, package: claim.package });
82
+ const published = Object.keys(exportsMap).filter((subpath) => subpath !== NON_CODE_SUBPATH);
83
+ const subpathsList = claim.subpaths ?? [];
84
+ for (const subpath of subpathsList) {
85
+ if (published.includes(subpath)) {
86
+ continue;
87
+ }
88
+ throw purityError(`[purity][manifest] '${claim.package}' claims '${subpath}', which its \`exports\` map no longer ` +
89
+ `publishes | Published: ${published.join(', ')} - a renamed sub-path must not silently drop its row`);
90
+ }
91
+ const claimExternalKeys = Object.keys(claim.external ?? {});
92
+ for (const subpath of claimExternalKeys) {
93
+ if (published.includes(subpath)) {
94
+ continue;
95
+ }
96
+ throw purityError(`[purity][manifest] '${claim.package}' declares \`external\` for '${subpath}', which its ` +
97
+ `\`exports\` map no longer publishes | An external that outlives its entry is a waiver ` +
98
+ `nobody is reading`);
99
+ }
100
+ const claimed = claim.subpaths ?? published;
101
+ const claimImpureKeys = Object.keys(claim.impure ?? {});
102
+ for (const subpath of claimImpureKeys) {
103
+ if (claimed.includes(subpath)) {
104
+ continue;
105
+ }
106
+ throw purityError(`[purity][manifest] '${claim.package}' waives '${subpath}' as impure, but that sub-path is not ` +
107
+ `claimed | Claimed: ${claimed.join(', ')} - a waiver against nothing is one nobody is reading`);
108
+ }
109
+ const entries = [];
110
+ for (const subpath of claimed) {
111
+ const targets = listConditionTargets(exportsMap[subpath]);
112
+ const conditions = claim.impure?.[subpath] ?? [];
113
+ for (const condition of conditions) {
114
+ if (targets.some((target) => target.condition === condition)) {
115
+ continue;
116
+ }
117
+ throw purityError(`[purity][manifest] '${claim.package}' waives '${subpath}' under condition '${condition}', ` +
118
+ `which its \`exports\` map does not publish | Published: ${targets.map((t) => t.condition).join(', ')}`);
119
+ }
120
+ for (const { condition, file } of targets) {
121
+ entries.push({
122
+ label: buildLabel({
123
+ package: claim.package,
124
+ subpath,
125
+ condition,
126
+ isSoleTarget: targets.length === 1,
127
+ }),
128
+ package: claim.package,
129
+ entry: (0, node_path_1.join)('packages', claim.package, file),
130
+ ...(claim.external?.[subpath] ? { external: claim.external[subpath] } : {}),
131
+ ...(claim.impure?.[subpath]?.includes(condition) ? { expectImpure: true } : {}),
132
+ });
133
+ }
134
+ }
135
+ return entries;
136
+ };
137
+ exports.deriveEntries = deriveEntries;
138
+ /**
139
+ * A browser claim is only honest if a browser bundler can actually consume the entry. A CommonJS
140
+ * entry forces every consumer into a workaround, so the claim carries the obligation: every claimed
141
+ * sub-path must publish its runtime entries below `esmEntryPrefix`. Checked here rather than trusted
142
+ * to a comment, for the same reason as {@link assertNoWorkspaceExternal} - the failure is otherwise
143
+ * invisible until someone tries to bundle the package downstream.
144
+ */
145
+ const assertBrowserEsmEntry = (opts) => {
146
+ const { claims, root, esmEntryPrefix } = opts;
147
+ for (const claim of claims) {
148
+ const exportsMap = readExportsMap({ root, package: claim.package });
149
+ const claimed = claim.subpaths ?? Object.keys(exportsMap).filter((s) => s !== NON_CODE_SUBPATH);
150
+ for (const subpath of claimed) {
151
+ const target = exportsMap[subpath];
152
+ const runtimeTargets = typeof target === 'object' && target !== null
153
+ ? Object.entries(target)
154
+ .filter(([condition]) => condition !== NON_RUNTIME_CONDITION)
155
+ .map(([, file]) => file)
156
+ : [];
157
+ if (runtimeTargets.length > 0 &&
158
+ runtimeTargets.every((file) => file.startsWith(esmEntryPrefix))) {
159
+ continue;
160
+ }
161
+ throw purityError(`[purity][manifest] '${claim.package}' claims browser purity for '${subpath}' but its runtime ` +
162
+ `entry is not in ${esmEntryPrefix.replace(/^\.\//, '')} | A browser bundler needs an ES module, so the claim ` +
163
+ `cannot be consumed - build the package as ES modules`);
164
+ }
165
+ }
166
+ return claims;
167
+ };
168
+ exports.assertBrowserEsmEntry = assertBrowserEsmEntry;
169
+ /** The rows of a whole repository: every claim derived from its package's `exports`, then the guards. */
170
+ const buildManifest = (opts) => {
171
+ const { claims, root, options = {} } = opts;
172
+ const { esmEntryPrefix, workspaceScope = DEFAULT_WORKSPACE_SCOPE } = options;
173
+ const checked = esmEntryPrefix === undefined ? claims : (0, exports.assertBrowserEsmEntry)({ claims, root, esmEntryPrefix });
174
+ return (0, exports.assertNoWorkspaceExternal)(checked.flatMap((claim) => (0, exports.deriveEntries)({ claim, root })), workspaceScope);
175
+ };
176
+ exports.buildManifest = buildManifest;
177
+ //# sourceMappingURL=manifest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../../../src/checks/purity/manifest.ts"],"names":[],"mappings":"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","sourcesContent":["import { readFileSync } from 'node:fs';\nimport { join } from 'node:path';\n\nexport interface IPurityEntry {\n label: string;\n package: string;\n /** Repository-relative path to the built file the `exports` map points at. */\n entry: string;\n /**\n * Specifiers to pass through as `bun build --external`. Only for a third-party dependency whose\n * OWN packaging is out of this gate's remit - e.g. one that relies on its `package.json` `browser`\n * field remap, which the downstream app's bundler (Vite, ...) honours and `bun build` does not.\n * Never for the repository's own source: an external hides that file from the module graph\n * entirely, so using it on our own code would let a real leak through unseen rather than prove\n * anything pure. Enforced by {@link assertNoWorkspaceExternal}, not left to this comment alone.\n */\n external?: string[];\n /**\n * This row is EXPECTED to be impure, so failing is its passing state. Derived from\n * {@link IPurityClaim.impure} - never authored on a row directly.\n */\n expectImpure?: boolean;\n}\n\n/**\n * A package whose published entry points carry a browser-purity claim.\n *\n * The ROW SET IS NEVER AUTHORED. It is read from the package's own `exports` map, so a sub-path\n * added to a manifest is probed the moment it is published.\n */\nexport interface IPurityClaim {\n /** Directory under `packages/`, and the token `devz check purity <token>` filters on. */\n package: string;\n /**\n * Sub-paths (as spelled in `exports`) when the claim covers only PART of the published surface.\n * Omit it and the whole surface is claimed. Every sub-path named here must still exist in\n * `exports`; a rename fails the derivation loudly rather than quietly dropping a row.\n */\n subpaths?: string[];\n /** `external` for one sub-path - see {@link IPurityEntry.external}. Keyed as spelled in `exports`. */\n external?: Record<string, string[]>;\n /**\n * Entries that CANNOT be browser-pure, keyed as spelled in `exports`, valued by the conditions\n * that are impure. The waiver is exact in BOTH directions: a listed entry that turns out to be\n * pure fails as loudly as an unlisted one that is not. Deriving still owns the ROW SET, so a\n * sub-path added later is claimed pure by default and has to earn its line here.\n */\n impure?: Record<string, string[]>;\n}\n\n/** What a repository may tune about the engine; everything else is the engine's contract. */\nexport interface IPurityOptions {\n /**\n * When set, every claimed sub-path must publish its runtime entries below this prefix (for\n * example `./dist/esm/`): a browser bundler needs an ES module, so a CommonJS-only entry makes\n * the claim unconsumable. Omit it for a repository that publishes a single build.\n */\n esmEntryPrefix?: string;\n\n /** Package-name prefix of this repository's own packages; an `external` may never start with it. Default `@venizia/`. */\n workspaceScope?: string;\n}\n\nconst DEFAULT_WORKSPACE_SCOPE = '@venizia/';\n\n/** The one place this engine builds an `Error`: `getError` would make dev-configs depend on inversion, which depends on dev-configs. */\n// eslint-disable-next-line venizia/no-new-error -- getError would be a dev-configs -> inversion cycle\nconst purityError = (message: string): Error => new Error(message);\n\n/** `types` describes the entry, it is not a runtime target; `./package.json` is not code. */\nconst NON_RUNTIME_CONDITION = 'types';\nconst NON_CODE_SUBPATH = './package.json';\n\n/**\n * A code-level guard, not just the JSDoc on `external`: it is an escape hatch from this gate, and a\n * hatch with no lock is one bad edit away from hiding a real leak - `external: ['@venizia/ignis-helpers']`\n * on a red entry would silently turn it green by removing the exact root-barrel import this gate\n * exists to catch from the module graph, rather than proving it absent.\n *\n * A plain `Error`, not `getError`: the engine runs from the repository root through `devz`, outside\n * every workspace package, and has no logger to resolve.\n */\nexport const assertNoWorkspaceExternal = (\n manifest: IPurityEntry[],\n workspaceScope: string = DEFAULT_WORKSPACE_SCOPE,\n): IPurityEntry[] => {\n for (const row of manifest) {\n const specifiers = row.external ?? [];\n for (const specifier of specifiers) {\n if (!specifier.startsWith(workspaceScope)) {\n continue;\n }\n\n throw purityError(\n `[purity][manifest] Entry '${row.label}' externalises '${specifier}', a workspace package ` +\n `| \\`external\\` may only exempt THIRD-PARTY packaging, never the repository's own code - doing so ` +\n `would hide the exact leak this gate exists to catch`,\n );\n }\n }\n\n return manifest;\n};\n\ntype TExportsTarget = string | Record<string, string>;\n\nconst readExportsMap = (opts: {\n root: string;\n package: string;\n}): Record<string, TExportsTarget> => {\n const manifestPath = join(opts.root, 'packages', opts.package, 'package.json');\n const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {\n exports?: Record<string, TExportsTarget>;\n };\n\n if (!manifest.exports) {\n throw purityError(\n `[purity][manifest] '${opts.package}' claims browser purity but publishes no \\`exports\\` map ` +\n `| There is nothing to derive rows from - remove the claim or add the map`,\n );\n }\n\n return manifest.exports;\n};\n\n/**\n * `exports` may spell one sub-path as several conditions pointing at different builds - the dual\n * CJS/ESM packages do exactly that - and each is a separately resolvable file that a consumer can\n * end up importing, so each is probed. `default` normally repeats `require`, so files are deduped\n * and the FIRST condition naming a file is the one that labels it.\n */\nconst listConditionTargets = (\n target: TExportsTarget,\n): Array<{ condition: string; file: string }> => {\n if (typeof target === 'string') {\n return [{ condition: 'default', file: target }];\n }\n\n const seen = new Set<string>();\n const targets: Array<{ condition: string; file: string }> = [];\n\n const targetEntries = Object.entries(target);\n for (const [condition, file] of targetEntries) {\n if (condition === NON_RUNTIME_CONDITION || seen.has(file)) {\n continue;\n }\n\n seen.add(file);\n targets.push({ condition, file });\n }\n\n return targets;\n};\n\nconst buildLabel = (opts: {\n package: string;\n subpath: string;\n condition: string;\n isSoleTarget: boolean;\n}): string => {\n const { package: packageName, subpath, condition, isSoleTarget } = opts;\n const suffix = subpath === '.' ? '' : subpath.slice(1);\n const base = `${packageName}${suffix}`;\n\n return isSoleTarget ? base : `${base} [${condition}]`;\n};\n\n/**\n * Exported for the tests, which have to drive a claim that no longer matches its package's\n * `exports` map - the staleness guard is the whole point of deriving, and it cannot be asserted\n * through a built manifest, which by construction is never stale.\n */\nexport const deriveEntries = (opts: { claim: IPurityClaim; root: string }): IPurityEntry[] => {\n const { claim, root } = opts;\n const exportsMap = readExportsMap({ root, package: claim.package });\n const published = Object.keys(exportsMap).filter((subpath) => subpath !== NON_CODE_SUBPATH);\n\n const subpathsList = claim.subpaths ?? [];\n for (const subpath of subpathsList) {\n if (published.includes(subpath)) {\n continue;\n }\n\n throw purityError(\n `[purity][manifest] '${claim.package}' claims '${subpath}', which its \\`exports\\` map no longer ` +\n `publishes | Published: ${published.join(', ')} - a renamed sub-path must not silently drop its row`,\n );\n }\n\n const claimExternalKeys = Object.keys(claim.external ?? {});\n for (const subpath of claimExternalKeys) {\n if (published.includes(subpath)) {\n continue;\n }\n\n throw purityError(\n `[purity][manifest] '${claim.package}' declares \\`external\\` for '${subpath}', which its ` +\n `\\`exports\\` map no longer publishes | An external that outlives its entry is a waiver ` +\n `nobody is reading`,\n );\n }\n\n const claimed = claim.subpaths ?? published;\n\n const claimImpureKeys = Object.keys(claim.impure ?? {});\n for (const subpath of claimImpureKeys) {\n if (claimed.includes(subpath)) {\n continue;\n }\n\n throw purityError(\n `[purity][manifest] '${claim.package}' waives '${subpath}' as impure, but that sub-path is not ` +\n `claimed | Claimed: ${claimed.join(', ')} - a waiver against nothing is one nobody is reading`,\n );\n }\n\n const entries: IPurityEntry[] = [];\n\n for (const subpath of claimed) {\n const targets = listConditionTargets(exportsMap[subpath]!);\n\n const conditions = claim.impure?.[subpath] ?? [];\n for (const condition of conditions) {\n if (targets.some((target) => target.condition === condition)) {\n continue;\n }\n\n throw purityError(\n `[purity][manifest] '${claim.package}' waives '${subpath}' under condition '${condition}', ` +\n `which its \\`exports\\` map does not publish | Published: ${targets.map((t) => t.condition).join(', ')}`,\n );\n }\n\n for (const { condition, file } of targets) {\n entries.push({\n label: buildLabel({\n package: claim.package,\n subpath,\n condition,\n isSoleTarget: targets.length === 1,\n }),\n package: claim.package,\n entry: join('packages', claim.package, file),\n ...(claim.external?.[subpath] ? { external: claim.external[subpath] } : {}),\n ...(claim.impure?.[subpath]?.includes(condition) ? { expectImpure: true } : {}),\n });\n }\n }\n\n return entries;\n};\n\n/**\n * A browser claim is only honest if a browser bundler can actually consume the entry. A CommonJS\n * entry forces every consumer into a workaround, so the claim carries the obligation: every claimed\n * sub-path must publish its runtime entries below `esmEntryPrefix`. Checked here rather than trusted\n * to a comment, for the same reason as {@link assertNoWorkspaceExternal} - the failure is otherwise\n * invisible until someone tries to bundle the package downstream.\n */\nexport const assertBrowserEsmEntry = (opts: {\n claims: IPurityClaim[];\n root: string;\n esmEntryPrefix: string;\n}): IPurityClaim[] => {\n const { claims, root, esmEntryPrefix } = opts;\n\n for (const claim of claims) {\n const exportsMap = readExportsMap({ root, package: claim.package });\n const claimed = claim.subpaths ?? Object.keys(exportsMap).filter((s) => s !== NON_CODE_SUBPATH);\n\n for (const subpath of claimed) {\n const target = exportsMap[subpath];\n\n const runtimeTargets =\n typeof target === 'object' && target !== null\n ? Object.entries(target)\n .filter(([condition]) => condition !== NON_RUNTIME_CONDITION)\n .map(([, file]) => file)\n : [];\n\n if (\n runtimeTargets.length > 0 &&\n runtimeTargets.every((file) => file.startsWith(esmEntryPrefix))\n ) {\n continue;\n }\n\n throw purityError(\n `[purity][manifest] '${claim.package}' claims browser purity for '${subpath}' but its runtime ` +\n `entry is not in ${esmEntryPrefix.replace(/^\\.\\//, '')} | A browser bundler needs an ES module, so the claim ` +\n `cannot be consumed - build the package as ES modules`,\n );\n }\n }\n\n return claims;\n};\n\n/** The rows of a whole repository: every claim derived from its package's `exports`, then the guards. */\nexport const buildManifest = (opts: {\n claims: IPurityClaim[];\n root: string;\n options?: IPurityOptions;\n}): IPurityEntry[] => {\n const { claims, root, options = {} } = opts;\n const { esmEntryPrefix, workspaceScope = DEFAULT_WORKSPACE_SCOPE } = options;\n\n const checked =\n esmEntryPrefix === undefined ? claims : assertBrowserEsmEntry({ claims, root, esmEntryPrefix });\n\n return assertNoWorkspaceExternal(\n checked.flatMap((claim) => deriveEntries({ claim, root })),\n workspaceScope,\n );\n};\n"]}
@@ -0,0 +1,68 @@
1
+ export interface IPurityResult {
2
+ entry: string;
3
+ ok: boolean;
4
+ sizeBytes: number;
5
+ builtins: string[];
6
+ globals: string[];
7
+ /** Node-global reads that cannot throw in a browser. Reported, never fatal. */
8
+ guardedGlobals: string[];
9
+ /**
10
+ * Specifiers this probe run excluded from its own module graph. Always echoed back, on both the
11
+ * passing and failing path - an entry that externalises something is measuring less than an entry
12
+ * that does not, and that must stay visible next to the result, not be a silent input.
13
+ */
14
+ external: string[];
15
+ buildError?: string;
16
+ }
17
+ /** Where the probe keeps its metafile, relative to the `cwd` it is given: the repository's own scratch, never the system temp. */
18
+ export declare const PURITY_SCRATCH_DIR = ".agents/local/tmp";
19
+ /** What a matched global means: ERROR is fatal, GUARDED is reported and never fatal. */
20
+ export declare class ProbeSeverities {
21
+ static readonly ERROR = "error";
22
+ static readonly GUARDED = "guarded";
23
+ static readonly SCHEME_SET: Set<string>;
24
+ static isValid(value: string): value is TProbeSeverity;
25
+ }
26
+ export type TProbeSeverity = Extract<(typeof ProbeSeverities)[keyof typeof ProbeSeverities], string>;
27
+ /**
28
+ * Node globals survive verbatim into a `target: 'browser'` bundle, so a gate reading only module
29
+ * specifiers reports clean on `process.env.NODE_ENV`. Each pattern must be anchored: a bundle
30
+ * legitimately contains identifiers like `processQueue`. A lookbehind that rejects a preceding dot
31
+ * also rejects `globalThis.process`, so every access path needs its own pattern.
32
+ *
33
+ * `severity` separates a read that throws from one that cannot. Optional chaining is not itself the
34
+ * difference: `process?.env` throws a ReferenceError when `process` is a free identifier, while
35
+ * `globalThis.process?.env` is an ordinary property read on an object that always exists.
36
+ *
37
+ * `guarded` means "reported, never fatal". Bare `process?.` sits there because this probe matches
38
+ * bundled text and has no scope information: hono's `getColorEnabled` writes
39
+ * `const { process, Deno } = globalThis;` and then `process?.env`, which is a local binding and
40
+ * perfectly safe, and no regex can tell that apart from a free global. Failing on it would make the
41
+ * gate cry wolf on a dependency; the unguarded `process.` and `globalThis.process.` forms stay
42
+ * fatal, and those are the ones that are unambiguously browser-breaking.
43
+ *
44
+ * Exported for `probe.test.ts` to assert against directly, alongside {@link stripBundlerPathComments}.
45
+ */
46
+ export declare const GLOBAL_PATTERNS: Array<{
47
+ name: string;
48
+ pattern: RegExp;
49
+ severity: TProbeSeverity;
50
+ }>;
51
+ /**
52
+ * Exported so `probe.test.ts` can assert against the literal collision string directly, without
53
+ * spawning `bun build` - a fixture whose OWN entry-path comment happens to reproduce the collision
54
+ * is not something a filename choice can be relied on to keep reproducing.
55
+ */
56
+ export declare const stripBundlerPathComments: (text: string) => string;
57
+ /**
58
+ * The CLI, not `Bun.build()`: the in-process bundler resolves this workspace's symlinked
59
+ * dependencies to directories and dies on them.
60
+ */
61
+ export declare const probeEntry: (opts: {
62
+ entry: string;
63
+ cwd: string;
64
+ external?: string[];
65
+ /** Defaults to {@link PURITY_SCRATCH_DIR} under `cwd`. */
66
+ scratchDir?: string;
67
+ }) => Promise<IPurityResult>;
68
+ //# sourceMappingURL=probe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"probe.d.ts","sourceRoot":"","sources":["../../../src/checks/purity/probe.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,OAAO,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,+EAA+E;IAC/E,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB;;;;OAIG;IACH,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,kIAAkI;AAClI,eAAO,MAAM,kBAAkB,sBAAsB,CAAC;AAStD,wFAAwF;AACxF,qBAAa,eAAe;IAC1B,MAAM,CAAC,QAAQ,CAAC,KAAK,WAAW;IAChC,MAAM,CAAC,QAAQ,CAAC,OAAO,aAAa;IAEpC,MAAM,CAAC,QAAQ,CAAC,UAAU,cAA+C;IAEzE,MAAM,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,cAAc;CAGvD;AAED,MAAM,MAAM,cAAc,GAAG,OAAO,CAClC,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,OAAO,eAAe,CAAC,EACtD,MAAM,CACP,CAAC;AAEF;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,eAAe,EAAE,KAAK,CAAC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,cAAc,CAAC;CAC1B,CAgCA,CAAC;AAaF;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,GAAI,MAAM,MAAM,KAAG,MAEvD,CAAC;AAqIF;;;GAGG;AACH,eAAO,MAAM,UAAU,GAAU,MAAM;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,0DAA0D;IAC1D,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,KAAG,OAAO,CAAC,aAAa,CA6GxB,CAAC"}
@@ -0,0 +1,295 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.probeEntry = exports.stripBundlerPathComments = exports.GLOBAL_PATTERNS = exports.ProbeSeverities = exports.PURITY_SCRATCH_DIR = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const promises_1 = require("node:fs/promises");
6
+ const node_module_1 = require("node:module");
7
+ const node_path_1 = require("node:path");
8
+ /** Where the probe keeps its metafile, relative to the `cwd` it is given: the repository's own scratch, never the system temp. */
9
+ exports.PURITY_SCRATCH_DIR = '.agents/local/tmp';
10
+ /**
11
+ * A browser entry must never reach `@venizia/dev-configs`: it is the Bun-only tooling package (its
12
+ * `./testing` entry imports `bun:test`). Matches the workspace folder (not its own test fixtures) and the installed copy.
13
+ */
14
+ const DEV_CONFIGS_PATH = /(^|\/)(packages\/dev-configs\/(?!src\/__tests__\/)|node_modules\/@venizia\/dev-configs\/)/;
15
+ /** What a matched global means: ERROR is fatal, GUARDED is reported and never fatal. */
16
+ class ProbeSeverities {
17
+ static { this.ERROR = 'error'; }
18
+ static { this.GUARDED = 'guarded'; }
19
+ static { this.SCHEME_SET = new Set([this.ERROR, this.GUARDED]); }
20
+ static isValid(value) {
21
+ return this.SCHEME_SET.has(value);
22
+ }
23
+ }
24
+ exports.ProbeSeverities = ProbeSeverities;
25
+ /**
26
+ * Node globals survive verbatim into a `target: 'browser'` bundle, so a gate reading only module
27
+ * specifiers reports clean on `process.env.NODE_ENV`. Each pattern must be anchored: a bundle
28
+ * legitimately contains identifiers like `processQueue`. A lookbehind that rejects a preceding dot
29
+ * also rejects `globalThis.process`, so every access path needs its own pattern.
30
+ *
31
+ * `severity` separates a read that throws from one that cannot. Optional chaining is not itself the
32
+ * difference: `process?.env` throws a ReferenceError when `process` is a free identifier, while
33
+ * `globalThis.process?.env` is an ordinary property read on an object that always exists.
34
+ *
35
+ * `guarded` means "reported, never fatal". Bare `process?.` sits there because this probe matches
36
+ * bundled text and has no scope information: hono's `getColorEnabled` writes
37
+ * `const { process, Deno } = globalThis;` and then `process?.env`, which is a local binding and
38
+ * perfectly safe, and no regex can tell that apart from a free global. Failing on it would make the
39
+ * gate cry wolf on a dependency; the unguarded `process.` and `globalThis.process.` forms stay
40
+ * fatal, and those are the ones that are unambiguously browser-breaking.
41
+ *
42
+ * Exported for `probe.test.ts` to assert against directly, alongside {@link stripBundlerPathComments}.
43
+ */
44
+ exports.GLOBAL_PATTERNS = [
45
+ { name: 'process', pattern: /(?<![A-Za-z0-9_$.])process\s*\./, severity: 'error' },
46
+ { name: 'process?.', pattern: /(?<![A-Za-z0-9_$.])process\s*\?\./, severity: 'guarded' },
47
+ {
48
+ name: 'globalThis.process',
49
+ pattern: /(?<![A-Za-z0-9_$.])globalThis\s*\.\s*process\s*\./,
50
+ severity: 'error',
51
+ },
52
+ {
53
+ name: 'globalThis.process?.',
54
+ pattern: /(?<![A-Za-z0-9_$.])globalThis\s*\.\s*process\s*\?\./,
55
+ severity: 'guarded',
56
+ },
57
+ {
58
+ name: '__dirname',
59
+ pattern: /(?<![A-Za-z0-9_$.])__dirname(?![A-Za-z0-9_$])/,
60
+ severity: 'error',
61
+ },
62
+ {
63
+ name: '__filename',
64
+ pattern: /(?<![A-Za-z0-9_$.])__filename(?![A-Za-z0-9_$])/,
65
+ severity: 'error',
66
+ },
67
+ {
68
+ name: 'createRequire',
69
+ pattern: /(?<![A-Za-z0-9_$.])createRequire\s*\(/,
70
+ severity: 'error',
71
+ },
72
+ { name: 'require(', pattern: /(?<![A-Za-z0-9_$.])require\s*\(/, severity: 'error' },
73
+ // Member access only. `typeof Bun !== 'undefined'` is the legitimate runtime probe and never
74
+ // throws; `Bun.serve(...)` in a browser does.
75
+ { name: 'Bun', pattern: /(?<![A-Za-z0-9_$.])Bun\s*\./, severity: 'error' },
76
+ ];
77
+ /**
78
+ * Bun's bundler prefixes every CommonJS-wrapped module with a full-line `// <path>.js` header (this
79
+ * whole workspace emits CJS `dist/*.js` - no package declares `"type": "module"`). A source path is
80
+ * free-form and can legitimately contain `process` as a substring with no identifier boundary on
81
+ * its left, e.g. this gate's own `transport/in-process.js`: the header reads
82
+ * `// packages/core-worker/dist/transport/in-process.js`, and `process.` there is a filename
83
+ * fragment, not a read of the global. `GLOBAL_PATTERNS` matches bundled TEXT with no scope
84
+ * information, so it cannot tell a path comment from code - stripped before matching.
85
+ */
86
+ const BUNDLER_PATH_COMMENT = /^\/\/ [\w@./-]+\.(?:c|m)?js$/gm;
87
+ /**
88
+ * Exported so `probe.test.ts` can assert against the literal collision string directly, without
89
+ * spawning `bun build` - a fixture whose OWN entry-path comment happens to reproduce the collision
90
+ * is not something a filename choice can be relied on to keep reproducing.
91
+ */
92
+ const stripBundlerPathComments = (text) => {
93
+ return text.replace(BUNDLER_PATH_COMMENT, '');
94
+ };
95
+ exports.stripBundlerPathComments = stripBundlerPathComments;
96
+ /**
97
+ * `node:module`'s own list, not a hand-maintained one: a bare `import ... from 'fs'` bundles
98
+ * identically to `'node:fs'` (Bun stubs both to an empty object), and the metafile records
99
+ * whichever spelling the source used. Matching by prefix alone misses the bare form entirely.
100
+ */
101
+ const BUILTIN_MODULE_NAMES = new Set(node_module_1.builtinModules);
102
+ const isBuiltinSpecifier = (specifier) => {
103
+ const bareName = specifier.startsWith('node:') ? specifier.slice('node:'.length) : specifier;
104
+ return BUILTIN_MODULE_NAMES.has(bareName);
105
+ };
106
+ /**
107
+ * Measured against Bun 1.3.14: `--target=browser` never fails on a `node:` import and never leaves
108
+ * the specifier in the bundled text. An unpolyfillable builtin (`fs`, `child_process`, ...) is
109
+ * stubbed to an empty object; a polyfillable one (`crypto`, `path`, ...) is inlined in full under a
110
+ * `/bun-vfs$$/` virtual path. Either way `exitCode` stays 0 and the specifier never survives into
111
+ * stdout. The `--metafile` module graph is the only place it is preserved.
112
+ */
113
+ const findBuiltinSpecifiers = (metafile) => {
114
+ const builtins = new Set();
115
+ const metafileInputsEntries = Object.entries(metafile.inputs);
116
+ for (const [inputPath, input] of metafileInputsEntries) {
117
+ if (isBuiltinSpecifier(inputPath)) {
118
+ builtins.add(inputPath);
119
+ }
120
+ for (const dependency of input.imports) {
121
+ const specifier = dependency.original ?? dependency.path;
122
+ if (isBuiltinSpecifier(specifier)) {
123
+ builtins.add(specifier);
124
+ }
125
+ }
126
+ }
127
+ return [...builtins].sort();
128
+ };
129
+ /**
130
+ * A real npm dependency bundles in full under this target (measured: `zod` produces zero
131
+ * `external` entries) and a genuinely missing package fails the build outright (nonzero exit,
132
+ * caught before this runs). But a package's own `browser` field can remap a dependency to `false` -
133
+ * the standard convention for disabling a Node-only dependency in browser builds - and Bun honours
134
+ * that remap by leaving the import external with exit 0, whether or not the remapped name is a
135
+ * builtin. An unresolved import in a browser bundle is not safe to wave through either way -
136
+ * UNLESS the manifest entry asked for exactly this specifier via `external`, in which case its
137
+ * presence here is the requested exclusion working, not a leak.
138
+ */
139
+ /**
140
+ * A relative specifier is the package's own file, and a truly unreachable one fails the build with a
141
+ * nonzero exit, which is caught before this runs. Bun's METAFILE nonetheless marks a re-export of the
142
+ * form `export { name } from './x.js'` as `external` while inlining it - measured on
143
+ * `helpers/dist/esm/core.js`: six such specifiers reported external, `Bundled 192 modules`, and the
144
+ * output contains `class BaseHelper` with no bare relative import left in it. Reading the metafile
145
+ * literally there reports a leak the bundle does not have.
146
+ */
147
+ const isRelativeSpecifier = (specifier) => {
148
+ return specifier.startsWith('./') || specifier.startsWith('../');
149
+ };
150
+ const findUnresolvedExternalSpecifiers = (opts) => {
151
+ const { metafile, builtins, requestedExternal } = opts;
152
+ const unresolved = new Set();
153
+ const isRequestedExternal = (specifier) => {
154
+ for (const requested of requestedExternal) {
155
+ if (specifier === requested || specifier.startsWith(`${requested}/`)) {
156
+ return true;
157
+ }
158
+ }
159
+ return false;
160
+ };
161
+ const metafileInputsValues = Object.values(metafile.inputs);
162
+ for (const input of metafileInputsValues) {
163
+ for (const dependency of input.imports) {
164
+ if (!dependency.external) {
165
+ continue;
166
+ }
167
+ const specifier = dependency.original ?? dependency.path;
168
+ if (isRelativeSpecifier(specifier) ||
169
+ builtins.has(specifier) ||
170
+ isRequestedExternal(specifier)) {
171
+ continue;
172
+ }
173
+ unresolved.add(specifier);
174
+ }
175
+ }
176
+ return [...unresolved].sort();
177
+ };
178
+ /**
179
+ * Reads and removes the metafile unconditionally. Returns `undefined` rather than throwing -
180
+ * `probeEntry` reports on every entry it is given rather than aborting the run of the other five.
181
+ */
182
+ const readMetafile = async (path) => {
183
+ try {
184
+ return JSON.parse(await Bun.file(path).text());
185
+ }
186
+ catch (error) {
187
+ const reason = error instanceof Error ? error.message : String(error);
188
+ console.error(`[purity] Failed to read the build metafile at ${path}: ${reason}`);
189
+ return undefined;
190
+ }
191
+ finally {
192
+ await (0, promises_1.rm)(path, { force: true });
193
+ }
194
+ };
195
+ /**
196
+ * The CLI, not `Bun.build()`: the in-process bundler resolves this workspace's symlinked
197
+ * dependencies to directories and dies on them.
198
+ */
199
+ const probeEntry = async (opts) => {
200
+ const { entry, cwd, external = [], scratchDir = (0, node_path_1.join)(cwd, exports.PURITY_SCRATCH_DIR) } = opts;
201
+ await (0, promises_1.mkdir)(scratchDir, { recursive: true });
202
+ const metafilePath = (0, node_path_1.join)(scratchDir, `purity-${(0, node_crypto_1.randomUUID)()}.json`);
203
+ // `--env=disable`: Bun inlines `process.env.NODE_ENV` as a compile-time string constant by
204
+ // default, regardless of whether the read is reachable. That erases the very read this probe
205
+ // exists to catch, and the erasure depends on the ambient `NODE_ENV` the probe happens to run
206
+ // under - the same failure shape as the undetected leak this gate was built to prevent.
207
+ const externalArgs = external.flatMap((specifier) => ['--external', specifier]);
208
+ const proc = Bun.spawn([
209
+ 'bun',
210
+ 'build',
211
+ entry,
212
+ '--target=browser',
213
+ '--format=esm',
214
+ '--env=disable',
215
+ `--metafile=${metafilePath}`,
216
+ ...externalArgs,
217
+ ], { cwd, stdout: 'pipe', stderr: 'pipe' });
218
+ const [stdout, stderr, exitCode] = await Promise.all([
219
+ new Response(proc.stdout).text(),
220
+ new Response(proc.stderr).text(),
221
+ proc.exited,
222
+ ]);
223
+ if (exitCode !== 0) {
224
+ await (0, promises_1.rm)(metafilePath, { force: true });
225
+ return {
226
+ entry,
227
+ ok: false,
228
+ sizeBytes: 0,
229
+ builtins: [],
230
+ globals: [],
231
+ guardedGlobals: [],
232
+ external,
233
+ buildError: stderr.trim(),
234
+ };
235
+ }
236
+ const metafile = await readMetafile(metafilePath);
237
+ if (!metafile) {
238
+ return {
239
+ entry,
240
+ ok: false,
241
+ sizeBytes: stdout.length,
242
+ builtins: [],
243
+ globals: [],
244
+ guardedGlobals: [],
245
+ external,
246
+ buildError: 'Could not read the bun build metafile - the probe result is not trustworthy',
247
+ };
248
+ }
249
+ const builtins = findBuiltinSpecifiers(metafile);
250
+ const reachesDevConfigs = Object.keys(metafile.inputs).some((inputPath) => DEV_CONFIGS_PATH.test(inputPath));
251
+ const unresolvedExternals = findUnresolvedExternalSpecifiers({
252
+ metafile,
253
+ builtins: new Set(builtins),
254
+ requestedExternal: new Set(external),
255
+ });
256
+ const scannableText = (0, exports.stripBundlerPathComments)(stdout);
257
+ const matched = exports.GLOBAL_PATTERNS.filter((g) => g.pattern.test(scannableText));
258
+ const globals = matched.filter((g) => g.severity === ProbeSeverities.ERROR).map((g) => g.name);
259
+ const guardedGlobals = matched
260
+ .filter((g) => g.severity === ProbeSeverities.GUARDED)
261
+ .map((g) => g.name);
262
+ if (builtins.length > 0 || unresolvedExternals.length > 0 || reachesDevConfigs) {
263
+ const reasons = [];
264
+ if (reachesDevConfigs) {
265
+ reasons.push('reaches @venizia/dev-configs, which is Bun-only tooling and never a browser dependency');
266
+ }
267
+ if (builtins.length > 0) {
268
+ reasons.push(`node builtin import(s): ${builtins.join(', ')}`);
269
+ }
270
+ if (unresolvedExternals.length > 0) {
271
+ reasons.push(`unresolved external import(s): ${unresolvedExternals.join(', ')}`);
272
+ }
273
+ return {
274
+ entry,
275
+ ok: false,
276
+ sizeBytes: stdout.length,
277
+ builtins,
278
+ globals,
279
+ guardedGlobals,
280
+ external,
281
+ buildError: reasons.join(' | '),
282
+ };
283
+ }
284
+ return {
285
+ entry,
286
+ ok: globals.length === 0,
287
+ sizeBytes: stdout.length,
288
+ builtins,
289
+ globals,
290
+ guardedGlobals,
291
+ external,
292
+ };
293
+ };
294
+ exports.probeEntry = probeEntry;
295
+ //# sourceMappingURL=probe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"probe.js","sourceRoot":"","sources":["../../../src/checks/purity/probe.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomUUID } from 'node:crypto';\nimport { mkdir, rm } from 'node:fs/promises';\nimport { builtinModules } from 'node:module';\nimport { join } from 'node:path';\n\nexport interface IPurityResult {\n entry: string;\n ok: boolean;\n sizeBytes: number;\n builtins: string[];\n globals: string[];\n /** Node-global reads that cannot throw in a browser. Reported, never fatal. */\n guardedGlobals: string[];\n /**\n * Specifiers this probe run excluded from its own module graph. Always echoed back, on both the\n * passing and failing path - an entry that externalises something is measuring less than an entry\n * that does not, and that must stay visible next to the result, not be a silent input.\n */\n external: string[];\n buildError?: string;\n}\n\n/** Where the probe keeps its metafile, relative to the `cwd` it is given: the repository's own scratch, never the system temp. */\nexport const PURITY_SCRATCH_DIR = '.agents/local/tmp';\n\n/**\n * A browser entry must never reach `@venizia/dev-configs`: it is the Bun-only tooling package (its\n * `./testing` entry imports `bun:test`). Matches the workspace folder (not its own test fixtures) and the installed copy.\n */\nconst DEV_CONFIGS_PATH =\n /(^|\\/)(packages\\/dev-configs\\/(?!src\\/__tests__\\/)|node_modules\\/@venizia\\/dev-configs\\/)/;\n\n/** What a matched global means: ERROR is fatal, GUARDED is reported and never fatal. */\nexport class ProbeSeverities {\n static readonly ERROR = 'error';\n static readonly GUARDED = 'guarded';\n\n static readonly SCHEME_SET = new Set<string>([this.ERROR, this.GUARDED]);\n\n static isValid(value: string): value is TProbeSeverity {\n return this.SCHEME_SET.has(value);\n }\n}\n\nexport type TProbeSeverity = Extract<\n (typeof ProbeSeverities)[keyof typeof ProbeSeverities],\n string\n>;\n\n/**\n * Node globals survive verbatim into a `target: 'browser'` bundle, so a gate reading only module\n * specifiers reports clean on `process.env.NODE_ENV`. Each pattern must be anchored: a bundle\n * legitimately contains identifiers like `processQueue`. A lookbehind that rejects a preceding dot\n * also rejects `globalThis.process`, so every access path needs its own pattern.\n *\n * `severity` separates a read that throws from one that cannot. Optional chaining is not itself the\n * difference: `process?.env` throws a ReferenceError when `process` is a free identifier, while\n * `globalThis.process?.env` is an ordinary property read on an object that always exists.\n *\n * `guarded` means \"reported, never fatal\". Bare `process?.` sits there because this probe matches\n * bundled text and has no scope information: hono's `getColorEnabled` writes\n * `const { process, Deno } = globalThis;` and then `process?.env`, which is a local binding and\n * perfectly safe, and no regex can tell that apart from a free global. Failing on it would make the\n * gate cry wolf on a dependency; the unguarded `process.` and `globalThis.process.` forms stay\n * fatal, and those are the ones that are unambiguously browser-breaking.\n *\n * Exported for `probe.test.ts` to assert against directly, alongside {@link stripBundlerPathComments}.\n */\nexport const GLOBAL_PATTERNS: Array<{\n name: string;\n pattern: RegExp;\n severity: TProbeSeverity;\n}> = [\n { name: 'process', pattern: /(?<![A-Za-z0-9_$.])process\\s*\\./, severity: 'error' },\n { name: 'process?.', pattern: /(?<![A-Za-z0-9_$.])process\\s*\\?\\./, severity: 'guarded' },\n {\n name: 'globalThis.process',\n pattern: /(?<![A-Za-z0-9_$.])globalThis\\s*\\.\\s*process\\s*\\./,\n severity: 'error',\n },\n {\n name: 'globalThis.process?.',\n pattern: /(?<![A-Za-z0-9_$.])globalThis\\s*\\.\\s*process\\s*\\?\\./,\n severity: 'guarded',\n },\n {\n name: '__dirname',\n pattern: /(?<![A-Za-z0-9_$.])__dirname(?![A-Za-z0-9_$])/,\n severity: 'error',\n },\n {\n name: '__filename',\n pattern: /(?<![A-Za-z0-9_$.])__filename(?![A-Za-z0-9_$])/,\n severity: 'error',\n },\n {\n name: 'createRequire',\n pattern: /(?<![A-Za-z0-9_$.])createRequire\\s*\\(/,\n severity: 'error',\n },\n { name: 'require(', pattern: /(?<![A-Za-z0-9_$.])require\\s*\\(/, severity: 'error' },\n // Member access only. `typeof Bun !== 'undefined'` is the legitimate runtime probe and never\n // throws; `Bun.serve(...)` in a browser does.\n { name: 'Bun', pattern: /(?<![A-Za-z0-9_$.])Bun\\s*\\./, severity: 'error' },\n];\n\n/**\n * Bun's bundler prefixes every CommonJS-wrapped module with a full-line `// <path>.js` header (this\n * whole workspace emits CJS `dist/*.js` - no package declares `\"type\": \"module\"`). A source path is\n * free-form and can legitimately contain `process` as a substring with no identifier boundary on\n * its left, e.g. this gate's own `transport/in-process.js`: the header reads\n * `// packages/core-worker/dist/transport/in-process.js`, and `process.` there is a filename\n * fragment, not a read of the global. `GLOBAL_PATTERNS` matches bundled TEXT with no scope\n * information, so it cannot tell a path comment from code - stripped before matching.\n */\nconst BUNDLER_PATH_COMMENT = /^\\/\\/ [\\w@./-]+\\.(?:c|m)?js$/gm;\n\n/**\n * Exported so `probe.test.ts` can assert against the literal collision string directly, without\n * spawning `bun build` - a fixture whose OWN entry-path comment happens to reproduce the collision\n * is not something a filename choice can be relied on to keep reproducing.\n */\nexport const stripBundlerPathComments = (text: string): string => {\n return text.replace(BUNDLER_PATH_COMMENT, '');\n};\n\ninterface IMetafileImport {\n path: string;\n original?: string;\n external?: boolean;\n}\n\ninterface IMetafileInput {\n imports: IMetafileImport[];\n}\n\ninterface IMetafile {\n inputs: Record<string, IMetafileInput>;\n}\n\n/**\n * `node:module`'s own list, not a hand-maintained one: a bare `import ... from 'fs'` bundles\n * identically to `'node:fs'` (Bun stubs both to an empty object), and the metafile records\n * whichever spelling the source used. Matching by prefix alone misses the bare form entirely.\n */\nconst BUILTIN_MODULE_NAMES = new Set(builtinModules);\n\nconst isBuiltinSpecifier = (specifier: string): boolean => {\n const bareName = specifier.startsWith('node:') ? specifier.slice('node:'.length) : specifier;\n return BUILTIN_MODULE_NAMES.has(bareName);\n};\n\n/**\n * Measured against Bun 1.3.14: `--target=browser` never fails on a `node:` import and never leaves\n * the specifier in the bundled text. An unpolyfillable builtin (`fs`, `child_process`, ...) is\n * stubbed to an empty object; a polyfillable one (`crypto`, `path`, ...) is inlined in full under a\n * `/bun-vfs$$/` virtual path. Either way `exitCode` stays 0 and the specifier never survives into\n * stdout. The `--metafile` module graph is the only place it is preserved.\n */\nconst findBuiltinSpecifiers = (metafile: IMetafile): string[] => {\n const builtins = new Set<string>();\n\n const metafileInputsEntries = Object.entries(metafile.inputs);\n for (const [inputPath, input] of metafileInputsEntries) {\n if (isBuiltinSpecifier(inputPath)) {\n builtins.add(inputPath);\n }\n\n for (const dependency of input.imports) {\n const specifier = dependency.original ?? dependency.path;\n if (isBuiltinSpecifier(specifier)) {\n builtins.add(specifier);\n }\n }\n }\n\n return [...builtins].sort();\n};\n\n/**\n * A real npm dependency bundles in full under this target (measured: `zod` produces zero\n * `external` entries) and a genuinely missing package fails the build outright (nonzero exit,\n * caught before this runs). But a package's own `browser` field can remap a dependency to `false` -\n * the standard convention for disabling a Node-only dependency in browser builds - and Bun honours\n * that remap by leaving the import external with exit 0, whether or not the remapped name is a\n * builtin. An unresolved import in a browser bundle is not safe to wave through either way -\n * UNLESS the manifest entry asked for exactly this specifier via `external`, in which case its\n * presence here is the requested exclusion working, not a leak.\n */\n/**\n * A relative specifier is the package's own file, and a truly unreachable one fails the build with a\n * nonzero exit, which is caught before this runs. Bun's METAFILE nonetheless marks a re-export of the\n * form `export { name } from './x.js'` as `external` while inlining it - measured on\n * `helpers/dist/esm/core.js`: six such specifiers reported external, `Bundled 192 modules`, and the\n * output contains `class BaseHelper` with no bare relative import left in it. Reading the metafile\n * literally there reports a leak the bundle does not have.\n */\nconst isRelativeSpecifier = (specifier: string): boolean => {\n return specifier.startsWith('./') || specifier.startsWith('../');\n};\n\nconst findUnresolvedExternalSpecifiers = (opts: {\n metafile: IMetafile;\n builtins: Set<string>;\n requestedExternal: Set<string>;\n}): string[] => {\n const { metafile, builtins, requestedExternal } = opts;\n const unresolved = new Set<string>();\n\n const isRequestedExternal = (specifier: string): boolean => {\n for (const requested of requestedExternal) {\n if (specifier === requested || specifier.startsWith(`${requested}/`)) {\n return true;\n }\n }\n return false;\n };\n\n const metafileInputsValues = Object.values(metafile.inputs);\n for (const input of metafileInputsValues) {\n for (const dependency of input.imports) {\n if (!dependency.external) {\n continue;\n }\n\n const specifier = dependency.original ?? dependency.path;\n if (\n isRelativeSpecifier(specifier) ||\n builtins.has(specifier) ||\n isRequestedExternal(specifier)\n ) {\n continue;\n }\n\n unresolved.add(specifier);\n }\n }\n\n return [...unresolved].sort();\n};\n\n/**\n * Reads and removes the metafile unconditionally. Returns `undefined` rather than throwing -\n * `probeEntry` reports on every entry it is given rather than aborting the run of the other five.\n */\nconst readMetafile = async (path: string): Promise<IMetafile | undefined> => {\n try {\n return JSON.parse(await Bun.file(path).text()) as IMetafile;\n } catch (error) {\n const reason = error instanceof Error ? error.message : String(error);\n console.error(`[purity] Failed to read the build metafile at ${path}: ${reason}`);\n return undefined;\n } finally {\n await rm(path, { force: true });\n }\n};\n\n/**\n * The CLI, not `Bun.build()`: the in-process bundler resolves this workspace's symlinked\n * dependencies to directories and dies on them.\n */\nexport const probeEntry = async (opts: {\n entry: string;\n cwd: string;\n external?: string[];\n /** Defaults to {@link PURITY_SCRATCH_DIR} under `cwd`. */\n scratchDir?: string;\n}): Promise<IPurityResult> => {\n const { entry, cwd, external = [], scratchDir = join(cwd, PURITY_SCRATCH_DIR) } = opts;\n await mkdir(scratchDir, { recursive: true });\n const metafilePath = join(scratchDir, `purity-${randomUUID()}.json`);\n\n // `--env=disable`: Bun inlines `process.env.NODE_ENV` as a compile-time string constant by\n // default, regardless of whether the read is reachable. That erases the very read this probe\n // exists to catch, and the erasure depends on the ambient `NODE_ENV` the probe happens to run\n // under - the same failure shape as the undetected leak this gate was built to prevent.\n const externalArgs = external.flatMap((specifier) => ['--external', specifier]);\n const proc = Bun.spawn(\n [\n 'bun',\n 'build',\n entry,\n '--target=browser',\n '--format=esm',\n '--env=disable',\n `--metafile=${metafilePath}`,\n ...externalArgs,\n ],\n { cwd, stdout: 'pipe', stderr: 'pipe' },\n );\n\n const [stdout, stderr, exitCode] = await Promise.all([\n new Response(proc.stdout).text(),\n new Response(proc.stderr).text(),\n proc.exited,\n ]);\n\n if (exitCode !== 0) {\n await rm(metafilePath, { force: true });\n return {\n entry,\n ok: false,\n sizeBytes: 0,\n builtins: [],\n globals: [],\n guardedGlobals: [],\n external,\n buildError: stderr.trim(),\n };\n }\n\n const metafile = await readMetafile(metafilePath);\n if (!metafile) {\n return {\n entry,\n ok: false,\n sizeBytes: stdout.length,\n builtins: [],\n globals: [],\n guardedGlobals: [],\n external,\n buildError: 'Could not read the bun build metafile - the probe result is not trustworthy',\n };\n }\n\n const builtins = findBuiltinSpecifiers(metafile);\n const reachesDevConfigs = Object.keys(metafile.inputs).some((inputPath) =>\n DEV_CONFIGS_PATH.test(inputPath),\n );\n const unresolvedExternals = findUnresolvedExternalSpecifiers({\n metafile,\n builtins: new Set(builtins),\n requestedExternal: new Set(external),\n });\n const scannableText = stripBundlerPathComments(stdout);\n const matched = GLOBAL_PATTERNS.filter((g) => g.pattern.test(scannableText));\n const globals = matched.filter((g) => g.severity === ProbeSeverities.ERROR).map((g) => g.name);\n const guardedGlobals = matched\n .filter((g) => g.severity === ProbeSeverities.GUARDED)\n .map((g) => g.name);\n\n if (builtins.length > 0 || unresolvedExternals.length > 0 || reachesDevConfigs) {\n const reasons: string[] = [];\n if (reachesDevConfigs) {\n reasons.push(\n 'reaches @venizia/dev-configs, which is Bun-only tooling and never a browser dependency',\n );\n }\n if (builtins.length > 0) {\n reasons.push(`node builtin import(s): ${builtins.join(', ')}`);\n }\n if (unresolvedExternals.length > 0) {\n reasons.push(`unresolved external import(s): ${unresolvedExternals.join(', ')}`);\n }\n\n return {\n entry,\n ok: false,\n sizeBytes: stdout.length,\n builtins,\n globals,\n guardedGlobals,\n external,\n buildError: reasons.join(' | '),\n };\n }\n\n return {\n entry,\n ok: globals.length === 0,\n sizeBytes: stdout.length,\n builtins,\n globals,\n guardedGlobals,\n external,\n };\n};\n"]}