@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 +176 -0
- package/README.md +75 -0
- package/package.json +33 -0
- package/src/scrub-swagger-plugin-paths.mjs +721 -0
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
|
+
});
|