@lenne.tech/cli 1.41.3 → 1.43.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.
@@ -0,0 +1,285 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.healVendorMigrateStore = healVendorMigrateStore;
37
+ const child_process_1 = require("child_process");
38
+ const fs_1 = require("fs");
39
+ const path_1 = require("path");
40
+ const ts = __importStar(require("typescript"));
41
+ /**
42
+ * Idempotently repair a vendor-mode project's migration store
43
+ * (`migrations-utils/migrate.js`).
44
+ *
45
+ * ## The defect this heals
46
+ *
47
+ * The store is generated ONCE, during `convertCloneToVendored()`. Projects that
48
+ * were converted before the template learned its lesson keep the old file
49
+ * forever: `migrations-utils/` is project scaffolding, not `src/core/`, so
50
+ * neither the core updater nor any other update path ever touches it again.
51
+ *
52
+ * The old variant registers the ts-node bootstrap UNCONDITIONALLY:
53
+ *
54
+ * ```js
55
+ * require('./ts-compiler'); // -> require('ts-node')
56
+ * ```
57
+ *
58
+ * `ts-node` is a devDependency that the production Dockerfile prunes with
59
+ * `pnpm install --prod`, while the image needs no transpiler at all (everything
60
+ * next to the store is already compiled). So every deployed container dies with
61
+ * `Cannot find module 'ts-node'` before applying a single migration.
62
+ *
63
+ * It stays invisible because `docker-entrypoint.sh` degrades a migration failure
64
+ * to a warning on purpose — a bad migration must not crash-loop the container and
65
+ * leave the orchestrator serving a stale build. The container reports healthy,
66
+ * nothing is migrated, and nobody notices.
67
+ *
68
+ * ## Why the detection is inverted — the expensive lesson
69
+ *
70
+ * This function replaces the file WHOLESALE, and the replacement is not
71
+ * behaviour-neutral: the bundled template hardcodes the collection name
72
+ * (`'migrations'`) and takes its URI from `./mongo-uri`. A project that used a
73
+ * different collection therefore gets an EMPTY migration ledger — and the next
74
+ * `migrate:up` re-runs every historical migration against the live database. A
75
+ * project that never had `./mongo-uri` crashes outright.
76
+ *
77
+ * The first implementation asked "can I SEE a guard?" and treated the answer
78
+ * "no" as proof that none exists. It recognised exactly two shapes — a
79
+ * `require.resolve` probe and a `try {` — so a perfectly production-safe
80
+ * `if (!fs.existsSync(compiled)) require('./ts-compiler')` read as broken and
81
+ * was destroyed, together with its collection name.
82
+ *
83
+ * So the question is inverted: heal ONLY when the hazard is positively proven,
84
+ * i.e. the require sits as a TOP-LEVEL, unconditional statement — the one shape
85
+ * that genuinely cannot survive a pruned image. Every other shape (inside `try`,
86
+ * `if`, a function, a ternary, a block) is by construction conditional, hence
87
+ * the project's own solution, and is left alone. "I did not recognise a guard"
88
+ * is no longer evidence that there is none.
89
+ *
90
+ * Detection runs on the TypeScript AST, not on regex-stripped text. A regex
91
+ * "lexer" has no string/template/regex-literal state, so a `/*` or `//` inside a
92
+ * literal earlier in the file silently erased the guard and triggered the very
93
+ * overwrite this function must avoid. `lib/strip-comments.ts` solves the comment
94
+ * half properly (TS scanner) and would have been the right reuse; the AST solves
95
+ * comments AND nesting in one step, and recognises a backtick require for free.
96
+ *
97
+ * ## Recoverability
98
+ *
99
+ * An overwrite is only acceptable when it can be undone. `git status --porcelain`
100
+ * returning nothing does NOT mean "committed" — it also means untracked-and-
101
+ * ignored, or not a git repo at all, i.e. exactly the cases where nothing can be
102
+ * recovered. The guard therefore establishes tracked-ness directly
103
+ * (`git ls-files --error-unmatch`) and writes a `.bak` whenever it cannot prove
104
+ * git has a copy. A file with UNCOMMITTED modifications is never overwritten —
105
+ * that would destroy work which exists nowhere else — and is reported as skipped.
106
+ *
107
+ * @param apiDir Absolute path to the api project (the directory holding `src/core`).
108
+ * @param assetPath Absolute path to the bundled `templates/vendor-scripts/migrate-store.js`.
109
+ * @returns Changed paths relative to `apiDir`; empty when nothing needed healing.
110
+ */
111
+ function healVendorMigrateStore(apiDir, assetPath) {
112
+ const changed = [];
113
+ // Vendor mode only. In npm mode the store requires the compiled
114
+ // `@lenne.tech/nest-server` package and never needs a transpiler.
115
+ if (!(0, fs_1.existsSync)((0, path_1.join)(apiDir, 'src', 'core', 'VENDOR.md'))) {
116
+ return changed;
117
+ }
118
+ const rel = 'migrations-utils/migrate.js';
119
+ const storePath = (0, path_1.join)(apiDir, 'migrations-utils', 'migrate.js');
120
+ if (!(0, fs_1.existsSync)(storePath) || !(0, fs_1.existsSync)(assetPath)) {
121
+ return changed;
122
+ }
123
+ // Never write THROUGH a symlink: the target may live anywhere, and the caller
124
+ // asked us to repair a store, not to overwrite whatever it points at.
125
+ if (isSymbolicLink(storePath)) {
126
+ changed.push(`${rel} (skipped: is a symlink — repair the file it points at instead)`);
127
+ return changed;
128
+ }
129
+ let current;
130
+ try {
131
+ current = (0, fs_1.readFileSync)(storePath, 'utf8');
132
+ }
133
+ catch (_a) {
134
+ return changed;
135
+ }
136
+ if (!hasTopLevelTsCompilerRequire(current)) {
137
+ return changed;
138
+ }
139
+ const recoverability = gitRecoverability(apiDir, rel);
140
+ if (recoverability === 'dirty') {
141
+ changed.push(`${rel} (skipped: uncommitted changes — commit or discard them, then re-run)`);
142
+ return changed;
143
+ }
144
+ let template;
145
+ try {
146
+ template = (0, fs_1.readFileSync)(assetPath, 'utf8');
147
+ }
148
+ catch (_b) {
149
+ return changed;
150
+ }
151
+ // No git copy to fall back on (untracked, ignored, or not a repo at all), so
152
+ // leave one on disk before touching the file.
153
+ let note = '';
154
+ if (recoverability === 'unknown') {
155
+ const backupPath = `${storePath}.bak`;
156
+ try {
157
+ if (!(0, fs_1.existsSync)(backupPath)) {
158
+ (0, fs_1.copyFileSync)(storePath, backupPath);
159
+ }
160
+ note = ` — previous version saved to ${rel}.bak`;
161
+ }
162
+ catch (_c) {
163
+ changed.push(`${rel} (skipped: git has no copy and the .bak could not be written)`);
164
+ return changed;
165
+ }
166
+ }
167
+ // Atomic: a crash between write and rename leaves the original intact rather
168
+ // than a truncated store the migrate CLI would then fail to parse.
169
+ if (!writeAtomic(storePath, template)) {
170
+ changed.push(`${rel} (skipped: write failed)`);
171
+ return changed;
172
+ }
173
+ changed.push(`${rel} (migrations never ran in deployed containers — see the file header)${note}`);
174
+ return changed;
175
+ }
176
+ /**
177
+ * Whether git holds a recoverable copy of `relPath`.
178
+ *
179
+ * - `recoverable` — tracked and unmodified: an overwrite is undoable via git.
180
+ * - `dirty` — tracked with uncommitted edits: must not be overwritten.
181
+ * - `unknown` — untracked, ignored, no repo, or no `git` on PATH. Git can
182
+ * recover nothing here, so the caller must back up itself.
183
+ *
184
+ * Deliberately does NOT infer "committed" from empty `status --porcelain`
185
+ * output: an ignored or untracked file is equally silent there, and treating
186
+ * that silence as safety is what made the overwrite unrecoverable.
187
+ */
188
+ function gitRecoverability(projectRoot, relPath) {
189
+ try {
190
+ // Throws unless the path is TRACKED — the property we actually depend on.
191
+ (0, child_process_1.execFileSync)('git', ['-C', projectRoot, 'ls-files', '--error-unmatch', '--', relPath], {
192
+ stdio: ['ignore', 'ignore', 'ignore'],
193
+ });
194
+ }
195
+ catch (_a) {
196
+ return 'unknown';
197
+ }
198
+ try {
199
+ const out = (0, child_process_1.execFileSync)('git', ['-C', projectRoot, 'status', '--porcelain', '--', relPath], {
200
+ encoding: 'utf8',
201
+ stdio: ['ignore', 'pipe', 'ignore'],
202
+ });
203
+ return out.trim().length > 0 ? 'dirty' : 'recoverable';
204
+ }
205
+ catch (_b) {
206
+ return 'unknown';
207
+ }
208
+ }
209
+ /**
210
+ * True when the file requires the ts-node bootstrap as a TOP-LEVEL, unconditional
211
+ * statement — the only shape that provably breaks in a production image where
212
+ * ts-node has been pruned.
213
+ *
214
+ * Anything nested is conditional by construction and therefore the project's own
215
+ * (working) solution — both of these are left alone, as is any other guard shape
216
+ * someone invents:
217
+ *
218
+ * ```js
219
+ * try { require.resolve(`${HELPER}.js`) } catch { require('./ts-compiler') }
220
+ * if (!fs.existsSync(compiled)) { require('./ts-compiler') }
221
+ * ```
222
+ *
223
+ * Uses the AST rather than text matching, so comments, string literals, template
224
+ * literals and regex literals cannot fake — or hide — a match.
225
+ */
226
+ function hasTopLevelTsCompilerRequire(source) {
227
+ let sourceFile;
228
+ try {
229
+ sourceFile = ts.createSourceFile('migrate.js', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
230
+ }
231
+ catch (_a) {
232
+ // Unparseable: we cannot prove the hazard, so we must not act.
233
+ return false;
234
+ }
235
+ return sourceFile.statements.some((statement) => {
236
+ if (!ts.isExpressionStatement(statement)) {
237
+ return false;
238
+ }
239
+ return isTsCompilerRequireCall(statement.expression);
240
+ });
241
+ }
242
+ /** True when `path` is a symlink (never follows it). */
243
+ function isSymbolicLink(path) {
244
+ try {
245
+ return (0, fs_1.lstatSync)(path).isSymbolicLink();
246
+ }
247
+ catch (_a) {
248
+ return false;
249
+ }
250
+ }
251
+ /** True for `require('./ts-compiler')` — single string or backtick argument. */
252
+ function isTsCompilerRequireCall(node) {
253
+ if (!ts.isCallExpression(node)) {
254
+ return false;
255
+ }
256
+ if (!ts.isIdentifier(node.expression) || node.expression.text !== 'require') {
257
+ return false;
258
+ }
259
+ if (node.arguments.length !== 1) {
260
+ return false;
261
+ }
262
+ const arg = node.arguments[0];
263
+ const isLiteral = ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg);
264
+ return isLiteral && arg.text === './ts-compiler';
265
+ }
266
+ /** Write via temp file + rename so a crash cannot leave a truncated store. */
267
+ function writeAtomic(target, content) {
268
+ const tmp = `${target}.lt-tmp`;
269
+ try {
270
+ (0, fs_1.writeFileSync)(tmp, content);
271
+ (0, fs_1.renameSync)(tmp, target);
272
+ return true;
273
+ }
274
+ catch (_a) {
275
+ try {
276
+ if ((0, fs_1.existsSync)(tmp)) {
277
+ (0, fs_1.unlinkSync)(tmp);
278
+ }
279
+ }
280
+ catch (_b) {
281
+ /* best effort */
282
+ }
283
+ return false;
284
+ }
285
+ }
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractKeyComments = extractKeyComments;
4
+ exports.reattachKeyComments = reattachKeyComments;
3
5
  exports.hoistPackageManager = hoistPackageManager;
4
6
  exports.hoistWorkspacePnpmConfig = hoistWorkspacePnpmConfig;
5
7
  const js_yaml_1 = require("js-yaml");
@@ -22,11 +24,177 @@ const fs_utils_1 = require("./fs-utils");
22
24
  * is hoisted too so a sub-project's first-party exemption (e.g.
23
25
  * `@lenne.tech/*`) keeps working in the monorepo — otherwise the
24
26
  * minimum-release-age gate would block freshly published own packages.
27
+ *
28
+ * `auditConfig` is nested (`{ ignoreGhsas: [...], ignoreCves: [...] }`), so it
29
+ * needs a one-level-deeper merge than the flat object fields. It MUST be hoisted:
30
+ * the CI audit job is deploy-blocking, and a settings-only sub-workspace file is
31
+ * deleted after hoisting (see hoistFromSubWorkspaceYaml). Without this the
32
+ * starter's assessed-advisory allowlist is destroyed rather than merely ignored,
33
+ * and the generated project's very first pipeline goes red on an advisory that
34
+ * was already justified upstream.
25
35
  */
26
36
  const OBJECT_FIELDS = ['overrides', 'allowBuilds'];
27
37
  const ARRAY_FIELDS = ['onlyBuiltDependencies', 'ignoredOptionalDependencies', 'minimumReleaseAgeExclude'];
28
- const WORKSPACE_SCOPED_PNPM_FIELDS = [...OBJECT_FIELDS, ...ARRAY_FIELDS];
38
+ /** Objects whose values are arrays to be unioned, not replaced. */
39
+ const NESTED_ARRAY_FIELDS = ['auditConfig'];
40
+ /** The union of all three, in declaration order. Declared here, with its inputs,
41
+ * because the comment-carrying helpers below default their `fields` parameter to it. */
42
+ const WORKSPACE_SCOPED_PNPM_FIELDS = [...OBJECT_FIELDS, ...ARRAY_FIELDS, ...NESTED_ARRAY_FIELDS];
43
+ /**
44
+ * Separator for the composite map key.
45
+ *
46
+ * `\0` rather than a space, because a YAML mapping key may legally contain
47
+ * spaces — `overrides` selectors like `minimatch@>=5.0.0 <10.2.6` do — and a
48
+ * space would let two different (field, key) pairs collide on one entry,
49
+ * silently attaching one entry's reasoning to another's. Written as an escape
50
+ * rather than a literal control character: a raw NUL in the source makes git
51
+ * treat this file as binary, which costs every future reviewer the diff.
52
+ */
53
+ const KEY_SEPARATOR = '\0';
54
+ /**
55
+ * Control characters that must never survive into an emitted YAML comment.
56
+ *
57
+ * Everything below U+0020 except TAB (U+0009) and LF (U+000A) — CR included,
58
+ * deliberately: it is the one that reads as whitespace and parses as a line
59
+ * break. LF cannot appear here (the harvest splits on it) and TAB is harmless.
60
+ */
61
+ const CONTROL_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F]/;
62
+ const commentKey = (field, key) => `${field}${KEY_SEPARATOR}${key}`;
63
+ /**
64
+ * Comment blocks attached to the entries of each top-level mapping in `raw`.
65
+ *
66
+ * Only contiguous `#` lines DIRECTLY above an entry are taken, and a blank line
67
+ * ends the block — a comment separated from a key by an empty line belongs to the
68
+ * section, not to that key, and re-attaching it would silently move a section
69
+ * header onto whichever entry happened to come first.
70
+ */
71
+ function extractKeyComments(raw, fields = WORKSPACE_SCOPED_PNPM_FIELDS) {
72
+ const out = new Map();
73
+ if (!raw)
74
+ return out;
75
+ const lines = raw.split('\n');
76
+ let field = null;
77
+ let fieldIndent = 0;
78
+ let pending = [];
79
+ for (const line of lines) {
80
+ const topLevel = /^([A-Za-z_][\w-]*):\s*$/.exec(line);
81
+ if (topLevel) {
82
+ field = fields.includes(topLevel[1]) ? topLevel[1] : null;
83
+ fieldIndent = 0;
84
+ pending = [];
85
+ continue;
86
+ }
87
+ if (field === null)
88
+ continue;
89
+ if (/^\s*$/.test(line)) {
90
+ pending = [];
91
+ continue;
92
+ }
93
+ const indent = line.search(/\S/);
94
+ // Back at column 0 → the mapping is over (a new top-level key or a list item).
95
+ if (indent === 0) {
96
+ field = null;
97
+ pending = [];
98
+ continue;
99
+ }
100
+ if (/^\s*#/.test(line)) {
101
+ // A bare CR is NOT a line break to `String.split('\n')` but IS one to every
102
+ // YAML parser. So a comment containing one is re-emitted verbatim, and
103
+ // everything after the CR becomes real YAML at a column of its author's
104
+ // choosing. Verified against pnpm 11: a comment carrying
105
+ // `\r left-pad: 9.9.9` installs as a workspace-wide `overrides` entry —
106
+ // an arbitrary version force in every generated project — while the line
107
+ // still renders as an ordinary comment in editors and diffs.
108
+ //
109
+ // Dropping the whole block is the right response rather than sanitising it:
110
+ // a rationale nobody can read is worth less than the risk of guessing what
111
+ // the author meant.
112
+ if (CONTROL_CHARS.test(line)) {
113
+ pending = [];
114
+ continue;
115
+ }
116
+ pending.push(line.trimStart());
117
+ continue;
118
+ }
119
+ const entry = /^\s*((?:'[^']*')|(?:"[^"]*")|(?:[^\s:#][^:]*?))\s*:/.exec(line);
120
+ if (!entry) {
121
+ pending = [];
122
+ continue;
123
+ }
124
+ // Nested deeper than the first entry level (e.g. `auditConfig.ignoreGhsas`
125
+ // items) — the block belongs to the inner key, which this pass does not carry.
126
+ if (fieldIndent === 0)
127
+ fieldIndent = indent;
128
+ if (indent === fieldIndent && pending.length) {
129
+ out.set(commentKey(field, unquoteYamlKey(entry[1])), pending.join('\n'));
130
+ }
131
+ pending = [];
132
+ }
133
+ return out;
134
+ }
135
+ /**
136
+ * Put the harvested comment blocks back above their keys in dumped YAML.
137
+ *
138
+ * A key whose comment is already present is left alone, so re-running the hoist
139
+ * over an already-annotated file is idempotent rather than stuttering.
140
+ */
141
+ function reattachKeyComments(yaml, comments, fields = WORKSPACE_SCOPED_PNPM_FIELDS) {
142
+ var _a;
143
+ if (comments.size === 0)
144
+ return yaml;
145
+ const lines = yaml.split('\n');
146
+ const out = [];
147
+ let field = null;
148
+ let fieldIndent = 0;
149
+ for (const line of lines) {
150
+ const topLevel = /^([A-Za-z_][\w-]*):\s*$/.exec(line);
151
+ if (topLevel) {
152
+ field = fields.includes(topLevel[1]) ? topLevel[1] : null;
153
+ fieldIndent = 0;
154
+ out.push(line);
155
+ continue;
156
+ }
157
+ if (field !== null && !/^\s*$/.test(line)) {
158
+ const indent = line.search(/\S/);
159
+ if (indent === 0) {
160
+ field = null;
161
+ }
162
+ else {
163
+ const entry = /^\s*((?:'[^']*')|(?:"[^"]*")|(?:[^\s:#][^:]*?))\s*:/.exec(line);
164
+ if (entry) {
165
+ if (fieldIndent === 0)
166
+ fieldIndent = indent;
167
+ if (indent === fieldIndent) {
168
+ const block = comments.get(commentKey(field, unquoteYamlKey(entry[1])));
169
+ // `out[out.length - 1]`, not `.at(-1)`: this project's tsconfig lib
170
+ // predates ES2022.
171
+ const already = ((_a = out[out.length - 1]) !== null && _a !== void 0 ? _a : '').trim().startsWith('#');
172
+ // Second gate on purpose: harvest is one source of blocks today, and a
173
+ // control character reaching the emitted file is the whole exploit.
174
+ if (block && !already && !CONTROL_CHARS.test(block)) {
175
+ const pad = ' '.repeat(indent);
176
+ out.push(...block.split('\n').map((l) => `${pad}${l}`));
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
182
+ out.push(line);
183
+ }
184
+ return out.join('\n');
185
+ }
186
+ /** `'msgpackr-extract'` / `"foo"` / `foo` all denote the same mapping key. */
187
+ function unquoteYamlKey(raw) {
188
+ const trimmed = raw.trim();
189
+ const quoted = /^(['"])([\s\S]*)\1$/.exec(trimmed);
190
+ return quoted ? quoted[2] : trimmed;
191
+ }
192
+ /** Provenance note written above a hoisted `auditConfig` — see `annotateAuditConfig`. */
193
+ const AUDIT_CONFIG_NOTE = '# Hoisted from the sub-projects by the lt CLI. These advisory suppressions now\n' +
194
+ '# apply to EVERY package in this workspace, not just the one that justified\n' +
195
+ '# them — review before adding, and drop entries once the advisory is fixed.';
29
196
  const isArrayField = (field) => ARRAY_FIELDS.includes(field);
197
+ const isNestedArrayField = (field) => NESTED_ARRAY_FIELDS.includes(field);
30
198
  /**
31
199
  * Hoist the Corepack `packageManager` pin from sub-projects into the monorepo
32
200
  * root `package.json`, keeping the highest version and stripping the pin from
@@ -142,13 +310,18 @@ function hoistPackageManager(options) {
142
310
  * @param options.subProjects Sub-project dirs relative to projectDir
143
311
  */
144
312
  function hoistWorkspacePnpmConfig(options) {
145
- var _a;
313
+ var _a, _b;
146
314
  const { filesystem, projectDir, subProjects } = options;
147
315
  const rootWsPath = `${projectDir}/pnpm-workspace.yaml`;
148
316
  // The root pnpm-workspace.yaml is the destination. It normally exists (the
149
317
  // lt-monorepo clone ships one declaring `packages:`); start from it so
150
318
  // `packages:` and any root-owned settings are preserved.
151
319
  const rootWs = (_a = readYaml(filesystem, rootWsPath)) !== null && _a !== void 0 ? _a : {};
320
+ // Why the reasons are harvested rather than regenerated: they are prose written
321
+ // by whoever added the entry, and no rule can reconstruct them. The root's own
322
+ // comments are collected FIRST so that where two sources annotate the same key,
323
+ // the root's wording wins — it is the file a maintainer of THIS workspace edits.
324
+ const comments = extractKeyComments((_b = filesystem.read(rootWsPath)) !== null && _b !== void 0 ? _b : '');
152
325
  let rootChanged = false;
153
326
  for (const subDir of subProjects) {
154
327
  const subPath = `${projectDir}/${subDir}`;
@@ -161,7 +334,7 @@ function hoistWorkspacePnpmConfig(options) {
161
334
  if (hoistFromSubPackageJson({ filesystem, rootWs, subPath })) {
162
335
  rootChanged = true;
163
336
  }
164
- if (hoistFromSubWorkspaceYaml({ filesystem, rootWs, subPath })) {
337
+ if (hoistFromSubWorkspaceYaml({ comments, filesystem, rootWs, subPath })) {
165
338
  rootChanged = true;
166
339
  }
167
340
  }
@@ -169,8 +342,31 @@ function hoistWorkspacePnpmConfig(options) {
169
342
  // Keep allowBuilds (pnpm 11) and onlyBuiltDependencies (pnpm 10) in sync so
170
343
  // the build-script allowlist survives regardless of which key pnpm reads.
171
344
  syncBuildAllowlists(rootWs);
172
- filesystem.write(rootWsPath, (0, js_yaml_1.dump)(rootWs, { lineWidth: -1, sortKeys: false }));
345
+ const dumped = (0, js_yaml_1.dump)(rootWs, { lineWidth: -1, sortKeys: false });
346
+ filesystem.write(rootWsPath, annotateAuditConfig(reattachKeyComments(dumped, comments)));
347
+ }
348
+ }
349
+ /**
350
+ * Mark a hoisted `auditConfig` as workspace-wide, in the file itself.
351
+ *
352
+ * `auditConfig.ignoreGhsas` / `.ignoreCves` are not ordinary settings — they
353
+ * SUPPRESS vulnerability findings, and the CI audit job is deploy-blocking.
354
+ * Hoisting changes their blast radius: an advisory a sub-project justified for
355
+ * one dev-only transitive dep now also silences that same advisory when it turns
356
+ * up in a sibling's RUNTIME tree, and pnpm's `auditConfig` has no expiry. That is
357
+ * the correct trade (the alternative — deleting the settings-only sub file
358
+ * unhoisted — destroys the allowlist and reddens the first pipeline), but it must
359
+ * not be invisible.
360
+ *
361
+ * A comment in the YAML is where a reviewer actually looks: it survives in the
362
+ * file, shows up in the `git diff` that introduces it, and needs no plumbing
363
+ * through the void-returning scaffolding call chain.
364
+ */
365
+ function annotateAuditConfig(yaml) {
366
+ if (!/^auditConfig:/m.test(yaml) || yaml.includes(AUDIT_CONFIG_NOTE)) {
367
+ return yaml;
173
368
  }
369
+ return yaml.replace(/^auditConfig:/m, `${AUDIT_CONFIG_NOTE}\nauditConfig:`);
174
370
  }
175
371
  /**
176
372
  * Compare the versions of two `packageManager` pins (`pnpm@11.13.1+sha512.…`).
@@ -227,13 +423,22 @@ function hoistFromSubPackageJson(options) {
227
423
  }
228
424
  /** Source 2: the sub-project's pnpm-workspace.yaml. */
229
425
  function hoistFromSubWorkspaceYaml(options) {
230
- const { filesystem, rootWs, subPath } = options;
426
+ var _a;
427
+ const { comments, filesystem, rootWs, subPath } = options;
231
428
  const subWsPath = `${subPath}/pnpm-workspace.yaml`;
232
429
  if (!filesystem.exists(subWsPath))
233
430
  return false;
431
+ const raw = (_a = filesystem.read(subWsPath)) !== null && _a !== void 0 ? _a : '';
234
432
  const ws = readYaml(filesystem, subWsPath);
235
433
  if (!ws)
236
434
  return false;
435
+ // Harvest BEFORE hoisting: this file is about to be deleted (or stripped of
436
+ // exactly these keys), and with it the only copy of the reasoning. An entry
437
+ // already annotated by the root keeps the root's wording.
438
+ for (const [key, block] of extractKeyComments(raw)) {
439
+ if (!comments.has(key))
440
+ comments.set(key, block);
441
+ }
237
442
  if (!hoistFields(rootWs, ws))
238
443
  return false;
239
444
  // A settings-only file (no `packages:`) exists solely to carry these
@@ -265,6 +470,26 @@ function mergePnpmFieldValue(field, rootValue, subValue) {
265
470
  const subArr = Array.isArray(subValue) ? subValue : [];
266
471
  return Array.from(new Set([...rootArr, ...subArr])).sort((a, b) => a.localeCompare(b));
267
472
  }
473
+ // Nested (`auditConfig.ignoreGhsas` / `.ignoreCves`): union each inner array
474
+ // instead of letting the sub-project's object replace the root's. A plain
475
+ // key-by-key merge would drop every advisory the root had already justified.
476
+ if (isNestedArrayField(field)) {
477
+ const asObj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
478
+ const rootObj = asObj(rootValue);
479
+ const subObj = asObj(subValue);
480
+ const merged = Object.assign({}, rootObj);
481
+ for (const [key, value] of Object.entries(subObj)) {
482
+ if (Array.isArray(value) || Array.isArray(merged[key])) {
483
+ const a = Array.isArray(merged[key]) ? merged[key] : [];
484
+ const b = Array.isArray(value) ? value : [];
485
+ merged[key] = Array.from(new Set([...a, ...b])).sort((x, y) => x.localeCompare(y));
486
+ }
487
+ else {
488
+ merged[key] = value;
489
+ }
490
+ }
491
+ return Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)));
492
+ }
268
493
  const rootObj = rootValue && typeof rootValue === 'object' && !Array.isArray(rootValue)
269
494
  ? rootValue
270
495
  : {};