@blackwell-systems/gcf 0.6.1 → 1.0.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.
Files changed (47) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +11 -1
  3. package/dist/decode.d.ts.map +1 -1
  4. package/dist/decode.js +15 -6
  5. package/dist/decode.js.map +1 -1
  6. package/dist/decode_generic.d.ts +1 -5
  7. package/dist/decode_generic.d.ts.map +1 -1
  8. package/dist/decode_generic.js +392 -142
  9. package/dist/decode_generic.js.map +1 -1
  10. package/dist/delta.js +1 -1
  11. package/dist/delta.js.map +1 -1
  12. package/dist/encode.d.ts.map +1 -1
  13. package/dist/encode.js +22 -7
  14. package/dist/encode.js.map +1 -1
  15. package/dist/generic.d.ts +0 -6
  16. package/dist/generic.d.ts.map +1 -1
  17. package/dist/generic.js +146 -114
  18. package/dist/generic.js.map +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/scalar.d.ts +27 -0
  24. package/dist/scalar.d.ts.map +1 -0
  25. package/dist/scalar.js +315 -0
  26. package/dist/scalar.js.map +1 -0
  27. package/dist/session.d.ts.map +1 -1
  28. package/dist/session.js +9 -1
  29. package/dist/session.js.map +1 -1
  30. package/dist/stream.d.ts +1 -1
  31. package/dist/stream.js +7 -7
  32. package/dist/stream.js.map +1 -1
  33. package/dist/stream_generic.d.ts +1 -1
  34. package/dist/stream_generic.d.ts.map +1 -1
  35. package/dist/stream_generic.js +5 -27
  36. package/dist/stream_generic.js.map +1 -1
  37. package/package.json +2 -2
  38. package/src/decode.ts +18 -6
  39. package/src/decode_generic.ts +352 -137
  40. package/src/delta.ts +1 -1
  41. package/src/encode.ts +19 -7
  42. package/src/generic.ts +127 -128
  43. package/src/index.ts +1 -0
  44. package/src/scalar.ts +249 -0
  45. package/src/session.ts +6 -1
  46. package/src/stream.ts +7 -7
  47. package/src/stream_generic.ts +5 -27
@@ -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).
package/src/encode.ts CHANGED
@@ -9,10 +9,16 @@ interface DistanceGroup {
9
9
  function groupByDistance(symbols: Symbol[]): DistanceGroup[] {
10
10
  if (symbols.length === 0) return [];
11
11
 
12
+ // Sort by distance ascending, then score descending.
13
+ const sorted = [...symbols].sort((a, b) => {
14
+ if (a.distance !== b.distance) return a.distance - b.distance;
15
+ return b.score - a.score;
16
+ });
17
+
12
18
  const groups: DistanceGroup[] = [];
13
19
  let current: DistanceGroup | null = null;
14
20
 
15
- for (const s of symbols) {
21
+ for (const s of sorted) {
16
22
  if (current === null || current.distance !== s.distance) {
17
23
  current = { distance: s.distance, symbols: [] };
18
24
  groups.push(current);
@@ -29,10 +35,14 @@ function groupByDistance(symbols: Symbol[]): DistanceGroup[] {
29
35
  export function encode(p: Payload): string {
30
36
  const lines: string[] = [];
31
37
 
32
- // Build symbol index for edge references.
38
+ // Group and sort first, then build index in output order.
39
+ const groups = groupByDistance(p.symbols);
33
40
  const symIndex = new Map<string, number>();
34
- for (let i = 0; i < p.symbols.length; i++) {
35
- symIndex.set(p.symbols[i].qualifiedName, i);
41
+ let nextID = 0;
42
+ for (const g of groups) {
43
+ for (const s of g.symbols) {
44
+ symIndex.set(s.qualifiedName, nextID++);
45
+ }
36
46
  }
37
47
 
38
48
  // Count valid edges (both endpoints in symbol index).
@@ -41,14 +51,16 @@ export function encode(p: Payload): string {
41
51
  ).length;
42
52
 
43
53
  // Header line.
44
- let header = `GCF tool=${p.tool} budget=${p.tokenBudget} tokens=${p.tokensUsed} symbols=${p.symbols.length} edges=${validEdges}`;
54
+ let header = `GCF profile=graph tool=${p.tool}`;
55
+ if (p.tokenBudget) header += ` budget=${p.tokenBudget}`;
56
+ if (p.tokensUsed) header += ` tokens=${p.tokensUsed}`;
57
+ header += ` symbols=${p.symbols.length}`;
58
+ if (validEdges > 0) header += ` edges=${validEdges}`;
45
59
  if (p.packRoot) {
46
60
  header += ` pack_root=${p.packRoot}`;
47
61
  }
48
62
  lines.push(header);
49
63
 
50
- // Group symbols by distance.
51
- const groups = groupByDistance(p.symbols);
52
64
  const groupNames = ['targets', 'related', 'extended'];
53
65
 
54
66
  for (const g of groups) {