@cortexkit/opencode-magic-context 0.36.0 → 0.36.1

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 (108) hide show
  1. package/dist/config/index.d.ts.map +1 -1
  2. package/dist/{conflict-warning-hook-dw9fs934.js → conflict-warning-hook-mt0ttc4x.js} +2 -2
  3. package/dist/features/magic-context/context-authority.d.ts +12 -0
  4. package/dist/features/magic-context/context-authority.d.ts.map +1 -1
  5. package/dist/features/magic-context/dreamer/classify.d.ts +2 -0
  6. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  7. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +2 -0
  8. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  9. package/dist/features/magic-context/dreamer/lease.d.ts +10 -3
  10. package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
  11. package/dist/features/magic-context/dreamer/map-memories.d.ts +2 -0
  12. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  13. package/dist/features/magic-context/dreamer/module-apply.d.ts +5 -0
  14. package/dist/features/magic-context/dreamer/module-apply.d.ts.map +1 -1
  15. package/dist/features/magic-context/dreamer/promote-primers.d.ts +2 -0
  16. package/dist/features/magic-context/dreamer/promote-primers.d.ts.map +1 -1
  17. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +2 -0
  18. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
  19. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  20. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +5 -2
  21. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  22. package/dist/features/magic-context/dreamer/verify.d.ts +2 -0
  23. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  24. package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
  25. package/dist/features/magic-context/mural/compress-cues.d.ts +2 -0
  26. package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
  27. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  28. package/dist/features/magic-context/schema-fence-probe.d.ts +1 -0
  29. package/dist/features/magic-context/schema-fence-probe.d.ts.map +1 -1
  30. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts +1 -0
  31. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts.map +1 -1
  32. package/dist/features/magic-context/storage-clone.d.ts +6 -0
  33. package/dist/features/magic-context/storage-clone.d.ts.map +1 -1
  34. package/dist/features/magic-context/storage-db.d.ts +1 -1
  35. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  36. package/dist/features/magic-context/storage-identity-merge.d.ts.map +1 -1
  37. package/dist/features/magic-context/storage-tags.d.ts +2 -0
  38. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  39. package/dist/features/magic-context/storage.d.ts +1 -1
  40. package/dist/features/magic-context/storage.d.ts.map +1 -1
  41. package/dist/features/magic-context/user-memory/review-user-memories.d.ts +2 -0
  42. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  43. package/dist/hooks/magic-context/child-session-spawn.d.ts +1 -0
  44. package/dist/hooks/magic-context/child-session-spawn.d.ts.map +1 -1
  45. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  46. package/dist/hooks/magic-context/compaction-off-transition.d.ts.map +1 -1
  47. package/dist/hooks/magic-context/compartment-render-epoch.d.ts +5 -3
  48. package/dist/hooks/magic-context/compartment-render-epoch.d.ts.map +1 -1
  49. package/dist/hooks/magic-context/embed-session-state.d.ts +1 -1
  50. package/dist/hooks/magic-context/embed-session-state.d.ts.map +1 -1
  51. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  52. package/dist/hooks/magic-context/inject-compartments.d.ts +8 -0
  53. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  54. package/dist/hooks/magic-context/lkg-replay.d.ts +5 -2
  55. package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
  56. package/dist/hooks/magic-context/module-state-sync.d.ts +1 -0
  57. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  58. package/dist/hooks/magic-context/module-transport.d.ts +20 -3
  59. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  60. package/dist/hooks/magic-context/read-session-formatting.d.ts +1 -0
  61. package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
  62. package/dist/hooks/magic-context/rust-mode-transform.d.ts +3 -0
  63. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  64. package/dist/hooks/magic-context/strip-content.d.ts +6 -1
  65. package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
  66. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  67. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +1 -0
  68. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  69. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  70. package/dist/{index-3nfgbsp4.js → index-1skzp4dc.js} +347 -44
  71. package/dist/{index-z8v31d85.js → index-3zqcdwqr.js} +137 -10
  72. package/dist/{index-0c9dmw9m.js → index-8mtsfhyg.js} +33 -3
  73. package/dist/{index-37rt2bka.js → index-zp6x6s6a.js} +1 -1
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +820 -548
  76. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  77. package/dist/plugin/rust-tool-backends.d.ts +4 -0
  78. package/dist/plugin/rust-tool-backends.d.ts.map +1 -1
  79. package/dist/{recomp-orchestrator-7wbbvf87.js → recomp-orchestrator-a0g9b52q.js} +3 -3
  80. package/dist/shared/conflict-fixer.d.ts.map +1 -1
  81. package/dist/shared/jsonc-edit.d.ts +18 -0
  82. package/dist/shared/jsonc-edit.d.ts.map +1 -0
  83. package/dist/shared/jsonc-parser.d.ts +11 -1
  84. package/dist/shared/jsonc-parser.d.ts.map +1 -1
  85. package/dist/shared/prompt-surface.d.ts +5 -3
  86. package/dist/shared/prompt-surface.d.ts.map +1 -1
  87. package/dist/shared/rpc-utils.d.ts +16 -0
  88. package/dist/shared/rpc-utils.d.ts.map +1 -1
  89. package/dist/shared/tui-preferences.d.ts +9 -0
  90. package/dist/shared/tui-preferences.d.ts.map +1 -1
  91. package/dist/{storage-meta-session-kh5egr11.js → storage-meta-session-df9tstzr.js} +1 -1
  92. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  93. package/package.json +2 -1
  94. package/src/config/index.test.ts +23 -0
  95. package/src/config/index.ts +41 -9
  96. package/src/config/schema/magic-context.test.ts +2 -1
  97. package/src/config/schema/magic-context.ts +3 -3
  98. package/src/shared/conflict-fixer.test.ts +174 -0
  99. package/src/shared/conflict-fixer.ts +66 -70
  100. package/src/shared/jsonc-edit.ts +295 -0
  101. package/src/shared/jsonc-parser.test.ts +33 -0
  102. package/src/shared/jsonc-parser.ts +54 -2
  103. package/src/shared/prompt-surface.test.ts +53 -2
  104. package/src/shared/prompt-surface.ts +20 -9
  105. package/src/shared/rpc-utils.test.ts +36 -0
  106. package/src/shared/rpc-utils.ts +71 -0
  107. package/src/shared/tui-preferences.test.ts +52 -2
  108. package/src/shared/tui-preferences.ts +42 -17
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { parse, stringify } from "comment-json";
4
+ import { parse } from "comment-json";
5
5
 
6
6
  import {
7
7
  type ConflictResult,
@@ -9,6 +9,7 @@ import {
9
9
  extractPluginName,
10
10
  matchesPackageName,
11
11
  } from "./conflict-detector";
12
+ import { appendJsoncArrayValues, removeJsoncArrayEntries, setJsoncValue } from "./jsonc-edit";
12
13
  import { getOpenCodeConfigPaths } from "./opencode-config-dir";
13
14
 
14
15
  type JsonObject = Record<string, unknown>;
@@ -39,21 +40,27 @@ function asStringArray(value: unknown): string[] {
39
40
  : [];
40
41
  }
41
42
 
42
- function readConfig(filePath: string): JsonObject | null {
43
+ interface JsonConfigDocument {
44
+ config: JsonObject;
45
+ text: string;
46
+ }
47
+
48
+ function readConfig(filePath: string): JsonConfigDocument | null {
43
49
  if (!existsSync(filePath)) {
44
50
  return null;
45
51
  }
46
52
 
47
53
  try {
48
- const parsed = parse(readFileSync(filePath, "utf-8"));
49
- return isRecord(parsed) ? parsed : null;
54
+ const text = readFileSync(filePath, "utf-8");
55
+ const parsed = parse(text);
56
+ return isRecord(parsed) ? { config: parsed, text } : null;
50
57
  } catch {
51
58
  return null;
52
59
  }
53
60
  }
54
61
 
55
- function writeConfig(filePath: string, config: JsonObject): void {
56
- writeFileSync(filePath, `${stringify(config, null, 2)}\n`);
62
+ function writeConfig(filePath: string, text: string): void {
63
+ writeFileSync(filePath, text);
57
64
  }
58
65
 
59
66
  function resolveUserOpenCodeConfigPath(): string {
@@ -130,26 +137,28 @@ function isUnifiedOmoPath(configPath: string): boolean {
130
137
  return basename === "omo.jsonc" || basename === "omo.json";
131
138
  }
132
139
 
133
- /**
134
- * Get or create the `[opencode]` block inside a unified omo.json(c) config.
135
- * Returns the block itself so callers can read/write `disabled_hooks` on it.
136
- */
137
- function getOrCreateOmoV2Block(config: JsonObject): JsonObject {
138
- const block = config["[opencode]"];
139
- if (isRecord(block)) {
140
- return block;
140
+ function disableCompactionFlags(
141
+ text: string,
142
+ config: JsonObject,
143
+ ): { text: string; changed: boolean } {
144
+ if (!isRecord(config.compaction)) {
145
+ return {
146
+ text: setJsoncValue(text, ["compaction"], { auto: false, prune: false }),
147
+ changed: true,
148
+ };
141
149
  }
142
- const newBlock: JsonObject = {};
143
- config["[opencode]"] = newBlock;
144
- return newBlock;
145
- }
146
150
 
147
- function filterDcpPluginEntries(entries: unknown[]): { plugins: unknown[]; removed: boolean } {
148
- const plugins = entries.filter((entry) => {
149
- const name = extractPluginName(entry);
150
- return name ? !matchesPackageName(name, DCP_PACKAGE_NAMES) : true;
151
- });
152
- return { plugins, removed: plugins.length !== entries.length };
151
+ let updated = text;
152
+ let changed = false;
153
+ if (config.compaction.auto !== false) {
154
+ updated = setJsoncValue(updated, ["compaction", "auto"], false);
155
+ changed = true;
156
+ }
157
+ if (config.compaction.prune !== false) {
158
+ updated = setJsoncValue(updated, ["compaction", "prune"], false);
159
+ changed = true;
160
+ }
161
+ return { text: updated, changed };
153
162
  }
154
163
 
155
164
  /**
@@ -190,44 +199,37 @@ export function fixConflicts(
190
199
 
191
200
  if (repairCompaction || conflicts.dcpPlugin) {
192
201
  for (const configPath of collectOpenCodeConfigPaths(directory)) {
193
- const config = readConfig(configPath);
194
- if (!config) {
202
+ const document = readConfig(configPath);
203
+ if (!document) {
195
204
  continue;
196
205
  }
197
206
 
207
+ let text = document.text;
198
208
  let changed = false;
199
209
 
200
210
  if (repairCompaction) {
201
- const compaction = isRecord(config.compaction) ? config.compaction : {};
202
-
203
- if (compaction.auto !== false) {
204
- compaction.auto = false;
205
- changed = true;
206
- updatedCompaction = true;
207
- }
208
-
209
- if (compaction.prune !== false) {
210
- compaction.prune = false;
211
+ const result = disableCompactionFlags(text, document.config);
212
+ if (result.changed) {
213
+ text = result.text;
211
214
  changed = true;
212
215
  updatedCompaction = true;
213
216
  }
214
-
215
- config.compaction = compaction;
216
217
  }
217
218
 
218
219
  if (conflicts.dcpPlugin) {
219
- const plugins = Array.isArray(config.plugin) ? config.plugin : [];
220
- const filtered = filterDcpPluginEntries(plugins);
221
-
222
- if (filtered.removed) {
223
- config.plugin = filtered.plugins;
220
+ const result = removeJsoncArrayEntries(text, ["plugin"], (entry) => {
221
+ const name = extractPluginName(entry);
222
+ return name ? matchesPackageName(name, DCP_PACKAGE_NAMES) : false;
223
+ });
224
+ if (result.removed) {
225
+ text = result.text;
224
226
  changed = true;
225
227
  removedDcpPlugin = true;
226
228
  }
227
229
  }
228
230
 
229
231
  if (changed) {
230
- writeConfig(configPath, config);
232
+ writeConfig(configPath, text);
231
233
  }
232
234
  }
233
235
  }
@@ -249,36 +251,30 @@ export function fixConflicts(
249
251
  }
250
252
 
251
253
  for (const configPath of collectOmoConfigPaths(directory)) {
252
- const config = readConfig(configPath);
253
- if (!config) {
254
+ const document = readConfig(configPath);
255
+ if (!document) {
254
256
  continue;
255
257
  }
256
258
 
257
- // Unified paths nest hooks under the [opencode] block; legacy paths use top-level
258
- const target = isUnifiedOmoPath(configPath) ? getOrCreateOmoV2Block(config) : config;
259
-
259
+ const unifiedPath = isUnifiedOmoPath(configPath);
260
+ const target =
261
+ unifiedPath && isRecord(document.config["[opencode]"])
262
+ ? document.config["[opencode]"]
263
+ : unifiedPath
264
+ ? {}
265
+ : document.config;
260
266
  const disabledHooks = new Set(asStringArray(target.disabled_hooks));
261
- let changed = false;
262
-
263
- for (const hook of hooksToDisable) {
264
- if (!disabledHooks.has(hook)) {
265
- disabledHooks.add(hook);
266
- changed = true;
267
- disabledOmoHooks = true;
268
- }
269
- }
270
-
271
- if (changed) {
272
- target.disabled_hooks = [
273
- ...CONFLICTING_OMO_HOOKS.filter((hook) => disabledHooks.has(hook)),
274
- ...[...disabledHooks].filter(
275
- (hook) =>
276
- !CONFLICTING_OMO_HOOKS.includes(
277
- hook as (typeof CONFLICTING_OMO_HOOKS)[number],
278
- ),
279
- ),
280
- ];
281
- writeConfig(configPath, config);
267
+ const hooksToAdd = CONFLICTING_OMO_HOOKS.filter(
268
+ (hook) => hooksToDisable.has(hook) && !disabledHooks.has(hook),
269
+ );
270
+
271
+ if (hooksToAdd.length > 0) {
272
+ const path = unifiedPath ? ["[opencode]", "disabled_hooks"] : ["disabled_hooks"];
273
+ const text = Array.isArray(target.disabled_hooks)
274
+ ? appendJsoncArrayValues(document.text, path, hooksToAdd)
275
+ : setJsoncValue(document.text, path, hooksToAdd);
276
+ writeConfig(configPath, text);
277
+ disabledOmoHooks = true;
282
278
  }
283
279
  }
284
280
  }
@@ -0,0 +1,295 @@
1
+ import {
2
+ applyEdits,
3
+ createScanner,
4
+ findNodeAtLocation,
5
+ getNodeValue,
6
+ type JSONPath,
7
+ modify,
8
+ type Node,
9
+ type ParseError,
10
+ parseTree,
11
+ } from "jsonc-parser";
12
+
13
+ interface TokenLocation {
14
+ offset: number;
15
+ length: number;
16
+ line: number;
17
+ }
18
+
19
+ /**
20
+ * jsonc-parser exposes SyntaxKind as a const enum, which cannot be referenced
21
+ * from this isolated TypeScript module. Keep the scanner token values local.
22
+ */
23
+ const TOKEN_COMMA = 5;
24
+ const TOKEN_LINE_COMMENT = 12;
25
+ const TOKEN_BLOCK_COMMENT = 13;
26
+ const TOKEN_EOF = 17;
27
+
28
+ function parseDocument(text: string): Node {
29
+ const errors: ParseError[] = [];
30
+ const root = parseTree(text, errors, { allowTrailingComma: true });
31
+ if (!root || errors.length > 0) {
32
+ throw new Error("Cannot edit invalid JSONC");
33
+ }
34
+ return root;
35
+ }
36
+
37
+ function findNode(text: string, path: JSONPath): Node | undefined {
38
+ return findNodeAtLocation(parseDocument(text), path);
39
+ }
40
+
41
+ function findComma(text: string, start: number, end: number): TokenLocation | undefined {
42
+ const scanner = createScanner(text, false);
43
+ scanner.setPosition(start);
44
+
45
+ for (;;) {
46
+ const kind = scanner.scan();
47
+ const offset = scanner.getTokenOffset();
48
+ if (kind === TOKEN_EOF || offset >= end) return undefined;
49
+ if (kind === TOKEN_COMMA) {
50
+ return {
51
+ offset,
52
+ length: scanner.getTokenLength(),
53
+ line: scanner.getTokenStartLine(),
54
+ };
55
+ }
56
+ }
57
+ }
58
+
59
+ function lineStart(text: string, offset: number): number {
60
+ const previousNewline = text.lastIndexOf("\n", offset - 1);
61
+ return previousNewline === -1 ? 0 : previousNewline + 1;
62
+ }
63
+
64
+ function lineEnd(text: string, offset: number): number {
65
+ const nextNewline = text.indexOf("\n", offset);
66
+ if (nextNewline === -1) return text.length;
67
+ return text[nextNewline - 1] === "\r" ? nextNewline - 1 : nextNewline;
68
+ }
69
+
70
+ function inlineCommentEnd(
71
+ text: string,
72
+ start: number,
73
+ line: number,
74
+ limit: number,
75
+ ): number | undefined {
76
+ const scanner = createScanner(text, false);
77
+ scanner.setPosition(start);
78
+ let end: number | undefined;
79
+
80
+ for (;;) {
81
+ const kind = scanner.scan();
82
+ const offset = scanner.getTokenOffset();
83
+ if (kind === TOKEN_EOF || offset >= limit || scanner.getTokenStartLine() !== line) {
84
+ return end;
85
+ }
86
+ if (kind === TOKEN_LINE_COMMENT || kind === TOKEN_BLOCK_COMMENT) {
87
+ end = offset + scanner.getTokenLength();
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Keep comments written inline after a preceding separator with that preceding
94
+ * entry. Comments on a later line belong to the entry that follows the comma.
95
+ */
96
+ function startAfterPrecedingInlineComments(
97
+ text: string,
98
+ comma: TokenLocation,
99
+ entryOffset: number,
100
+ ): number {
101
+ const commentEnd = inlineCommentEnd(text, comma.offset + comma.length, comma.line, entryOffset);
102
+ if (commentEnd === undefined) return comma.offset + comma.length;
103
+
104
+ const nextNewline = text.indexOf("\n", commentEnd);
105
+ return nextNewline === -1 ? commentEnd : nextNewline + 1;
106
+ }
107
+
108
+ /** Remove inline comments following a removed entry's comma with that entry. */
109
+ function endAfterFollowingInlineComments(
110
+ text: string,
111
+ comma: TokenLocation,
112
+ nextEntryOffset: number,
113
+ ): number {
114
+ const commentEnd = inlineCommentEnd(
115
+ text,
116
+ comma.offset + comma.length,
117
+ comma.line,
118
+ nextEntryOffset,
119
+ );
120
+ return commentEnd === undefined ? comma.offset + comma.length : lineEnd(text, commentEnd);
121
+ }
122
+
123
+ function closingWhitespace(text: string, entryEnd: number, closingBracket: number): string {
124
+ const trailing = text.slice(entryEnd, closingBracket);
125
+ const finalNewline = trailing.lastIndexOf("\n");
126
+ if (finalNewline === -1) return "";
127
+ const newlineStart = trailing[finalNewline - 1] === "\r" ? finalNewline - 1 : finalNewline;
128
+ return trailing.slice(newlineStart);
129
+ }
130
+
131
+ function removeArrayEntry(text: string, array: Node, index: number): string {
132
+ const entries = array.children ?? [];
133
+ const entry = entries[index];
134
+ if (!entry) return text;
135
+
136
+ const closingBracket = array.offset + array.length - 1;
137
+ if (entries.length === 1) {
138
+ const start = array.offset + 1;
139
+ const replacement = closingWhitespace(text, entry.offset + entry.length, closingBracket);
140
+ return text.slice(0, start) + replacement + text.slice(closingBracket);
141
+ }
142
+
143
+ if (index === entries.length - 1) {
144
+ const previous = entries[index - 1];
145
+ if (!previous) return text;
146
+ const comma = findComma(text, previous.offset + previous.length, entry.offset);
147
+ if (!comma) return text;
148
+ const replacement = closingWhitespace(text, entry.offset + entry.length, closingBracket);
149
+ return text.slice(0, comma.offset) + replacement + text.slice(closingBracket);
150
+ }
151
+
152
+ const next = entries[index + 1];
153
+ if (!next) return text;
154
+ const followingComma = findComma(text, entry.offset + entry.length, next.offset);
155
+ if (!followingComma) return text;
156
+
157
+ const start =
158
+ index === 0
159
+ ? array.offset + 1
160
+ : (() => {
161
+ const previous = entries[index - 1];
162
+ if (!previous) return entry.offset;
163
+ const precedingComma = findComma(
164
+ text,
165
+ previous.offset + previous.length,
166
+ entry.offset,
167
+ );
168
+ return precedingComma
169
+ ? startAfterPrecedingInlineComments(text, precedingComma, entry.offset)
170
+ : entry.offset;
171
+ })();
172
+ const end = endAfterFollowingInlineComments(text, followingComma, next.offset);
173
+
174
+ return text.slice(0, start) + text.slice(end);
175
+ }
176
+
177
+ function indentationAt(text: string, offset: number): string {
178
+ const prefix = text.slice(lineStart(text, offset), offset);
179
+ return /^[\t ]*$/.test(prefix) ? prefix : "";
180
+ }
181
+
182
+ function inferIndent(text: string, array: Node): string {
183
+ const firstEntry = array.children?.[0];
184
+ if (firstEntry) return indentationAt(text, firstEntry.offset);
185
+
186
+ const closingBracket = array.offset + array.length - 1;
187
+ const closingIndent = indentationAt(text, closingBracket);
188
+ return `${closingIndent}${closingIndent.includes("\t") ? "\t" : " "}`;
189
+ }
190
+
191
+ function appendArrayValue(text: string, array: Node, value: unknown): string {
192
+ const entries = array.children ?? [];
193
+ const closingBracket = array.offset + array.length - 1;
194
+ const serialized = JSON.stringify(value);
195
+
196
+ if (entries.length === 0) {
197
+ if (!text.slice(array.offset, closingBracket).includes("\n")) {
198
+ return text.slice(0, closingBracket) + serialized + text.slice(closingBracket);
199
+ }
200
+
201
+ const indent = inferIndent(text, array);
202
+ const closingLineStart = lineStart(text, closingBracket);
203
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
204
+ return (
205
+ text.slice(0, closingLineStart) +
206
+ indent +
207
+ serialized +
208
+ eol +
209
+ text.slice(closingLineStart)
210
+ );
211
+ }
212
+
213
+ const lastEntry = entries.at(-1);
214
+ if (!lastEntry) return text;
215
+ const trailingComma = findComma(text, lastEntry.offset + lastEntry.length, closingBracket);
216
+ const isMultiline = text.slice(array.offset, closingBracket).includes("\n");
217
+
218
+ if (!isMultiline) {
219
+ return (
220
+ text.slice(0, lastEntry.offset + lastEntry.length) +
221
+ `,${serialized}` +
222
+ text.slice(lastEntry.offset + lastEntry.length)
223
+ );
224
+ }
225
+
226
+ const closingLineStart = lineStart(text, closingBracket);
227
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
228
+ const inserted = `${inferIndent(text, array)}${serialized}${trailingComma ? "," : ""}${eol}`;
229
+ const withValue = text.slice(0, closingLineStart) + inserted + text.slice(closingLineStart);
230
+
231
+ if (trailingComma) return withValue;
232
+ return (
233
+ withValue.slice(0, lastEntry.offset + lastEntry.length) +
234
+ "," +
235
+ withValue.slice(lastEntry.offset + lastEntry.length)
236
+ );
237
+ }
238
+
239
+ /**
240
+ * Replace one JSONC value without reserializing the rest of the document. New
241
+ * paths use jsonc-parser's structural edit so the original document remains
242
+ * untouched outside the inserted property.
243
+ */
244
+ export function setJsoncValue(text: string, path: JSONPath, value: unknown): string {
245
+ const node = findNode(text, path);
246
+ if (node) {
247
+ if (Object.is(getNodeValue(node), value)) return text;
248
+ const serialized = JSON.stringify(value);
249
+ return text.slice(0, node.offset) + serialized + text.slice(node.offset + node.length);
250
+ }
251
+
252
+ return applyEdits(text, modify(text, path, value, {}));
253
+ }
254
+
255
+ /**
256
+ * Remove matching array entries while retaining the exact bytes for survivor
257
+ * comments and surrounding JSONC regions.
258
+ */
259
+ export function removeJsoncArrayEntries(
260
+ text: string,
261
+ path: JSONPath,
262
+ shouldRemove: (entry: unknown) => boolean,
263
+ ): { text: string; removed: boolean } {
264
+ let nextText = text;
265
+ let removed = false;
266
+
267
+ for (;;) {
268
+ const array = findNode(nextText, path);
269
+ if (array?.type !== "array") return { text: nextText, removed };
270
+ const index = (array.children ?? []).findIndex((entry) =>
271
+ shouldRemove(getNodeValue(entry)),
272
+ );
273
+ if (index === -1) return { text: nextText, removed };
274
+
275
+ const updated = removeArrayEntry(nextText, array, index);
276
+ if (updated === nextText) return { text: nextText, removed };
277
+ nextText = updated;
278
+ removed = true;
279
+ }
280
+ }
281
+
282
+ /** Append values to an existing JSONC array without reserializing sibling fields. */
283
+ export function appendJsoncArrayValues(text: string, path: JSONPath, values: unknown[]): string {
284
+ let nextText = text;
285
+
286
+ for (const value of values) {
287
+ const array = findNode(nextText, path);
288
+ if (array?.type !== "array") {
289
+ return setJsoncValue(nextText, path, values);
290
+ }
291
+ nextText = appendArrayValue(nextText, array, value);
292
+ }
293
+
294
+ return nextText;
295
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { parseJsonc } from "./jsonc-parser";
4
+
5
+ describe("parseJsonc prototype-pollution hardening", () => {
6
+ it("rejects dangerous keys recursively, including inside arrays", () => {
7
+ const rejected: string[] = [];
8
+ const parsed = parseJsonc<Record<string, unknown>>(
9
+ `{
10
+ "safe": 1,
11
+ "constructor": { "hidden": true },
12
+ "nested": { "prototype": { "hidden": true }, "safe": 2 },
13
+ "items": [
14
+ { "__proto__": { "polluted": true }, "safe": 3 },
15
+ { "safe": 4 }
16
+ ]
17
+ }`,
18
+ { onRejectedKey: (path) => rejected.push(path.join(".")) },
19
+ );
20
+
21
+ expect(rejected).toEqual(["constructor", "nested.prototype", "items.0.__proto__"]);
22
+ expect(Object.hasOwn(parsed, "constructor")).toBe(false);
23
+
24
+ const nested = parsed.nested as Record<string, unknown>;
25
+ expect(nested).toEqual({ safe: 2 });
26
+ expect(Object.getPrototypeOf(nested)).toBe(Object.prototype);
27
+
28
+ const items = parsed.items as Array<Record<string, unknown>>;
29
+ expect(items).toEqual([{ safe: 3 }, { safe: 4 }]);
30
+ expect(Object.getPrototypeOf(items[0])).toBe(Object.prototype);
31
+ expect("polluted" in items[0]).toBe(false);
32
+ });
33
+ });
@@ -106,9 +106,61 @@ function stripTrailingCommas(content: string): string {
106
106
  return result;
107
107
  }
108
108
 
109
- export function parseJsonc<T = unknown>(content: string): T {
109
+ const PROTOTYPE_POLLUTION_KEYS = new Set(["__proto__", "constructor", "prototype"]);
110
+
111
+ export function isPrototypePollutionKey(key: string): boolean {
112
+ return PROTOTYPE_POLLUTION_KEYS.has(key);
113
+ }
114
+
115
+ export interface ParsedJsonSanitizerOptions {
116
+ onRejectedKey?: (path: readonly (string | number)[]) => void;
117
+ }
118
+
119
+ /**
120
+ * Copy parsed JSON into fresh own-property-only containers while rejecting keys
121
+ * that can alter an object's prototype during a later merge. Rebuilding objects
122
+ * also removes an already-polluted prototype produced by third-party parsers.
123
+ */
124
+ export function sanitizeParsedJson<T>(
125
+ value: T,
126
+ options: ParsedJsonSanitizerOptions = {},
127
+ path: readonly (string | number)[] = [],
128
+ ): T {
129
+ if (Array.isArray(value)) {
130
+ return value.map((entry, index) =>
131
+ sanitizeParsedJson(entry, options, [...path, index]),
132
+ ) as T;
133
+ }
134
+ if (value === null || typeof value !== "object") return value;
135
+
136
+ const source = value as Record<string, unknown>;
137
+ const sourcePrototype = Object.getPrototypeOf(source);
138
+ if (sourcePrototype !== null && sourcePrototype !== Object.prototype) {
139
+ options.onRejectedKey?.([...path, "__proto__"]);
140
+ }
141
+
142
+ const sanitized: Record<string, unknown> = {};
143
+ for (const key of Object.keys(source)) {
144
+ if (isPrototypePollutionKey(key)) {
145
+ options.onRejectedKey?.([...path, key]);
146
+ continue;
147
+ }
148
+ Object.defineProperty(sanitized, key, {
149
+ value: sanitizeParsedJson(source[key], options, [...path, key]),
150
+ enumerable: true,
151
+ configurable: true,
152
+ writable: true,
153
+ });
154
+ }
155
+ return sanitized as T;
156
+ }
157
+
158
+ export function parseJsonc<T = unknown>(
159
+ content: string,
160
+ options: ParsedJsonSanitizerOptions = {},
161
+ ): T {
110
162
  const normalized = stripTrailingCommas(stripJsonComments(content));
111
- return JSON.parse(normalized) as T;
163
+ return sanitizeParsedJson(JSON.parse(normalized) as T, options);
112
164
  }
113
165
 
114
166
  export function readJsoncFile<T = unknown>(filePath: string): T | null {