@gmickel/gno 1.30.7 → 1.31.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 +2 -2
- package/assets/skill/SKILL.md +2 -0
- package/assets/skill/mcp-reference.md +6 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.30.7.zip → gno-browser-clipper-v1.31.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.31.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/mcp.md +62 -0
- package/spec/output-schemas/section-target-create-result.schema.json +20 -0
- package/spec/output-schemas/section-target-resolve-result.schema.json +194 -0
- package/spec/output-schemas/section-target.schema.json +118 -0
- package/spec/output-schemas/section.schema.json +113 -0
- package/src/core/section-parse.ts +187 -0
- package/src/core/section-target-link.ts +154 -0
- package/src/core/section-target-resolve.ts +351 -0
- package/src/core/section-target-transport.ts +519 -0
- package/src/core/section-target.ts +263 -0
- package/src/core/sections.ts +60 -115
- package/src/mcp/AGENTS.md +1 -0
- package/src/mcp/CLAUDE.md +1 -0
- package/src/mcp/http-egress.ts +1 -0
- package/src/mcp/tools/index.ts +19 -0
- package/src/mcp/tools/sections.ts +512 -0
- package/src/sdk/client.ts +71 -1
- package/src/sdk/index.ts +5 -0
- package/src/sdk/types.ts +29 -1
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/lib/section-links.ts +189 -0
- package/src/serve/public/pages/DocView.tsx +219 -36
- package/src/serve/routes/section-targets.ts +221 -0
- package/src/serve/server.ts +34 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.30.7.zip.sha256 +0 -1
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closed validation and transport projection for section targets.
|
|
3
|
+
* Private helper — import public API from `./sections`.
|
|
4
|
+
*
|
|
5
|
+
* Shared by REST and SDK so response shapes stay identical without
|
|
6
|
+
* duplicating parser/resolver logic.
|
|
7
|
+
*
|
|
8
|
+
* @module src/core/section-target-transport
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
SECTION_TARGET_BOUNDS,
|
|
13
|
+
SECTION_TARGET_SCHEMA_VERSION,
|
|
14
|
+
isBoundedSectionTarget,
|
|
15
|
+
type SectionTargetV1,
|
|
16
|
+
} from "./section-target";
|
|
17
|
+
import {
|
|
18
|
+
isNavigableSectionResolution,
|
|
19
|
+
type SectionResolution,
|
|
20
|
+
type SectionResolutionCandidate,
|
|
21
|
+
type SectionResolutionStatus,
|
|
22
|
+
} from "./section-target-resolve";
|
|
23
|
+
|
|
24
|
+
export type TransportValidationResult<T> =
|
|
25
|
+
| { ok: true; value: T }
|
|
26
|
+
| { ok: false; error: string };
|
|
27
|
+
|
|
28
|
+
export interface SectionTargetCreateSelector {
|
|
29
|
+
anchor?: string;
|
|
30
|
+
line?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** REST/SDK create response — canonical stored URI + target. */
|
|
34
|
+
export interface SectionTargetCreateResult {
|
|
35
|
+
uri: string;
|
|
36
|
+
target: SectionTargetV1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Citation evidence exposed only for navigable resolutions. */
|
|
40
|
+
export interface SectionCitationV1 {
|
|
41
|
+
uri: string;
|
|
42
|
+
anchor: string;
|
|
43
|
+
title: string;
|
|
44
|
+
lineStart: number;
|
|
45
|
+
lineEnd: number;
|
|
46
|
+
sourceFingerprint: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Transport diagnostics. Candidates are filtered/projected to schema bounds
|
|
51
|
+
* without truncating identity strings; `candidateCount` is the pre-filter
|
|
52
|
+
* total and `candidatesTruncated` is true when any candidate was omitted.
|
|
53
|
+
*/
|
|
54
|
+
export interface SectionResolutionDiagnostics {
|
|
55
|
+
reason?: string;
|
|
56
|
+
candidates?: SectionResolutionCandidate[];
|
|
57
|
+
candidateCount?: number;
|
|
58
|
+
candidatesTruncated?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* REST/SDK resolve response. Non-navigable results omit citation and any
|
|
63
|
+
* navigation fields.
|
|
64
|
+
*/
|
|
65
|
+
export interface SectionTargetResolveResult {
|
|
66
|
+
uri: string;
|
|
67
|
+
status: SectionResolutionStatus;
|
|
68
|
+
currentFingerprint: string;
|
|
69
|
+
target: SectionTargetV1;
|
|
70
|
+
diagnostics: SectionResolutionDiagnostics;
|
|
71
|
+
citation?: SectionCitationV1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Transport-only bounds for resolve diagnostics / citation projection. */
|
|
75
|
+
export const SECTION_TARGET_TRANSPORT_BOUNDS = {
|
|
76
|
+
diagnosticsCandidatesMaxItems: 32,
|
|
77
|
+
reasonMaxChars: 256,
|
|
78
|
+
titleMaxChars: SECTION_TARGET_BOUNDS.anchorMaxChars,
|
|
79
|
+
} as const;
|
|
80
|
+
|
|
81
|
+
/** Stable reason when a navigable citation cannot fit transport bounds. */
|
|
82
|
+
export const CITATION_EXCEEDS_TRANSPORT_BOUNDS =
|
|
83
|
+
"citation_exceeds_transport_bounds" as const;
|
|
84
|
+
|
|
85
|
+
/** Stable VALIDATION message when a stored canonical URI cannot fit transport. */
|
|
86
|
+
export const CANONICAL_URI_EXCEEDS_TRANSPORT_BOUNDS =
|
|
87
|
+
`Canonical document URI exceeds the maximum length of ${SECTION_TARGET_BOUNDS.uriMaxChars}` as const;
|
|
88
|
+
|
|
89
|
+
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
|
|
90
|
+
|
|
91
|
+
const invalid = <T>(error: string): TransportValidationResult<T> => ({
|
|
92
|
+
ok: false,
|
|
93
|
+
error,
|
|
94
|
+
});
|
|
95
|
+
/**
|
|
96
|
+
* Snapshot only own data properties. Accessors, exotic prototypes, symbols,
|
|
97
|
+
* proxy traps, arrays, and unknown keys are rejected without invoking getters.
|
|
98
|
+
*/
|
|
99
|
+
const closedRecord = (
|
|
100
|
+
value: unknown,
|
|
101
|
+
allowedKeys: readonly string[]
|
|
102
|
+
): TransportValidationResult<Record<string, unknown>> => {
|
|
103
|
+
try {
|
|
104
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
105
|
+
return invalid("Expected a JSON object");
|
|
106
|
+
}
|
|
107
|
+
const prototype = Object.getPrototypeOf(value);
|
|
108
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
109
|
+
return invalid("Expected a plain JSON object");
|
|
110
|
+
}
|
|
111
|
+
const keys = Reflect.ownKeys(value);
|
|
112
|
+
if (
|
|
113
|
+
keys.length > allowedKeys.length ||
|
|
114
|
+
keys.some((key) => typeof key !== "string" || !allowedKeys.includes(key))
|
|
115
|
+
) {
|
|
116
|
+
return invalid("Object contains unknown fields");
|
|
117
|
+
}
|
|
118
|
+
const result: Record<string, unknown> = {};
|
|
119
|
+
for (const key of keys) {
|
|
120
|
+
if (typeof key !== "string") return invalid("Invalid object field");
|
|
121
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
122
|
+
if (!descriptor || !("value" in descriptor)) {
|
|
123
|
+
return invalid("Accessor fields are not allowed");
|
|
124
|
+
}
|
|
125
|
+
result[key] = descriptor.value;
|
|
126
|
+
}
|
|
127
|
+
return { ok: true, value: result };
|
|
128
|
+
} catch {
|
|
129
|
+
return invalid("Unreadable input object");
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const parseNonEmptyString = (
|
|
134
|
+
value: unknown,
|
|
135
|
+
field: string,
|
|
136
|
+
maxChars: number
|
|
137
|
+
): TransportValidationResult<string> => {
|
|
138
|
+
if (typeof value !== "string") {
|
|
139
|
+
return invalid(`${field} must be a string`);
|
|
140
|
+
}
|
|
141
|
+
if (value.length < 1) {
|
|
142
|
+
return invalid(`${field} must be a non-empty string`);
|
|
143
|
+
}
|
|
144
|
+
if (value.length > maxChars) {
|
|
145
|
+
return invalid(`${field} exceeds the maximum length of ${maxChars}`);
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, value };
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const parseBoundedString = (
|
|
151
|
+
value: unknown,
|
|
152
|
+
field: string,
|
|
153
|
+
maxChars: number
|
|
154
|
+
): TransportValidationResult<string> => {
|
|
155
|
+
if (typeof value !== "string") {
|
|
156
|
+
return invalid(`${field} must be a string`);
|
|
157
|
+
}
|
|
158
|
+
if (value.length > maxChars) {
|
|
159
|
+
return invalid(`${field} exceeds the maximum length of ${maxChars}`);
|
|
160
|
+
}
|
|
161
|
+
return { ok: true, value };
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const parsePositiveInt = (
|
|
165
|
+
value: unknown,
|
|
166
|
+
field: string
|
|
167
|
+
): TransportValidationResult<number> => {
|
|
168
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
|
|
169
|
+
return invalid(`${field} must be a positive safe integer`);
|
|
170
|
+
}
|
|
171
|
+
return { ok: true, value };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const parseNonNegativeInt = (
|
|
175
|
+
value: unknown,
|
|
176
|
+
field: string
|
|
177
|
+
): TransportValidationResult<number> => {
|
|
178
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
179
|
+
return invalid(`${field} must be a non-negative safe integer`);
|
|
180
|
+
}
|
|
181
|
+
return { ok: true, value };
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* Require exactly one selector: `anchor` XOR `line`.
|
|
185
|
+
* Rejects unknown fields and empty/oversized values.
|
|
186
|
+
*/
|
|
187
|
+
export const parseSectionTargetCreateSelector = (
|
|
188
|
+
value: unknown
|
|
189
|
+
): TransportValidationResult<SectionTargetCreateSelector> => {
|
|
190
|
+
const record = closedRecord(value, ["anchor", "line"]);
|
|
191
|
+
if (!record.ok) return record;
|
|
192
|
+
|
|
193
|
+
const hasAnchor = Object.hasOwn(record.value, "anchor");
|
|
194
|
+
const hasLine = Object.hasOwn(record.value, "line");
|
|
195
|
+
if (hasAnchor === hasLine) {
|
|
196
|
+
return invalid("Provide exactly one of anchor or line");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (hasAnchor) {
|
|
200
|
+
const anchor = parseNonEmptyString(
|
|
201
|
+
record.value.anchor,
|
|
202
|
+
"anchor",
|
|
203
|
+
SECTION_TARGET_BOUNDS.anchorMaxChars
|
|
204
|
+
);
|
|
205
|
+
if (!anchor.ok) return anchor;
|
|
206
|
+
return { ok: true, value: { anchor: anchor.value } };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const line = parsePositiveInt(record.value.line, "line");
|
|
210
|
+
if (!line.ok) return line;
|
|
211
|
+
return { ok: true, value: { line: line.value } };
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Validate an untrusted SectionTargetV1 before core resolve.
|
|
216
|
+
* Enforces closed shape, bounds, fingerprint format, and serialized size.
|
|
217
|
+
*/
|
|
218
|
+
export const parseSectionTargetV1 = (
|
|
219
|
+
value: unknown
|
|
220
|
+
): TransportValidationResult<SectionTargetV1> => {
|
|
221
|
+
const record = closedRecord(value, [
|
|
222
|
+
"schemaVersion",
|
|
223
|
+
"document",
|
|
224
|
+
"anchor",
|
|
225
|
+
"headingPath",
|
|
226
|
+
"occurrence",
|
|
227
|
+
"quote",
|
|
228
|
+
"sourceFingerprint",
|
|
229
|
+
"hints",
|
|
230
|
+
]);
|
|
231
|
+
if (!record.ok) return record;
|
|
232
|
+
|
|
233
|
+
if (record.value.schemaVersion !== SECTION_TARGET_SCHEMA_VERSION) {
|
|
234
|
+
return invalid('schemaVersion must be "1"');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const document = closedRecord(record.value.document, ["uri"]);
|
|
238
|
+
if (!document.ok) return invalid(`document: ${document.error}`);
|
|
239
|
+
const uri = parseNonEmptyString(
|
|
240
|
+
document.value.uri,
|
|
241
|
+
"document.uri",
|
|
242
|
+
SECTION_TARGET_BOUNDS.uriMaxChars
|
|
243
|
+
);
|
|
244
|
+
if (!uri.ok) return uri;
|
|
245
|
+
|
|
246
|
+
const anchor = parseNonEmptyString(
|
|
247
|
+
record.value.anchor,
|
|
248
|
+
"anchor",
|
|
249
|
+
SECTION_TARGET_BOUNDS.anchorMaxChars
|
|
250
|
+
);
|
|
251
|
+
if (!anchor.ok) return anchor;
|
|
252
|
+
|
|
253
|
+
if (!Array.isArray(record.value.headingPath)) {
|
|
254
|
+
return invalid("headingPath must be an array");
|
|
255
|
+
}
|
|
256
|
+
if (
|
|
257
|
+
record.value.headingPath.length < 1 ||
|
|
258
|
+
record.value.headingPath.length > SECTION_TARGET_BOUNDS.headingPathMaxItems
|
|
259
|
+
) {
|
|
260
|
+
return invalid(
|
|
261
|
+
`headingPath must contain 1 to ${SECTION_TARGET_BOUNDS.headingPathMaxItems} items`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
const headingPath: string[] = [];
|
|
265
|
+
for (const [index, item] of record.value.headingPath.entries()) {
|
|
266
|
+
const parsed = parseNonEmptyString(
|
|
267
|
+
item,
|
|
268
|
+
`headingPath[${index}]`,
|
|
269
|
+
SECTION_TARGET_BOUNDS.headingPathItemMaxChars
|
|
270
|
+
);
|
|
271
|
+
if (!parsed.ok) return parsed;
|
|
272
|
+
headingPath.push(parsed.value);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const occurrence = parsePositiveInt(record.value.occurrence, "occurrence");
|
|
276
|
+
if (!occurrence.ok) return occurrence;
|
|
277
|
+
|
|
278
|
+
const quoteRecord = closedRecord(record.value.quote, [
|
|
279
|
+
"exact",
|
|
280
|
+
"prefix",
|
|
281
|
+
"suffix",
|
|
282
|
+
]);
|
|
283
|
+
if (!quoteRecord.ok) return invalid(`quote: ${quoteRecord.error}`);
|
|
284
|
+
const exact = parseBoundedString(
|
|
285
|
+
quoteRecord.value.exact,
|
|
286
|
+
"quote.exact",
|
|
287
|
+
SECTION_TARGET_BOUNDS.exactMaxChars
|
|
288
|
+
);
|
|
289
|
+
if (!exact.ok) return exact;
|
|
290
|
+
const prefix = parseBoundedString(
|
|
291
|
+
quoteRecord.value.prefix,
|
|
292
|
+
"quote.prefix",
|
|
293
|
+
SECTION_TARGET_BOUNDS.prefixMaxChars
|
|
294
|
+
);
|
|
295
|
+
if (!prefix.ok) return prefix;
|
|
296
|
+
const suffix = parseBoundedString(
|
|
297
|
+
quoteRecord.value.suffix,
|
|
298
|
+
"quote.suffix",
|
|
299
|
+
SECTION_TARGET_BOUNDS.suffixMaxChars
|
|
300
|
+
);
|
|
301
|
+
if (!suffix.ok) return suffix;
|
|
302
|
+
|
|
303
|
+
if (
|
|
304
|
+
typeof record.value.sourceFingerprint !== "string" ||
|
|
305
|
+
!FINGERPRINT_PATTERN.test(record.value.sourceFingerprint)
|
|
306
|
+
) {
|
|
307
|
+
return invalid("sourceFingerprint must be a lowercase SHA-256 hex digest");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const hintsRecord = closedRecord(record.value.hints, [
|
|
311
|
+
"line",
|
|
312
|
+
"startOffset",
|
|
313
|
+
"endOffset",
|
|
314
|
+
]);
|
|
315
|
+
if (!hintsRecord.ok) return invalid(`hints: ${hintsRecord.error}`);
|
|
316
|
+
const hintsLine = parsePositiveInt(hintsRecord.value.line, "hints.line");
|
|
317
|
+
if (!hintsLine.ok) return hintsLine;
|
|
318
|
+
const startOffset = parseNonNegativeInt(
|
|
319
|
+
hintsRecord.value.startOffset,
|
|
320
|
+
"hints.startOffset"
|
|
321
|
+
);
|
|
322
|
+
if (!startOffset.ok) return startOffset;
|
|
323
|
+
const endOffset = parseNonNegativeInt(
|
|
324
|
+
hintsRecord.value.endOffset,
|
|
325
|
+
"hints.endOffset"
|
|
326
|
+
);
|
|
327
|
+
if (!endOffset.ok) return endOffset;
|
|
328
|
+
if (endOffset.value < startOffset.value) {
|
|
329
|
+
return invalid("hints.endOffset must be >= hints.startOffset");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const target: SectionTargetV1 = {
|
|
333
|
+
schemaVersion: SECTION_TARGET_SCHEMA_VERSION,
|
|
334
|
+
document: { uri: uri.value },
|
|
335
|
+
anchor: anchor.value,
|
|
336
|
+
headingPath,
|
|
337
|
+
occurrence: occurrence.value,
|
|
338
|
+
quote: {
|
|
339
|
+
exact: exact.value,
|
|
340
|
+
prefix: prefix.value,
|
|
341
|
+
suffix: suffix.value,
|
|
342
|
+
},
|
|
343
|
+
sourceFingerprint: record.value.sourceFingerprint,
|
|
344
|
+
hints: {
|
|
345
|
+
line: hintsLine.value,
|
|
346
|
+
startOffset: startOffset.value,
|
|
347
|
+
endOffset: endOffset.value,
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// Serialized-byte budget is owned by isBoundedSectionTarget — no recheck.
|
|
352
|
+
if (!isBoundedSectionTarget(target)) {
|
|
353
|
+
return invalid("Section target exceeds size bounds");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { ok: true, value: target };
|
|
357
|
+
};
|
|
358
|
+
/** Resolve-body wrapper: `{ target: SectionTargetV1 }` only. */
|
|
359
|
+
export const parseSectionTargetResolveBody = (
|
|
360
|
+
value: unknown
|
|
361
|
+
): TransportValidationResult<{ target: SectionTargetV1 }> => {
|
|
362
|
+
const record = closedRecord(value, ["target"]);
|
|
363
|
+
if (!record.ok) return record;
|
|
364
|
+
if (!Object.hasOwn(record.value, "target")) {
|
|
365
|
+
return invalid("target is required");
|
|
366
|
+
}
|
|
367
|
+
const target = parseSectionTargetV1(record.value.target);
|
|
368
|
+
if (!target.ok) return target;
|
|
369
|
+
return { ok: true, value: { target: target.value } };
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
export const projectSectionTargetCreateResult = (
|
|
373
|
+
uri: string,
|
|
374
|
+
target: SectionTargetV1
|
|
375
|
+
): SectionTargetCreateResult => ({
|
|
376
|
+
uri,
|
|
377
|
+
target,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const isBoundedTransportString = (
|
|
381
|
+
value: string,
|
|
382
|
+
minChars: number,
|
|
383
|
+
maxChars: number
|
|
384
|
+
): boolean => value.length >= minChars && value.length <= maxChars;
|
|
385
|
+
|
|
386
|
+
const isBoundedPositiveSafeInt = (value: number): boolean =>
|
|
387
|
+
Number.isSafeInteger(value) && value >= 1;
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* True when a canonical document URI fits top-level transport `uri` bounds.
|
|
391
|
+
* Adapters must reject before create/projection — never truncate or rewrite.
|
|
392
|
+
*/
|
|
393
|
+
export const isTransportBoundedCanonicalUri = (uri: string): boolean =>
|
|
394
|
+
isBoundedTransportString(uri, 1, SECTION_TARGET_BOUNDS.uriMaxChars);
|
|
395
|
+
|
|
396
|
+
/** True when a candidate fits transport schema bounds without truncation. */
|
|
397
|
+
export const isTransportBoundedCandidate = (
|
|
398
|
+
candidate: SectionResolutionCandidate
|
|
399
|
+
): boolean =>
|
|
400
|
+
isBoundedTransportString(
|
|
401
|
+
candidate.anchor,
|
|
402
|
+
1,
|
|
403
|
+
SECTION_TARGET_BOUNDS.anchorMaxChars
|
|
404
|
+
) &&
|
|
405
|
+
isBoundedTransportString(
|
|
406
|
+
candidate.title,
|
|
407
|
+
1,
|
|
408
|
+
SECTION_TARGET_TRANSPORT_BOUNDS.titleMaxChars
|
|
409
|
+
) &&
|
|
410
|
+
isBoundedPositiveSafeInt(candidate.line) &&
|
|
411
|
+
isBoundedPositiveSafeInt(candidate.occurrence) &&
|
|
412
|
+
candidate.headingPath.length >= 1 &&
|
|
413
|
+
candidate.headingPath.length <= SECTION_TARGET_BOUNDS.headingPathMaxItems &&
|
|
414
|
+
candidate.headingPath.every((item) =>
|
|
415
|
+
isBoundedTransportString(
|
|
416
|
+
item,
|
|
417
|
+
1,
|
|
418
|
+
SECTION_TARGET_BOUNDS.headingPathItemMaxChars
|
|
419
|
+
)
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
/** True when a citation fits transport schema bounds without truncation. */
|
|
423
|
+
export const isTransportBoundedCitation = (
|
|
424
|
+
citation: SectionCitationV1
|
|
425
|
+
): boolean =>
|
|
426
|
+
isBoundedTransportString(
|
|
427
|
+
citation.uri,
|
|
428
|
+
1,
|
|
429
|
+
SECTION_TARGET_BOUNDS.uriMaxChars
|
|
430
|
+
) &&
|
|
431
|
+
isBoundedTransportString(
|
|
432
|
+
citation.anchor,
|
|
433
|
+
1,
|
|
434
|
+
SECTION_TARGET_BOUNDS.anchorMaxChars
|
|
435
|
+
) &&
|
|
436
|
+
isBoundedTransportString(
|
|
437
|
+
citation.title,
|
|
438
|
+
1,
|
|
439
|
+
SECTION_TARGET_TRANSPORT_BOUNDS.titleMaxChars
|
|
440
|
+
) &&
|
|
441
|
+
isBoundedPositiveSafeInt(citation.lineStart) &&
|
|
442
|
+
isBoundedPositiveSafeInt(citation.lineEnd) &&
|
|
443
|
+
citation.lineEnd >= citation.lineStart &&
|
|
444
|
+
FINGERPRINT_PATTERN.test(citation.sourceFingerprint);
|
|
445
|
+
|
|
446
|
+
const projectDiagnostics = (
|
|
447
|
+
resolution: SectionResolution
|
|
448
|
+
): SectionResolutionDiagnostics => {
|
|
449
|
+
const diagnostics: SectionResolutionDiagnostics = {};
|
|
450
|
+
if (
|
|
451
|
+
resolution.reason !== undefined &&
|
|
452
|
+
resolution.reason.length >= 1 &&
|
|
453
|
+
resolution.reason.length <= SECTION_TARGET_TRANSPORT_BOUNDS.reasonMaxChars
|
|
454
|
+
) {
|
|
455
|
+
diagnostics.reason = resolution.reason;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (resolution.candidates === undefined) {
|
|
459
|
+
return diagnostics;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const candidateCount = resolution.candidates.length;
|
|
463
|
+
const fitting = resolution.candidates.filter(isTransportBoundedCandidate);
|
|
464
|
+
const emitted = fitting.slice(
|
|
465
|
+
0,
|
|
466
|
+
SECTION_TARGET_TRANSPORT_BOUNDS.diagnosticsCandidatesMaxItems
|
|
467
|
+
);
|
|
468
|
+
diagnostics.candidates = emitted;
|
|
469
|
+
diagnostics.candidateCount = candidateCount;
|
|
470
|
+
diagnostics.candidatesTruncated = emitted.length < candidateCount;
|
|
471
|
+
return diagnostics;
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
export const projectSectionTargetResolveResult = (
|
|
475
|
+
uri: string,
|
|
476
|
+
resolution: SectionResolution
|
|
477
|
+
): SectionTargetResolveResult => {
|
|
478
|
+
const diagnostics = projectDiagnostics(resolution);
|
|
479
|
+
|
|
480
|
+
if (isNavigableSectionResolution(resolution)) {
|
|
481
|
+
const citation: SectionCitationV1 = {
|
|
482
|
+
uri,
|
|
483
|
+
anchor: resolution.section.anchor,
|
|
484
|
+
title: resolution.section.title,
|
|
485
|
+
lineStart: resolution.section.line,
|
|
486
|
+
lineEnd: resolution.section.endLine,
|
|
487
|
+
sourceFingerprint: resolution.currentFingerprint,
|
|
488
|
+
};
|
|
489
|
+
if (isTransportBoundedCitation(citation)) {
|
|
490
|
+
return {
|
|
491
|
+
uri,
|
|
492
|
+
status: resolution.status,
|
|
493
|
+
currentFingerprint: resolution.currentFingerprint,
|
|
494
|
+
target: resolution.target,
|
|
495
|
+
diagnostics,
|
|
496
|
+
citation,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
// Fail closed: never truncate identity or emit an unbound citation.
|
|
500
|
+
return {
|
|
501
|
+
uri,
|
|
502
|
+
status: "stale",
|
|
503
|
+
currentFingerprint: resolution.currentFingerprint,
|
|
504
|
+
target: resolution.target,
|
|
505
|
+
diagnostics: {
|
|
506
|
+
...diagnostics,
|
|
507
|
+
reason: CITATION_EXCEEDS_TRANSPORT_BOUNDS,
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
uri,
|
|
514
|
+
status: resolution.status,
|
|
515
|
+
currentFingerprint: resolution.currentFingerprint,
|
|
516
|
+
target: resolution.target,
|
|
517
|
+
diagnostics,
|
|
518
|
+
};
|
|
519
|
+
};
|