Setting the file. One moment. Contract · Build Evidence Map · github/awesome-copilot · Skills Docs103
function reaches
— line 103
This file
- Number
- 70.3
- Position
- 3 of 4
- Type
- JavaScript
- Size
- 18 KB
- Lines
- 517
scripts/contract.mjs
JavaScript·517 lines·18 KB
)-(
\d
{2}
)-(
\d
{2}
)
$
/
;
5const ISO_UTC_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?Z$/;
6const LOCATOR_PATTERNS = [
7 /\bp(?:age)?\.?\s*\d+(?:\s*[-–]\s*\d+)?\b/i,
8 /§\s*[\p{L}\p{N}][\p{L}\p{N}._-]*/u,
9 /\bL\d+(?:\s*[-–]\s*L?\d+)?\b/i,
10 /\blines?\s+\d+(?:\s*[-–]\s*\d+)?\b/i,
11 /\b(?:\d{1,2}:)?\d{2}:\d{2}(?:\s*[-–]\s*(?:\d{1,2}:)?\d{2}:\d{2})?\b/,
12 /^(?:section|chapter|heading)\s*(?::|§)\s*\S.{1,}$/i,
13];
14
15export class MapValidationError extends Error {
16 constructor(findings) {
17 super(`Evidence map is invalid (${findings.length} ${findings.length === 1 ? "finding" : "findings"}).`);
18 this.name = "MapValidationError";
19 this.findings = findings;
20 }
21}
22
23function canonical(value) {
24 if (Array.isArray(value)) return value.map(canonical);
25 if (!value || typeof value !== "object") return value;
26 return Object.fromEntries(
27 Object.keys(value)
28 .sort()
29 .map((key) => [key, canonical(value[key])]),
30 );
31}
32
33export function canonicalJson(map) {
34 return JSON.stringify(canonical(map));
35}
36
37export function receiptPayload(map, sourceSnapshots) {
38 return {
39 contract: "doubt-evidence-receipt-v1",
40 map,
41 sourceSnapshots,
42 };
43}
44
45function finding(path, rule, message) {
46 return { path, rule, message };
47}
48
49function parseIsoDate(value) {
50 if (typeof value !== "string") return null;
51 const match = value.match(ISO_DATE);
52 if (!match) return null;
53 const [, year, month, day] = match.map(Number);
54 const time = Date.UTC(year, month - 1, day);
55 const date = new Date(time);
56 if (
57 date.getUTCFullYear() !== year
58 || date.getUTCMonth() !== month - 1
59 || date.getUTCDate() !== day
60 ) return null;
61 return time;
62}
63
64function retrievalDate(value) {
65 const date = parseIsoDate(value);
66 if (date !== null) return date;
67 if (typeof value !== "string") return null;
68 const match = value.match(ISO_UTC_TIMESTAMP);
69 if (!match) return null;
70 const [, year, month, day, hour, minute, second] = match.map(Number);
71 if (hour > 23 || minute > 59 || second > 59) return null;
72 const dayValue = parseIsoDate(
73 `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
74 );
75 return dayValue === null ? null : dayValue;
76}
77
78function validUtcTimestamp(value) {
79 if (typeof value !== "string") return false;
80 const match = value.match(ISO_UTC_TIMESTAMP);
81 if (!match) return false;
82 const [, year, month, day, hour, minute, second] = match.map(Number);
83 if (hour > 23 || minute > 59 || second > 59) return false;
84 return parseIsoDate(
85 `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
86 ) !== null;
87}
88
89function boundedLocator(value) {
90 return typeof value === "string" && LOCATOR_PATTERNS.some((pattern) => pattern.test(value.trim()));
91}
92
93function sourceLocation(value) {
94 return typeof value === "string" && /^(?:https?:\/\/|file:\/\/|\.\.?[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(value);
95}
96
97function substantiveExcerpt(value) {
98 if (typeof value !== "string") return false;
99 const symbols = value.toLowerCase().match(/[\p{L}\p{N}]/gu) || [];
100 return new Set(symbols).size >= 6;
101}
102
103function reaches(start, target, adjacency, seen = new Set()) {
104 if (start === target) return true;
105 if (seen.has(start)) return false;
106 seen.add(start);
107 return (adjacency.get(start) || []).some((next) => reaches(next, target, adjacency, seen));
108}
109
110export function inspectMapContract(map) {
111 const findings = [];
112 if (!map || typeof map !== "object" || Array.isArray(map)) {
113 return {
114 findings: [finding("$", "map-type", "The map must be a JSON object.")],
115 metrics: { claims: 0, contradictions: 0, evidence: 0, sources: 0, unknowns: 0 },
116 receipt: null,
117 valid: false,
118 };
119 }
120
121 for (const key of ["title", "question", "verdict", "updatedAt"]) {
122 if (!map[key] || typeof map[key] !== "string") {
123 findings.push(finding(`$.${key}`, "required-field", `${key} must be a non-empty string.`));
124 }
125 }
126 const updatedAt = parseIsoDate(map.updatedAt);
127 if (typeof map.updatedAt === "string" && updatedAt === null) {
128 findings.push(
129 finding("$.updatedAt", "map-date", "updatedAt must be a real calendar date in YYYY-MM-DD format."),
130 );
131 }
132 if (!Array.isArray(map.nodes) || map.nodes.length === 0) {
133 findings.push(finding("$.nodes", "required-nodes", "nodes must be a non-empty array."));
134 }
135 if (!Array.isArray(map.edges)) {
136 findings.push(finding("$.edges", "required-edges", "edges must be an array."));
137 }
138 if (!Array.isArray(map.sources)) {
139 findings.push(finding("$.sources", "required-sources", "sources must be an array."));
140 }
141
142 const nodes = Array.isArray(map.nodes) ? map.nodes : [];
143 const edges = Array.isArray(map.edges) ? map.edges : [];
144 const sources = Array.isArray(map.sources) ? map.sources : [];
145 const nodeIds = new Set();
146 const sourceIds = new Set();
147
148 for (const [index, node] of nodes.entries()) {
149 const base = `$.nodes[${index}]`;
150 if (!node || typeof node !== "object" || Array.isArray(node)) {
151 findings.push(finding(base, "node-type", "Each node must be an object."));
152 continue;
153 }
154 if (!node.id || typeof node.id !== "string") {
155 findings.push(finding(`${base}.id`, "node-id", "Each node needs a string id."));
156 } else if (nodeIds.has(node.id)) {
157 findings.push(finding(`${base}.id`, "duplicate-node", `Duplicate node id: ${node.id}.`));
158 } else {
159 nodeIds.add(node.id);
160 }
161 if (!NODE_TYPES.has(node.type)) {
162 findings.push(
163 finding(
164 `${base}.type`,
165 "node-type",
166 `Node type must be one of: ${[...NODE_TYPES].join(", ")}.`,
167 ),
168 );
169 }
170 for (const key of ["label", "text"]) {
171 if (!node[key] || typeof node[key] !== "string") {
172 findings.push(finding(`${base}.${key}`, "node-copy", `${key} must be a non-empty string.`));
173 }
174 }
175 if (node.confidence != null) {
176 findings.push(
177 finding(
178 `${base}.confidence`,
179 "false-precision",
180 "Confidence percentages are not supported; use an unknown or a qualified claim instead.",
181 ),
182 );
183 }
184 }
185
186 for (const [index, source] of sources.entries()) {
187 const base = `$.sources[${index}]`;
188 if (!source || typeof source !== "object" || Array.isArray(source)) {
189 findings.push(finding(base, "source-type", "Each source must be an object."));
190 continue;
191 }
192 if (!source.id || typeof source.id !== "string") {
193 findings.push(finding(`${base}.id`, "source-id", "Each source needs a string id."));
194 } else if (sourceIds.has(source.id)) {
195 findings.push(finding(`${base}.id`, "duplicate-source", `Duplicate source id: ${source.id}.`));
196 } else {
197 sourceIds.add(source.id);
198 }
199 for (const key of ["title", "publisher", "date", "retrievedAt", "url", "locator", "excerpt"]) {
200 if (!source[key] || typeof source[key] !== "string") {
201 findings.push(
202 finding(`${base}.${key}`, "source-field", `${key} must be a non-empty string.`),
203 );
204 }
205 }
206 const sourceDate = parseIsoDate(source.date);
207 if (typeof source.date === "string" && sourceDate === null) {
208 findings.push(
209 finding(`${base}.date`, "source-date", "Source date must be a real calendar date in YYYY-MM-DD format."),
210 );
211 } else if (sourceDate !== null && updatedAt !== null && sourceDate > updatedAt) {
212 findings.push(
213 finding(`${base}.date`, "future-source-date", "Source date cannot be later than map.updatedAt."),
214 );
215 }
216 const retrievedAt = retrievalDate(source.retrievedAt);
217 if (typeof source.retrievedAt === "string" && retrievedAt === null) {
218 findings.push(
219 finding(
220 `${base}.retrievedAt`,
221 "retrieval-date",
222 "retrievedAt must be YYYY-MM-DD or an ISO UTC timestamp ending in Z.",
223 ),
224 );
225 } else if (retrievedAt !== null && updatedAt !== null && retrievedAt > updatedAt) {
226 findings.push(
227 finding(`${base}.retrievedAt`, "future-retrieval", "retrievedAt cannot be later than map.updatedAt."),
228 );
229 } else if (retrievedAt !== null && sourceDate !== null && retrievedAt < sourceDate) {
230 findings.push(
231 finding(`${base}.retrievedAt`, "retrieval-before-source", "retrievedAt cannot predate the source date."),
232 );
233 }
234 if (typeof source.url === "string" && !sourceLocation(source.url)) {
235 findings.push(
236 finding(
237 `${base}.url`,
238 "source-url",
239 "Source location must be http(s), file://, or a relative or absolute local path.",
240 ),
241 );
242 }
243 if (typeof source.locator === "string" && !boundedLocator(source.locator)) {
244 findings.push(
245 finding(
246 `${base}.locator`,
247 "source-locator",
248 "Locator must identify a bounded page, section, line range, or timestamp (for example p. 7, § 2.1, L12-L18, Section: Results, or 00:04:31).",
249 ),
250 );
251 }
252 if (typeof source.excerpt === "string" && source.excerpt.trim().length < 40) {
253 findings.push(
254 finding(
255 `${base}.excerpt`,
256 "thin-excerpt",
257 "Source excerpt must contain at least 40 characters of checkable context.",
258 ),
259 );
260 }
261 if (typeof source.excerpt === "string" && source.excerpt.trim().length > 500) {
262 findings.push(
263 finding(
264 `${base}.excerpt`,
265 "oversized-excerpt",
266 "Keep source excerpts under 500 characters and link to the full source.",
267 ),
268 );
269 }
270 if (
271 typeof source.excerpt === "string"
272 && source.excerpt.trim().length >= 40
273 && source.excerpt.trim().length <= 500
274 && !substantiveExcerpt(source.excerpt)
275 ) {
276 findings.push(
277 finding(
278 `${base}.excerpt`,
279 "low-information-excerpt",
280 "Source excerpt must contain varied, checkable content rather than repeated filler.",
281 ),
282 );
283 }
284 if (source.verification != null) {
285 const verification = source.verification;
286 const verificationBase = `${base}.verification`;
287 if (!verification || typeof verification !== "object" || Array.isArray(verification)) {
288 findings.push(
289 finding(verificationBase, "verification-type", "verification must be an object."),
290 );
291 } else {
292 if (verification.status !== "verified") {
293 findings.push(
294 finding(`${verificationBase}.status`, "verification-status", "Verification status must be verified."),
295 );
296 }
297 if (verification.method !== "normalized-excerpt-match") {
298 findings.push(
299 finding(
300 `${verificationBase}.method`,
301 "verification-method",
302 "Verification method must be normalized-excerpt-match.",
303 ),
304 );
305 }
306 if (!validUtcTimestamp(verification.checkedAt)) {
307 findings.push(
308 finding(
309 `${verificationBase}.checkedAt`,
310 "verification-time",
311 "Verification checkedAt must be an ISO UTC timestamp ending in Z.",
312 ),
313 );
314 } else if (source.retrievedAt !== verification.checkedAt.slice(0, 10)) {
315 findings.push(
316 finding(
317 `${verificationBase}.checkedAt`,
318 "verification-retrieval-mismatch",
319 "A verified source retrievedAt must equal the UTC date in verification.checkedAt.",
320 ),
321 );
322 }
323 for (const key of ["contentSha256", "excerptSha256"]) {
324 if (typeof verification[key] !== "string" || !/^[a-f0-9]{64}$/.test(verification[key])) {
325 findings.push(
326 finding(
327 `${verificationBase}.${key}`,
328 "verification-digest",
329 `${key} must be a lowercase SHA-256 digest.`,
330 ),
331 );
332 }
333 }
334 if (!["matched", "not-machine-checked"].includes(verification.locatorStatus)) {
335 findings.push(
336 finding(
337 `${verificationBase}.locatorStatus`,
338 "verification-locator",
339 "locatorStatus must be matched or not-machine-checked.",
340 ),
341 );
342 }
343 if (typeof verification.finalUrl !== "string" || !sourceLocation(verification.finalUrl)) {
344 findings.push(
345 finding(
346 `${verificationBase}.finalUrl`,
347 "verification-url",
348 "finalUrl must be an http(s), file://, or local path source location.",
349 ),
350 );
351 }
352 }
353 }
354 }
355
356 const incoming = new Map(nodes.filter((node) => node?.id).map((node) => [node.id, 0]));
357 const adjacency = new Map(nodes.filter((node) => node?.id).map((node) => [node.id, []]));
358 const uniqueEdges = new Set();
359 for (const [index, edge] of edges.entries()) {
360 const base = `$.edges[${index}]`;
361 if (!edge || typeof edge !== "object" || Array.isArray(edge)) {
362 findings.push(finding(base, "edge-type", "Each edge must be an object."));
363 continue;
364 }
365 if (!nodeIds.has(edge.from)) {
366 findings.push(finding(`${base}.from`, "unknown-node", `Unknown from node: ${edge.from}.`));
367 }
368 if (!nodeIds.has(edge.to)) {
369 findings.push(finding(`${base}.to`, "unknown-node", `Unknown to node: ${edge.to}.`));
370 }
371 if (edge.from && edge.from === edge.to) {
372 findings.push(finding(base, "self-edge", `Node ${edge.from} cannot point to itself.`));
373 }
374 const edgeKey = `${edge.from}\0${edge.to}\0${edge.relation}`;
375 if (uniqueEdges.has(edgeKey)) {
376 findings.push(
377 finding(base, "duplicate-edge", "Duplicate from/to/relation edges are not allowed."),
378 );
379 } else {
380 uniqueEdges.add(edgeKey);
381 }
382 if (!RELATIONS.has(edge.relation)) {
383 findings.push(
384 finding(
385 `${base}.relation`,
386 "edge-relation",
387 `Relation must be one of: ${[...RELATIONS].join(", ")}.`,
388 ),
389 );
390 }
391 if (!edge.note || typeof edge.note !== "string") {
392 findings.push(
393 finding(`${base}.note`, "edge-note", "Each reasoning edge needs a plain-language note."),
394 );
395 }
396 if (nodeIds.has(edge.to)) incoming.set(edge.to, (incoming.get(edge.to) || 0) + 1);
397 if (nodeIds.has(edge.from) && nodeIds.has(edge.to) && edge.from !== edge.to) {
398 adjacency.get(edge.from).push(edge.to);
399 }
400 }
401
402 for (const [index, node] of nodes.entries()) {
403 if (!node || typeof node !== "object") continue;
404 const base = `$.nodes[${index}]`;
405 if (node.type === "evidence" && !node.sourceId) {
406 findings.push(
407 finding(`${base}.sourceId`, "unsourced-evidence", "Evidence nodes require sourceId."),
408 );
409 }
410 if (node.sourceId && !sourceIds.has(node.sourceId)) {
411 findings.push(
412 finding(
413 `${base}.sourceId`,
414 "unknown-source",
415 `Node references unknown source: ${node.sourceId}.`,
416 ),
417 );
418 }
419 if (
420 node.type === "evidence" &&
421 node.id &&
422 !edges.some((edge) => edge?.from === node.id)
423 ) {
424 findings.push(
425 finding(base, "unused-evidence", "Evidence must participate in at least one reasoning edge."),
426 );
427 }
428 }
429
430 for (const [index, source] of sources.entries()) {
431 if (
432 source?.id &&
433 !nodes.some((node) => node?.type === "evidence" && node.sourceId === source.id)
434 ) {
435 findings.push(
436 finding(
437 `$.sources[${index}]`,
438 "unused-source",
439 "Every source must be attached to at least one evidence node.",
440 ),
441 );
442 }
443 }
444
445 const positions = nodes.filter((node) => node?.type === "position");
446 if (positions.length !== 1) {
447 findings.push(
448 finding("$.nodes", "position-count", "The map must contain exactly one position node."),
449 );
450 } else if (!incoming.get(positions[0].id)) {
451 findings.push(
452 finding(
453 `$.nodes[${nodes.indexOf(positions[0])}]`,
454 "unsupported-position",
455 "The position needs at least one incoming reasoning edge.",
456 ),
457 );
458 } else {
459 for (const [index, node] of nodes.entries()) {
460 if (!node?.id || node.id === positions[0].id) continue;
461 if (!reaches(node.id, positions[0].id, adjacency)) {
462 findings.push(
463 finding(
464 `$.nodes[${index}]`,
465 "disconnected-node",
466 `Node ${node.id} must have a directed reasoning path to the position.`,
467 ),
468 );
469 }
470 }
471 }
472
473 const visitState = new Map();
474 const cyclicNodes = new Set();
475 function visit(nodeId, stack = []) {
476 const state = visitState.get(nodeId) || 0;
477 if (state === 1) {
478 for (const member of stack.slice(stack.indexOf(nodeId))) cyclicNodes.add(member);
479 return;
480 }
481 if (state === 2) return;
482 visitState.set(nodeId, 1);
483 for (const next of adjacency.get(nodeId) || []) visit(next, [...stack, nodeId]);
484 visitState.set(nodeId, 2);
485 }
486 for (const nodeId of nodeIds) visit(nodeId);
487 for (const nodeId of cyclicNodes) {
488 const index = nodes.findIndex((node) => node?.id === nodeId);
489 findings.push(
490 finding(`$.nodes[${index}]`, "reasoning-cycle", `Node ${nodeId} participates in a reasoning cycle.`),
491 );
492 }
493
494 const metrics = {
495 claims: nodes.filter((node) => node?.type === "claim").length,
496 contradictions: new Set(
497 edges
498 .filter((edge) => edge?.relation === "contradicts")
499 .map((edge) => `${edge.from}\0${edge.to}\0${edge.relation}`),
500 ).size,
501 evidence: nodes.filter((node) => node?.type === "evidence").length,
502 sources: sources.length,
503 unknowns: nodes.filter((node) => node?.type === "unknown").length,
504 };
505 return {
506 findings,
507 metrics,
508 receipt: null,
509 valid: findings.length === 0,
510 };
511}
512
513export function validateMapContract(map) {
514 const result = inspectMapContract(map);
515 if (!result.valid) throw new MapValidationError(result.findings);
516 return result;
517}