dep-up-surgeon 2.2.2 → 2.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +122 -12
  2. package/dist/cli/overrideFlow.d.ts +50 -0
  3. package/dist/cli/overrideFlow.d.ts.map +1 -1
  4. package/dist/cli/overrideFlow.js +112 -69
  5. package/dist/cli/overrideFlow.js.map +1 -1
  6. package/dist/cli/summary.d.ts.map +1 -1
  7. package/dist/cli/summary.js +17 -4
  8. package/dist/cli/summary.js.map +1 -1
  9. package/dist/cli/undo.d.ts +105 -0
  10. package/dist/cli/undo.d.ts.map +1 -0
  11. package/dist/cli/undo.js +410 -0
  12. package/dist/cli/undo.js.map +1 -0
  13. package/dist/cli/undoCommand.d.ts +2 -0
  14. package/dist/cli/undoCommand.d.ts.map +1 -0
  15. package/dist/cli/undoCommand.js +101 -0
  16. package/dist/cli/undoCommand.js.map +1 -0
  17. package/dist/cli.js +56 -14
  18. package/dist/cli.js.map +1 -1
  19. package/dist/config/loadConfig.d.ts +64 -0
  20. package/dist/config/loadConfig.d.ts.map +1 -1
  21. package/dist/config/loadConfig.js +200 -1
  22. package/dist/config/loadConfig.js.map +1 -1
  23. package/dist/core/audit.d.ts +18 -0
  24. package/dist/core/audit.d.ts.map +1 -1
  25. package/dist/core/audit.js +29 -0
  26. package/dist/core/audit.js.map +1 -1
  27. package/dist/core/peerResolver.d.ts +64 -0
  28. package/dist/core/peerResolver.d.ts.map +1 -1
  29. package/dist/core/peerResolver.js +225 -2
  30. package/dist/core/peerResolver.js.map +1 -1
  31. package/dist/core/peerResolverAdHoc.d.ts +42 -0
  32. package/dist/core/peerResolverAdHoc.d.ts.map +1 -0
  33. package/dist/core/peerResolverAdHoc.js +226 -0
  34. package/dist/core/peerResolverAdHoc.js.map +1 -0
  35. package/dist/core/upgrader.d.ts +12 -1
  36. package/dist/core/upgrader.d.ts.map +1 -1
  37. package/dist/core/upgrader.js +171 -7
  38. package/dist/core/upgrader.js.map +1 -1
  39. package/dist/types.d.ts +24 -4
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/utils/overrides.d.ts +81 -7
  42. package/dist/utils/overrides.d.ts.map +1 -1
  43. package/dist/utils/overrides.js +344 -30
  44. package/dist/utils/overrides.js.map +1 -1
  45. package/package.json +3 -3
@@ -11,6 +11,27 @@ export interface OverrideEntry {
11
11
  * version without speculating that a `>=` range would also be safe.
12
12
  */
13
13
  range: string;
14
+ /**
15
+ * Parent chain from outermost package down to (but not including) `name`. When omitted or
16
+ * empty, the override is FLAT — written as a top-level key. When non-empty, the override is
17
+ * PARENT-SCOPED: we walk this chain to the matching nested object (npm) or encode it into
18
+ * the key with the manager-specific separator (`>` for pnpm, `/` for yarn).
19
+ *
20
+ * Example: `{ parentChain: ['some-dep'], name: 'foo', range: '1.2.3' }` →
21
+ * - npm: `{ "overrides": { "some-dep": { "foo": "1.2.3" } } }`
22
+ * - pnpm: `{ "pnpm": { "overrides": { "some-dep>foo": "1.2.3" } } }`
23
+ * - yarn: `{ "resolutions": { "some-dep/foo": "1.2.3" } }`
24
+ */
25
+ parentChain?: string[];
26
+ }
27
+ /** Structured representation of an override entry after reading it back, chain included. */
28
+ export interface OverrideEntryRead extends OverrideEntry {
29
+ /**
30
+ * Full chain from outermost parent down to `name` (always length ≥ 1). Flat entries have
31
+ * `chain = [name]`; parent-scoped entries have `chain = [...parentChain, name]`. Provided so
32
+ * consumers don't need to re-assemble it from `parentChain + name` themselves.
33
+ */
34
+ chain: string[];
14
35
  }
15
36
  export interface ReadOverridesResult {
16
37
  /** True when the field was present (may still be empty `{}`). */
@@ -18,11 +39,16 @@ export interface ReadOverridesResult {
18
39
  /** Field layout: `overrides` (npm), `pnpm.overrides` (pnpm), or `resolutions` (yarn). */
19
40
  field: OverrideField;
20
41
  /**
21
- * Flat list of top-level entries. Nested npm/pnpm override shapes (e.g. `{"foo": {"bar": "1"}}`)
22
- * are surfaced as-is in `nested` so a caller can decide whether to preserve them.
42
+ * All entries in the block, flat + parent-scoped mixed together. Each has a `chain` array
43
+ * describing where it lives; `chain.length === 1` is the flat case. npm nested objects,
44
+ * pnpm `>`-chains, and yarn `/`-chains are all surfaced uniformly here.
45
+ */
46
+ entries: OverrideEntryRead[];
47
+ /**
48
+ * Anything that didn't fit the string|object pattern we parse (e.g. booleans, arrays, or
49
+ * unknown extensions pnpm added). Handed back raw so callers that REWRITE the block can
50
+ * preserve these entries without knowing what they mean.
23
51
  */
24
- entries: OverrideEntry[];
25
- /** Any non-string values we saw — handed back raw for round-trip preservation. */
26
52
  nested: Record<string, unknown>;
27
53
  }
28
54
  export interface ApplyOverrideResult {
@@ -40,8 +66,21 @@ export interface ApplyOverrideResult {
40
66
  }
41
67
  /**
42
68
  * Read the override block from a given `package.json` object (NOT from disk — caller owns the
43
- * I/O). Returns a normalized `{ present, entries, nested }` shape that works across managers.
44
- * Accepts a `pkg` of `unknown` so callers can pass raw JSON.parse output.
69
+ * I/O). Returns a normalized `{ present, entries, nested }` shape that works across managers
70
+ * AND flattens all three parent-scoped encodings into a single `chain` array per entry:
71
+ *
72
+ * - npm nested: `{ "overrides": { "foo": { "bar": "1" } } }`
73
+ * → entry `{ chain: ['foo', 'bar'], name: 'bar', range: '1', parentChain: ['foo'] }`.
74
+ * The special key `"."` (npm's "the parent itself" selector) is folded into the chain
75
+ * too: `{"foo": {".": "2"}}` → chain `['foo']` (pins `foo` when nested under itself).
76
+ * - pnpm `>`-chain keys: `{ "pnpm": { "overrides": { "foo>bar": "1" } } }`
77
+ * → entry `{ chain: ['foo', 'bar'], ... }`.
78
+ * - yarn `/`-chain keys: `{ "resolutions": { "a/b": "1" } }`
79
+ * → entry `{ chain: ['a', 'b'], ... }`. Scoped names (`@org/foo`) complicate `/` parsing,
80
+ * so we treat `/` as a separator ONLY after a non-`@` segment. `"@org/foo"` stays one
81
+ * name; `"@org/foo/child"` splits into `["@org/foo", "child"]`.
82
+ *
83
+ * Accepts a `pkg` of `unknown` so callers can pass raw `JSON.parse` output.
45
84
  */
46
85
  export declare function readOverrides(pkg: unknown, manager: PackageManager): ReadOverridesResult;
47
86
  /**
@@ -72,8 +111,35 @@ export declare function decideOverride(existing: string | undefined, target: str
72
111
  * In-memory write: return a new `package.json` object with the override applied. Does NOT
73
112
  * touch disk — caller serializes + writes. Preserves ordering for existing keys and inserts
74
113
  * the field at a stable position when it's new.
114
+ *
115
+ * When `entry.parentChain` is non-empty the write is **parent-scoped**:
116
+ * - npm writes a nested object (`{parent: {child: "X"}}`), creating intermediate levels as
117
+ * needed and preserving sibling keys at every level.
118
+ * - pnpm writes a flat `"parent>child"` key (pnpm's native chain encoding).
119
+ * - yarn writes a flat `"parent/child"` key. Scoped names are kept intact thanks to the
120
+ * chain being an array rather than a pre-encoded string.
75
121
  */
76
122
  export declare function applyOverrideInMemory(pkg: Record<string, unknown>, manager: PackageManager, entry: OverrideEntry): Record<string, unknown>;
123
+ /**
124
+ * Encode a `chain: string[]` into the single-string key pnpm / yarn store in their flat
125
+ * overrides map. pnpm uses `>`; yarn uses `/`. Scoped names are preserved verbatim inside
126
+ * each segment since we only join WITH the separator — we never touch the segment contents.
127
+ */
128
+ export declare function encodeChainKey(chain: string[], manager: 'pnpm' | 'yarn'): string;
129
+ /**
130
+ * Parse a user-facing selector string into a `chain + range` pair.
131
+ *
132
+ * Accepts BOTH the pnpm-style `parent>child@1.2.3` AND the yarn-style `parent/child@1.2.3`
133
+ * form (after accounting for scoped-name slashes). When neither separator is present the
134
+ * selector is treated as a flat override. The trailing `@<range>` is optional — callers that
135
+ * know the range separately (e.g. audit-derived pins) can pass just the chain.
136
+ *
137
+ * Returns `undefined` when the selector is malformed (empty, dangling `@`, trailing separator).
138
+ */
139
+ export declare function parseOverrideSelector(spec: string): {
140
+ chain: string[];
141
+ range?: string;
142
+ } | undefined;
77
143
  export interface ApplyOverrideToFileOptions {
78
144
  packageJsonPath: string;
79
145
  manager: PackageManager;
@@ -93,8 +159,16 @@ export declare function applyOverrideToFile(opts: ApplyOverrideToFileOptions): P
93
159
  /**
94
160
  * Inverse of `applyOverrideToFile` — delete a single override entry. Used by the rollback
95
161
  * path when the install + validator fails AFTER we added a pin.
162
+ *
163
+ * `target` can be a bare package name (drops the flat top-level entry, legacy API) or a
164
+ * structured `{ chain: string[] }` selector (drops a parent-scoped entry matching the exact
165
+ * chain). When a parent-scoped write was adjacent to a sibling-writing flat pin, we leave the
166
+ * sibling intact; same-level empty objects are pruned recursively so the file shrinks back to
167
+ * what existed before the write.
96
168
  */
97
- export declare function removeOverrideFromFile(packageJsonPath: string, manager: PackageManager, name: string): Promise<{
169
+ export declare function removeOverrideFromFile(packageJsonPath: string, manager: PackageManager, target: string | {
170
+ chain: string[];
171
+ }): Promise<{
98
172
  ok: boolean;
99
173
  removed: boolean;
100
174
  reason?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"overrides.d.ts","sourceRoot":"","sources":["../../src/utils/overrides.ts"],"names":[],"mappings":"AA+BA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAE5D,MAAM,MAAM,aAAa,GAAG,WAAW,GAAG,gBAAgB,GAAG,aAAa,CAAC;AAE3E,2DAA2D;AAC3D,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,cAAc,GAAG,aAAa,CAUvE;AAED,MAAM,WAAW,aAAa;IAC5B,gEAAgE;IAChE,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,mBAAmB;IAClC,iEAAiE;IACjE,OAAO,EAAE,OAAO,CAAC;IACjB,yFAAyF;IACzF,KAAK,EAAE,aAAa,CAAC;IACrB;;;OAGG;IACH,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,kFAAkF;IAClF,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,OAAO,CAAC;IACZ,4FAA4F;IAC5F,OAAO,EAAE,OAAO,CAAC;IACjB,0CAA0C;IAC1C,KAAK,EAAE,aAAa,CAAC;IACrB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8DAA8D;IAC9D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,sEAAsE;IACtE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,GAAG,EAAE,OAAO,EACZ,OAAO,EAAE,cAAc,GACtB,mBAAmB,CA2BrB;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,gBAAgB,GACxB;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACpD;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACvD;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7D,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,MAAM,GACb,gBAAgB,CA6BlB;AA8BD;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5B,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,aAAa,GACnB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA2BzB;AAED,MAAM,WAAW,0BAA0B;IACzC,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,cAAc,CAAC;IACxB,KAAK,EAAE,aAAa,CAAC;IACrB;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED;;;GAGG;AACH,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,0BAA0B,GAC/B,OAAO,CAAC,mBAAmB,CAAC,CAgF9B;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAC1C,eAAe,EAAE,MAAM,EACvB,OAAO,EAAE,cAAc,EACvB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAiE7D"}
1
+ {"version":3,"file":"overrides.d.ts","sourceRoot":"","sources":["../../src/utils/overrides.ts"],"names":[],"mappings":"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"}
@@ -1,23 +1,37 @@
1
1
  /**
2
2
  * Read / write the package-manager-specific "pin a transitive dep to version X" block inside
3
- * `package.json`. Used by `--security-only --apply-overrides` so we can fix CVEs that live in
4
- * transitive dependencies (the kind a direct bump can't reach).
3
+ * `package.json`. Used by `--security-only --apply-overrides` (flat audit-driven pins) and by
4
+ * the user-facing `--override <selector>` CLI flag (parent-scoped pins).
5
5
  *
6
- * Manager-specific field layouts (MVP flat name range; no nested parent-scoped overrides):
6
+ * Manager-specific field layouts — we support BOTH the flat form and the parent-scoped /
7
+ * nested form each manager offers:
7
8
  *
8
- * - **npm (>=8.3.0)**: `{ "overrides": { "<pkg>": "<range>" } }` — top-level.
9
- * Ref: https://docs.npmjs.com/cli/v9/configuring-npm/package-json#overrides
10
- * - **pnpm**: `{ "pnpm": { "overrides": { "<pkg>": "<range>" } } }` — nested under `pnpm`.
11
- * Ref: https://pnpm.io/package_json#pnpmoverrides
12
- * - **yarn (classic + berry)**: `{ "resolutions": { "<pkg>": "<range>" } }` — top-level.
13
- * Same field for v1.x and v2+. Berry additionally supports `patches` / `portals` but those
14
- * aren't override-shaped, so we ignore them.
15
- * Ref: https://classic.yarnpkg.com/lang/en/docs/selective-version-resolutions/
9
+ * - **npm (>=8.3.0)**:
10
+ * Flat: `{ "overrides": { "<pkg>": "<range>" } }`
11
+ * Nested: `{ "overrides": { "<parent>": { "<pkg>": "<range>" } } }`
12
+ * Use `"."` to pin the parent itself.
13
+ * Ref: https://docs.npmjs.com/cli/v9/configuring-npm/package-json#overrides
14
+ *
15
+ * - **pnpm**:
16
+ * Flat: `{ "pnpm": { "overrides": { "<pkg>": "<range>" } } }`
17
+ * Chain: `{ "pnpm": { "overrides": { "<parent>>...>":"<range>", "<a>>b>c": "..." } } }`
18
+ * pnpm encodes the parent chain directly in the key using `>` as separator.
19
+ * Ref: https://pnpm.io/package_json#pnpmoverrides
20
+ *
21
+ * - **yarn (classic + berry)**:
22
+ * Flat: `{ "resolutions": { "<pkg>": "<range>" } }`
23
+ * Chain: `{ "resolutions": { "<parent>/<pkg>": "<range>" } }` — path-style, `/`-separated.
24
+ * Ref: https://classic.yarnpkg.com/lang/en/docs/selective-version-resolutions/
25
+ *
26
+ * Internal model: every override is a `{ chain: string[], range: string }` pair where `chain`
27
+ * is the parent chain from outermost package down to the pinned package. `chain.length === 1`
28
+ * is the flat case. On write, we translate the chain into the manager-specific encoding above;
29
+ * on read, we reverse the encoding so downstream consumers never worry about the format.
16
30
  *
17
31
  * Design notes:
18
32
  * - Pure I/O helpers here — no engine integration. The higher-level "apply an override, run
19
- * install, roll back on failure" loop lives in the caller (`cli.ts` for now).
20
- * - `applyOverride` is conservative: it refuses to downgrade an existing override (so a user
33
+ * install, roll back on failure" loop lives in the caller (`cli/overrideFlow.ts`).
34
+ * - `decideOverride` is conservative: it refuses to downgrade an existing override (so a user
21
35
  * who manually pinned `foo@5.x` never gets auto-bumped to `5.1.x` because audit happens to
22
36
  * suggest it). Upgrades and brand-new entries are always safe.
23
37
  * - Writes preserve the original file's ordering for every untouched key, add the new block
@@ -43,8 +57,21 @@ export function overrideFieldFor(manager) {
43
57
  }
44
58
  /**
45
59
  * Read the override block from a given `package.json` object (NOT from disk — caller owns the
46
- * I/O). Returns a normalized `{ present, entries, nested }` shape that works across managers.
47
- * Accepts a `pkg` of `unknown` so callers can pass raw JSON.parse output.
60
+ * I/O). Returns a normalized `{ present, entries, nested }` shape that works across managers
61
+ * AND flattens all three parent-scoped encodings into a single `chain` array per entry:
62
+ *
63
+ * - npm nested: `{ "overrides": { "foo": { "bar": "1" } } }`
64
+ * → entry `{ chain: ['foo', 'bar'], name: 'bar', range: '1', parentChain: ['foo'] }`.
65
+ * The special key `"."` (npm's "the parent itself" selector) is folded into the chain
66
+ * too: `{"foo": {".": "2"}}` → chain `['foo']` (pins `foo` when nested under itself).
67
+ * - pnpm `>`-chain keys: `{ "pnpm": { "overrides": { "foo>bar": "1" } } }`
68
+ * → entry `{ chain: ['foo', 'bar'], ... }`.
69
+ * - yarn `/`-chain keys: `{ "resolutions": { "a/b": "1" } }`
70
+ * → entry `{ chain: ['a', 'b'], ... }`. Scoped names (`@org/foo`) complicate `/` parsing,
71
+ * so we treat `/` as a separator ONLY after a non-`@` segment. `"@org/foo"` stays one
72
+ * name; `"@org/foo/child"` splits into `["@org/foo", "child"]`.
73
+ *
74
+ * Accepts a `pkg` of `unknown` so callers can pass raw `JSON.parse` output.
48
75
  */
49
76
  export function readOverrides(pkg, manager) {
50
77
  const field = overrideFieldFor(manager);
@@ -68,14 +95,95 @@ export function readOverrides(pkg, manager) {
68
95
  const nested = {};
69
96
  for (const [k, v] of Object.entries(block)) {
70
97
  if (typeof v === 'string') {
71
- entries.push({ name: k, range: v });
98
+ // Leaf: a direct `name` OR a chain key for pnpm/yarn.
99
+ const chain = parseChainKey(k, manager);
100
+ pushEntry(entries, chain, v);
101
+ }
102
+ else if (manager === 'npm' && v && typeof v === 'object' && !Array.isArray(v)) {
103
+ // npm nested object form. Walk it recursively so grandchildren (`foo>bar>baz` in npm
104
+ // parlance = `{ foo: { bar: { baz: "X" } } }`) are flattened into a single chain.
105
+ walkNpmNested([k], v, entries);
72
106
  }
73
107
  else {
108
+ // Anything we don't recognize — keep it so a rewrite doesn't lose data.
74
109
  nested[k] = v;
75
110
  }
76
111
  }
77
112
  return { present: true, field, entries, nested };
78
113
  }
114
+ /**
115
+ * Split a pnpm / yarn chain key into its package-name segments. The separator is `>` for pnpm
116
+ * and `/` for yarn — except `/` is ALSO legal inside scoped package names (`@org/pkg`), so we
117
+ * treat `/` as a separator only when the preceding segment doesn't start with `@`.
118
+ */
119
+ function parseChainKey(key, manager) {
120
+ const trimmed = key.trim();
121
+ if (!trimmed)
122
+ return [trimmed];
123
+ if (manager === 'pnpm') {
124
+ // pnpm supports an optional `@<version>` suffix on any segment to constrain WHICH version
125
+ // of the parent the override applies to. We strip it for identification purposes — the
126
+ // rest of the pipeline compares by NAME — but keep it verbatim when writing back.
127
+ return trimmed.split('>').map((s) => s.trim()).filter(Boolean);
128
+ }
129
+ if (manager === 'yarn') {
130
+ const parts = [];
131
+ let buf = '';
132
+ const raw = trimmed;
133
+ for (let i = 0; i < raw.length; i++) {
134
+ const c = raw[i];
135
+ if (c === '/' && !buf.startsWith('@')) {
136
+ if (buf)
137
+ parts.push(buf);
138
+ buf = '';
139
+ continue;
140
+ }
141
+ // Allow ONE `/` after a `@scope` so `@scope/pkg` is kept whole; any SECOND `/` splits.
142
+ if (c === '/' && buf.startsWith('@') && !buf.slice(1).includes('/')) {
143
+ buf += c;
144
+ continue;
145
+ }
146
+ if (c === '/' && buf.startsWith('@')) {
147
+ parts.push(buf);
148
+ buf = '';
149
+ continue;
150
+ }
151
+ buf += c;
152
+ }
153
+ if (buf)
154
+ parts.push(buf);
155
+ return parts;
156
+ }
157
+ // npm: its flat keys never contain chain markers (nesting uses object form handled above).
158
+ return [trimmed];
159
+ }
160
+ function pushEntry(entries, chain, range) {
161
+ if (chain.length === 0)
162
+ return;
163
+ const name = chain[chain.length - 1];
164
+ const parentChain = chain.slice(0, -1);
165
+ const entry = { chain: [...chain], name, range };
166
+ if (parentChain.length > 0)
167
+ entry.parentChain = parentChain;
168
+ entries.push(entry);
169
+ }
170
+ function walkNpmNested(chain, obj, out) {
171
+ for (const [k, v] of Object.entries(obj)) {
172
+ // npm's special `"."` selector means "pin the parent package itself" (the outermost key of
173
+ // THIS scope). It produces an entry whose name equals the current `chain` tail, not `.`.
174
+ if (k === '.' && typeof v === 'string') {
175
+ pushEntry(out, chain, v);
176
+ continue;
177
+ }
178
+ const nextChain = [...chain, k];
179
+ if (typeof v === 'string') {
180
+ pushEntry(out, nextChain, v);
181
+ }
182
+ else if (v && typeof v === 'object' && !Array.isArray(v)) {
183
+ walkNpmNested(nextChain, v, out);
184
+ }
185
+ }
186
+ }
79
187
  export function decideOverride(existing, target) {
80
188
  const cleanTarget = target.trim();
81
189
  if (!cleanTarget) {
@@ -134,14 +242,83 @@ function safeSatisfies(version, range) {
134
242
  return false;
135
243
  }
136
244
  }
245
+ function chainsEqual(a, b) {
246
+ if (a.length !== b.length)
247
+ return false;
248
+ for (let i = 0; i < a.length; i++) {
249
+ if (a[i] !== b[i])
250
+ return false;
251
+ }
252
+ return true;
253
+ }
254
+ /**
255
+ * Walk `block` along `chain`, delete the leaf, and prune now-empty intermediate objects on
256
+ * the way back out. Returns `true` when something was actually removed. Works in-place: the
257
+ * caller should pass a shallow-cloned copy (we own the top level).
258
+ */
259
+ function removeChainFromNpmNested(block, chain) {
260
+ if (chain.length === 0)
261
+ return false;
262
+ const [head, ...tail] = chain;
263
+ if (!(head in block))
264
+ return false;
265
+ const value = block[head];
266
+ // Leaf write: chain of length 1. The value MUST be a string for the flat match; if it's a
267
+ // nested object we instead look for a `"."` key inside (npm's self-pin) — that's the only
268
+ // way a length-1 chain can have created a nested shape.
269
+ if (tail.length === 0) {
270
+ if (typeof value === 'string') {
271
+ delete block[head];
272
+ return true;
273
+ }
274
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
275
+ const inner = { ...value };
276
+ if ('.' in inner && typeof inner['.'] === 'string') {
277
+ delete inner['.'];
278
+ if (Object.keys(inner).length === 0) {
279
+ delete block[head];
280
+ }
281
+ else {
282
+ block[head] = inner;
283
+ }
284
+ return true;
285
+ }
286
+ }
287
+ return false;
288
+ }
289
+ // Internal node: descend. `value` must be a nested object for the chain to be meaningful.
290
+ if (!value || typeof value !== 'object' || Array.isArray(value))
291
+ return false;
292
+ const child = { ...value };
293
+ const ok = removeChainFromNpmNested(child, tail);
294
+ if (!ok)
295
+ return false;
296
+ if (Object.keys(child).length === 0) {
297
+ delete block[head];
298
+ }
299
+ else {
300
+ block[head] = child;
301
+ }
302
+ return true;
303
+ }
137
304
  /**
138
305
  * In-memory write: return a new `package.json` object with the override applied. Does NOT
139
306
  * touch disk — caller serializes + writes. Preserves ordering for existing keys and inserts
140
307
  * the field at a stable position when it's new.
308
+ *
309
+ * When `entry.parentChain` is non-empty the write is **parent-scoped**:
310
+ * - npm writes a nested object (`{parent: {child: "X"}}`), creating intermediate levels as
311
+ * needed and preserving sibling keys at every level.
312
+ * - pnpm writes a flat `"parent>child"` key (pnpm's native chain encoding).
313
+ * - yarn writes a flat `"parent/child"` key. Scoped names are kept intact thanks to the
314
+ * chain being an array rather than a pre-encoded string.
141
315
  */
142
316
  export function applyOverrideInMemory(pkg, manager, entry) {
143
317
  const field = overrideFieldFor(manager);
144
318
  const next = { ...pkg };
319
+ const chain = [...(entry.parentChain ?? []), entry.name];
320
+ if (chain.length === 0)
321
+ return next; // degenerate — nothing to write
145
322
  if (field === 'pnpm.overrides') {
146
323
  const pnpmBlock = pkg.pnpm && typeof pkg.pnpm === 'object'
147
324
  ? { ...pkg.pnpm }
@@ -149,19 +326,123 @@ export function applyOverrideInMemory(pkg, manager, entry) {
149
326
  const overrides = pnpmBlock.overrides && typeof pnpmBlock.overrides === 'object'
150
327
  ? { ...pnpmBlock.overrides }
151
328
  : {};
152
- overrides[entry.name] = entry.range;
329
+ overrides[encodeChainKey(chain, 'pnpm')] = entry.range;
153
330
  pnpmBlock.overrides = overrides;
154
331
  next.pnpm = pnpmBlock;
155
332
  return next;
156
333
  }
334
+ if (field === 'resolutions') {
335
+ const current = pkg[field];
336
+ const block = current && typeof current === 'object' && !Array.isArray(current)
337
+ ? { ...current }
338
+ : {};
339
+ block[encodeChainKey(chain, 'yarn')] = entry.range;
340
+ next[field] = block;
341
+ return next;
342
+ }
343
+ // npm nested-object form. For flat overrides (chain.length === 1) we still write to the top
344
+ // level; otherwise we descend, cloning every intermediate level so we don't mutate the
345
+ // input object.
157
346
  const current = pkg[field];
158
- const block = current && typeof current === 'object' && !Array.isArray(current)
347
+ const rootBlock = current && typeof current === 'object' && !Array.isArray(current)
159
348
  ? { ...current }
160
349
  : {};
161
- block[entry.name] = entry.range;
162
- next[field] = block;
350
+ if (chain.length === 1) {
351
+ rootBlock[chain[0]] = entry.range;
352
+ next[field] = rootBlock;
353
+ return next;
354
+ }
355
+ // Descend into `rootBlock[parent[0]] = {...}`, merging with whatever's already there. If an
356
+ // existing entry at the parent is a STRING (flat pin), npm's spec promotes it under `"."`
357
+ // before we nest — that preserves the existing pin AND adds the scoped child.
358
+ let cursor = rootBlock;
359
+ for (let i = 0; i < chain.length - 1; i++) {
360
+ const seg = chain[i];
361
+ const existing = cursor[seg];
362
+ if (typeof existing === 'string') {
363
+ cursor[seg] = { '.': existing };
364
+ }
365
+ else if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
366
+ cursor[seg] = {};
367
+ }
368
+ else {
369
+ cursor[seg] = { ...existing };
370
+ }
371
+ cursor = cursor[seg];
372
+ }
373
+ cursor[chain[chain.length - 1]] = entry.range;
374
+ next[field] = rootBlock;
163
375
  return next;
164
376
  }
377
+ /**
378
+ * Encode a `chain: string[]` into the single-string key pnpm / yarn store in their flat
379
+ * overrides map. pnpm uses `>`; yarn uses `/`. Scoped names are preserved verbatim inside
380
+ * each segment since we only join WITH the separator — we never touch the segment contents.
381
+ */
382
+ export function encodeChainKey(chain, manager) {
383
+ const sep = manager === 'pnpm' ? '>' : '/';
384
+ return chain.join(sep);
385
+ }
386
+ /**
387
+ * Parse a user-facing selector string into a `chain + range` pair.
388
+ *
389
+ * Accepts BOTH the pnpm-style `parent>child@1.2.3` AND the yarn-style `parent/child@1.2.3`
390
+ * form (after accounting for scoped-name slashes). When neither separator is present the
391
+ * selector is treated as a flat override. The trailing `@<range>` is optional — callers that
392
+ * know the range separately (e.g. audit-derived pins) can pass just the chain.
393
+ *
394
+ * Returns `undefined` when the selector is malformed (empty, dangling `@`, trailing separator).
395
+ */
396
+ export function parseOverrideSelector(spec) {
397
+ const trimmed = spec.trim();
398
+ if (!trimmed)
399
+ return undefined;
400
+ // Split off the `@<range>` suffix. Scoped names start with `@`, so we look for the LAST `@`
401
+ // that has a separator-free tail (version / range) after it. Missing `@` is OK — caller
402
+ // supplies the range elsewhere.
403
+ let chainPart = trimmed;
404
+ let range;
405
+ const atIdx = findRangeAt(trimmed);
406
+ if (atIdx !== -1) {
407
+ chainPart = trimmed.slice(0, atIdx);
408
+ range = trimmed.slice(atIdx + 1).trim();
409
+ if (!range)
410
+ return undefined;
411
+ }
412
+ // Chain separator detection: `>` wins if present (pnpm style is unambiguous); else `/`
413
+ // (yarn style, scoped-name-aware). Plain names fall through as `[chainPart]`.
414
+ let chain;
415
+ if (chainPart.includes('>')) {
416
+ chain = chainPart.split('>').map((s) => s.trim()).filter(Boolean);
417
+ }
418
+ else {
419
+ chain = parseChainKey(chainPart, 'yarn');
420
+ }
421
+ if (chain.length === 0)
422
+ return undefined;
423
+ const result = { chain };
424
+ if (range)
425
+ result.range = range;
426
+ return result;
427
+ }
428
+ /**
429
+ * Locate the `@` that separates the chain from the version suffix. Scoped names contain `@`
430
+ * in front so we skip leading `@`s; the version `@` is always the LAST `@` in the string and
431
+ * must be followed by at least one non-space character.
432
+ */
433
+ function findRangeAt(s) {
434
+ // Walk backwards. The range `@` is the first `@` we encounter that is NOT immediately after
435
+ // a chain separator (`/` or `>`) and is NOT at position 0 (scoped package prefix).
436
+ for (let i = s.length - 1; i > 0; i--) {
437
+ if (s[i] !== '@')
438
+ continue;
439
+ const prev = s[i - 1];
440
+ if (prev === '/' || prev === '>')
441
+ continue; // scoped name inside chain segment
442
+ return i;
443
+ }
444
+ return -1;
445
+ }
165
446
  /**
166
447
  * Disk-level convenience: read the package.json, compute the decision, write the new pin when
167
448
  * appropriate, and return a structured result. The file is read + written with 2-space JSON.
@@ -196,9 +477,12 @@ export async function applyOverrideToFile(opts) {
196
477
  reason: `parse failed: ${e instanceof Error ? e.message : String(e)}`,
197
478
  };
198
479
  }
199
- // Read existing pin so we can decide skip / write / conflict.
480
+ // Read existing pin so we can decide skip / write / conflict. Match on the EXACT chain,
481
+ // not just the name — a flat `foo` pin and a parent-scoped `bar>foo` pin are separate
482
+ // entries and don't interact (both can coexist; overwriting one must never touch the other).
200
483
  const read = readOverrides(pkg, opts.manager);
201
- const existing = read.entries.find((e) => e.name === opts.entry.name);
484
+ const wantChain = [...(opts.entry.parentChain ?? []), opts.entry.name];
485
+ const existing = read.entries.find((e) => chainsEqual(e.chain, wantChain));
202
486
  const decision = decideOverride(existing?.range, opts.entry.range);
203
487
  if (decision.action === 'skip') {
204
488
  return {
@@ -250,8 +534,18 @@ export async function applyOverrideToFile(opts) {
250
534
  /**
251
535
  * Inverse of `applyOverrideToFile` — delete a single override entry. Used by the rollback
252
536
  * path when the install + validator fails AFTER we added a pin.
537
+ *
538
+ * `target` can be a bare package name (drops the flat top-level entry, legacy API) or a
539
+ * structured `{ chain: string[] }` selector (drops a parent-scoped entry matching the exact
540
+ * chain). When a parent-scoped write was adjacent to a sibling-writing flat pin, we leave the
541
+ * sibling intact; same-level empty objects are pruned recursively so the file shrinks back to
542
+ * what existed before the write.
253
543
  */
254
- export async function removeOverrideFromFile(packageJsonPath, manager, name) {
544
+ export async function removeOverrideFromFile(packageJsonPath, manager, target) {
545
+ const chain = typeof target === 'string' ? [target] : [...target.chain];
546
+ if (chain.length === 0) {
547
+ return { ok: true, removed: false };
548
+ }
255
549
  let raw;
256
550
  try {
257
551
  raw = await fs.readFile(packageJsonPath, 'utf8');
@@ -273,9 +567,13 @@ export async function removeOverrideFromFile(packageJsonPath, manager, name) {
273
567
  if (pnpm && typeof pnpm === 'object') {
274
568
  const pnpmObj = { ...pnpm };
275
569
  const overrides = pnpmObj.overrides;
276
- if (overrides && typeof overrides === 'object' && name in overrides) {
570
+ if (overrides && typeof overrides === 'object') {
277
571
  const next = { ...overrides };
278
- delete next[name];
572
+ const key = encodeChainKey(chain, 'pnpm');
573
+ if (key in next) {
574
+ delete next[key];
575
+ removed = true;
576
+ }
279
577
  if (Object.keys(next).length === 0) {
280
578
  delete pnpmObj.overrides;
281
579
  if (Object.keys(pnpmObj).length === 0) {
@@ -289,22 +587,38 @@ export async function removeOverrideFromFile(packageJsonPath, manager, name) {
289
587
  pnpmObj.overrides = next;
290
588
  pkg.pnpm = pnpmObj;
291
589
  }
292
- removed = true;
293
590
  }
294
591
  }
295
592
  }
296
- else {
593
+ else if (field === 'resolutions') {
297
594
  const block = pkg[field];
298
- if (block && typeof block === 'object' && name in block) {
595
+ if (block && typeof block === 'object') {
299
596
  const next = { ...block };
300
- delete next[name];
597
+ const key = encodeChainKey(chain, 'yarn');
598
+ if (key in next) {
599
+ delete next[key];
600
+ removed = true;
601
+ }
301
602
  if (Object.keys(next).length === 0) {
302
603
  delete pkg[field];
303
604
  }
304
605
  else {
305
606
  pkg[field] = next;
306
607
  }
307
- removed = true;
608
+ }
609
+ }
610
+ else {
611
+ // npm: flat OR nested object path.
612
+ const block = pkg[field];
613
+ if (block && typeof block === 'object' && !Array.isArray(block)) {
614
+ const nextRoot = { ...block };
615
+ removed = removeChainFromNpmNested(nextRoot, chain);
616
+ if (Object.keys(nextRoot).length === 0) {
617
+ delete pkg[field];
618
+ }
619
+ else {
620
+ pkg[field] = nextRoot;
621
+ }
308
622
  }
309
623
  }
310
624
  if (!removed) {