@blackwell-systems/gcf 1.0.0 → 2.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.
- package/README.md +4 -4
- package/dist/cli.js +16 -4
- package/dist/cli.js.map +1 -1
- package/dist/decode_generic.d.ts +1 -1
- package/dist/decode_generic.js +204 -46
- package/dist/decode_generic.js.map +1 -1
- package/dist/delta.d.ts +11 -1
- package/dist/delta.d.ts.map +1 -1
- package/dist/delta.js +23 -0
- package/dist/delta.js.map +1 -1
- package/dist/generic.js +159 -7
- package/dist/generic.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/packroot.d.ts +14 -0
- package/dist/packroot.d.ts.map +1 -0
- package/dist/packroot.js +47 -0
- package/dist/packroot.js.map +1 -0
- package/dist/scalar.d.ts +1 -1
- package/dist/scalar.d.ts.map +1 -1
- package/dist/scalar.js +19 -8
- package/dist/scalar.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.ts +16 -4
- package/src/decode_generic.ts +207 -40
- package/src/delta.ts +40 -1
- package/src/generic.ts +138 -8
- package/src/index.ts +2 -1
- package/src/packroot.ts +54 -0
- package/src/scalar.ts +15 -7
package/src/decode_generic.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
} from './scalar.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Decode GCF
|
|
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('.'))
|
|
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) {
|
|
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
|
-
|
|
281
|
-
const
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
|
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] === '"') {
|
|
442
|
+
if (rest[j] === '"') {
|
|
443
|
+
const name = parseQuotedString(rest.slice(0, j + 1));
|
|
444
|
+
return [name, rest.slice(j + 1)];
|
|
445
|
+
}
|
|
331
446
|
}
|
|
332
|
-
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
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 (
|
|
112
|
-
|
|
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 += `${
|
|
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
|
+
export { packRoot } from './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';
|
package/src/packroot.ts
ADDED
|
@@ -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
|
|
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 = /^[+-]
|
|
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
|
|
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.
|