mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-07 18:03:02 +00:00
37ade4b521
* Add source-grounded evidence map skill * Bundle offline evidence map validation * Avoid implicit package execution
518 lines
18 KiB
JavaScript
518 lines
18 KiB
JavaScript
export const NODE_TYPES = new Set(["position", "claim", "evidence", "unknown"]);
|
||
export const RELATIONS = new Set(["supports", "contradicts", "qualifies", "missing"]);
|
||
|
||
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||
const ISO_UTC_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?Z$/;
|
||
const LOCATOR_PATTERNS = [
|
||
/\bp(?:age)?\.?\s*\d+(?:\s*[-–]\s*\d+)?\b/i,
|
||
/§\s*[\p{L}\p{N}][\p{L}\p{N}._-]*/u,
|
||
/\bL\d+(?:\s*[-–]\s*L?\d+)?\b/i,
|
||
/\blines?\s+\d+(?:\s*[-–]\s*\d+)?\b/i,
|
||
/\b(?:\d{1,2}:)?\d{2}:\d{2}(?:\s*[-–]\s*(?:\d{1,2}:)?\d{2}:\d{2})?\b/,
|
||
/^(?:section|chapter|heading)\s*(?::|§)\s*\S.{1,}$/i,
|
||
];
|
||
|
||
export class MapValidationError extends Error {
|
||
constructor(findings) {
|
||
super(`Evidence map is invalid (${findings.length} ${findings.length === 1 ? "finding" : "findings"}).`);
|
||
this.name = "MapValidationError";
|
||
this.findings = findings;
|
||
}
|
||
}
|
||
|
||
function canonical(value) {
|
||
if (Array.isArray(value)) return value.map(canonical);
|
||
if (!value || typeof value !== "object") return value;
|
||
return Object.fromEntries(
|
||
Object.keys(value)
|
||
.sort()
|
||
.map((key) => [key, canonical(value[key])]),
|
||
);
|
||
}
|
||
|
||
export function canonicalJson(map) {
|
||
return JSON.stringify(canonical(map));
|
||
}
|
||
|
||
export function receiptPayload(map, sourceSnapshots) {
|
||
return {
|
||
contract: "doubt-evidence-receipt-v1",
|
||
map,
|
||
sourceSnapshots,
|
||
};
|
||
}
|
||
|
||
function finding(path, rule, message) {
|
||
return { path, rule, message };
|
||
}
|
||
|
||
function parseIsoDate(value) {
|
||
if (typeof value !== "string") return null;
|
||
const match = value.match(ISO_DATE);
|
||
if (!match) return null;
|
||
const [, year, month, day] = match.map(Number);
|
||
const time = Date.UTC(year, month - 1, day);
|
||
const date = new Date(time);
|
||
if (
|
||
date.getUTCFullYear() !== year
|
||
|| date.getUTCMonth() !== month - 1
|
||
|| date.getUTCDate() !== day
|
||
) return null;
|
||
return time;
|
||
}
|
||
|
||
function retrievalDate(value) {
|
||
const date = parseIsoDate(value);
|
||
if (date !== null) return date;
|
||
if (typeof value !== "string") return null;
|
||
const match = value.match(ISO_UTC_TIMESTAMP);
|
||
if (!match) return null;
|
||
const [, year, month, day, hour, minute, second] = match.map(Number);
|
||
if (hour > 23 || minute > 59 || second > 59) return null;
|
||
const dayValue = parseIsoDate(
|
||
`${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
|
||
);
|
||
return dayValue === null ? null : dayValue;
|
||
}
|
||
|
||
function validUtcTimestamp(value) {
|
||
if (typeof value !== "string") return false;
|
||
const match = value.match(ISO_UTC_TIMESTAMP);
|
||
if (!match) return false;
|
||
const [, year, month, day, hour, minute, second] = match.map(Number);
|
||
if (hour > 23 || minute > 59 || second > 59) return false;
|
||
return parseIsoDate(
|
||
`${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
|
||
) !== null;
|
||
}
|
||
|
||
function boundedLocator(value) {
|
||
return typeof value === "string" && LOCATOR_PATTERNS.some((pattern) => pattern.test(value.trim()));
|
||
}
|
||
|
||
function sourceLocation(value) {
|
||
return typeof value === "string" && /^(?:https?:\/\/|file:\/\/|\.\.?[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(value);
|
||
}
|
||
|
||
function substantiveExcerpt(value) {
|
||
if (typeof value !== "string") return false;
|
||
const symbols = value.toLowerCase().match(/[\p{L}\p{N}]/gu) || [];
|
||
return new Set(symbols).size >= 6;
|
||
}
|
||
|
||
function reaches(start, target, adjacency, seen = new Set()) {
|
||
if (start === target) return true;
|
||
if (seen.has(start)) return false;
|
||
seen.add(start);
|
||
return (adjacency.get(start) || []).some((next) => reaches(next, target, adjacency, seen));
|
||
}
|
||
|
||
export function inspectMapContract(map) {
|
||
const findings = [];
|
||
if (!map || typeof map !== "object" || Array.isArray(map)) {
|
||
return {
|
||
findings: [finding("$", "map-type", "The map must be a JSON object.")],
|
||
metrics: { claims: 0, contradictions: 0, evidence: 0, sources: 0, unknowns: 0 },
|
||
receipt: null,
|
||
valid: false,
|
||
};
|
||
}
|
||
|
||
for (const key of ["title", "question", "verdict", "updatedAt"]) {
|
||
if (!map[key] || typeof map[key] !== "string") {
|
||
findings.push(finding(`$.${key}`, "required-field", `${key} must be a non-empty string.`));
|
||
}
|
||
}
|
||
const updatedAt = parseIsoDate(map.updatedAt);
|
||
if (typeof map.updatedAt === "string" && updatedAt === null) {
|
||
findings.push(
|
||
finding("$.updatedAt", "map-date", "updatedAt must be a real calendar date in YYYY-MM-DD format."),
|
||
);
|
||
}
|
||
if (!Array.isArray(map.nodes) || map.nodes.length === 0) {
|
||
findings.push(finding("$.nodes", "required-nodes", "nodes must be a non-empty array."));
|
||
}
|
||
if (!Array.isArray(map.edges)) {
|
||
findings.push(finding("$.edges", "required-edges", "edges must be an array."));
|
||
}
|
||
if (!Array.isArray(map.sources)) {
|
||
findings.push(finding("$.sources", "required-sources", "sources must be an array."));
|
||
}
|
||
|
||
const nodes = Array.isArray(map.nodes) ? map.nodes : [];
|
||
const edges = Array.isArray(map.edges) ? map.edges : [];
|
||
const sources = Array.isArray(map.sources) ? map.sources : [];
|
||
const nodeIds = new Set();
|
||
const sourceIds = new Set();
|
||
|
||
for (const [index, node] of nodes.entries()) {
|
||
const base = `$.nodes[${index}]`;
|
||
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
||
findings.push(finding(base, "node-type", "Each node must be an object."));
|
||
continue;
|
||
}
|
||
if (!node.id || typeof node.id !== "string") {
|
||
findings.push(finding(`${base}.id`, "node-id", "Each node needs a string id."));
|
||
} else if (nodeIds.has(node.id)) {
|
||
findings.push(finding(`${base}.id`, "duplicate-node", `Duplicate node id: ${node.id}.`));
|
||
} else {
|
||
nodeIds.add(node.id);
|
||
}
|
||
if (!NODE_TYPES.has(node.type)) {
|
||
findings.push(
|
||
finding(
|
||
`${base}.type`,
|
||
"node-type",
|
||
`Node type must be one of: ${[...NODE_TYPES].join(", ")}.`,
|
||
),
|
||
);
|
||
}
|
||
for (const key of ["label", "text"]) {
|
||
if (!node[key] || typeof node[key] !== "string") {
|
||
findings.push(finding(`${base}.${key}`, "node-copy", `${key} must be a non-empty string.`));
|
||
}
|
||
}
|
||
if (node.confidence != null) {
|
||
findings.push(
|
||
finding(
|
||
`${base}.confidence`,
|
||
"false-precision",
|
||
"Confidence percentages are not supported; use an unknown or a qualified claim instead.",
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const [index, source] of sources.entries()) {
|
||
const base = `$.sources[${index}]`;
|
||
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
||
findings.push(finding(base, "source-type", "Each source must be an object."));
|
||
continue;
|
||
}
|
||
if (!source.id || typeof source.id !== "string") {
|
||
findings.push(finding(`${base}.id`, "source-id", "Each source needs a string id."));
|
||
} else if (sourceIds.has(source.id)) {
|
||
findings.push(finding(`${base}.id`, "duplicate-source", `Duplicate source id: ${source.id}.`));
|
||
} else {
|
||
sourceIds.add(source.id);
|
||
}
|
||
for (const key of ["title", "publisher", "date", "retrievedAt", "url", "locator", "excerpt"]) {
|
||
if (!source[key] || typeof source[key] !== "string") {
|
||
findings.push(
|
||
finding(`${base}.${key}`, "source-field", `${key} must be a non-empty string.`),
|
||
);
|
||
}
|
||
}
|
||
const sourceDate = parseIsoDate(source.date);
|
||
if (typeof source.date === "string" && sourceDate === null) {
|
||
findings.push(
|
||
finding(`${base}.date`, "source-date", "Source date must be a real calendar date in YYYY-MM-DD format."),
|
||
);
|
||
} else if (sourceDate !== null && updatedAt !== null && sourceDate > updatedAt) {
|
||
findings.push(
|
||
finding(`${base}.date`, "future-source-date", "Source date cannot be later than map.updatedAt."),
|
||
);
|
||
}
|
||
const retrievedAt = retrievalDate(source.retrievedAt);
|
||
if (typeof source.retrievedAt === "string" && retrievedAt === null) {
|
||
findings.push(
|
||
finding(
|
||
`${base}.retrievedAt`,
|
||
"retrieval-date",
|
||
"retrievedAt must be YYYY-MM-DD or an ISO UTC timestamp ending in Z.",
|
||
),
|
||
);
|
||
} else if (retrievedAt !== null && updatedAt !== null && retrievedAt > updatedAt) {
|
||
findings.push(
|
||
finding(`${base}.retrievedAt`, "future-retrieval", "retrievedAt cannot be later than map.updatedAt."),
|
||
);
|
||
} else if (retrievedAt !== null && sourceDate !== null && retrievedAt < sourceDate) {
|
||
findings.push(
|
||
finding(`${base}.retrievedAt`, "retrieval-before-source", "retrievedAt cannot predate the source date."),
|
||
);
|
||
}
|
||
if (typeof source.url === "string" && !sourceLocation(source.url)) {
|
||
findings.push(
|
||
finding(
|
||
`${base}.url`,
|
||
"source-url",
|
||
"Source location must be http(s), file://, or a relative or absolute local path.",
|
||
),
|
||
);
|
||
}
|
||
if (typeof source.locator === "string" && !boundedLocator(source.locator)) {
|
||
findings.push(
|
||
finding(
|
||
`${base}.locator`,
|
||
"source-locator",
|
||
"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).",
|
||
),
|
||
);
|
||
}
|
||
if (typeof source.excerpt === "string" && source.excerpt.trim().length < 40) {
|
||
findings.push(
|
||
finding(
|
||
`${base}.excerpt`,
|
||
"thin-excerpt",
|
||
"Source excerpt must contain at least 40 characters of checkable context.",
|
||
),
|
||
);
|
||
}
|
||
if (typeof source.excerpt === "string" && source.excerpt.trim().length > 500) {
|
||
findings.push(
|
||
finding(
|
||
`${base}.excerpt`,
|
||
"oversized-excerpt",
|
||
"Keep source excerpts under 500 characters and link to the full source.",
|
||
),
|
||
);
|
||
}
|
||
if (
|
||
typeof source.excerpt === "string"
|
||
&& source.excerpt.trim().length >= 40
|
||
&& source.excerpt.trim().length <= 500
|
||
&& !substantiveExcerpt(source.excerpt)
|
||
) {
|
||
findings.push(
|
||
finding(
|
||
`${base}.excerpt`,
|
||
"low-information-excerpt",
|
||
"Source excerpt must contain varied, checkable content rather than repeated filler.",
|
||
),
|
||
);
|
||
}
|
||
if (source.verification != null) {
|
||
const verification = source.verification;
|
||
const verificationBase = `${base}.verification`;
|
||
if (!verification || typeof verification !== "object" || Array.isArray(verification)) {
|
||
findings.push(
|
||
finding(verificationBase, "verification-type", "verification must be an object."),
|
||
);
|
||
} else {
|
||
if (verification.status !== "verified") {
|
||
findings.push(
|
||
finding(`${verificationBase}.status`, "verification-status", "Verification status must be verified."),
|
||
);
|
||
}
|
||
if (verification.method !== "normalized-excerpt-match") {
|
||
findings.push(
|
||
finding(
|
||
`${verificationBase}.method`,
|
||
"verification-method",
|
||
"Verification method must be normalized-excerpt-match.",
|
||
),
|
||
);
|
||
}
|
||
if (!validUtcTimestamp(verification.checkedAt)) {
|
||
findings.push(
|
||
finding(
|
||
`${verificationBase}.checkedAt`,
|
||
"verification-time",
|
||
"Verification checkedAt must be an ISO UTC timestamp ending in Z.",
|
||
),
|
||
);
|
||
} else if (source.retrievedAt !== verification.checkedAt.slice(0, 10)) {
|
||
findings.push(
|
||
finding(
|
||
`${verificationBase}.checkedAt`,
|
||
"verification-retrieval-mismatch",
|
||
"A verified source retrievedAt must equal the UTC date in verification.checkedAt.",
|
||
),
|
||
);
|
||
}
|
||
for (const key of ["contentSha256", "excerptSha256"]) {
|
||
if (typeof verification[key] !== "string" || !/^[a-f0-9]{64}$/.test(verification[key])) {
|
||
findings.push(
|
||
finding(
|
||
`${verificationBase}.${key}`,
|
||
"verification-digest",
|
||
`${key} must be a lowercase SHA-256 digest.`,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
if (!["matched", "not-machine-checked"].includes(verification.locatorStatus)) {
|
||
findings.push(
|
||
finding(
|
||
`${verificationBase}.locatorStatus`,
|
||
"verification-locator",
|
||
"locatorStatus must be matched or not-machine-checked.",
|
||
),
|
||
);
|
||
}
|
||
if (typeof verification.finalUrl !== "string" || !sourceLocation(verification.finalUrl)) {
|
||
findings.push(
|
||
finding(
|
||
`${verificationBase}.finalUrl`,
|
||
"verification-url",
|
||
"finalUrl must be an http(s), file://, or local path source location.",
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const incoming = new Map(nodes.filter((node) => node?.id).map((node) => [node.id, 0]));
|
||
const adjacency = new Map(nodes.filter((node) => node?.id).map((node) => [node.id, []]));
|
||
const uniqueEdges = new Set();
|
||
for (const [index, edge] of edges.entries()) {
|
||
const base = `$.edges[${index}]`;
|
||
if (!edge || typeof edge !== "object" || Array.isArray(edge)) {
|
||
findings.push(finding(base, "edge-type", "Each edge must be an object."));
|
||
continue;
|
||
}
|
||
if (!nodeIds.has(edge.from)) {
|
||
findings.push(finding(`${base}.from`, "unknown-node", `Unknown from node: ${edge.from}.`));
|
||
}
|
||
if (!nodeIds.has(edge.to)) {
|
||
findings.push(finding(`${base}.to`, "unknown-node", `Unknown to node: ${edge.to}.`));
|
||
}
|
||
if (edge.from && edge.from === edge.to) {
|
||
findings.push(finding(base, "self-edge", `Node ${edge.from} cannot point to itself.`));
|
||
}
|
||
const edgeKey = `${edge.from}\0${edge.to}\0${edge.relation}`;
|
||
if (uniqueEdges.has(edgeKey)) {
|
||
findings.push(
|
||
finding(base, "duplicate-edge", "Duplicate from/to/relation edges are not allowed."),
|
||
);
|
||
} else {
|
||
uniqueEdges.add(edgeKey);
|
||
}
|
||
if (!RELATIONS.has(edge.relation)) {
|
||
findings.push(
|
||
finding(
|
||
`${base}.relation`,
|
||
"edge-relation",
|
||
`Relation must be one of: ${[...RELATIONS].join(", ")}.`,
|
||
),
|
||
);
|
||
}
|
||
if (!edge.note || typeof edge.note !== "string") {
|
||
findings.push(
|
||
finding(`${base}.note`, "edge-note", "Each reasoning edge needs a plain-language note."),
|
||
);
|
||
}
|
||
if (nodeIds.has(edge.to)) incoming.set(edge.to, (incoming.get(edge.to) || 0) + 1);
|
||
if (nodeIds.has(edge.from) && nodeIds.has(edge.to) && edge.from !== edge.to) {
|
||
adjacency.get(edge.from).push(edge.to);
|
||
}
|
||
}
|
||
|
||
for (const [index, node] of nodes.entries()) {
|
||
if (!node || typeof node !== "object") continue;
|
||
const base = `$.nodes[${index}]`;
|
||
if (node.type === "evidence" && !node.sourceId) {
|
||
findings.push(
|
||
finding(`${base}.sourceId`, "unsourced-evidence", "Evidence nodes require sourceId."),
|
||
);
|
||
}
|
||
if (node.sourceId && !sourceIds.has(node.sourceId)) {
|
||
findings.push(
|
||
finding(
|
||
`${base}.sourceId`,
|
||
"unknown-source",
|
||
`Node references unknown source: ${node.sourceId}.`,
|
||
),
|
||
);
|
||
}
|
||
if (
|
||
node.type === "evidence" &&
|
||
node.id &&
|
||
!edges.some((edge) => edge?.from === node.id)
|
||
) {
|
||
findings.push(
|
||
finding(base, "unused-evidence", "Evidence must participate in at least one reasoning edge."),
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const [index, source] of sources.entries()) {
|
||
if (
|
||
source?.id &&
|
||
!nodes.some((node) => node?.type === "evidence" && node.sourceId === source.id)
|
||
) {
|
||
findings.push(
|
||
finding(
|
||
`$.sources[${index}]`,
|
||
"unused-source",
|
||
"Every source must be attached to at least one evidence node.",
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
const positions = nodes.filter((node) => node?.type === "position");
|
||
if (positions.length !== 1) {
|
||
findings.push(
|
||
finding("$.nodes", "position-count", "The map must contain exactly one position node."),
|
||
);
|
||
} else if (!incoming.get(positions[0].id)) {
|
||
findings.push(
|
||
finding(
|
||
`$.nodes[${nodes.indexOf(positions[0])}]`,
|
||
"unsupported-position",
|
||
"The position needs at least one incoming reasoning edge.",
|
||
),
|
||
);
|
||
} else {
|
||
for (const [index, node] of nodes.entries()) {
|
||
if (!node?.id || node.id === positions[0].id) continue;
|
||
if (!reaches(node.id, positions[0].id, adjacency)) {
|
||
findings.push(
|
||
finding(
|
||
`$.nodes[${index}]`,
|
||
"disconnected-node",
|
||
`Node ${node.id} must have a directed reasoning path to the position.`,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
const visitState = new Map();
|
||
const cyclicNodes = new Set();
|
||
function visit(nodeId, stack = []) {
|
||
const state = visitState.get(nodeId) || 0;
|
||
if (state === 1) {
|
||
for (const member of stack.slice(stack.indexOf(nodeId))) cyclicNodes.add(member);
|
||
return;
|
||
}
|
||
if (state === 2) return;
|
||
visitState.set(nodeId, 1);
|
||
for (const next of adjacency.get(nodeId) || []) visit(next, [...stack, nodeId]);
|
||
visitState.set(nodeId, 2);
|
||
}
|
||
for (const nodeId of nodeIds) visit(nodeId);
|
||
for (const nodeId of cyclicNodes) {
|
||
const index = nodes.findIndex((node) => node?.id === nodeId);
|
||
findings.push(
|
||
finding(`$.nodes[${index}]`, "reasoning-cycle", `Node ${nodeId} participates in a reasoning cycle.`),
|
||
);
|
||
}
|
||
|
||
const metrics = {
|
||
claims: nodes.filter((node) => node?.type === "claim").length,
|
||
contradictions: new Set(
|
||
edges
|
||
.filter((edge) => edge?.relation === "contradicts")
|
||
.map((edge) => `${edge.from}\0${edge.to}\0${edge.relation}`),
|
||
).size,
|
||
evidence: nodes.filter((node) => node?.type === "evidence").length,
|
||
sources: sources.length,
|
||
unknowns: nodes.filter((node) => node?.type === "unknown").length,
|
||
};
|
||
return {
|
||
findings,
|
||
metrics,
|
||
receipt: null,
|
||
valid: findings.length === 0,
|
||
};
|
||
}
|
||
|
||
export function validateMapContract(map) {
|
||
const result = inspectMapContract(map);
|
||
if (!result.valid) throw new MapValidationError(result.findings);
|
||
return result;
|
||
}
|