@xemahq/repo-build-tooling 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,176 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # `@xemahq/repo-build-tooling`
2
+
3
+ This package belongs to Layer 1 — dev-time build tooling shared by every Xema
4
+ repository. Nothing here ever ships inside a service image; every consumer
5
+ declares it as a `devDependency`.
6
+
7
+ ## Why it is a package and not a file
8
+
9
+ It used to be a file — five byte-identical copies of one 700-line script, one
10
+ per repository that needed it, kept honest by a boundary check that ran each
11
+ copy's `--self-test`.
12
+
13
+ That arrangement could not hold the invariant it existed for. A defect found in
14
+ one copy is fixed in one copy; the other four keep shipping it until someone
15
+ remembers. Exactly that happened: three of the five copies deliberately carried
16
+ *less* behaviour than the other two (they detected a leak shape without
17
+ rewriting it), which was a reasonable decision at the time and then became a
18
+ permanent, undocumented divergence nobody re-examined. A parity check on N
19
+ copies is a workaround for not having one artifact.
20
+
21
+ ## How the two resolution modes work
22
+
23
+ This is the standard shape in this fleet, and getting it wrong is the single
24
+ most common source of "green locally, red in CI":
25
+
26
+ - **Inside the `xema-monorepo` aggregator**, the root `package.json`'s
27
+ `pnpm.overrides` maps `@xemahq/repo-build-tooling` to `workspace:*`, so it
28
+ resolves to this directory. Editing the tool and rebuilding a service in the
29
+ same checkout needs no publish, no version bump and no registry round-trip.
30
+ - **Inside each independent repository's own CI**, there is no aggregator and no
31
+ sibling checkout. The declared semver range is what resolves, from npm.
32
+
33
+ So the range in a consumer's `package.json` is the real contract, and the local
34
+ link is a development convenience layered on top. A symbol added here is not
35
+ available to a consumer until this package is published AND that consumer's
36
+ range admits the new version — the aggregator will happily compile against the
37
+ local source and tell you nothing.
38
+
39
+ ## Contents
40
+
41
+ ### `xema-scrub-swagger-paths <dist-dir> [<dist-dir>…]`
42
+
43
+ Post-build sanitizer for `@nestjs/swagger` plugin output, and the verification
44
+ step for the same property. The plugin writes `require()` literals into
45
+ `_OPENAPI_METADATA_FACTORY()` at `nest build` time — they exist in no source
46
+ file — so this is the only thing in the fleet that can see them, and it runs
47
+ immediately after `nest build` in all 54 services that use the plugin.
48
+
49
+ It rewrites specifiers that name the builder's private install layout into the
50
+ target package's public specifier, then re-reads the output from disk and fails
51
+ the build if any leak survives.
52
+
53
+ The rewrite is **derived and proved**, never tabled: the replacement comes from
54
+ the target package's own `exports` map, and is accepted only if the emitted
55
+ re-export chain (read statically — package code is never executed) reaches the
56
+ same module. It refuses rather than guesses. That matters because the two
57
+ packages that leak most are opposites — `@xemahq/kernel-contracts` exports only
58
+ `./*` and has no root export, so rewriting to the bare name would swap one crash
59
+ for another, while `@xemahq/platform-common` exports both `.` and `./dist/*`.
60
+
61
+ `--self-test` proves, on a scratch tree with no build and no fixtures, that a
62
+ missing/empty/no-`.js` directory each FAIL, that the detector matches every leak
63
+ shape and no clean literal, and that a residual leak fails the run. This step's
64
+ failure mode is silence, so "0 leaks" and "0 files read" must never be
65
+ indistinguishable.
66
+
67
+ ## Scope boundary
68
+
69
+ Dev-time tooling that **more than one repository** runs. Two rules keep it from
70
+ becoming a junk drawer:
71
+
72
+ - Nothing with a runtime consumer. If a service imports it at run time it is not
73
+ build tooling and belongs in an SDK package.
74
+ - Nothing with a single consumer. A tool used by one repository stays in that
75
+ repository until a second one needs it.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@xemahq/repo-build-tooling",
3
+ "version": "0.1.0",
4
+ "description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM with zero dependencies so the published artifact is the reviewed source.",
5
+ "license": "Apache-2.0",
6
+ "author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
7
+ "homepage": "https://xema.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/xema-dev/xema-runtime-core.git",
11
+ "directory": "packages/sdks/repo-build-tooling"
12
+ },
13
+ "bugs": "https://github.com/xema-dev/xema-runtime-core/issues",
14
+ "publishConfig": {
15
+ "registry": "https://registry.npmjs.org/",
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "files": [
20
+ "src",
21
+ "README.md"
22
+ ],
23
+ "bin": {
24
+ "xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs"
25
+ },
26
+ "exports": {
27
+ "./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs",
28
+ "./package.json": "./package.json"
29
+ },
30
+ "scripts": {
31
+ "test": "node src/scrub-swagger-plugin-paths.mjs --self-test"
32
+ }
33
+ }
@@ -0,0 +1,721 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // scrub-swagger-plugin-paths.mjs
4
+ //
5
+ // Post-build sanitizer for @nestjs/swagger plugin v11.x output.
6
+ //
7
+ // CONTEXT
8
+ // The swagger CLI plugin emits `_OPENAPI_METADATA_FACTORY()` blocks that
9
+ // contain `require("<path>")` calls for any enum referenced via
10
+ // `@ApiProperty({ enum: ... })`. The plugin derives the path from
11
+ // TypeScript's resolved declaration file location and trims at the FIRST
12
+ // `node_modules/` segment in the relative path
13
+ // (see `node_modules/@nestjs/swagger/dist/plugin/utils/plugin-utils.js`
14
+ // around the `nodeModulesText` slice).
15
+ //
16
+ // This produces two leak shapes, both fatal at pod boot:
17
+ //
18
+ // (1) NESTED leak — pnpm's nested layout. When a service builds a DTO
19
+ // that references an enum reached via a transitive contract package,
20
+ // TypeScript can resolve the type via a nested symlink such as:
21
+ //
22
+ // node_modules/@xemahq/kernel-contracts/policy/node_modules/
23
+ // @xemahq/kernel-contracts/execution-context/dist/lib/subject.d.ts
24
+ //
25
+ // The first `node_modules/` slice trims off the outermost
26
+ // `node_modules/` but leaves the SECOND one inside the path,
27
+ // producing leaked emits like:
28
+ //
29
+ // require("@xemahq/kernel-contracts/policy/node_modules/
30
+ // @xemahq/kernel-contracts/execution-context/dist/lib/subject")
31
+ //
32
+ // …which crashes the pod at boot with ERR_PACKAGE_PATH_NOT_EXPORTED
33
+ // because `policy-contracts`' `exports` field correctly does not
34
+ // (and should not) cover `./node_modules/*`.
35
+ //
36
+ // (2) FLAT leak — direct dependency, deep dist subpath. When a service
37
+ // directly depends on a contracts package and references an enum
38
+ // via `@ApiProperty({ enum: ... })`, the plugin emits the
39
+ // declaration file path under the package's published layout:
40
+ //
41
+ // require("@xemahq/kernel-contracts/capability/dist/lib/capability-grant")
42
+ //
43
+ // This crashes at boot with the same ERR_PACKAGE_PATH_NOT_EXPORTED
44
+ // whenever the contracts package's published `exports` map does NOT
45
+ // expose `./dist/lib/*` (the kernel-leaf packages publish with a
46
+ // single `.` entry only — the deep subpath is an internal layout
47
+ // detail, not a public contract).
48
+ //
49
+ // FIX
50
+ // This script walks each `dist/**/*.js` under a service's build output
51
+ // and rewrites ALL THREE leak shapes to the canonical bare package specifier:
52
+ //
53
+ // require("@xemahq/<outer>/node_modules/@xemahq/<inner>/<rest>")
54
+ // → require("@xemahq/<inner>/<rest>")
55
+ // require("@xemahq/<pkg>/dist/lib/<file>")
56
+ // → require("@xemahq/<pkg>")
57
+ // require("../../packages/contracts/<pkg>/dist/<subpath>/<file>")
58
+ // → require("@xemahq/<pkg>/<subpath>")
59
+ //
60
+ // The nested case keeps the inner subpath because the inner package's
61
+ // `exports` covers `./dist/*` (the leak was caused by the OUTER package's
62
+ // exports). The flat case drops the subpath because the contracts
63
+ // package's root `exports` always re-exports every public symbol from
64
+ // `dist/index.js`; the enum that the plugin needed is available on the
65
+ // bare module. The relative workspace case reconstructs the canonical
66
+ // subpath specifier: the last directory before `/dist/` is the package
67
+ // name (maps to `@xemahq/<name>`); the first directory after `/dist/` is
68
+ // the subpath export (valid for packages using `./*` exports, e.g.
69
+ // community-contracts). The rewrite is structurally safe — the bare
70
+ // specifier is what the plugin would have emitted under a "flat resolution"
71
+ // model.
72
+ //
73
+ // The complementary `tsconfig` `paths` pin (PR #47) covers the same root
74
+ // cause at TypeScript resolution time; this scrubber is the structural
75
+ // safety net that protects every current AND future app from the same
76
+ // regression without per-app config maintenance.
77
+ //
78
+ // USAGE
79
+ // node tooling/codegen/scrub-swagger-plugin-paths.mjs <dist-dir> [<dist-dir>…]
80
+ // node tooling/codegen/scrub-swagger-plugin-paths.mjs --self-test
81
+ //
82
+ // Each <dist-dir> is scanned recursively. The script is idempotent —
83
+ // running it twice on the same tree is a no-op. It exits 0 on success
84
+ // even when no rewrites are needed (a service may not import any
85
+ // contract enum). It exits 1 if a directory does not exist or a file
86
+ // cannot be read/written — fail fast, never silent.
87
+ //
88
+ // ── THIS STEP IS ALSO THE VERIFICATION (2026-08-06) ───────────────────────
89
+ // This script is not only a rewriter; it is the ONLY thing in the fleet that
90
+ // reads a service's compiled output at the moment it is produced. The leak
91
+ // literal exists in NO source file — the swagger plugin writes it into
92
+ // `_OPENAPI_METADATA_FACTORY()` at `nest build` time — so a check that wants
93
+ // to read it needs a built tree, and the aggregator's buildless boundary gate
94
+ // can never have one. `tooling/boundaries/check-no-deep-require-leaks.mjs`
95
+ // spent its whole life in that gate reporting a confident green over ZERO of
96
+ // its 53 subject packages.
97
+ //
98
+ // The subject set is exactly "every package whose build chains this script"
99
+ // (CI-enforced by `check-swagger-plugin-scrub-wired.mjs`), and this script
100
+ // runs immediately after `nest build` in all 53 of them — in the carved
101
+ // repos' own CI (`pnpm -r --if-present run build`), inside every service
102
+ // image build, and locally. So the verification belongs HERE, where the
103
+ // build output exists, not in a gate that cannot build. Two properties make
104
+ // it able to fail:
105
+ //
106
+ // 1. A named directory that does not exist, is not a directory, or holds
107
+ // NO compiled `.js` is a hard error. It used to be a silent no-op
108
+ // ("a service may not have a dist/ yet") — which meant a service whose
109
+ // `outDir` moved kept a green scrub step over nothing at all. No caller
110
+ // invokes this before `tsc`: every one of the 53 build scripts is
111
+ // `nest build && node …/scrub-swagger-plugin-paths.mjs dist`.
112
+ // 2. After rewriting, the output is RE-READ FROM DISK and asserted clean
113
+ // against the detector set below. The detectors are deliberately NOT
114
+ // the rewrite regexes: a single `String.replace` pass cannot fix a
115
+ // DOUBLY-nested literal (`@xemahq/a/node_modules/@xemahq/b/node_modules/
116
+ // @xemahq/c/x` rewrites to a literal that is still a leak), and this
117
+ // repo's copies of this file do not all rewrite the same shapes. A
118
+ // residual leak now fails the build that produced it instead of
119
+ // shipping into an image that crashes at boot with
120
+ // ERR_PACKAGE_PATH_NOT_EXPORTED.
121
+ //
122
+ // `--self-test` re-proves both properties on a scratch tree, with no build
123
+ // and no fixtures on disk, for the usual reason: this step's failure mode is
124
+ // silence, so "0 leaks" and "0 files read" must never be indistinguishable.
125
+ // The boundary gate runs it for EVERY copy of this file in the tree
126
+ // (`check-swagger-plugin-scrub-wired.mjs`), which is what keeps the carved
127
+ // duplicates from drifting back into inertness.
128
+ // =============================================================================
129
+
130
+ import { createRequire } from 'node:module';
131
+ import { readFileSync, realpathSync } from 'node:fs';
132
+ import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
133
+ import { tmpdir } from 'node:os';
134
+ import { dirname, join, resolve } from 'node:path';
135
+ import process from 'node:process';
136
+
137
+ // Matches the two known leak shapes. We intentionally scope to `@xemahq`
138
+ // because:
139
+ // (1) every leak we have seen comes from a workspace contracts package
140
+ // (always `@xemahq/*`); third-party leaks would be an unrelated bug
141
+ // in a third-party package we cannot rewrite blindly;
142
+ // (2) scoping the match keeps the rewrite reversible and auditable.
143
+ // If a future leak appears under another scope (e.g. `@xema-platform`),
144
+ // extend the alternation rather than dropping the scope guard.
145
+ const SCOPE_PATTERN = '@xemahq';
146
+
147
+ // Shape 1 — NESTED: `require("@xemahq/<outer>/node_modules/@xemahq/<inner>/<rest>")`
148
+ // Capture group 1: inner package + subpath we want to keep.
149
+ const NESTED_LEAK_REGEX = new RegExp(
150
+ `require\\("${SCOPE_PATTERN}\\/[a-z0-9-]+\\/node_modules\\/(${SCOPE_PATTERN}\\/[a-z0-9-]+(?:\\/[^"]+)?)"\\)`,
151
+ 'g',
152
+ );
153
+
154
+ // Shape 2 — FLAT: `require("@xemahq/<pkg>/dist/<rest>")`
155
+ // Capture group 1: package name. We drop the `/dist/<rest>` tail because the
156
+ // package's root `exports` re-exports every public symbol; the enum the
157
+ // swagger plugin needed is available on the bare specifier.
158
+ const FLAT_LEAK_REGEX = new RegExp(
159
+ `require\\("(${SCOPE_PATTERN}\\/[a-z0-9-]+)\\/dist\\/[^"]+"\\)`,
160
+ 'g',
161
+ );
162
+
163
+ // Shape 3 — RELATIVE WORKSPACE: `require("../../packages/<tier>/<pkg>/dist/<subpath>/<rest>")`
164
+ // TypeScript (with default preserveSymlinks:false) follows pnpm workspace
165
+ // symlinks to their physical location. The NestJS Swagger plugin then emits
166
+ // a relative path from the service source file back up to the workspace root
167
+ // and down into the contracts package. There is no `node_modules/` segment
168
+ // so shapes 1 and 2 above never fire.
169
+ // Capture group 1: package directory name (= npm name without @xemahq/ scope).
170
+ // Capture group 2: first path segment after dist/ = the subpath export name.
171
+ // Scoped to paths that pass through `packages/`, `biomes/`, or `kernel/` to
172
+ // avoid false-positive matches on intra-service relative requires.
173
+ const RELATIVE_WORKSPACE_REGEX = new RegExp(
174
+ `require\\("(?:\\.\\./)+(?:packages|biomes|kernel)/(?:[a-z0-9][a-z0-9.-]*/)*([a-z0-9][a-z0-9.-]*)/dist/([a-z0-9][a-z0-9-]*)(?:/[^"]*)?"\\)`,
175
+ 'g',
176
+ );
177
+
178
+ // Shape 4 — PNPM VIRTUAL STORE:
179
+ // `require(".pnpm/<pkg>@<ver>_<peer-hash>/node_modules/@xemahq/<pkg>/dist/<rest>")`
180
+ //
181
+ // This one shipped undetected in 25 services (387 literals) because the
182
+ // detector set below was, until now, the union of the REWRITE shapes — so a
183
+ // shape nothing rewrote was also a shape nothing could see, and the scrub step
184
+ // reported success on every build.
185
+ //
186
+ // It is a genuine runtime hazard, not a cosmetic path. The specifier does not
187
+ // begin with `./`, so Node treats `.pnpm` as a package name and finds it only
188
+ // because `node_modules/.pnpm` happens to exist in the BUILDER's layout. The
189
+ // directory name embeds a content hash of the peer-resolution key, and
190
+ // `pnpm deploy --prod` (what the runtime stage copies) re-resolves that graph
191
+ // with devDependencies pruned. A hash computed from the unpruned graph need not
192
+ // exist in the pruned one.
193
+ //
194
+ // Capture group 1: package name. Group 2: subpath INSIDE the package.
195
+ const PNPM_STORE_LEAK_REGEX = new RegExp(
196
+ `require\\("\\.pnpm\\/[^"]*?\\/node_modules\\/(${SCOPE_PATTERN}\\/[a-z0-9.-]+)\\/([^"]+)"\\)`,
197
+ 'g',
198
+ );
199
+
200
+ // ── DETECTORS (verification only — never used to rewrite) ────────────────────
201
+ // The union of every leak shape any copy of this script rewrites, kept
202
+ // SEPARATE from the rewrite regexes above on purpose. A net whose pattern is
203
+ // the rewriter's pattern can only ever report what the rewriter already fixed;
204
+ // these are what the post-scrub re-read asserts against, so they must be able
205
+ // to match something the rewrite pass leaves behind:
206
+ //
207
+ // • NESTED / FLAT — a single `String.replace` pass does not re-scan its own
208
+ // replacement, so a doubly-nested literal survives the rewrite as a
209
+ // still-leaking single-nested one. That is the shape `--self-test` plants.
210
+ // • RELATIVE-WORKSPACE — emitted when TypeScript resolves a workspace
211
+ // contracts package through its physical path rather than through
212
+ // `node_modules`, so neither shape above fires. Only the `xema-base` /
213
+ // `xema-community` copies of this file REWRITE it; every copy DETECTS it,
214
+ // so a repo that has not audited the rewrite fails loudly instead of
215
+ // silently shipping a require that cannot resolve inside the image.
216
+ //
217
+ // Mirrors `tooling/boundaries/check-no-deep-require-leaks.mjs` — if you change
218
+ // one, change both.
219
+ const LEAK_DETECTORS = [
220
+ // STRUCTURAL, and deliberately not a mirror of any rewrite regex above.
221
+ //
222
+ // The property is objective and shape-free: a require specifier in built
223
+ // output may name a package, or a path relative to the emitting file. It may
224
+ // NOT name a segment of the builder's install layout — `node_modules/`
225
+ // anywhere, or the pnpm virtual store `.pnpm/` — because that layout is a
226
+ // private implementation detail of the machine that compiled the file and is
227
+ // re-computed, differently, in the image that runs it.
228
+ //
229
+ // The previous `nested` detector was the nested REWRITER's own pattern with
230
+ // its capture group removed. That is why shape 4 (`.pnpm/…`) shipped in 25
231
+ // services with the scrub step green on every build: nothing rewrote it, so
232
+ // by construction nothing detected it. This one subsumes `nested` and every
233
+ // future variant of the same class, including packages outside `@xemahq`.
234
+ [
235
+ 'install-layout',
236
+ /require\("(?:[^"]*\/)?node_modules\/[^"]+"\)|require\("\.pnpm\/[^"]+"\)/g,
237
+ ],
238
+ ['flat', new RegExp(`require\\("${SCOPE_PATTERN}\\/[a-z0-9-]+\\/dist\\/[^"]+"\\)`, 'g')],
239
+ [
240
+ 'relative-workspace',
241
+ /require\("(?:\.\.\/)+(?:packages|biomes|kernel)\/(?:[a-z0-9][a-z0-9.-]*\/)*[a-z0-9][a-z0-9.-]*\/dist\/[a-z0-9][a-z0-9-]*(?:\/[^"]*)?"\)/g,
242
+ ],
243
+ ];
244
+
245
+ /** Every leak literal in `content`, de-duplicated, tagged with its shape. */
246
+ function findLeaks(content) {
247
+ const found = [];
248
+ for (const [shape, regex] of LEAK_DETECTORS) {
249
+ const matches = content.match(regex);
250
+ if (!matches) continue;
251
+ for (const literal of new Set(matches)) found.push({ shape, literal });
252
+ }
253
+ return found;
254
+ }
255
+
256
+ /** Thrown for every fail-fast condition so `--self-test` can assert on it. */
257
+ class ScrubFailure extends Error {}
258
+
259
+ // ── Public specifier for a physical subpath (shape 4) ────────────────────────
260
+ //
261
+ // The other three shapes can hardcode their replacement because every package
262
+ // they have ever matched exposes a root export. Shape 4 cannot: the two
263
+ // packages it leaks most are opposites.
264
+ //
265
+ // @xemahq/kernel-contracts exports ONLY `./*` -> `./dist/*/index.js`.
266
+ // It has NO root export, so rewriting to the bare name produces a
267
+ // specifier that cannot resolve — a fix that swaps one crash for another.
268
+ // Its public form is `@xemahq/kernel-contracts/<first segment after dist>`.
269
+ //
270
+ // @xemahq/platform-common exports `.` AND `./dist/*`.
271
+ // Its public form is the bare name.
272
+ //
273
+ // So the replacement is DERIVED from the target package's own `exports` map,
274
+ // not from a table here — a table would need an entry per package and would be
275
+ // wrong the first time a package changed its export style. Whatever it derives
276
+ // is then PROVED: both the original and the candidate are resolved, and the
277
+ // rewrite only happens if they name the same physical file. A rewrite that
278
+ // cannot be proved is refused rather than guessed.
279
+ const specifierCache = new Map();
280
+
281
+ /**
282
+ * Is `target` reachable from `entry` by following emitted re-exports?
283
+ *
284
+ * `tsc` compiles `export * from './lib/x'` to `__exportStar(require("./lib/x"),
285
+ * exports)`, so a barrel's re-export graph is legible in the emitted JS. This
286
+ * walks it with a visited set (barrels do form cycles) and a depth bound, and
287
+ * reads only relative requires — a re-export that leaves the package cannot
288
+ * make a symbol of THIS package public.
289
+ *
290
+ * Static on purpose. The alternative — importing both modules and diffing
291
+ * their export keys — would prove the same thing while executing arbitrary
292
+ * package code inside a build step.
293
+ */
294
+ function reExportsReach(entry, target, depth = 6, seen = new Set()) {
295
+ if (entry === target) return true;
296
+ if (depth === 0 || seen.has(entry)) return false;
297
+ seen.add(entry);
298
+
299
+ let src;
300
+ try {
301
+ src = readFileSync(entry, 'utf8');
302
+ } catch {
303
+ return false;
304
+ }
305
+ const dir = dirname(entry);
306
+ for (const m of src.matchAll(/require\("(\.[^"]*)"\)/g)) {
307
+ let next;
308
+ try {
309
+ next = realpathSync(createRequire(entry).resolve(m[1]));
310
+ } catch {
311
+ continue;
312
+ }
313
+ if (reExportsReach(next, target, depth - 1, seen)) return true;
314
+ }
315
+ return false;
316
+ }
317
+
318
+ /**
319
+ * @param {(spec: string) => string} resolveFrom resolver bound to the file being scrubbed
320
+ * @param {string} pkg e.g. `@xemahq/kernel-contracts`
321
+ * @param {string} subpath physical path inside the package, e.g. `dist/agent-composition/lib/x`
322
+ * @param {string} original the full leaked specifier, for the proof and the error
323
+ */
324
+ function publicSpecifierFor(resolveFrom, pkg, subpath, original) {
325
+ const cacheKey = `${pkg}${subpath}`;
326
+ if (specifierCache.has(cacheKey)) return specifierCache.get(cacheKey);
327
+
328
+ let target;
329
+ try {
330
+ target = resolveFrom(original);
331
+ } catch (err) {
332
+ throw new ScrubFailure(
333
+ `cannot resolve the leaked specifier, so no rewrite can be proved correct:\n` +
334
+ ` ${original}\n ${err.message}\n` +
335
+ ` Refusing to guess a replacement.`,
336
+ );
337
+ }
338
+
339
+ // Read the manifest off DISK by walking up from the resolved module, not
340
+ // through `resolveFrom(`${pkg}/package.json`)`. A well-formed `exports` map
341
+ // is entitled to omit `./package.json` — `@xemahq/entitlement-client` does —
342
+ // and resolving through it would make this step fail on exactly the packages
343
+ // whose export maps are strictest.
344
+ let exportsMap;
345
+ {
346
+ let dir = dirname(target);
347
+ let manifest = null;
348
+ for (let i = 0; i < 12 && dir !== dirname(dir); i += 1) {
349
+ try {
350
+ const candidate = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
351
+ if (candidate.name === pkg) {
352
+ manifest = candidate;
353
+ break;
354
+ }
355
+ } catch {
356
+ /* keep walking */
357
+ }
358
+ dir = dirname(dir);
359
+ }
360
+ if (!manifest) {
361
+ throw new ScrubFailure(
362
+ `cannot locate the package manifest of ${pkg} above its resolved module, so no\n` +
363
+ ` public specifier can be derived for\n ${original}`,
364
+ );
365
+ }
366
+ exportsMap = manifest.exports ?? {};
367
+ // A package with no `exports` map has no subpath restrictions at all: its
368
+ // `main` is the public entry and every file is reachable. Bare is correct.
369
+ if (Object.keys(exportsMap).length === 0) exportsMap = { '.': manifest.main ?? 'index.js' };
370
+ }
371
+
372
+ // Candidates, most specific first. Every one is verified below; the order
373
+ // only decides which correct answer is preferred.
374
+ const afterDist = subpath.replace(/^dist\//, '').split('/')[0];
375
+ const candidates = [];
376
+ if (exportsMap['./*'] && afterDist) candidates.push(`${pkg}/${afterDist}`);
377
+ if (exportsMap['.']) candidates.push(pkg);
378
+ if (exportsMap['./dist/*'] || exportsMap['./dist/lib/*']) candidates.push(`${pkg}/${subpath}`);
379
+
380
+ for (const candidate of candidates) {
381
+ let resolved;
382
+ try {
383
+ resolved = resolveFrom(candidate);
384
+ } catch {
385
+ continue; // not admitted by the exports map after all
386
+ }
387
+ // Same file is the easy case. The common case is not that: a package's
388
+ // public entry is a BARREL that re-exports the leaked module, so the two
389
+ // are different files and the symbols are nonetheless there. Demanding
390
+ // file equality would refuse every correct rewrite for a barrelled package
391
+ // (it refused all 333 kernel-contracts leaks). Proving reachability
392
+ // through the re-export chain is the assertion that actually matters, and
393
+ // it is done by READING the emitted barrels — never by executing them, so
394
+ // a build step cannot be made to run package code.
395
+ if (resolved === target || reExportsReach(resolved, target)) {
396
+ specifierCache.set(cacheKey, candidate);
397
+ return candidate;
398
+ }
399
+ }
400
+
401
+ throw new ScrubFailure(
402
+ `no public specifier of ${pkg} resolves to the same file as\n` +
403
+ ` ${original}\n` +
404
+ ` tried: ${candidates.length > 0 ? candidates.join(', ') : '(none — the exports map admits no matching form)'}\n` +
405
+ ` Add the needed entry to that package's "exports", or export the symbol from a\n` +
406
+ ` path that is public. Rewriting to a specifier that resolves elsewhere would\n` +
407
+ ` substitute a different module at runtime, which is worse than the leak.`,
408
+ );
409
+ }
410
+
411
+ async function* walk(dir) {
412
+ let entries;
413
+ try {
414
+ entries = await readdir(dir, { withFileTypes: true });
415
+ } catch (err) {
416
+ if (err.code === 'ENOENT') {
417
+ return;
418
+ }
419
+ throw err;
420
+ }
421
+ for (const entry of entries) {
422
+ const full = join(dir, entry.name);
423
+ if (entry.isDirectory()) {
424
+ // Skip nested node_modules — those are NOT our build output.
425
+ if (entry.name === 'node_modules') continue;
426
+ yield* walk(full);
427
+ } else if (entry.isFile() && entry.name.endsWith('.js')) {
428
+ yield full;
429
+ }
430
+ }
431
+ }
432
+
433
+ async function scrubFile(path) {
434
+ const src = await readFile(path, 'utf8');
435
+ let count = 0;
436
+ // Resolution is anchored at the file being rewritten, so a package resolves
437
+ // exactly as it will at runtime from that file — not as it happens to
438
+ // resolve from this script's own location.
439
+ const requireFrom = createRequire(path);
440
+ const resolveFrom = (spec) => realpathSync(requireFrom.resolve(spec));
441
+
442
+ let out = src.replace(PNPM_STORE_LEAK_REGEX, (match, pkg, subpath) => {
443
+ count += 1;
444
+ return `require("${publicSpecifierFor(resolveFrom, pkg, subpath, match.slice('require("'.length, -2))}")`;
445
+ });
446
+ out = out.replace(NESTED_LEAK_REGEX, (_match, kept) => {
447
+ count += 1;
448
+ return `require("${kept}")`;
449
+ });
450
+ out = out.replace(FLAT_LEAK_REGEX, (_match, pkg) => {
451
+ count += 1;
452
+ return `require("${pkg}")`;
453
+ });
454
+ out = out.replace(RELATIVE_WORKSPACE_REGEX, (_match, pkgName, subpath) => {
455
+ count += 1;
456
+ return `require("@xemahq/${pkgName}/${subpath}")`;
457
+ });
458
+ if (count > 0) {
459
+ await writeFile(path, out, 'utf8');
460
+ }
461
+ return count;
462
+ }
463
+
464
+ /**
465
+ * A named output directory must exist, be a directory, and hold at least one
466
+ * compiled `.js`. Anything else is a hard failure.
467
+ *
468
+ * This used to return `false` on ENOENT and be skipped, "so the build step
469
+ * composes cleanly when invoked before `tsc`". No caller does that — every
470
+ * subject's build script is `nest build && node …/scrub-swagger-plugin-paths.mjs
471
+ * dist` — and the lenient branch meant a service whose `outDir` moved kept a
472
+ * green scrub step over an empty scan. This step is a verification step; a
473
+ * verification that scanned nothing must never report success.
474
+ */
475
+ async function assertScannable(dir) {
476
+ let s;
477
+ try {
478
+ s = await stat(dir);
479
+ } catch (err) {
480
+ if (err.code === 'ENOENT') {
481
+ throw new ScrubFailure(
482
+ `output directory does not exist: ${dir}\n` +
483
+ ` This script runs AFTER \`nest build\`, so the directory it is handed must hold that\n` +
484
+ ` build's output. A missing path means the argument disagrees with the service's\n` +
485
+ ` tsconfig \`outDir\` — which would leave the swagger-plugin path leak unscrubbed AND\n` +
486
+ ` unverified in the bytes that actually ship.`,
487
+ );
488
+ }
489
+ throw err;
490
+ }
491
+ if (!s.isDirectory()) throw new ScrubFailure(`not a directory: ${dir}`);
492
+
493
+ for await (const _file of walk(dir)) return; // eslint-disable-line no-unused-vars
494
+ throw new ScrubFailure(
495
+ `output directory holds no compiled .js: ${dir}\n` +
496
+ ` Same failure as a missing directory: nothing was scrubbed and nothing was verified.`,
497
+ );
498
+ }
499
+
500
+ /**
501
+ * Re-read the written tree and assert no leak literal survived the rewrite.
502
+ * Reads from DISK rather than trusting the in-memory rewrite result, so a
503
+ * write that did not land is caught too.
504
+ */
505
+ async function verifyDirs(dirs) {
506
+ const residual = [];
507
+ for (const dir of dirs) {
508
+ for await (const file of walk(dir)) {
509
+ const leaks = findLeaks(await readFile(file, 'utf8'));
510
+ if (leaks.length > 0) residual.push({ file, leaks });
511
+ }
512
+ }
513
+ return residual;
514
+ }
515
+
516
+ /** Scrub + verify. Throws `ScrubFailure` on any fail-fast condition. */
517
+ async function run(args) {
518
+ const dirs = args.map((a) => resolve(a));
519
+ for (const dir of dirs) await assertScannable(dir);
520
+
521
+ let totalFiles = 0;
522
+ let totalRewrites = 0;
523
+ const touched = [];
524
+
525
+ for (const dir of dirs) {
526
+ for await (const file of walk(dir)) {
527
+ totalFiles += 1;
528
+ const count = await scrubFile(file);
529
+ if (count > 0) {
530
+ totalRewrites += count;
531
+ touched.push({ file, count });
532
+ }
533
+ }
534
+ }
535
+
536
+ const residual = await verifyDirs(dirs);
537
+ if (residual.length > 0) {
538
+ const lines = [
539
+ `${residual.length} file(s) still contain a leaked require() literal AFTER scrubbing.`,
540
+ '',
541
+ 'Each one crashes the pod at boot with ERR_PACKAGE_PATH_NOT_EXPORTED, because the',
542
+ "outer package's exports field correctly does not cover the leaked subpath. The",
543
+ 'rewrite pass could not repair these, so the build that produced them fails here',
544
+ 'rather than shipping them into an image.',
545
+ '',
546
+ 'Fix:',
547
+ ' 1. Pin the enum in the service tsconfig `paths` so TypeScript resolves it flat.',
548
+ ' 2. If the shape is new, extend the rewrite regexes in this file AND the detector',
549
+ ' set in tooling/boundaries/check-no-deep-require-leaks.mjs together.',
550
+ '',
551
+ ];
552
+ for (const { file, leaks } of residual) {
553
+ lines.push(` • ${file}`);
554
+ for (const { shape, literal } of leaks) lines.push(` [${shape}] ${literal}`);
555
+ }
556
+ throw new ScrubFailure(lines.join('\n'));
557
+ }
558
+
559
+ return { totalFiles, totalRewrites, touched };
560
+ }
561
+
562
+ // ── Self-test ────────────────────────────────────────────────────────────────
563
+
564
+ async function expectFailure(label, fn) {
565
+ let threw = null;
566
+ try {
567
+ await fn();
568
+ } catch (err) {
569
+ threw = err;
570
+ }
571
+ if (!(threw instanceof ScrubFailure)) {
572
+ throw new Error(
573
+ `self-test: "${label}" was expected to FAIL and did not (${threw ? threw.message : 'no error thrown'}).`,
574
+ );
575
+ }
576
+ return threw;
577
+ }
578
+
579
+ async function selfTest() {
580
+ const root = await mkdtemp(join(tmpdir(), 'scrub-swagger-selftest-'));
581
+ try {
582
+ // 1. Missing directory — the wrong-outDir case.
583
+ await expectFailure('missing directory', () => run([join(root, 'does-not-exist')]));
584
+
585
+ // 2. Present but empty — the zero-scan case that used to pass silently.
586
+ const empty = join(root, 'empty');
587
+ await mkdir(empty, { recursive: true });
588
+ await expectFailure('empty directory', () => run([empty]));
589
+
590
+ // 2b. Present, non-empty, but no compiled .js.
591
+ const noJs = join(root, 'no-js');
592
+ await mkdir(noJs, { recursive: true });
593
+ await writeFile(join(noJs, 'readme.txt'), 'not compiled output\n', 'utf8');
594
+ await expectFailure('directory with no compiled .js', () => run([noJs]));
595
+
596
+ // 3. Detector proves it matches every shape, and does NOT match clean code.
597
+ const samples = [
598
+ [
599
+ 'install-layout',
600
+ 'require("@xemahq/policy-contracts/node_modules/@xemahq/subject-contracts/dist/lib/x")',
601
+ ],
602
+ // The shape that shipped in 25 services while this step reported success.
603
+ // It is listed here NOT because a rewriter handles it — that is exactly
604
+ // the reasoning that hid it — but because the detector's property
605
+ // ("names a segment of the builder's install layout") covers it.
606
+ [
607
+ 'install-layout',
608
+ 'require(".pnpm/@xemahq+kernel-contracts@7.5.0_abc123/node_modules/@xemahq/kernel-contracts/dist/capability/lib/x")',
609
+ ],
610
+ // Not an @xemahq package, and matched by nothing this script rewrites.
611
+ // A detector that can only see what a rewriter already fixes is the
612
+ // defect, so prove it sees beyond them.
613
+ ['install-layout', 'require("some-vendor/node_modules/left-pad/dist/index")'],
614
+ ['flat', 'require("@xemahq/capability-contracts/dist/lib/capability-grant")'],
615
+ ['relative-workspace', 'require("../../packages/kernel/kernel-contracts/dist/capability/lib/x")'],
616
+ ];
617
+ for (const [shape, literal] of samples) {
618
+ const hits = findLeaks(literal);
619
+ if (!hits.some((h) => h.shape === shape)) {
620
+ throw new Error(`self-test: detector missed the ${shape} shape: ${literal}`);
621
+ }
622
+ }
623
+ const clean = 'require("@xemahq/kernel-contracts");require("./local");require("../shared/util");';
624
+ if (findLeaks(clean).length !== 0) {
625
+ throw new Error(`self-test: detector matched clean output: ${JSON.stringify(findLeaks(clean))}`);
626
+ }
627
+
628
+ // 4. End-to-end GREEN — a single nested leak is rewritten and verifies clean.
629
+ const green = join(root, 'green');
630
+ await mkdir(green, { recursive: true });
631
+ const greenFile = join(green, 'dto.js');
632
+ await writeFile(greenFile, `${samples[0][1]};\n`, 'utf8');
633
+ const greenResult = await run([green]);
634
+ if (greenResult.totalRewrites < 1) {
635
+ throw new Error(`self-test: expected at least 1 rewrite, got ${greenResult.totalRewrites}`);
636
+ }
637
+ const greenAfter = await readFile(greenFile, 'utf8');
638
+ // The NESTED pass unwraps to `@xemahq/subject-contracts/dist/lib/x`, which
639
+ // the FLAT pass then reduces to the bare specifier — the two passes chain,
640
+ // and the verification proves the chain terminated clean.
641
+ if (greenAfter.trim() !== 'require("@xemahq/subject-contracts");') {
642
+ throw new Error(`self-test: unexpected rewrite result: ${greenAfter.trim()}`);
643
+ }
644
+ if (findLeaks(greenAfter).length !== 0) {
645
+ throw new Error(`self-test: rewritten file still leaks: ${greenAfter}`);
646
+ }
647
+
648
+ // 5. End-to-end RED — a DOUBLY-nested literal. One `String.replace` pass
649
+ // does not re-scan its own replacement, so the rewrite leaves a literal
650
+ // that is still a leak. This is the case the old script shipped
651
+ // silently, and the reason the verification re-reads the tree.
652
+ const red = join(root, 'red');
653
+ await mkdir(red, { recursive: true });
654
+ await writeFile(
655
+ join(red, 'dto.js'),
656
+ 'require("@xemahq/policy-contracts/node_modules/@xemahq/outer-contracts/node_modules/@xemahq/inner-contracts/dist/lib/x");\n',
657
+ 'utf8',
658
+ );
659
+ const redErr = await expectFailure('residual leak after rewrite', () => run([red]));
660
+ if (!redErr.message.includes('still contain a leaked require')) {
661
+ throw new Error(`self-test: residual failure had the wrong message: ${redErr.message}`);
662
+ }
663
+
664
+ process.stdout.write(
665
+ 'scrub-swagger-plugin-paths --self-test: PASS — missing dir, empty dir, no-.js dir all FAIL; ' +
666
+ 'detector matches all 3 leak shapes and no clean literal; a rewritable leak verifies clean; ' +
667
+ 'a residual leak FAILS the run.\n',
668
+ );
669
+ } finally {
670
+ await rm(root, { recursive: true, force: true });
671
+ }
672
+ }
673
+
674
+ // ── Entry point ──────────────────────────────────────────────────────────────
675
+
676
+ async function main() {
677
+ const args = process.argv.slice(2);
678
+ if (args.includes('--self-test')) {
679
+ if (args.length !== 1) {
680
+ throw new ScrubFailure('--self-test takes no other arguments');
681
+ }
682
+ await selfTest();
683
+ return;
684
+ }
685
+
686
+ const unknownFlags = args.filter((a) => a.startsWith('-'));
687
+ if (unknownFlags.length > 0) {
688
+ throw new ScrubFailure(
689
+ `unknown flag(s): ${unknownFlags.join(', ')}. This script takes output directories, or --self-test.`,
690
+ );
691
+ }
692
+ if (args.length === 0) {
693
+ process.stderr.write(
694
+ 'usage: scrub-swagger-plugin-paths.mjs <dist-dir> [<dist-dir>…] | --self-test\n',
695
+ );
696
+ process.exit(2);
697
+ }
698
+
699
+ const { totalFiles, totalRewrites, touched } = await run(args);
700
+ if (totalRewrites === 0) {
701
+ // Silent on the common (clean) path — keeps build logs quiet. The run is
702
+ // still a verified one: `assertScannable` proved there was output to read.
703
+ return;
704
+ }
705
+
706
+ process.stdout.write(
707
+ `scrub-swagger-plugin-paths: rewrote ${totalRewrites} leaked require() literal(s) across ${touched.length} file(s) (scanned ${totalFiles}, verified clean).\n`,
708
+ );
709
+ for (const { file, count } of touched) {
710
+ process.stdout.write(` ${file} (${count})\n`);
711
+ }
712
+ }
713
+
714
+ main().catch((err) => {
715
+ process.stderr.write(
716
+ `scrub-swagger-plugin-paths: FAILED — ${
717
+ err instanceof ScrubFailure ? err.message : err instanceof Error ? (err.stack ?? err.message) : String(err)
718
+ }\n`,
719
+ );
720
+ process.exit(1);
721
+ });