@blackwell-systems/gcf 0.6.1 → 1.0.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 (50) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +13 -3
  3. package/dist/cli.js +16 -4
  4. package/dist/cli.js.map +1 -1
  5. package/dist/decode.d.ts.map +1 -1
  6. package/dist/decode.js +15 -6
  7. package/dist/decode.js.map +1 -1
  8. package/dist/decode_generic.d.ts +1 -5
  9. package/dist/decode_generic.d.ts.map +1 -1
  10. package/dist/decode_generic.js +392 -142
  11. package/dist/decode_generic.js.map +1 -1
  12. package/dist/delta.js +1 -1
  13. package/dist/delta.js.map +1 -1
  14. package/dist/encode.d.ts.map +1 -1
  15. package/dist/encode.js +22 -7
  16. package/dist/encode.js.map +1 -1
  17. package/dist/generic.d.ts +0 -6
  18. package/dist/generic.d.ts.map +1 -1
  19. package/dist/generic.js +146 -114
  20. package/dist/generic.js.map +1 -1
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/scalar.d.ts +27 -0
  26. package/dist/scalar.d.ts.map +1 -0
  27. package/dist/scalar.js +315 -0
  28. package/dist/scalar.js.map +1 -0
  29. package/dist/session.d.ts.map +1 -1
  30. package/dist/session.js +9 -1
  31. package/dist/session.js.map +1 -1
  32. package/dist/stream.d.ts +1 -1
  33. package/dist/stream.js +7 -7
  34. package/dist/stream.js.map +1 -1
  35. package/dist/stream_generic.d.ts +1 -1
  36. package/dist/stream_generic.d.ts.map +1 -1
  37. package/dist/stream_generic.js +5 -27
  38. package/dist/stream_generic.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/cli.ts +16 -4
  41. package/src/decode.ts +18 -6
  42. package/src/decode_generic.ts +352 -137
  43. package/src/delta.ts +1 -1
  44. package/src/encode.ts +19 -7
  45. package/src/generic.ts +127 -128
  46. package/src/index.ts +1 -0
  47. package/src/scalar.ts +249 -0
  48. package/src/session.ts +6 -1
  49. package/src/stream.ts +7 -7
  50. package/src/stream_generic.ts +5 -27
package/src/decode.ts CHANGED
@@ -27,9 +27,13 @@ export function decode(input: string): Payload {
27
27
  parseHeader(header.slice(4), p);
28
28
 
29
29
  if (!p.tool) {
30
- throw new Error("gcf: header missing required 'tool' field");
30
+ throw new Error("missing_tool: header missing required 'tool' field");
31
31
  }
32
32
 
33
+ // Detect delta mode.
34
+ const isDelta = header.includes(' delta=true');
35
+ const validDeltaSections = new Set(['removed', 'added', 'edges_removed', 'edges_added']);
36
+
33
37
  // Parse body: symbols and edges.
34
38
  const symbols: Symbol[] = [];
35
39
  const symByID = new Map<number, Symbol>();
@@ -40,6 +44,9 @@ export function decode(input: string): Payload {
40
44
  let line = lines[i].replace(/\r$/, '');
41
45
  if (line === '') continue;
42
46
 
47
+ // Skip ##! summary trailer.
48
+ if (line.startsWith('##! ')) continue;
49
+
43
50
  // Group header.
44
51
  if (line.startsWith('## ')) {
45
52
  let group = line.slice(3);
@@ -48,6 +55,11 @@ export function decode(input: string): Payload {
48
55
  if (bracketIdx >= 0) {
49
56
  group = group.slice(0, bracketIdx);
50
57
  }
58
+
59
+ if (isDelta && !validDeltaSections.has(group)) {
60
+ throw new Error(`malformed_delta: invalid delta section "${group}"`);
61
+ }
62
+
51
63
  inEdges = group === 'edges';
52
64
  if (!inEdges) {
53
65
  switch (group) {
@@ -137,14 +149,14 @@ function parseSymbolLine(
137
149
  const parts = line.split(/\s+/);
138
150
  if (parts.length < 5) {
139
151
  throw new Error(
140
- `gcf: symbol line needs at least 5 fields, got ${parts.length} in "${line}"`
152
+ `invalid_node_line: symbol line needs at least 5 fields, got ${parts.length} in "${line}"`
141
153
  );
142
154
  }
143
155
 
144
156
  const idStr = parts[0].slice(1); // strip @
145
157
  const id = parseInt(idStr, 10);
146
158
  if (isNaN(id)) {
147
- throw new Error(`gcf: invalid symbol id "${idStr}"`);
159
+ throw new Error(`invalid_symbol_id: invalid symbol id "${idStr}"`);
148
160
  }
149
161
 
150
162
  let kind = parts[1];
@@ -156,7 +168,7 @@ function parseSymbolLine(
156
168
 
157
169
  const score = parseFloat(parts[3]);
158
170
  if (isNaN(score)) {
159
- throw new Error(`gcf: invalid score "${parts[3]}"`);
171
+ throw new Error(`invalid_score: invalid score "${parts[3]}"`);
160
172
  }
161
173
 
162
174
  const provenance = parts[4];
@@ -182,7 +194,7 @@ function parseEdgeLine(line: string, symByID: Map<number, Symbol>): Edge {
182
194
  const ref = parts[0];
183
195
  const ltIdx = ref.indexOf('<');
184
196
  if (ltIdx < 0) {
185
- throw new Error(`gcf: edge line missing '<' separator in "${ref}"`);
197
+ throw new Error(`invalid_edge_syntax: edge line missing '<' separator in "${ref}"`);
186
198
  }
187
199
 
188
200
  const targetIDStr = ref.slice(1, ltIdx); // strip leading @
@@ -201,7 +213,7 @@ function parseEdgeLine(line: string, symByID: Map<number, Symbol>): Edge {
201
213
  const sourceSym = symByID.get(sourceID);
202
214
  if (!targetSym || !sourceSym) {
203
215
  throw new Error(
204
- `gcf: edge references unknown symbol id(s): target=${targetID} source=${sourceID}`
216
+ `unknown_edge_reference: edge references unknown symbol id(s): target=${targetID} source=${sourceID}`
205
217
  );
206
218
  }
207
219
 
@@ -1,20 +1,23 @@
1
1
  import { decode } from './decode.js';
2
+ import {
3
+ parseScalar, parseQuotedString, splitRespectingQuotes, splitFieldDecl,
4
+ isBareKey, MISSING, ATTACHMENT,
5
+ } from './scalar.js';
2
6
 
3
7
  /**
4
- * Decode any GCF text (tabular or graph profile) back into a JS value.
5
- * Returns objects, arrays, and primitives matching the original structure.
6
- *
7
- * If the input starts with "GCF " (graph profile), falls back to decode()
8
- * and returns the Payload as a plain object.
8
+ * Decode GCF v2.0 generic or graph profile text into a JS value.
9
9
  */
10
10
  export function decodeGeneric(input: string): any {
11
11
  input = input.trimEnd();
12
- if (!input) return null;
12
+ if (!input) throw new Error('missing_header: empty input');
13
13
 
14
14
  const lines = input.split('\n');
15
+ const header = lines[0].replace(/\r$/, '');
16
+ if (!header.startsWith('GCF ')) throw new Error('missing_header: first line does not begin with GCF');
15
17
 
16
- // Graph profile fallback.
17
- if (lines[0].startsWith('GCF ')) {
18
+ const profile = parseHeaderProfile(header);
19
+
20
+ if (profile === 'graph') {
18
21
  const p = decode(input);
19
22
  return {
20
23
  tool: p.tool,
@@ -32,206 +35,418 @@ export function decodeGeneric(input: string): any {
32
35
  source: e.source,
33
36
  target: e.target,
34
37
  edgeType: e.edgeType,
35
- ...(e.status ? { status: e.status } : {}),
38
+ status: e.status ?? '',
36
39
  })),
37
40
  };
38
41
  }
39
42
 
43
+ if (profile !== 'generic') throw new Error(`unknown_profile: ${profile}`);
44
+
45
+ // Filter body.
46
+ const contentLines: string[] = [];
47
+ let summaryLine = '';
48
+ let deferredSectionCount = 0;
49
+ for (let i = 1; i < lines.length; i++) {
50
+ const l = lines[i].replace(/\r$/, '');
51
+ if (l === '') continue;
52
+ // Tab check.
53
+ for (let j = 0; j < l.length; j++) {
54
+ if (l[j] === '\t') throw new Error('tab_indentation: tabs in leading whitespace');
55
+ if (l[j] !== ' ') break;
56
+ }
57
+ const trimmed = l.trimStart();
58
+ if (trimmed.startsWith('# ')) continue;
59
+ if (trimmed.startsWith('##! ')) { summaryLine = trimmed; continue; }
60
+ if (trimmed.startsWith('## ') && trimmed.includes('[?]')) deferredSectionCount++;
61
+ contentLines.push(l);
62
+ }
63
+
64
+ // Validate ##! summary counts.
65
+ if (summaryLine && deferredSectionCount > 0) {
66
+ validateSummaryCounts(summaryLine, deferredSectionCount, contentLines);
67
+ }
68
+
69
+ if (contentLines.length === 0) return {};
70
+
71
+ const first = contentLines[0].trimStart();
72
+
73
+ // Root scalar.
74
+ if (first.startsWith('=')) {
75
+ if (contentLines.length > 1) throw new Error('trailing_characters: extra lines after root scalar');
76
+ return parseScalar(first.slice(1), false);
77
+ }
78
+
79
+ // Root array.
80
+ if (first.startsWith('## [')) {
81
+ const [arr] = parseArrayFromHeader(contentLines, 0, 0, first.slice(3));
82
+ return arr;
83
+ }
84
+
85
+ // Root object.
40
86
  const result: Record<string, any> = {};
41
- parseObject(lines, 0, 0, result);
87
+ parseObjectBody(contentLines, 0, 0, result);
42
88
  return result;
43
89
  }
44
90
 
45
- function parseObject(lines: string[], start: number, depth: number, out: Record<string, any>): number {
46
- const indent = ' '.repeat(depth);
91
+ function parseHeaderProfile(header: string): string {
92
+ const parts = header.split(/\s+/);
93
+ if (parts.length < 2) throw new Error('missing_profile');
94
+ const seen = new Set<string>();
95
+ let profile = '';
96
+ for (let i = 1; i < parts.length; i++) {
97
+ const eq = parts[i].indexOf('=');
98
+ if (eq < 0) throw new Error(`malformed_header_field: ${parts[i]}`);
99
+ const key = parts[i].slice(0, eq);
100
+ if (seen.has(key)) throw new Error(`duplicate_header_field: ${key}`);
101
+ seen.add(key);
102
+ if (key === 'profile') profile = parts[i].slice(eq + 1);
103
+ }
104
+ if (!profile) throw new Error('missing_profile');
105
+ return profile;
106
+ }
107
+
108
+ function parseObjectBody(lines: string[], start: number, depth: number, out: Record<string, any>): number {
109
+ const ind = ' '.repeat(depth);
47
110
  let i = start;
48
111
 
49
112
  while (i < lines.length) {
50
- const raw = lines[i].replace(/\r$/, '');
51
- if (raw === '' || raw.startsWith('# ')) { i++; continue; }
52
-
53
- if (depth > 0 && !raw.startsWith(indent)) break;
54
-
55
- const content = depth > 0 ? raw.slice(indent.length) : raw;
56
-
57
- // Skip _summary.
58
- if (content.startsWith('## _summary')) { i++; continue; }
113
+ const line = lines[i];
114
+ if (depth > 0 && !line.startsWith(ind)) break;
115
+ const content = depth > 0 ? line.slice(ind.length) : line;
116
+ if (content.length > 0 && content[0] === ' ') {
117
+ throw new Error('invalid_indent: indentation increases by more than one level');
118
+ }
59
119
 
60
- // Tabular array or section header.
120
+ // Array section.
61
121
  if (content.startsWith('## ')) {
62
- const header = content.slice(3);
63
- const bracketIdx = header.indexOf(' [');
64
-
65
- if (bracketIdx >= 0) {
66
- const name = header.slice(0, bracketIdx);
67
- const rest = header.slice(bracketIdx + 2);
68
- const closeBracket = rest.indexOf(']');
69
-
70
- if (closeBracket >= 0) {
71
- const afterBracket = rest.slice(closeBracket + 1);
72
-
73
- if (afterBracket.startsWith('{')) {
74
- // Tabular with fields.
75
- const fieldEnd = afterBracket.indexOf('}');
76
- if (fieldEnd >= 0) {
77
- const fields = afterBracket.slice(1, fieldEnd).split(',');
78
- i++;
79
- const [rows, consumed] = parseTabularRows(lines, i, depth, fields);
80
- out[name] = rows;
81
- i += consumed;
82
- continue;
83
- }
84
- } else {
85
- const countStr = rest.slice(0, closeBracket);
86
- if (countStr === '0') {
87
- out[name] = [];
88
- i++;
89
- continue;
90
- }
91
- // Non-uniform array.
92
- i++;
93
- const [items, consumed] = parseNonUniformArray(lines, i, depth);
94
- out[name] = items;
95
- i += consumed;
96
- continue;
97
- }
98
- }
122
+ const hdr = content.slice(3);
123
+ const bi = hdr.indexOf(' [');
124
+ if (bi >= 0) {
125
+ const name = parseKeyFromHeader(hdr.slice(0, bi));
126
+ checkDup(out, name);
127
+ const [arr, consumed] = parseArrayFromHeader(lines, i, depth, hdr.slice(bi));
128
+ out[name] = arr;
129
+ i += consumed;
130
+ continue;
99
131
  }
100
-
101
- // Plain section header.
102
- let name = header;
103
- const bi = name.indexOf(' [');
104
- if (bi >= 0) name = name.slice(0, bi);
132
+ const name = parseKeyFromHeader(hdr);
133
+ checkDup(out, name);
105
134
  i++;
106
135
  const nested: Record<string, any> = {};
107
- const consumed = parseObject(lines, i, depth + 1, nested);
136
+ const consumed = parseObjectBody(lines, i, depth + 1, nested);
108
137
  out[name] = nested;
109
138
  i += consumed;
110
139
  continue;
111
140
  }
112
141
 
113
- // Inline primitive array: name[N]: val1,val2,...
114
- const bracketIdx = content.indexOf('[');
115
- if (bracketIdx > 0) {
116
- const colonIdx = content.indexOf(']: ');
117
- if (colonIdx > bracketIdx) {
118
- const name = content.slice(0, bracketIdx);
119
- const valsStr = content.slice(colonIdx + 3);
120
- out[name] = valsStr.split(',').map(v => parseValue(v.trim()));
121
- i++;
122
- continue;
142
+ // Inline array.
143
+ if (!content.startsWith('@') && !content.startsWith('##')) {
144
+ const bracketIdx = content.indexOf('[');
145
+ if (bracketIdx > 0) {
146
+ const rest = content.slice(bracketIdx);
147
+ const closeIdx = rest.indexOf(']');
148
+ if (closeIdx >= 0) {
149
+ const after = rest.slice(closeIdx + 1);
150
+ if (after.startsWith(': ') || after === ':') {
151
+ const name = parseKeyFromHeader(content.slice(0, bracketIdx));
152
+ checkDup(out, name);
153
+ const [arr] = parseArrayFromHeader(lines, i, depth, rest);
154
+ out[name] = arr;
155
+ i++;
156
+ continue;
157
+ }
158
+ }
123
159
  }
124
160
  }
125
161
 
126
162
  // Key=value.
127
- const eqIdx = content.indexOf('=');
163
+ const eqIdx = findKeyValueSplit(content);
128
164
  if (eqIdx > 0) {
129
- const key = content.slice(0, eqIdx);
130
- const val = content.slice(eqIdx + 1);
131
- out[key] = parseValue(val);
165
+ const name = parseKeyFromHeader(content.slice(0, eqIdx));
166
+ checkDup(out, name);
167
+ out[name] = parseScalar(content.slice(eqIdx + 1), false);
132
168
  i++;
133
169
  continue;
134
170
  }
135
171
 
136
172
  i++;
137
173
  }
138
-
139
174
  return i - start;
140
175
  }
141
176
 
142
- function parseTabularRows(lines: string[], start: number, depth: number, fields: string[]): [any[], number] {
143
- const indent = ' '.repeat(depth);
177
+ function findKeyValueSplit(s: string): number {
178
+ if (!s.length) return -1;
179
+ if (s[0] === '"') {
180
+ for (let i = 1; i < s.length; i++) {
181
+ if (s[i] === '\\') { i++; continue; }
182
+ if (s[i] === '"') return (i + 1 < s.length && s[i + 1] === '=') ? i + 1 : -1;
183
+ }
184
+ return -1;
185
+ }
186
+ return s.indexOf('=');
187
+ }
188
+
189
+ function parseKeyFromHeader(s: string): string {
190
+ s = s.trim();
191
+ if (s.length >= 2 && s[0] === '"') return parseQuotedString(s);
192
+ return s;
193
+ }
194
+
195
+ function checkDup(obj: Record<string, any>, key: string): void {
196
+ if (key in obj) throw new Error(`duplicate_key: ${key}`);
197
+ }
198
+
199
+ function parseArrayFromHeader(lines: string[], headerLine: number, depth: number, bracketPart: string): [any, number] {
200
+ const bp = bracketPart.trimStart();
201
+ if (!bp.startsWith('[')) throw new Error('invalid_count');
202
+ const closeIdx = bp.indexOf(']');
203
+ if (closeIdx < 0) throw new Error('invalid_count');
204
+
205
+ const countStr = bp.slice(1, closeIdx);
206
+ const afterBracket = bp.slice(closeIdx + 1);
207
+ let count = -1;
208
+ if (countStr !== '?') count = parseCount(countStr);
209
+
210
+ if (count === 0 && !afterBracket.startsWith('{') && !afterBracket.startsWith(':')) {
211
+ return [[], 1];
212
+ }
213
+
214
+ // Inline.
215
+ if (afterBracket.startsWith(': ') || afterBracket === ':') {
216
+ const valsStr = afterBracket.startsWith(': ') ? afterBracket.slice(2) : '';
217
+ if (!valsStr) {
218
+ if (count >= 0 && count !== 0) throw new Error(`count_mismatch: declared ${count}, got 0`);
219
+ return [[], 1];
220
+ }
221
+ const vals = splitRespectingQuotes(valsStr, ',');
222
+ if (count >= 0 && vals.length !== count) throw new Error(`count_mismatch: declared ${count}, got ${vals.length}`);
223
+ return [vals.map(v => parseScalar(v.trim(), false)), 1];
224
+ }
225
+
226
+ // Tabular.
227
+ if (afterBracket.startsWith('{')) {
228
+ const braceEnd = findClosingBrace(afterBracket);
229
+ if (braceEnd < 0) throw new Error('invalid field declaration');
230
+ const fields = splitFieldDecl(afterBracket.slice(0, braceEnd + 1));
231
+ const [rows, consumed] = parseTabularBody(lines, headerLine + 1, depth, fields, count);
232
+ if (count >= 0 && rows.length !== count) throw new Error(`count_mismatch: declared ${count}, got ${rows.length}`);
233
+ return [rows, consumed + 1];
234
+ }
235
+
236
+ // Expanded.
237
+ const [items, consumed] = parseExpandedBody(lines, headerLine + 1, depth);
238
+ if (count >= 0 && items.length !== count) throw new Error(`count_mismatch: declared ${count}, got ${items.length}`);
239
+ return [items, consumed + 1];
240
+ }
241
+
242
+ function findClosingBrace(s: string): number {
243
+ let inQuote = false, escaped = false;
244
+ for (let i = 0; i < s.length; i++) {
245
+ if (escaped) { escaped = false; continue; }
246
+ if (s[i] === '\\' && inQuote) { escaped = true; continue; }
247
+ if (s[i] === '"') { inQuote = !inQuote; continue; }
248
+ if (s[i] === '}' && !inQuote) return i;
249
+ }
250
+ return -1;
251
+ }
252
+
253
+ function parseTabularBody(lines: string[], start: number, depth: number, fields: string[], expectedCount: number): [any[], number] {
254
+ const ind = ' '.repeat(depth);
144
255
  const rows: any[] = [];
145
256
  let i = start;
146
257
 
147
258
  while (i < lines.length) {
148
- const raw = lines[i].replace(/\r$/, '');
149
- if (raw === '') { i++; continue; }
150
-
151
- const content = depth > 0 ? (raw.startsWith(indent) ? raw.slice(indent.length) : null) : raw;
259
+ const line = lines[i];
260
+ const content = depth > 0 ? (line.startsWith(ind) ? line.slice(ind.length) : null) : line;
152
261
  if (content === null) break;
153
- if (content.startsWith('## ')) break;
154
- if (content.startsWith('# ')) { i++; continue; }
262
+ if (content.startsWith('## ') || content.startsWith('##!')) break;
263
+
264
+ if (content.length > 0 && content[0] === ' ') {
265
+ const trimmed = content.trimStart();
266
+ if (trimmed.startsWith('.')) throw new Error(`orphan_attachment: ${trimmed}`);
267
+ break;
268
+ }
155
269
 
156
270
  let rowData = content;
157
- let hasNested = false;
271
+ let rowHasID = false;
158
272
  if (rowData.startsWith('@')) {
159
273
  const sp = rowData.indexOf(' ');
160
- if (sp > 0) {
161
- rowData = rowData.slice(sp + 1);
162
- hasNested = true;
163
- }
274
+ if (sp > 0) { rowData = rowData.slice(sp + 1); rowHasID = true; }
164
275
  }
165
276
 
166
- const vals = rowData.split('|');
277
+ const vals = splitRespectingQuotes(rowData, '|');
278
+ if (vals.length !== fields.length) throw new Error(`row_width_mismatch: expected ${fields.length}, got ${vals.length}`);
279
+
167
280
  const row: Record<string, any> = {};
281
+ const attachmentFields: string[] = [];
168
282
  for (let j = 0; j < fields.length; j++) {
169
- row[fields[j]] = j < vals.length ? parseValue(vals[j]) : null;
283
+ const parsed = parseScalar(vals[j], true);
284
+ if (parsed === MISSING) continue;
285
+ if (parsed === ATTACHMENT) { attachmentFields.push(fields[j]); continue; }
286
+ row[fields[j]] = parsed;
170
287
  }
171
-
172
288
  i++;
173
289
 
174
- if (hasNested) {
175
- const nestedIndent = indent + ' ';
290
+ if (rowHasID && attachmentFields.length > 0) {
291
+ const attIndent = ind + ' ';
292
+ const resolved = new Set<string>();
176
293
  while (i < lines.length) {
177
- const nl = lines[i].replace(/\r$/, '');
178
- if (!nl.startsWith(nestedIndent)) break;
179
- const nc = nl.slice(nestedIndent.length);
180
-
181
- if (nc.startsWith('.')) {
182
- const fieldName = nc.slice(1);
183
- i++;
184
- const nested: Record<string, any> = {};
185
- const consumed = parseObject(lines, i, depth + 2, nested);
186
- row[fieldName] = nested;
187
- i += consumed;
188
- } else {
189
- break;
190
- }
294
+ const al = lines[i];
295
+ if (!al.startsWith(attIndent)) break;
296
+ const ac = al.slice(attIndent.length);
297
+ if (!ac.startsWith('.')) break;
298
+ const [name, val, consumed] = parseAttachment(lines, i, ac.slice(1), depth + 2);
299
+ if (resolved.has(name)) throw new Error(`duplicate_attachment: ${name}`);
300
+ resolved.add(name);
301
+ row[name] = val;
302
+ i += consumed;
303
+ }
304
+ for (const f of attachmentFields) {
305
+ if (!resolved.has(f)) throw new Error(`missing_attachment: ${f}`);
306
+ }
307
+ }
308
+
309
+ if (!rowHasID || attachmentFields.length === 0) {
310
+ const attIndent = ind + ' ';
311
+ if (i < lines.length && lines[i].startsWith(attIndent)) {
312
+ const peek = lines[i].slice(attIndent.length);
313
+ if (peek.startsWith('.')) throw new Error(`orphan_attachment: ${peek}`);
191
314
  }
192
315
  }
193
316
 
194
317
  rows.push(row);
318
+ if (expectedCount >= 0 && rows.length >= expectedCount) break;
195
319
  }
196
-
197
320
  return [rows, i - start];
198
321
  }
199
322
 
200
- function parseNonUniformArray(lines: string[], start: number, depth: number): [any[], number] {
201
- const indent = ' '.repeat(depth);
323
+ function parseAttachment(lines: string[], lineIdx: number, rest: string, depth: number): [string, any, number] {
324
+ let name: string;
325
+ let afterName: string;
326
+ if (rest[0] === '"') {
327
+ let closeIdx = -1;
328
+ for (let j = 1; j < rest.length; j++) {
329
+ if (rest[j] === '\\') { j++; continue; }
330
+ if (rest[j] === '"') { closeIdx = j; break; }
331
+ }
332
+ if (closeIdx < 0) throw new Error('unterminated_quote');
333
+ name = parseQuotedString(rest.slice(0, closeIdx + 1));
334
+ afterName = rest.slice(closeIdx + 1).trimStart();
335
+ } else {
336
+ const sp = rest.indexOf(' ');
337
+ if (sp < 0) throw new Error(`invalid attachment: ${rest}`);
338
+ name = rest.slice(0, sp);
339
+ afterName = rest.slice(sp).trimStart();
340
+ }
341
+
342
+ if (afterName.startsWith('{}')) {
343
+ const nested: Record<string, any> = {};
344
+ const consumed = parseObjectBody(lines, lineIdx + 1, depth, nested);
345
+ return [name, nested, consumed + 1];
346
+ }
347
+ if (afterName.startsWith('[')) {
348
+ const [arr, consumed] = parseArrayFromHeader(lines, lineIdx, depth, afterName);
349
+ return [name, arr, consumed];
350
+ }
351
+ throw new Error(`invalid attachment form: ${afterName}`);
352
+ }
353
+
354
+ function parseExpandedBody(lines: string[], start: number, depth: number): [any[], number] {
355
+ const ind = ' '.repeat(depth);
202
356
  const items: any[] = [];
203
357
  let i = start;
204
358
 
205
359
  while (i < lines.length) {
206
- const raw = lines[i].replace(/\r$/, '');
207
- if (raw === '') { i++; continue; }
208
- const content = depth > 0 ? (raw.startsWith(indent) ? raw.slice(indent.length) : null) : raw;
360
+ const line = lines[i];
361
+ const content = depth > 0 ? (line.startsWith(ind) ? line.slice(ind.length) : null) : line;
209
362
  if (content === null) break;
210
- if (content.startsWith('## ')) break;
363
+ if (content.startsWith('## ') || content.startsWith('##!')) break;
364
+ if (!content.startsWith('@')) break;
211
365
 
212
- if (content.startsWith('@')) {
213
- const sp = content.indexOf(' ');
214
- if (sp > 0) {
215
- items.push(parseValue(content.slice(sp + 1)));
216
- }
366
+ const sp = content.indexOf(' ');
367
+ if (sp < 0) break;
368
+
369
+ const idStr = content.slice(1, sp);
370
+ const id = parseInt(idStr, 10);
371
+ if (!isNaN(id) && id !== items.length) {
372
+ throw new Error(`invalid_item_id: expected @${items.length}, got @${idStr}`);
373
+ }
374
+
375
+ const marker = content.slice(sp + 1);
376
+
377
+ if (marker.startsWith('=')) {
378
+ items.push(parseScalar(marker.slice(1), false));
217
379
  i++;
218
- } else {
219
- break;
380
+ continue;
381
+ }
382
+ if (marker.startsWith('{}')) {
383
+ const nested: Record<string, any> = {};
384
+ i++;
385
+ const consumed = parseObjectBody(lines, i, depth + 1, nested);
386
+ items.push(nested);
387
+ i += consumed;
388
+ continue;
220
389
  }
390
+ if (marker.startsWith('[')) {
391
+ const [arr, consumed] = parseArrayFromHeader(lines, i, depth + 1, marker);
392
+ items.push(arr);
393
+ i += consumed;
394
+ continue;
395
+ }
396
+ break;
221
397
  }
222
-
223
398
  return [items, i - start];
224
399
  }
225
400
 
226
- function parseValue(s: string): any {
227
- if (s === '-') return null;
228
- if (s === 'true') return true;
229
- if (s === 'false') return false;
230
- if (s === '""') return '';
231
- if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') {
232
- return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
401
+ function parseCount(s: string): number {
402
+ if (s === '0') return 0;
403
+ if (!s.length || s[0] === '0') throw new Error(`invalid_count: ${s}`);
404
+ const n = parseInt(s, 10);
405
+ if (isNaN(n) || String(n) !== s) throw new Error(`invalid_count: ${s}`);
406
+ return n;
407
+ }
408
+
409
+ function validateSummaryCounts(summaryLine: string, deferredCount: number, contentLines: string[]): void {
410
+ // Parse counts from "##! summary counts=N,M,..."
411
+ const parts = summaryLine.split(/\s+/);
412
+ let countsStr = '';
413
+ for (const p of parts) {
414
+ if (p.startsWith('counts=')) { countsStr = p.slice(7); break; }
415
+ }
416
+ if (!countsStr) return;
417
+
418
+ const countVals = countsStr.split(',');
419
+ if (countVals.length !== deferredCount) {
420
+ throw new Error(`count_mismatch: summary has ${countVals.length} count entries but ${deferredCount} deferred sections`);
421
+ }
422
+
423
+ // Count actual items per deferred section.
424
+ const actualCounts: number[] = [];
425
+ let inDeferred = false;
426
+ let currentCount = 0;
427
+ for (const l of contentLines) {
428
+ const trimmed = l.trimStart();
429
+ if (trimmed.startsWith('## ') && trimmed.includes('[?]')) {
430
+ if (inDeferred) actualCounts.push(currentCount);
431
+ inDeferred = true;
432
+ currentCount = 0;
433
+ continue;
434
+ }
435
+ if (trimmed.startsWith('## ')) {
436
+ if (inDeferred) { actualCounts.push(currentCount); inDeferred = false; }
437
+ continue;
438
+ }
439
+ if (inDeferred && !trimmed.startsWith(' ') && !trimmed.startsWith('.')) {
440
+ currentCount++;
441
+ }
442
+ }
443
+ if (inDeferred) actualCounts.push(currentCount);
444
+
445
+ for (let i = 0; i < countVals.length; i++) {
446
+ const declared = parseInt(countVals[i], 10);
447
+ if (isNaN(declared)) throw new Error(`count_mismatch: invalid count value "${countVals[i]}"`);
448
+ if (i < actualCounts.length && declared !== actualCounts[i]) {
449
+ throw new Error(`count_mismatch: section ${i} declared ${declared} in summary, actual ${actualCounts[i]}`);
450
+ }
233
451
  }
234
- const n = Number(s);
235
- if (!isNaN(n) && s !== '') return n;
236
- return s;
237
452
  }
package/src/delta.ts CHANGED
@@ -13,7 +13,7 @@ export function encodeDelta(d: DeltaPayload): string {
13
13
  savings = Math.round(100 * (1 - d.deltaTokens / d.fullTokens));
14
14
  }
15
15
  lines.push(
16
- `GCF tool=${d.tool} delta=true base_root=${d.baseRoot} new_root=${d.newRoot} tokens=${d.deltaTokens} savings=${savings}%`
16
+ `GCF profile=graph tool=${d.tool} delta=true base_root=${d.baseRoot} new_root=${d.newRoot} tokens=${d.deltaTokens} savings=${savings}%`
17
17
  );
18
18
 
19
19
  // Removed symbols: short references (consumer already has the full declaration).