@blackwell-systems/gcf 1.0.1 → 2.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.
@@ -5,7 +5,7 @@ import {
5
5
  } from './scalar.js';
6
6
 
7
7
  /**
8
- * Decode GCF v2.0 generic or graph profile text into a JS value.
8
+ * Decode GCF generic or graph profile text into a JS value.
9
9
  */
10
10
  export function decodeGeneric(input: string): any {
11
11
  input = input.trimEnd();
@@ -255,6 +255,10 @@ function parseTabularBody(lines: string[], start: number, depth: number, fields:
255
255
  const rows: any[] = [];
256
256
  let i = start;
257
257
 
258
+ // Track inline schemas and shared array schemas.
259
+ const inlineSchemas = new Map<string, string[]>();
260
+ const sharedArraySchemas = new Map<string, string[]>();
261
+
258
262
  while (i < lines.length) {
259
263
  const line = lines[i];
260
264
  const content = depth > 0 ? (line.startsWith(ind) ? line.slice(ind.length) : null) : line;
@@ -263,50 +267,161 @@ function parseTabularBody(lines: string[], start: number, depth: number, fields:
263
267
 
264
268
  if (content.length > 0 && content[0] === ' ') {
265
269
  const trimmed = content.trimStart();
266
- if (trimmed.startsWith('.')) throw new Error(`orphan_attachment: ${trimmed}`);
270
+ if (trimmed.startsWith('.')) break; // attachment lines handled below
267
271
  break;
268
272
  }
269
273
 
274
+ // Strip @N prefix (must be @digits).
270
275
  let rowData = content;
271
276
  let rowHasID = false;
272
277
  if (rowData.startsWith('@')) {
273
278
  const sp = rowData.indexOf(' ');
274
- if (sp > 0) { rowData = rowData.slice(sp + 1); rowHasID = true; }
279
+ if (sp > 0) {
280
+ const idStr = rowData.slice(1, sp);
281
+ if (/^\d+$/.test(idStr)) {
282
+ rowData = rowData.slice(sp + 1);
283
+ rowHasID = true;
284
+ }
285
+ }
275
286
  }
276
287
 
277
288
  const vals = splitRespectingQuotes(rowData, '|');
278
289
  if (vals.length !== fields.length) throw new Error(`row_width_mismatch: expected ${fields.length}, got ${vals.length}`);
279
290
 
280
- const row: Record<string, any> = {};
281
- const attachmentFields: string[] = [];
291
+ // Parse cells: scalars, traditional attachments, and inline schema attachments.
292
+ const cellValues = new Map<string, any>();
293
+ const traditionalAttFields: string[] = [];
294
+ const inlineAttFields: string[] = [];
295
+ const inlineAttOrder: string[] = [];
296
+ const missingFields = new Set<string>();
297
+
282
298
  for (let j = 0; j < fields.length; j++) {
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;
299
+ const cellVal = vals[j];
300
+
301
+ // Check for ^{fields} inline schema declaration.
302
+ if (cellVal.startsWith('^{') && cellVal.endsWith('}')) {
303
+ const schemaStr = cellVal.slice(1);
304
+ const ifs = splitFieldDecl(schemaStr);
305
+ inlineSchemas.set(fields[j], ifs);
306
+ inlineAttFields.push(fields[j]);
307
+ inlineAttOrder.push(fields[j]);
308
+ continue;
309
+ }
310
+
311
+ const parsed = parseScalar(cellVal, true);
312
+ if (parsed === MISSING) { missingFields.add(fields[j]); continue; }
313
+ if (parsed === ATTACHMENT) {
314
+ // Check if this field has a stored inline schema.
315
+ if (inlineSchemas.has(fields[j])) {
316
+ inlineAttFields.push(fields[j]);
317
+ inlineAttOrder.push(fields[j]);
318
+ } else {
319
+ traditionalAttFields.push(fields[j]);
320
+ }
321
+ continue;
322
+ }
323
+ // Handle inline schema objects returned by parseScalar (for ^{...} that got through).
324
+ if (parsed && typeof parsed === 'object' && parsed.__inlineSchema) {
325
+ const ifs = splitFieldDecl(parsed.__inlineSchema);
326
+ inlineSchemas.set(fields[j], ifs);
327
+ inlineAttFields.push(fields[j]);
328
+ inlineAttOrder.push(fields[j]);
329
+ continue;
330
+ }
331
+ cellValues.set(fields[j], parsed);
287
332
  }
288
333
  i++;
289
334
 
290
- if (rowHasID && attachmentFields.length > 0) {
291
- const attIndent = ind + ' ';
292
- const resolved = new Set<string>();
293
- while (i < lines.length) {
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;
335
+ // Parse attachments in line order.
336
+ const allAttFields = [...traditionalAttFields, ...inlineAttFields];
337
+ const attachmentValues = new Map<string, any>();
338
+
339
+ if (rowHasID && allAttFields.length > 0) {
340
+ let inlineIdx = 0;
341
+
342
+ while (i < lines.length && attachmentValues.size < allAttFields.length) {
343
+ const aLine = lines[i];
344
+ let aContent: string | null = null;
345
+ if (depth === 0 || aLine.startsWith(ind)) {
346
+ aContent = depth > 0 ? aLine.slice(ind.length) : aLine;
347
+ } else {
348
+ break;
349
+ }
350
+ if (aContent === null) break;
351
+
352
+ // Line starts with ".": traditional or prefixed inline attachment.
353
+ if (aContent.startsWith('.')) {
354
+ const rest = aContent.slice(1);
355
+ const [attName, afterName] = parseAttachmentName(rest);
356
+
357
+ // Check if this is an inline schema field with pipe-delimited data.
358
+ const ifs = inlineSchemas.get(attName);
359
+ if (ifs && !afterName.trimStart().startsWith('{}') && !afterName.trimStart().startsWith('[')) {
360
+ const data = afterName.trimStart();
361
+ const inlineVals = splitRespectingQuotes(data, '|');
362
+ if (inlineVals.length !== ifs.length) throw new Error(`inline_width_mismatch: ${attName} expected ${ifs.length}, got ${inlineVals.length}`);
363
+ const obj: Record<string, any> = {};
364
+ for (let k = 0; k < ifs.length; k++) {
365
+ const p = parseScalar(inlineVals[k], true);
366
+ if (p !== MISSING) obj[ifs[k]] = p;
367
+ }
368
+ attachmentValues.set(attName, obj);
369
+ i++;
370
+ continue;
371
+ }
372
+
373
+ // Traditional attachment.
374
+ const [name, val, consumed, parsedFields] = parseAttachment(lines, i, rest, depth + 2, sharedArraySchemas);
375
+ // Store shared array schema from first row.
376
+ if (rows.length === 0 && parsedFields) {
377
+ sharedArraySchemas.set(name, parsedFields);
378
+ }
379
+ attachmentValues.set(name, val);
380
+ i += consumed;
381
+ continue;
382
+ }
383
+
384
+ // No-prefix line: positional inline data.
385
+ let foundInline = false;
386
+ let nextInlineField = '';
387
+ while (inlineIdx < inlineAttOrder.length) {
388
+ const candidate = inlineAttOrder[inlineIdx];
389
+ if (!attachmentValues.has(candidate)) {
390
+ nextInlineField = candidate;
391
+ foundInline = true;
392
+ break;
393
+ }
394
+ inlineIdx++;
395
+ }
396
+ if (!foundInline) break;
397
+
398
+ const ifs = inlineSchemas.get(nextInlineField)!;
399
+ const inlineVals = splitRespectingQuotes(aContent, '|');
400
+ if (inlineVals.length !== ifs.length) throw new Error(`inline_width_mismatch: ${nextInlineField} expected ${ifs.length}, got ${inlineVals.length}`);
401
+ const obj: Record<string, any> = {};
402
+ for (let k = 0; k < ifs.length; k++) {
403
+ const p = parseScalar(inlineVals[k], true);
404
+ if (p !== MISSING) obj[ifs[k]] = p;
405
+ }
406
+ attachmentValues.set(nextInlineField, obj);
407
+ inlineIdx++;
408
+ i++;
303
409
  }
304
- for (const f of attachmentFields) {
305
- if (!resolved.has(f)) throw new Error(`missing_attachment: ${f}`);
410
+
411
+ for (const f of allAttFields) {
412
+ if (!attachmentValues.has(f)) throw new Error(`missing_attachment: ${f}`);
306
413
  }
307
414
  }
308
415
 
309
- if (!rowHasID || attachmentFields.length === 0) {
416
+ // Build row in field declaration order.
417
+ const row: Record<string, any> = {};
418
+ for (const f of fields) {
419
+ if (missingFields.has(f)) continue;
420
+ if (cellValues.has(f)) { row[f] = cellValues.get(f); continue; }
421
+ if (attachmentValues.has(f)) { row[f] = attachmentValues.get(f); continue; }
422
+ }
423
+
424
+ if (!rowHasID || allAttFields.length === 0) {
310
425
  const attIndent = ind + ' ';
311
426
  if (i < lines.length && lines[i].startsWith(attIndent)) {
312
427
  const peek = lines[i].slice(attIndent.length);
@@ -320,34 +435,86 @@ function parseTabularBody(lines: string[], start: number, depth: number, fields:
320
435
  return [rows, i - start];
321
436
  }
322
437
 
323
- function parseAttachment(lines: string[], lineIdx: number, rest: string, depth: number): [string, any, number] {
324
- let name: string;
325
- let afterName: string;
438
+ function parseAttachmentName(rest: string): [string, string] {
326
439
  if (rest[0] === '"') {
327
- let closeIdx = -1;
328
440
  for (let j = 1; j < rest.length; j++) {
329
441
  if (rest[j] === '\\') { j++; continue; }
330
- if (rest[j] === '"') { closeIdx = j; break; }
442
+ if (rest[j] === '"') {
443
+ const name = parseQuotedString(rest.slice(0, j + 1));
444
+ return [name, rest.slice(j + 1)];
445
+ }
331
446
  }
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();
447
+ return ['', rest];
340
448
  }
449
+ const sp = rest.indexOf(' ');
450
+ if (sp >= 0) return [rest.slice(0, sp), rest.slice(sp)];
451
+ return [rest, ''];
452
+ }
453
+
454
+ /** Attachment parser: returns [name, value, consumed, parsedFields]. parsedFields is set for tabular arrays with explicit {fields}. */
455
+ function parseAttachment(lines: string[], lineIdx: number, rest: string, depth: number, sharedSchemas: Map<string, string[]>): [string, any, number, string[] | null] {
456
+ const [name, afterNameRaw] = parseAttachmentName(rest);
457
+ const afterName = afterNameRaw.trimStart();
341
458
 
342
459
  if (afterName.startsWith('{}')) {
343
460
  const nested: Record<string, any> = {};
344
461
  const consumed = parseObjectBody(lines, lineIdx + 1, depth, nested);
345
- return [name, nested, consumed + 1];
462
+ return [name, nested, consumed + 1, null];
346
463
  }
464
+
347
465
  if (afterName.startsWith('[')) {
466
+ const closeBracket = afterName.indexOf(']');
467
+ if (closeBracket < 0) throw new Error('invalid_count: missing ]');
468
+ const afterClose = afterName.slice(closeBracket + 1);
469
+
470
+ // [N]{fields}: has its own schema.
471
+ if (afterClose.startsWith('{')) {
472
+ const endBrace = findClosingBrace(afterClose);
473
+ let parsedFields: string[] | null = null;
474
+ if (endBrace >= 0) {
475
+ try { parsedFields = splitFieldDecl(afterClose.slice(0, endBrace + 1)); } catch {}
476
+ }
477
+ const [arr, consumed] = parseArrayFromHeader(lines, lineIdx, depth, afterName);
478
+ return [name, arr, consumed, parsedFields];
479
+ }
480
+
481
+ // [N]: values or [N] (check for inline primitive array first).
482
+ const afterCloseForInline = afterName.slice(closeBracket + 1);
483
+ if (afterCloseForInline.startsWith(': ') || afterCloseForInline === ':') {
484
+ // Inline primitive array: don't use shared schema.
485
+ const [arr, consumed] = parseArrayFromHeader(lines, lineIdx, depth, afterName);
486
+ return [name, arr, consumed, null];
487
+ }
488
+
489
+ // [N] without {fields}: check for shared schema.
490
+ if (sharedSchemas.has(name)) {
491
+ const sf = sharedSchemas.get(name)!;
492
+ const countStr = afterName.slice(1, closeBracket);
493
+ let count = -1;
494
+ if (countStr !== '?') count = parseInt(countStr, 10);
495
+ if (count === 0) return [name, [], 1, null];
496
+
497
+ // Peek: if next line starts with @, it's expanded, not tabular.
498
+ const nextIdx = lineIdx + 1;
499
+ const ind = ' '.repeat(depth);
500
+ let useShared = true;
501
+ if (nextIdx < lines.length) {
502
+ let nextContent = lines[nextIdx];
503
+ if (depth > 0 && nextContent.startsWith(ind)) nextContent = nextContent.slice(ind.length);
504
+ if (nextContent.trimStart().startsWith('@')) useShared = false;
505
+ }
506
+ if (useShared) {
507
+ const [rows, consumed] = parseTabularBody(lines, lineIdx + 1, depth, sf, count);
508
+ if (count >= 0 && rows.length !== count) throw new Error(`count_mismatch: declared ${count}, got ${rows.length}`);
509
+ return [name, rows, consumed + 1, null];
510
+ }
511
+ }
512
+
513
+ // No shared schema: standard parsing.
348
514
  const [arr, consumed] = parseArrayFromHeader(lines, lineIdx, depth, afterName);
349
- return [name, arr, consumed];
515
+ return [name, arr, consumed, null];
350
516
  }
517
+
351
518
  throw new Error(`invalid attachment form: ${afterName}`);
352
519
  }
353
520
 
package/src/delta.ts CHANGED
@@ -1,5 +1,6 @@
1
- import type { DeltaPayload } from './types.js';
1
+ import type { DeltaPayload, Symbol, Edge } from './types.js';
2
2
  import { KIND_ABBREV } from './constants.js';
3
+ import { packRoot } from './packroot.js';
3
4
 
4
5
  /**
5
6
  * EncodeDelta serializes a DeltaPayload into GCF delta format.
@@ -53,3 +54,41 @@ export function encodeDelta(d: DeltaPayload): string {
53
54
 
54
55
  return lines.join('\n') + '\n';
55
56
  }
57
+
58
+ /**
59
+ * Apply a delta to a base set of symbols/edges and verify the resulting pack root.
60
+ *
61
+ * Returns the new symbol and edge sets if the computed root matches expectedNewRoot.
62
+ * Throws if the root does not match.
63
+ */
64
+ export function verifyDelta(
65
+ baseSymbols: Symbol[],
66
+ baseEdges: Edge[],
67
+ removedSymbols: Symbol[],
68
+ addedSymbols: Symbol[],
69
+ removedEdges: Edge[],
70
+ addedEdges: Edge[],
71
+ expectedNewRoot: string,
72
+ ): { symbols: Symbol[]; edges: Edge[] } {
73
+ // Remove symbols by qualifiedName.
74
+ const removedNames = new Set(removedSymbols.map((s) => s.qualifiedName));
75
+ const newSymbols = baseSymbols.filter((s) => !removedNames.has(s.qualifiedName)).concat(addedSymbols);
76
+
77
+ // Remove edges by (source, target, edgeType).
78
+ const removedEdgeKeys = new Set(
79
+ removedEdges.map((e) => `${e.source}\t${e.target}\t${e.edgeType}`),
80
+ );
81
+ const newEdges = baseEdges
82
+ .filter((e) => !removedEdgeKeys.has(`${e.source}\t${e.target}\t${e.edgeType}`))
83
+ .concat(addedEdges);
84
+
85
+ // Compute pack root and verify.
86
+ const computed = packRoot(newSymbols, newEdges);
87
+ if (computed !== expectedNewRoot) {
88
+ throw new Error(
89
+ `pack root mismatch: expected ${expectedNewRoot}, computed ${computed}`,
90
+ );
91
+ }
92
+
93
+ return { symbols: newSymbols, edges: newEdges };
94
+ }
package/src/generic.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Generic encoder: converts any JS value into GCF v2.0 generic profile.
2
+ * Generic encoder: converts any JS value into GCF generic profile.
3
3
  */
4
4
  import { formatScalar, formatKey, ATTACHMENT } from './scalar.js';
5
5
 
@@ -74,15 +74,93 @@ function tabularFields(arr: unknown[]): string[] | null {
74
74
  return fieldOrder.length > 0 ? fieldOrder : null;
75
75
  }
76
76
 
77
+ /** Check if a field is eligible for inline schema: all rows have same flat object shape with 3+ keys. */
78
+ function inlineSchemaFields(arr: unknown[], fieldName: string): string[] | null {
79
+ // First row must have the field.
80
+ const first = arr[0] as Record<string, unknown> | undefined;
81
+ if (!first || !(fieldName in first)) return null;
82
+ const firstVal = first[fieldName];
83
+ if (firstVal === null || firstVal === undefined || typeof firstVal !== 'object' || Array.isArray(firstVal)) return null;
84
+
85
+ let canonicalKeys: string[] | null = null;
86
+ for (const item of arr) {
87
+ const obj = item as Record<string, unknown>;
88
+ if (!(fieldName in obj) || obj[fieldName] === null || obj[fieldName] === undefined) continue;
89
+ const v = obj[fieldName];
90
+ if (typeof v !== 'object' || Array.isArray(v)) return null;
91
+ const keys = Object.keys(v as Record<string, unknown>);
92
+ // All values must be scalars.
93
+ for (const k of keys) {
94
+ const val = (v as Record<string, unknown>)[k];
95
+ if (val !== null && val !== undefined && typeof val === 'object') return null;
96
+ }
97
+ if (!canonicalKeys) {
98
+ canonicalKeys = keys;
99
+ } else {
100
+ if (keys.length !== canonicalKeys.length) return null;
101
+ for (let i = 0; i < keys.length; i++) {
102
+ if (keys[i] !== canonicalKeys[i]) return null;
103
+ }
104
+ }
105
+ }
106
+ if (!canonicalKeys || canonicalKeys.length < 3) return null;
107
+ return canonicalKeys;
108
+ }
109
+
110
+ /** Check if array attachment has same tabular schema across all rows (first row must have it). All values must be scalars. */
111
+ function sharedArraySchema(arr: unknown[], fieldName: string): string[] | null {
112
+ const first = arr[0] as Record<string, unknown> | undefined;
113
+ if (!first || !(fieldName in first)) return null;
114
+ const firstVal = first[fieldName];
115
+ if (!Array.isArray(firstVal)) return null;
116
+
117
+ let canonicalFields: string[] | null = null;
118
+ for (const item of arr) {
119
+ const obj = item as Record<string, unknown>;
120
+ if (!(fieldName in obj) || obj[fieldName] === null || obj[fieldName] === undefined) continue;
121
+ const v = obj[fieldName];
122
+ if (!Array.isArray(v)) return null;
123
+ const fields = tabularFields(v);
124
+ if (!fields) return null;
125
+ // All values must be scalars.
126
+ for (const arrItem of v) {
127
+ if (typeof arrItem !== 'object' || arrItem === null) return null;
128
+ for (const val of Object.values(arrItem as Record<string, unknown>)) {
129
+ if (val !== null && val !== undefined && typeof val === 'object') return null;
130
+ }
131
+ }
132
+ if (!canonicalFields) {
133
+ canonicalFields = fields;
134
+ } else {
135
+ if (fields.length !== canonicalFields.length) return null;
136
+ for (let i = 0; i < fields.length; i++) {
137
+ if (fields[i] !== canonicalFields[i]) return null;
138
+ }
139
+ }
140
+ }
141
+ return canonicalFields;
142
+ }
143
+
77
144
  function encodeTabular(headerPrefix: string, arr: unknown[], fields: string[], depth: number): string {
78
145
  const prefix = indent(depth);
146
+
147
+ // Pre-compute inline schemas and shared array schemas.
148
+ const inlineSchemas = new Map<string, string[]>();
149
+ const sharedArrSchemas = new Map<string, string[]>();
150
+ for (const f of fields) {
151
+ const ifs = inlineSchemaFields(arr, f);
152
+ if (ifs) inlineSchemas.set(f, ifs);
153
+ const sas = sharedArraySchema(arr, f);
154
+ if (sas) sharedArrSchemas.set(f, sas);
155
+ }
156
+
79
157
  const fmtFields = fields.map(f => formatKey(f));
80
158
  let out = `${headerPrefix}[${arr.length}]{${fmtFields.join(',')}}\n`;
81
159
 
82
160
  for (let i = 0; i < arr.length; i++) {
83
161
  const obj = arr[i] as Record<string, unknown>;
84
162
  const cells: string[] = [];
85
- const attachments: { name: string; value: unknown }[] = [];
163
+ const attachments: { name: string; value: unknown; inline: boolean; inlineFields?: string[] }[] = [];
86
164
  let rowHasAttachment = false;
87
165
 
88
166
  for (const f of fields) {
@@ -90,8 +168,20 @@ function encodeTabular(headerPrefix: string, arr: unknown[], fields: string[], d
90
168
  const v = obj[f];
91
169
  if (v === null || v === undefined) { cells.push('-'); continue; }
92
170
  if (typeof v === 'object') {
93
- cells.push('^');
94
- attachments.push({ name: f, value: v });
171
+ const ifs = inlineSchemas.get(f);
172
+ if (ifs && !Array.isArray(v)) {
173
+ // Inline schema: first row declares, subsequent use bare ^.
174
+ if (i === 0) {
175
+ const fmtIF = ifs.map(k => formatKey(k));
176
+ cells.push(`^{${fmtIF.join(',')}}`);
177
+ } else {
178
+ cells.push('^');
179
+ }
180
+ attachments.push({ name: f, value: v, inline: true, inlineFields: ifs });
181
+ } else {
182
+ cells.push('^');
183
+ attachments.push({ name: f, value: v, inline: false });
184
+ }
95
185
  rowHasAttachment = true;
96
186
  } else {
97
187
  cells.push(formatScalar(v, 0x7c));
@@ -106,12 +196,25 @@ function encodeTabular(headerPrefix: string, arr: unknown[], fields: string[], d
106
196
  }
107
197
 
108
198
  for (const att of attachments) {
109
- const attPrefix = prefix + ' ';
110
199
  const fk = formatKey(att.name);
111
- if (Array.isArray(att.value)) {
112
- out += encodeAttachmentArray(attPrefix, fk, att.value as unknown[], depth + 2);
200
+ if (att.inline && att.inlineFields) {
201
+ // Inline: single pipe-delimited row, no prefix, no indent.
202
+ const vals = att.inlineFields.map(inf => {
203
+ const val = (att.value as Record<string, unknown>)[inf];
204
+ if (val === undefined) return '~';
205
+ return formatScalar(val, 0x7c);
206
+ });
207
+ out += `${prefix}${vals.join('|')}\n`;
208
+ } else if (Array.isArray(att.value)) {
209
+ // Shared array schema: omit {fields} on subsequent rows.
210
+ const sas = sharedArrSchemas.get(att.name);
211
+ if (sas && i > 0) {
212
+ out += encodeAttachmentArrayShared(prefix, fk, att.value as unknown[], depth + 2, sas);
213
+ } else {
214
+ out += encodeAttachmentArray(prefix, fk, att.value as unknown[], depth + 2);
215
+ }
113
216
  } else {
114
- out += `${attPrefix}.${fk} {}\n`;
217
+ out += `${prefix}.${fk} {}\n`;
115
218
  out += encodeObject(att.value as Record<string, unknown>, depth + 2);
116
219
  }
117
220
  }
@@ -130,6 +233,33 @@ function encodeAttachmentArray(attPrefix: string, fk: string, arr: unknown[], de
130
233
  return encodeExpanded(`${attPrefix}.${fk} `, arr, depth);
131
234
  }
132
235
 
236
+ function encodeAttachmentArrayShared(attPrefix: string, fk: string, arr: unknown[], depth: number, sharedFields: string[]): string {
237
+ if (arr.length === 0) return `${attPrefix}.${fk} [0]\n`;
238
+ if (allPrimitives(arr)) {
239
+ const vals = arr.map(v => formatScalar(v, 0x2c));
240
+ return `${attPrefix}.${fk} [${arr.length}]: ${vals.join(',')}\n`;
241
+ }
242
+ // Verify fields match shared schema.
243
+ const fields = tabularFields(arr);
244
+ if (fields && fields.length === sharedFields.length && fields.every((f, i) => f === sharedFields[i])) {
245
+ // Omit {fields}, use shared schema.
246
+ const prefix = indent(depth);
247
+ let out = `${attPrefix}.${fk} [${arr.length}]\n`;
248
+ for (const item of arr) {
249
+ const obj = item as Record<string, unknown>;
250
+ const cells = sharedFields.map(f => {
251
+ if (!(f in obj)) return '~';
252
+ if (obj[f] === null || obj[f] === undefined) return '-';
253
+ return formatScalar(obj[f], 0x7c);
254
+ });
255
+ out += `${prefix}${cells.join('|')}\n`;
256
+ }
257
+ return out;
258
+ }
259
+ // Fields don't match: fall back to full encoding.
260
+ return encodeAttachmentArray(attPrefix, fk, arr, depth);
261
+ }
262
+
133
263
  function encodeExpanded(headerPrefix: string, arr: unknown[], depth: number): string {
134
264
  const prefix = indent(depth);
135
265
  let out = `${headerPrefix}[${arr.length}]\n`;
package/src/index.ts CHANGED
@@ -3,7 +3,8 @@ export { KIND_ABBREV, KIND_EXPAND } from './constants.js';
3
3
  export { encode } from './encode.js';
4
4
  export { decode } from './decode.js';
5
5
  export { Session, encodeWithSession } from './session.js';
6
- export { encodeDelta } from './delta.js';
6
+ export { encodeDelta, verifyDelta } from './delta.js';
7
+ // packRoot is Node-only (uses crypto.createHash). Import directly: import { packRoot } from '@blackwell-systems/gcf/dist/packroot.js'
7
8
  export { encodeGeneric } from './generic.js';
8
9
  export { decodeGeneric } from './decode_generic.js';
9
10
  export { formatScalar, formatKey, parseScalar, needsQuote, quoteString } from './scalar.js';
@@ -0,0 +1,54 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { Symbol, Edge } from './types.js';
3
+ import { KIND_ABBREV } from './constants.js';
4
+ import { formatNumber } from './scalar.js';
5
+
6
+ /**
7
+ * Compute a content-addressed PackRoot hash for a set of symbols and edges.
8
+ *
9
+ * Algorithm:
10
+ * 1. Build canonical symbol records: S\t{kind}\t{qname}\t{score}\t{provenance}\t{distance}\n
11
+ * 2. Build canonical edge records: E\t{srcKind}\t{source}\t{tgtKind}\t{target}\t{edgeType}\n
12
+ * 3. Sort both arrays independently by UTF-8 byte order
13
+ * 4. Concatenate: all symbols then all edges
14
+ * 5. SHA-256 hash
15
+ * 6. Return sha256:{hex}
16
+ */
17
+ export function packRoot(symbols: Symbol[], edges: Edge[], symbolKinds?: Map<string, string>): string {
18
+ const symRecords: string[] = [];
19
+ const edgeRecords: string[] = [];
20
+
21
+ // Build symbol records.
22
+ for (const s of symbols) {
23
+ const kind = KIND_ABBREV[s.kind] || s.kind;
24
+ symRecords.push(`S\t${kind}\t${s.qualifiedName}\t${formatNumber(s.score)}\t${s.provenance}\t${s.distance}\n`);
25
+ }
26
+
27
+ // Build edge records.
28
+ // For edges, we need kind lookups for source and target.
29
+ // Build a map of qualifiedName -> kind from symbols.
30
+ const kindMap = symbolKinds ?? new Map<string, string>();
31
+ if (!symbolKinds) {
32
+ for (const s of symbols) {
33
+ const kind = KIND_ABBREV[s.kind] || s.kind;
34
+ kindMap.set(s.qualifiedName, kind);
35
+ }
36
+ }
37
+
38
+ for (const e of edges) {
39
+ const srcKind = kindMap.get(e.source) || 'fn';
40
+ const tgtKind = kindMap.get(e.target) || 'fn';
41
+ edgeRecords.push(`E\t${srcKind}\t${e.source}\t${tgtKind}\t${e.target}\t${e.edgeType}\n`);
42
+ }
43
+
44
+ // Sort both arrays by UTF-8 byte order.
45
+ symRecords.sort();
46
+ edgeRecords.sort();
47
+
48
+ // Concatenate.
49
+ const data = symRecords.join('') + edgeRecords.join('');
50
+
51
+ // SHA-256 hash.
52
+ const hash = createHash('sha256').update(data, 'utf8').digest('hex');
53
+ return `sha256:${hash}`;
54
+ }
package/src/scalar.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Common scalar grammar for GCF v2.0.
2
+ * Common scalar grammar for GCF.
3
3
  * Shared between encoder, decoder, and streaming encoder.
4
4
  */
5
5
 
6
6
  const JSON_NUMBER_RE = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
7
- const NUMERIC_LIKE_RE = /^[+-]?\.?\d/;
7
+ const NUMERIC_LIKE_RE = /^[+-]\.?\d|^\.\d|^0\d/;
8
8
 
9
9
  /** Check if a string value must be quoted per Section 2.4. */
10
10
  export function needsQuote(s: string): boolean {
@@ -13,11 +13,17 @@ export function needsQuote(s: string): boolean {
13
13
  if (JSON_NUMBER_RE.test(s)) return true;
14
14
  if (NUMERIC_LIKE_RE.test(s)) return true;
15
15
  if (s[0] === ' ' || s[s.length - 1] === ' ') return true;
16
- if (s[0] === '#' || s[0] === '@') return true;
16
+ if (s[0] === '#' || s[0] === '@' || s[0] === '.') return true;
17
17
  for (let i = 0; i < s.length; i++) {
18
18
  const c = s.charCodeAt(i);
19
19
  if (c === 0x22 || c === 0x5c || c < 0x20 || c === 0x0a || c === 0x0d ||
20
- c === 0x7c || c === 0x2c) return true; // " \ control \n \r | ,
20
+ c === 0x7c) return true; // " \ control \n \r |
21
+ // C1 control characters
22
+ if (c >= 0x80 && c <= 0x9f) return true;
23
+ // Unicode whitespace beyond ASCII
24
+ if (c > 0x7f && (c === 0xa0 || c === 0x2028 || c === 0x2029 || c === 0xfeff ||
25
+ c === 0x1680 || (c >= 0x2000 && c <= 0x200a) || c === 0x202f ||
26
+ c === 0x205f || c === 0x3000)) return true;
21
27
  }
22
28
  return false;
23
29
  }
@@ -108,10 +114,12 @@ export function parseScalar(s: string, tabularContext: boolean): any {
108
114
  return MISSING;
109
115
  }
110
116
 
111
- // 4. Attachment (tabular only).
112
- if (s === '^') {
117
+ // 4. Attachment (tabular only). Plain ^ or ^{fields} (inline schema).
118
+ if (s === '^' || (s.startsWith('^{') && s.endsWith('}'))) {
113
119
  if (!tabularContext) throw new Error('invalid_attachment_marker: ^ outside tabular row cell');
114
- return ATTACHMENT;
120
+ if (s === '^') return ATTACHMENT;
121
+ // Inline schema: return the schema string for the caller to parse.
122
+ return { __inlineSchema: s.slice(1) }; // e.g. "{name,email,tier}"
115
123
  }
116
124
 
117
125
  // 5. Boolean.