archgen-mcp 0.0.7 → 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/README.md +2 -6
- package/assets/briefs/investigate.md +16 -17
- package/assets/briefs/review.md +41 -29
- package/assets/briefs/survey.md +17 -20
- package/assets/briefs/verifier.md +52 -40
- package/assets/harness-mcp.json +6 -6
- package/assets/platform-capabilities.json +387 -71
- package/assets/references/artifact-templates.md +67 -232
- package/assets/references/interview.md +83 -199
- package/assets/references/mcp-and-skills.md +35 -125
- package/assets/references/orchestration.md +96 -209
- package/assets/references/platforms.md +41 -110
- package/assets/skill-registry.json +10 -1
- package/assets/templates/adr.md +3 -3
- package/assets/templates/architecture.yaml +31 -28
- package/assets/templates/edge-matrix.md +8 -8
- package/assets/templates/mermaid.md +3 -10
- package/assets/templates/prd.md +15 -15
- package/assets/templates/tasks.yaml +25 -18
- package/package.json +2 -2
- package/src/cli.mjs +1 -2
- package/src/context.mjs +12 -42
- package/src/core/brief-context.mjs +106 -0
- package/src/core/brief-scope.mjs +218 -0
- package/src/core/briefs.mjs +109 -1
- package/src/core/codegraph-manager.mjs +12 -0
- package/src/core/doc-navigator.mjs +101 -17
- package/src/core/errors.mjs +1 -1
- package/src/core/harness-wiring.mjs +65 -22
- package/src/core/jsonc-config.mjs +706 -0
- package/src/core/plan-grapher.mjs +11 -3
- package/src/core/skill-manager.mjs +181 -13
- package/src/core/skill-roots.mjs +92 -0
- package/src/core/tokens.mjs +78 -0
- package/src/core/worker-contract.mjs +145 -18
- package/src/server.mjs +0 -3
- package/src/tools/help.mjs +17 -34
- package/src/tools/plan.mjs +36 -22
- package/src/tools/skills.mjs +67 -5
- package/src/tools/tasks.mjs +44 -18
- package/src/core/ui-channel.mjs +0 -222
- package/src/tools/ui.mjs +0 -67
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
// jsonc-config.mjs — lossless JSONC support for the harness config writer.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS: two harnesses keep their MCP config as JSONC, not strict
|
|
4
|
+
// JSON — opencode (~/.config/opencode/opencode.json) and Zed
|
|
5
|
+
// (~/.config/zed/settings.json). JSON.parse rejects both, so the writer used
|
|
6
|
+
// to refuse them and leave them permanently unwired.
|
|
7
|
+
//
|
|
8
|
+
// WHY NOT JUST PARSE AND REWRITE: rewriting a JSONC file as plain JSON would
|
|
9
|
+
// delete the user's comments, their key order and their formatting. That is
|
|
10
|
+
// as destructive as never wiring the file at all, so a JSONC config is edited
|
|
11
|
+
// TEXTUALLY: only the bytes of the target entry are spliced and everything
|
|
12
|
+
// else survives verbatim.
|
|
13
|
+
|
|
14
|
+
/** Raised for any JSONC text this module cannot handle safely. */
|
|
15
|
+
class JsoncError extends Error {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse strict JSON or JSONC.
|
|
19
|
+
*
|
|
20
|
+
* Comments and trailing commas are removed for the parse only — the input
|
|
21
|
+
* text is never modified, because the writer edits the raw bytes.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} text
|
|
24
|
+
* @returns {{data: any, error: string|null}}
|
|
25
|
+
*/
|
|
26
|
+
export function parseJsonc(text) {
|
|
27
|
+
try {
|
|
28
|
+
const scan = maskJsonc(text);
|
|
29
|
+
return { data: JSON.parse(stripTrailingCommas(scan)), error: null };
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return { data: null, error: e.message };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Merge a server entry into a JSON/JSONC MCP config.
|
|
37
|
+
*
|
|
38
|
+
* Strict JSON is merged and re-serialized as a whole (the historic
|
|
39
|
+
* behaviour). JSONC is spliced textually so every unrelated byte survives.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} text the current file contents
|
|
42
|
+
* @param {string} key the top-level key ('mcpServers' | 'mcp' | 'context_servers')
|
|
43
|
+
* @param {string} serverId the entry name inside the bucket
|
|
44
|
+
* @param {any} entry the server entry
|
|
45
|
+
* @returns {{text: string, changed: boolean, reason?: string}}
|
|
46
|
+
* `changed:false` with no reason means "already up to date"; with a reason
|
|
47
|
+
* it means "refused, the text is untouched".
|
|
48
|
+
*/
|
|
49
|
+
export function mergeEntry(text, key, serverId, entry) {
|
|
50
|
+
const strict = parseStrict(text);
|
|
51
|
+
if (strict.ok) return mergeStrict(text, strict.data, key, serverId, entry);
|
|
52
|
+
|
|
53
|
+
const parsed = parseJsonc(text);
|
|
54
|
+
if (parsed.error) {
|
|
55
|
+
return { text, changed: false, reason: `not valid JSON or JSONC (${parsed.error})` };
|
|
56
|
+
}
|
|
57
|
+
const installed = bucketOf(parsed.data, key)?.[serverId];
|
|
58
|
+
if (installed && JSON.stringify(installed) === JSON.stringify(entry)) {
|
|
59
|
+
return { text, changed: false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let root;
|
|
63
|
+
try {
|
|
64
|
+
root = scanDocument(text);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return { text, changed: false, reason: `cannot locate the target safely (${e.message})` };
|
|
67
|
+
}
|
|
68
|
+
const style = detectStyle(text, root);
|
|
69
|
+
const parts = key.split('.');
|
|
70
|
+
const resolved = resolvePath(root, parts);
|
|
71
|
+
if (resolved.ambiguous) {
|
|
72
|
+
// A duplicated key makes the target ambiguous: JSON.parse would read the
|
|
73
|
+
// last one, so splicing the first would have no effect.
|
|
74
|
+
return { text, changed: false, reason: `the key '${key}' appears more than once` };
|
|
75
|
+
}
|
|
76
|
+
if (resolved.blocked) {
|
|
77
|
+
return { text, changed: false, reason: `'${resolved.blocked}' is not an object` };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!resolved.member) {
|
|
81
|
+
// The path does not exist yet. Build the missing chain and insert it.
|
|
82
|
+
const value = buildChain(parts, resolved.missingIndex ?? parts.length - 1, serverId, entry);
|
|
83
|
+
const out = insertMember(text, resolved.parent, JSON.stringify(resolved.missing), value, style, false);
|
|
84
|
+
return { text: out, changed: out !== text };
|
|
85
|
+
}
|
|
86
|
+
if (resolved.member.kind !== 'object') {
|
|
87
|
+
return { text, changed: false, reason: `the key '${key}' is not an object` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const bucket = resolved.member.object;
|
|
91
|
+
const existing = bucket.members.filter((m) => m.key === serverId);
|
|
92
|
+
if (existing.length > 1) {
|
|
93
|
+
return { text, changed: false, reason: `the entry '${serverId}' appears more than once under '${key}'` };
|
|
94
|
+
}
|
|
95
|
+
const current = existing[0];
|
|
96
|
+
if (!current) {
|
|
97
|
+
const out = insertMember(text, bucket, JSON.stringify(serverId), entry, style, isMultiline(text, root));
|
|
98
|
+
return { text: out, changed: out !== text };
|
|
99
|
+
}
|
|
100
|
+
// A replaced value keeps the shape it had: a value written over several
|
|
101
|
+
// lines stays over several lines.
|
|
102
|
+
const multiline = text.slice(current.valueStart, current.valueEnd).includes('\n');
|
|
103
|
+
const replacement = renderValue(entry, style, multiline, memberIndent(text, bucket, style));
|
|
104
|
+
const out = text.slice(0, current.valueStart) + replacement + text.slice(current.valueEnd);
|
|
105
|
+
return { text: out, changed: out !== text };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Walk a dotted key path through the scanned tree.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} root the scanned top-level object
|
|
112
|
+
* @param {string[]} parts the path segments
|
|
113
|
+
* @returns {{member: object|null, parent: object|null, missing: string|null,
|
|
114
|
+
* missingIndex: number, ambiguous: boolean, blocked: string|null}}
|
|
115
|
+
*/
|
|
116
|
+
function resolvePath(root, parts) {
|
|
117
|
+
let container = root;
|
|
118
|
+
for (let i = 0; i < parts.length; i++) {
|
|
119
|
+
const part = parts[i];
|
|
120
|
+
const hits = container.members.filter((m) => m.key === part);
|
|
121
|
+
if (hits.length > 1) return { member: null, parent: null, missing: null, missingIndex: 0, ambiguous: true, blocked: null };
|
|
122
|
+
if (hits.length === 0) {
|
|
123
|
+
return { member: null, parent: container, missing: part, missingIndex: i, ambiguous: false, blocked: null };
|
|
124
|
+
}
|
|
125
|
+
const member = hits[0];
|
|
126
|
+
if (i === parts.length - 1) {
|
|
127
|
+
return { member, parent: container, missing: null, missingIndex: 0, ambiguous: false, blocked: null };
|
|
128
|
+
}
|
|
129
|
+
if (member.kind !== 'object') {
|
|
130
|
+
return { member: null, parent: null, missing: null, missingIndex: 0, ambiguous: false, blocked: part };
|
|
131
|
+
}
|
|
132
|
+
container = member.object;
|
|
133
|
+
}
|
|
134
|
+
return { member: null, parent: null, missing: null, missingIndex: 0, ambiguous: false, blocked: null };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The value for the missing segment of a key path.
|
|
139
|
+
*
|
|
140
|
+
* `parts` is the full path and `fromIndex` the index of its first missing
|
|
141
|
+
* segment. The value is what goes UNDER that segment: the chain from the
|
|
142
|
+
* segment below it down to the entry. The segment itself is the member's
|
|
143
|
+
* name, so it is not repeated here.
|
|
144
|
+
*/
|
|
145
|
+
function buildChain(parts, fromIndex, serverId, entry) {
|
|
146
|
+
let value = { [serverId]: entry };
|
|
147
|
+
for (let i = parts.length - 1; i > fromIndex; i--) {
|
|
148
|
+
value = { [parts[i]]: value };
|
|
149
|
+
}
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Remove one server entry from a JSON/JSONC MCP config.
|
|
155
|
+
*
|
|
156
|
+
* The JSONC branch exists because the writer can now install into a JSONC
|
|
157
|
+
* file: uninstalling by re-serializing the parsed document would silently
|
|
158
|
+
* delete the user's comments.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} text
|
|
161
|
+
* @param {string} key
|
|
162
|
+
* @param {string} serverId
|
|
163
|
+
* @returns {{text: string, changed: boolean, reason?: string}}
|
|
164
|
+
*/
|
|
165
|
+
export function removeEntry(text, key, serverId) {
|
|
166
|
+
const strict = parseStrict(text);
|
|
167
|
+
if (strict.ok) {
|
|
168
|
+
const obj = strict.data && typeof strict.data === 'object' && !Array.isArray(strict.data) ? strict.data : null;
|
|
169
|
+
const bucket = obj ? bucketOf(obj, key) : null;
|
|
170
|
+
if (!bucket || !(serverId in bucket)) {
|
|
171
|
+
return { text, changed: false, reason: `no '${serverId}' entry under '${key}'` };
|
|
172
|
+
}
|
|
173
|
+
delete bucket[serverId];
|
|
174
|
+
const out = JSON.stringify(obj, null, 2) + '\n';
|
|
175
|
+
return { text: out, changed: out !== text };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const parsed = parseJsonc(text);
|
|
179
|
+
if (parsed.error) {
|
|
180
|
+
return { text, changed: false, reason: `not valid JSON or JSONC (${parsed.error})` };
|
|
181
|
+
}
|
|
182
|
+
let root;
|
|
183
|
+
try {
|
|
184
|
+
root = scanDocument(text);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
return { text, changed: false, reason: `cannot locate the target safely (${e.message})` };
|
|
187
|
+
}
|
|
188
|
+
const resolved = resolvePath(root, key.split('.'));
|
|
189
|
+
if (resolved.ambiguous) {
|
|
190
|
+
return { text, changed: false, reason: `the key '${key}' appears more than once` };
|
|
191
|
+
}
|
|
192
|
+
const target = resolved.member;
|
|
193
|
+
if (!target || target.kind !== 'object') {
|
|
194
|
+
return { text, changed: false, reason: `no '${key}' object found` };
|
|
195
|
+
}
|
|
196
|
+
const member = target.object.members.find((m) => m.key === serverId);
|
|
197
|
+
if (!member) {
|
|
198
|
+
return { text, changed: false, reason: `no '${serverId}' entry under '${key}'` };
|
|
199
|
+
}
|
|
200
|
+
const out = removeMemberText(text, target.object, member);
|
|
201
|
+
return { text: out, changed: out !== text };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── parsing helpers ──────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
/** Strict JSON, or a failed attempt. */
|
|
207
|
+
function parseStrict(text) {
|
|
208
|
+
try {
|
|
209
|
+
return { ok: true, data: JSON.parse(text) };
|
|
210
|
+
} catch {
|
|
211
|
+
return { ok: false, data: null };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Merge into a strict JSON document and re-serialize the whole file.
|
|
217
|
+
*
|
|
218
|
+
* Strict JSON carries no formatting worth preserving, so the historic
|
|
219
|
+
* behaviour (parse, merge, re-serialize) is kept.
|
|
220
|
+
*
|
|
221
|
+
* The key may be a DOTTED PATH: opencode keeps its servers at
|
|
222
|
+
* `mcp.servers.<id>`, not `mcp.<id>`. A single top-level key cannot express
|
|
223
|
+
* that, and writing to `mcp.<id>` put the entry where opencode never looks —
|
|
224
|
+
* the server was installed and invisible.
|
|
225
|
+
*/
|
|
226
|
+
function mergeStrict(text, data, key, serverId, entry) {
|
|
227
|
+
const obj = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
228
|
+
const parts = key.split('.');
|
|
229
|
+
let container = obj;
|
|
230
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
231
|
+
const part = parts[i];
|
|
232
|
+
if (!container[part] || typeof container[part] !== 'object' || Array.isArray(container[part])) {
|
|
233
|
+
container[part] = {};
|
|
234
|
+
}
|
|
235
|
+
container = container[part];
|
|
236
|
+
}
|
|
237
|
+
const leaf = parts[parts.length - 1];
|
|
238
|
+
const bucket =
|
|
239
|
+
container[leaf] && typeof container[leaf] === 'object' && !Array.isArray(container[leaf])
|
|
240
|
+
? container[leaf]
|
|
241
|
+
: {};
|
|
242
|
+
const existing = bucket[serverId];
|
|
243
|
+
if (existing && JSON.stringify(existing) === JSON.stringify(entry)) {
|
|
244
|
+
return { text, changed: false };
|
|
245
|
+
}
|
|
246
|
+
bucket[serverId] = entry;
|
|
247
|
+
container[leaf] = bucket;
|
|
248
|
+
const out = JSON.stringify(obj, null, 2) + '\n';
|
|
249
|
+
return { text: out, changed: out !== text };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The bucket object for a key path, or null when it is missing or not an
|
|
254
|
+
* object. A dotted path names a nested bucket (`mcp.servers`).
|
|
255
|
+
*/
|
|
256
|
+
function bucketOf(data, key) {
|
|
257
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
|
|
258
|
+
const parts = key.split('.');
|
|
259
|
+
let value = data;
|
|
260
|
+
for (const part of parts) {
|
|
261
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
262
|
+
value = value[part];
|
|
263
|
+
}
|
|
264
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Blank out comments so JSON.parse can read the document.
|
|
269
|
+
*
|
|
270
|
+
* The masked text keeps the original length and every newline, so a parse
|
|
271
|
+
* error still points at the right offset in the ORIGINAL file.
|
|
272
|
+
*/
|
|
273
|
+
function maskJsonc(text) {
|
|
274
|
+
let masked = '';
|
|
275
|
+
const commas = [];
|
|
276
|
+
let i = 0;
|
|
277
|
+
const n = text.length;
|
|
278
|
+
let inString = false;
|
|
279
|
+
while (i < n) {
|
|
280
|
+
const c = text[i];
|
|
281
|
+
if (inString) {
|
|
282
|
+
if (c === '\\') {
|
|
283
|
+
masked += c;
|
|
284
|
+
if (i + 1 < n) {
|
|
285
|
+
masked += text[i + 1];
|
|
286
|
+
i += 2;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
i += 1;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
masked += c;
|
|
293
|
+
if (c === '"') inString = false;
|
|
294
|
+
i += 1;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (c === '"') {
|
|
298
|
+
inString = true;
|
|
299
|
+
masked += c;
|
|
300
|
+
i += 1;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (c === '/' && text[i + 1] === '/') {
|
|
304
|
+
// A line comment runs to the end of the line; the newline itself stays
|
|
305
|
+
// so every following offset is unchanged.
|
|
306
|
+
while (i < n && text[i] !== '\n') {
|
|
307
|
+
masked += ' ';
|
|
308
|
+
i += 1;
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (c === '/' && text[i + 1] === '*') {
|
|
313
|
+
const end = text.indexOf('*/', i + 2);
|
|
314
|
+
if (end === -1) throw new JsoncError('unterminated block comment');
|
|
315
|
+
masked += text.slice(i, end + 2).replace(/[^\n]/g, ' ');
|
|
316
|
+
i = end + 2;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (c === ',') commas.push(masked.length);
|
|
320
|
+
masked += c;
|
|
321
|
+
i += 1;
|
|
322
|
+
}
|
|
323
|
+
if (inString) throw new JsoncError('unterminated string');
|
|
324
|
+
// A leading BOM is not JSON whitespace; blank it so the document parses
|
|
325
|
+
// without moving any other byte.
|
|
326
|
+
if (masked.charCodeAt(0) === 0xfeff) masked = ' ' + masked.slice(1);
|
|
327
|
+
return { masked, commas };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Turn trailing commas into whitespace.
|
|
332
|
+
*
|
|
333
|
+
* Only commas that are really outside a string are considered — a comma in
|
|
334
|
+
* "a, }" is part of the string and must survive.
|
|
335
|
+
*/
|
|
336
|
+
function stripTrailingCommas(scan) {
|
|
337
|
+
const chars = scan.masked.split('');
|
|
338
|
+
for (const idx of scan.commas) {
|
|
339
|
+
let j = idx + 1;
|
|
340
|
+
while (j < chars.length && /\s/.test(chars[j])) j += 1;
|
|
341
|
+
if (chars[j] === '}' || chars[j] === ']') chars[idx] = ' ';
|
|
342
|
+
}
|
|
343
|
+
return chars.join('');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── structural scanning ──────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* A scanner over the RAW text.
|
|
350
|
+
*
|
|
351
|
+
* It finds the byte ranges of the members of an object, so a target can be
|
|
352
|
+
* spliced without re-serializing anything around it.
|
|
353
|
+
*/
|
|
354
|
+
class RawScanner {
|
|
355
|
+
constructor(text) {
|
|
356
|
+
this.text = text;
|
|
357
|
+
this.i = 0;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
peek() {
|
|
361
|
+
return this.text[this.i];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Skip whitespace and comments. */
|
|
365
|
+
skipTrivia() {
|
|
366
|
+
const t = this.text;
|
|
367
|
+
for (;;) {
|
|
368
|
+
const c = t[this.i];
|
|
369
|
+
if (c === undefined) return;
|
|
370
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
|
|
371
|
+
this.i += 1;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (c === '/' && t[this.i + 1] === '/') {
|
|
375
|
+
while (this.i < t.length && t[this.i] !== '\n') this.i += 1;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (c === '/' && t[this.i + 1] === '*') {
|
|
379
|
+
const end = t.indexOf('*/', this.i + 2);
|
|
380
|
+
if (end === -1) throw new JsoncError('unterminated block comment');
|
|
381
|
+
this.i = end + 2;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Read a quoted string; the opening quote must be at the cursor. */
|
|
389
|
+
readString() {
|
|
390
|
+
const start = this.i;
|
|
391
|
+
this.i += 1;
|
|
392
|
+
while (this.i < this.text.length) {
|
|
393
|
+
const c = this.text[this.i];
|
|
394
|
+
if (c === '\\') {
|
|
395
|
+
this.i += 2;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (c === '"') {
|
|
399
|
+
this.i += 1;
|
|
400
|
+
const raw = this.text.slice(start, this.i);
|
|
401
|
+
try {
|
|
402
|
+
return { start, end: this.i, value: JSON.parse(raw) };
|
|
403
|
+
} catch {
|
|
404
|
+
throw new JsoncError(`invalid string literal at offset ${start}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
this.i += 1;
|
|
408
|
+
}
|
|
409
|
+
throw new JsoncError(`unterminated string at offset ${start}`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Scan an object; `level` is the nesting level of the members inside it. */
|
|
413
|
+
scanObject(level) {
|
|
414
|
+
const open = this.i;
|
|
415
|
+
this.i += 1;
|
|
416
|
+
const members = [];
|
|
417
|
+
for (;;) {
|
|
418
|
+
this.skipTrivia();
|
|
419
|
+
const c = this.peek();
|
|
420
|
+
if (c === undefined) throw new JsoncError('unterminated object');
|
|
421
|
+
if (c === '}') {
|
|
422
|
+
this.i += 1;
|
|
423
|
+
return { open, close: this.i, members, level };
|
|
424
|
+
}
|
|
425
|
+
if (c === ',') {
|
|
426
|
+
this.i += 1;
|
|
427
|
+
continue; // a trailing comma before the closing brace
|
|
428
|
+
}
|
|
429
|
+
if (c !== '"') throw new JsoncError(`expected a property name at offset ${this.i}`);
|
|
430
|
+
const key = this.readString();
|
|
431
|
+
this.skipTrivia();
|
|
432
|
+
if (this.peek() !== ':') throw new JsoncError(`expected ':' at offset ${this.i}`);
|
|
433
|
+
this.i += 1;
|
|
434
|
+
this.skipTrivia();
|
|
435
|
+
const value = this.scanValue(level);
|
|
436
|
+
members.push({
|
|
437
|
+
key: key.value,
|
|
438
|
+
keyStart: key.start,
|
|
439
|
+
keyEnd: key.end,
|
|
440
|
+
valueStart: value.start,
|
|
441
|
+
valueEnd: value.end,
|
|
442
|
+
kind: value.kind,
|
|
443
|
+
object: value.object,
|
|
444
|
+
level,
|
|
445
|
+
});
|
|
446
|
+
this.skipTrivia();
|
|
447
|
+
const d = this.peek();
|
|
448
|
+
if (d === ',') {
|
|
449
|
+
this.i += 1;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (d === '}') {
|
|
453
|
+
this.i += 1;
|
|
454
|
+
return { open, close: this.i, members, level };
|
|
455
|
+
}
|
|
456
|
+
throw new JsoncError(`expected ',' or '}' at offset ${this.i}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
scanArray(level) {
|
|
461
|
+
const open = this.i;
|
|
462
|
+
this.i += 1;
|
|
463
|
+
for (;;) {
|
|
464
|
+
this.skipTrivia();
|
|
465
|
+
const c = this.peek();
|
|
466
|
+
if (c === undefined) throw new JsoncError('unterminated array');
|
|
467
|
+
if (c === ']') {
|
|
468
|
+
this.i += 1;
|
|
469
|
+
return { open, close: this.i };
|
|
470
|
+
}
|
|
471
|
+
if (c === ',') {
|
|
472
|
+
this.i += 1;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
this.scanValue(level);
|
|
476
|
+
this.skipTrivia();
|
|
477
|
+
const d = this.peek();
|
|
478
|
+
if (d === ',') {
|
|
479
|
+
this.i += 1;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (d === ']') {
|
|
483
|
+
this.i += 1;
|
|
484
|
+
return { open, close: this.i };
|
|
485
|
+
}
|
|
486
|
+
throw new JsoncError(`expected ',' or ']' at offset ${this.i}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
scanValue(level) {
|
|
491
|
+
const c = this.peek();
|
|
492
|
+
if (c === '{') {
|
|
493
|
+
const obj = this.scanObject(level + 1);
|
|
494
|
+
return { kind: 'object', start: obj.open, end: obj.close, object: obj };
|
|
495
|
+
}
|
|
496
|
+
if (c === '[') {
|
|
497
|
+
const arr = this.scanArray(level + 1);
|
|
498
|
+
return { kind: 'array', start: arr.open, end: arr.close };
|
|
499
|
+
}
|
|
500
|
+
if (c === '"') {
|
|
501
|
+
const s = this.readString();
|
|
502
|
+
return { kind: 'string', start: s.start, end: s.end };
|
|
503
|
+
}
|
|
504
|
+
const start = this.i;
|
|
505
|
+
while (this.i < this.text.length && !/[\s,}\]\/]/.test(this.text[this.i])) this.i += 1;
|
|
506
|
+
const raw = this.text.slice(start, this.i);
|
|
507
|
+
if (!/^(true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)$/.test(raw)) {
|
|
508
|
+
throw new JsoncError(`unexpected token at offset ${start}`);
|
|
509
|
+
}
|
|
510
|
+
return { kind: 'literal', start, end: this.i };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Scan the whole document.
|
|
516
|
+
*
|
|
517
|
+
* The root must be an object and must be the only thing in the file —
|
|
518
|
+
* anything else is refused rather than guessed at.
|
|
519
|
+
*/
|
|
520
|
+
function scanDocument(text) {
|
|
521
|
+
const s = new RawScanner(text);
|
|
522
|
+
s.skipTrivia();
|
|
523
|
+
if (s.peek() !== '{') {
|
|
524
|
+
throw new JsoncError(s.peek() === undefined ? 'the document is empty' : 'the top-level value is not an object');
|
|
525
|
+
}
|
|
526
|
+
const root = s.scanObject(1);
|
|
527
|
+
s.skipTrivia();
|
|
528
|
+
if (s.peek() !== undefined) throw new JsoncError('unexpected content after the top-level object');
|
|
529
|
+
return root;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── style, splicing and rendering ───────────────────────────────────────────
|
|
533
|
+
|
|
534
|
+
/** The offset of the first character on the line that contains `pos`. */
|
|
535
|
+
function lineStart(text, pos) {
|
|
536
|
+
return text.lastIndexOf('\n', pos - 1) + 1;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** The leading whitespace of the line that contains `pos`. */
|
|
540
|
+
function lineIndent(text, pos) {
|
|
541
|
+
const start = lineStart(text, pos);
|
|
542
|
+
let end = start;
|
|
543
|
+
while (end < text.length && (text[end] === ' ' || text[end] === '\t')) end += 1;
|
|
544
|
+
return text.slice(start, end);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function isSameLine(text, a, b) {
|
|
548
|
+
return lineStart(text, a) === lineStart(text, b);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** The indentation one level deeper than `parent`. */
|
|
552
|
+
function indentDiff(parent, child) {
|
|
553
|
+
if (child.startsWith(parent)) return child.slice(parent.length);
|
|
554
|
+
return child.length > parent.length ? child : null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The file's own formatting: its line ending and its indentation unit.
|
|
559
|
+
*
|
|
560
|
+
* The unit is measured from the root object, so an inserted member lines up
|
|
561
|
+
* with the members that are already there.
|
|
562
|
+
*/
|
|
563
|
+
function detectStyle(text, root) {
|
|
564
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
565
|
+
const rootIndent = lineIndent(text, root.open);
|
|
566
|
+
let unit = null;
|
|
567
|
+
for (const m of root.members) {
|
|
568
|
+
if (isSameLine(text, m.keyStart, root.open)) continue;
|
|
569
|
+
const diff = indentDiff(rootIndent, lineIndent(text, m.keyStart));
|
|
570
|
+
if (diff && (unit === null || diff.length < unit.length)) unit = diff;
|
|
571
|
+
}
|
|
572
|
+
return { eol, unit: unit ?? ' ' };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** The indentation of the members of an object. */
|
|
576
|
+
function memberIndent(text, obj, style) {
|
|
577
|
+
const first = obj.members[0];
|
|
578
|
+
if (first && !isSameLine(text, first.keyStart, obj.open)) return lineIndent(text, first.keyStart);
|
|
579
|
+
return lineIndent(text, obj.open) + style.unit;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function isMultiline(text, obj) {
|
|
583
|
+
return !isSameLine(text, obj.open, obj.close - 1);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Render a value the way the file already writes values.
|
|
588
|
+
*
|
|
589
|
+
* A multiline object gets multiline values, a single-line object gets
|
|
590
|
+
* single-line values: the edit must not restyle the file around it.
|
|
591
|
+
*/
|
|
592
|
+
function renderValue(value, style, multiline, indent) {
|
|
593
|
+
if (!multiline) return JSON.stringify(value);
|
|
594
|
+
return stringifyPretty(value, indent + style.unit, indent, style.unit, style.eol);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Pretty-print a value at a given indentation. */
|
|
598
|
+
function stringifyPretty(value, memberPad, closePad, unit, eol) {
|
|
599
|
+
if (Array.isArray(value)) {
|
|
600
|
+
if (!value.length) return '[]';
|
|
601
|
+
const parts = value.map((v) => memberPad + stringifyPretty(v, memberPad + unit, memberPad, unit, eol));
|
|
602
|
+
return '[' + eol + parts.join(',' + eol) + eol + closePad + ']';
|
|
603
|
+
}
|
|
604
|
+
if (value && typeof value === 'object') {
|
|
605
|
+
const keys = Object.keys(value);
|
|
606
|
+
if (!keys.length) return '{}';
|
|
607
|
+
const parts = keys.map(
|
|
608
|
+
(k) => memberPad + JSON.stringify(k) + ': ' + stringifyPretty(value[k], memberPad + unit, memberPad, unit, eol),
|
|
609
|
+
);
|
|
610
|
+
return '{' + eol + parts.join(',' + eol) + eol + closePad + '}';
|
|
611
|
+
}
|
|
612
|
+
return JSON.stringify(value);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** The index of the next comma between `from` and `to`, or -1. */
|
|
616
|
+
function findCommaAfter(text, from, to) {
|
|
617
|
+
const s = new RawScanner(text);
|
|
618
|
+
s.i = from;
|
|
619
|
+
s.skipTrivia();
|
|
620
|
+
if (s.i < to && text[s.i] === ',') return s.i;
|
|
621
|
+
return -1;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** The index of the previous comma between `from` and `to`, or -1. */
|
|
625
|
+
function findCommaBefore(text, from, to) {
|
|
626
|
+
let i = to - 1;
|
|
627
|
+
while (i >= from) {
|
|
628
|
+
const c = text[i];
|
|
629
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
|
|
630
|
+
i -= 1;
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (c === '/' && text[i + 1] === '/') {
|
|
634
|
+
i = text.lastIndexOf('\n', i - 1);
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
if (c === '/' && text[i - 1] === '*') {
|
|
638
|
+
i = text.lastIndexOf('/*', i - 1) - 1;
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
return c === ',' ? i : -1;
|
|
642
|
+
}
|
|
643
|
+
return -1;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Insert `keyText: value` as the last member of an object.
|
|
648
|
+
*
|
|
649
|
+
* Only the bytes around the insertion point move; every other byte of the
|
|
650
|
+
* file is left exactly as it was.
|
|
651
|
+
*/
|
|
652
|
+
function insertMember(text, obj, keyText, value, style, parentMultiline) {
|
|
653
|
+
const closeIdx = obj.close - 1;
|
|
654
|
+
const indent = memberIndent(text, obj, style);
|
|
655
|
+
// An object with no members yet has no style of its own to copy, so it
|
|
656
|
+
// follows the object it lives in.
|
|
657
|
+
const multiline = isMultiline(text, obj) || (!obj.members.length && parentMultiline);
|
|
658
|
+
const memberText = keyText + ': ' + renderValue(value, style, multiline, indent);
|
|
659
|
+
|
|
660
|
+
if (!obj.members.length) {
|
|
661
|
+
// The object holds nothing yet, so its braces are the only thing to
|
|
662
|
+
// replace.
|
|
663
|
+
const inner = text.slice(obj.open + 1, closeIdx);
|
|
664
|
+
if (!multiline) return text.slice(0, obj.open + 1) + memberText + text.slice(closeIdx);
|
|
665
|
+
const nl = inner.lastIndexOf('\n');
|
|
666
|
+
const head = nl >= 0 ? inner.slice(0, nl + 1) : style.eol;
|
|
667
|
+
const closePad = lineIndent(text, obj.open);
|
|
668
|
+
const pad = closePad + style.unit;
|
|
669
|
+
return text.slice(0, obj.open + 1) + head + pad + memberText + style.eol + closePad + text.slice(closeIdx);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const last = obj.members[obj.members.length - 1];
|
|
673
|
+
const comma = findCommaAfter(text, last.valueEnd, closeIdx);
|
|
674
|
+
if (!multiline) {
|
|
675
|
+
if (comma >= 0) {
|
|
676
|
+
return text.slice(0, comma + 1) + ' ' + memberText + text.slice(comma + 1, closeIdx) + text.slice(closeIdx);
|
|
677
|
+
}
|
|
678
|
+
return text.slice(0, last.valueEnd) + ', ' + memberText + text.slice(closeIdx);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const before = comma >= 0 ? text.slice(0, comma + 1) : text.slice(0, last.valueEnd) + ',';
|
|
682
|
+
const after = comma >= 0 ? text.slice(comma + 1, closeIdx) : text.slice(last.valueEnd, closeIdx);
|
|
683
|
+
// The first newline after the comma separates the new member from what was
|
|
684
|
+
// there; anything after it (a note, the closing indent) keeps its place.
|
|
685
|
+
const nl = after.indexOf('\n');
|
|
686
|
+
const sep = nl >= 0 ? after.slice(0, nl + 1) : '';
|
|
687
|
+
const rest = nl >= 0 ? after.slice(nl + 1) : after;
|
|
688
|
+
return before + sep + indent + memberText + style.eol + rest + text.slice(closeIdx);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Remove one member from an object, taking its comma with it. */
|
|
692
|
+
function removeMemberText(text, obj, member) {
|
|
693
|
+
const closeIdx = obj.close - 1;
|
|
694
|
+
const commaAfter = findCommaAfter(text, member.valueEnd, closeIdx);
|
|
695
|
+
if (commaAfter >= 0) {
|
|
696
|
+
let start = member.keyStart;
|
|
697
|
+
const ls = lineStart(text, member.keyStart);
|
|
698
|
+
if (/^[ \t]*$/.test(text.slice(ls, member.keyStart))) {
|
|
699
|
+
start = ls > 0 && text[ls - 1] === '\n' ? ls - 1 : ls;
|
|
700
|
+
}
|
|
701
|
+
return text.slice(0, start) + text.slice(commaAfter + 1);
|
|
702
|
+
}
|
|
703
|
+
const commaBefore = findCommaBefore(text, obj.open + 1, member.keyStart);
|
|
704
|
+
if (commaBefore >= 0) return text.slice(0, commaBefore) + text.slice(member.valueEnd);
|
|
705
|
+
return text.slice(0, member.keyStart) + text.slice(member.valueEnd);
|
|
706
|
+
}
|