@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.
- package/LICENSE +1 -1
- package/README.md +13 -3
- package/dist/cli.js +16 -4
- package/dist/cli.js.map +1 -1
- package/dist/decode.d.ts.map +1 -1
- package/dist/decode.js +15 -6
- package/dist/decode.js.map +1 -1
- package/dist/decode_generic.d.ts +1 -5
- package/dist/decode_generic.d.ts.map +1 -1
- package/dist/decode_generic.js +392 -142
- package/dist/decode_generic.js.map +1 -1
- package/dist/delta.js +1 -1
- package/dist/delta.js.map +1 -1
- package/dist/encode.d.ts.map +1 -1
- package/dist/encode.js +22 -7
- package/dist/encode.js.map +1 -1
- package/dist/generic.d.ts +0 -6
- package/dist/generic.d.ts.map +1 -1
- package/dist/generic.js +146 -114
- package/dist/generic.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/scalar.d.ts +27 -0
- package/dist/scalar.d.ts.map +1 -0
- package/dist/scalar.js +315 -0
- package/dist/scalar.js.map +1 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +9 -1
- package/dist/session.js.map +1 -1
- package/dist/stream.d.ts +1 -1
- package/dist/stream.js +7 -7
- package/dist/stream.js.map +1 -1
- package/dist/stream_generic.d.ts +1 -1
- package/dist/stream_generic.d.ts.map +1 -1
- package/dist/stream_generic.js +5 -27
- package/dist/stream_generic.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.ts +16 -4
- package/src/decode.ts +18 -6
- package/src/decode_generic.ts +352 -137
- package/src/delta.ts +1 -1
- package/src/encode.ts +19 -7
- package/src/generic.ts +127 -128
- package/src/index.ts +1 -0
- package/src/scalar.ts +249 -0
- package/src/session.ts +6 -1
- package/src/stream.ts +7 -7
- 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("
|
|
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
|
-
`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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
|
-
`
|
|
216
|
+
`unknown_edge_reference: edge references unknown symbol id(s): target=${targetID} source=${sourceID}`
|
|
205
217
|
);
|
|
206
218
|
}
|
|
207
219
|
|
package/src/decode_generic.ts
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
+
parseObjectBody(contentLines, 0, 0, result);
|
|
42
88
|
return result;
|
|
43
89
|
}
|
|
44
90
|
|
|
45
|
-
function
|
|
46
|
-
const
|
|
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
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
//
|
|
120
|
+
// Array section.
|
|
61
121
|
if (content.startsWith('## ')) {
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
163
|
+
const eqIdx = findKeyValueSplit(content);
|
|
128
164
|
if (eqIdx > 0) {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
out[
|
|
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
|
|
143
|
-
|
|
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
|
|
149
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
175
|
-
const
|
|
290
|
+
if (rowHasID && attachmentFields.length > 0) {
|
|
291
|
+
const attIndent = ind + ' ';
|
|
292
|
+
const resolved = new Set<string>();
|
|
176
293
|
while (i < lines.length) {
|
|
177
|
-
const
|
|
178
|
-
if (!
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
201
|
-
|
|
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
|
|
207
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
|
227
|
-
if (s === '
|
|
228
|
-
if (s === '
|
|
229
|
-
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
|
|
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).
|