UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

407 lines • 16.8 kB
"use strict"; /** * overclaim — the rule pack that checks a declaration like a claim: a refusal * or an absence never asserts more than was read. * * Pattern: a pure rule pack (the `tool-lint/rules.ts` shape), asked by the * `check:semantics` gate (`check.ts`). No IO, no model. * Role: lib/ layer (L1), pure. Reads the recognizers this folder owns. * Emits: N/A. * * ## The finding it answers * * A registered study (two models) found that a tool's refusal text that said * more than its check — "No array matches 'X'. Known arrays: … a wrong * identifier, not missing data" — made replies WORSE: models told users "X is * not in your estate" when the tool had only failed to find X in one * inventory export. A refused call read nothing; an absence read what its * `checked` names. Neither may say what does not exist, or that the input is * wrong. * * ## The rules * * • `refusal-asserts-absence` — a not-read result (a refusal a tool throws * or returns; an effects envelope whose status is `invalid`, `denied` or * `failure`) carries an absence (`af_absent`), or a sentence about what * was looked up from {@link NON_EXISTENCE} or {@link WRONGNESS}. * • `absence-asserts-wrongness` — an item's `what`/`why`, `try_instead`, * the typed tool's `why`, or an extra key's text has a sentence about * what was looked up that calls it wrong ({@link WRONGNESS}). * • `authoritative-names` — every `among.authoritative` use, listed for * review: it removes the gap "names outside <source>". * * A sentence is ABOUT what was looked up when it names the looked-up argument * or its value (`LookupSubject` — a described refusal sample's, or an * absence's `looked_for` and facet argument; with neither, a quoted span * stands for the value). A format, range, permission or transport message * exempts a WRONGNESS phrase only; a NON-EXISTENCE phrase about the lookup * always fires. * * ## The phrase packs — closed, versioned, small * * Two fixed lists of English phrases, matched after NFC, case folding and * curly-apostrophe folding. They are a gate's net, not a grammar: a phrasing * they miss is a missed finding, never a pass. The fuller detection grammar * (clauses, negation scope, languages) belongs to the delivery check, a later * piece; it may reuse these lists. {@link OVERCLAIM_PHRASES_VERSION} changes * whenever a list does. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.absencesIn = exports.absenceIssues = exports.refusalIssues = exports.isNotReadResult = exports.isExemptMessage = exports.wrongnessPhrase = exports.nonExistencePhrase = exports.SEMANTICS_RULE_SETS = exports.OVERCLAIM_PHRASES_VERSION = void 0; const recognize_js_1 = require("./coverage/recognize.js"); const among_js_1 = require("./coverage/among.js"); const toolEffects_js_1 = require("./toolEffects.js"); /** Bumped whenever {@link NON_EXISTENCE} or {@link WRONGNESS} changes. */ exports.OVERCLAIM_PHRASES_VERSION = 1; /** The closed list of rule sets, as data. */ exports.SEMANTICS_RULE_SETS = ['overclaim']; /** A sentence that says a thing is not there. */ const NON_EXISTENCE = [ /\b(?:does|do|did) not exist\b/, /\b(?:doesn't|don't|didn't) exist\b/, /\bno longer exists?\b/, /\bnon-?existent\b/, /\bno such\b/, /\bnot found\b/, /\bthere (?:is|are|was|were) no\b/, /\bno \w+ (?:matches|matched|exists|was found|were found)\b/, /\bnone of\b/, /\bnothing (?:is|was|matches|matched)\b/, /\b(?:is|are|was|were) not (?:in|among|listed)\b/, /\b(?:isn't|aren't|wasn't|weren't) (?:in|among|listed)\b/, /\b(?:has|have|had) no\b/, /\b0 [a-z]+s\b/, ]; /** A sentence that calls the input wrong. */ const WRONGNESS = [ /\bwrong (?:identifier|id|name|value|key|input)\b/, /\bnot an? valid\b/, /\binvalid (?:identifier|id|name|value|key|input)\b/, /\bno such\b/, /\bnot missing data\b/, /\btypos?\b/, /\bmisspel(?:led|t|ling)\b/, /\bmistyped\b/, ]; /** One spelling for matching: composed, case-folded, straight apostrophes. */ const folded = (text) => text .normalize('NFC') .toLowerCase() .replace(/[\u2018\u2019\u02BC]/g, "'"); function firstHit(text, pack) { const plain = folded(text); for (const pattern of pack) { const hit = pattern.exec(plain); if (hit !== null) return hit[0]; } return undefined; } /** The first non-existence phrase in `text`, or `undefined`. */ function nonExistencePhrase(text) { return firstHit(text, NON_EXISTENCE); } exports.nonExistencePhrase = nonExistencePhrase; /** The first phrase in `text` that calls the input wrong, or `undefined`. */ function wrongnessPhrase(text) { return firstHit(text, WRONGNESS); } exports.wrongnessPhrase = wrongnessPhrase; /** * Messages of a kind that says why a call was refused: a FORMAT the value * broke, a RANGE it left, a PERMISSION it lacked, or a TRANSPORT failure * upstream. Such a sentence may call the value wrong in that sense ("array_id * 'PS-ARRAY-99' is not a valid identifier format"), so it exempts a WRONGNESS * phrase. It never exempts a NON-EXISTENCE phrase: "No array matches 'X' * (expected one of …)" still says X does not exist, whatever else the * sentence holds. */ const EXEMPT_KINDS = [ // format /\bformat\b/, /\biso[- ]?8601\b/, /\b(?:date|timestamp|time ?zone|regex|pattern|json|schema|type)\b/, /\bmust (?:match|be an? )/, /\bexpected\b/, /\binvalid value for\b/, // range /\brange\b/, /\bbetween\b/, /\bat (?:least|most)\b/, /\b(?:minimum|maximum)\b/, /\btoo (?:long|short|large|small|many|few)\b/, /\bexceeds?\b/, /\bout of bounds\b/, /\ballowed values?\b/, /\bone of\b/, // permission /\bpermissions?\b/, /\baccess\b/, /\b(?:un)?authori[sz](?:ed|ation)\b/, /\bforbidden\b/, /\bdenied\b/, /\bapi key\b/, /\btokens?\b/, /\bcredentials?\b/, /\bnot allowed\b/, /\b40[13]\b/, // transport /\bupstream\b/, /\bhttp\b/, /\b404\b/, /\b5\d\d\b/, /\bstatus code\b/, /\btimed? ?out\b/, /\bunavailable\b/, ]; /** True when a sentence is a format, range, permission or transport message. */ function isExemptMessage(sentence) { const plain = folded(sentence); return EXEMPT_KINDS.some((pattern) => pattern.test(plain)); } exports.isExemptMessage = isExemptMessage; /** `needle` as a whole word of `haystack` (both folded), not a piece of a longer token. */ function namesWord(haystack, needle) { const word = folded(needle).trim(); if (word === '') return false; const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return new RegExp(`(?<![\\p{L}\\p{N}_-])${escaped}(?![\\p{L}\\p{N}_-])`, 'u').test(folded(haystack)); } /** The quote pairs a message echoes a value in: straight, back and curly quotes. */ const QUOTE_PAIRS = [ ["'", "'"], ['"', '"'], ['`', '`'], [String.fromCodePoint(0x2018), String.fromCodePoint(0x2019)], [String.fromCodePoint(0x201c), String.fromCodePoint(0x201d)], ]; /** * True when `text` holds a non-empty span in quotes — how a message usually * echoes the value it was given. A scan, never a backtracking pattern: the * text is a tool's, and a run of opening quotes with no closing one must cost * linear time (CodeQL's polynomial-regex finding on the first cut). */ function hasQuotedSpan(text) { for (const [open, close] of QUOTE_PAIRS) { let at = text.indexOf(open); while (at !== -1) { const end = text.indexOf(close, at + 1); if (end === -1) break; if (end > at + 1) return true; // An empty pair: with one character for both ends, its close may open // the next span; with two, look past it. at = open === close ? end : text.indexOf(open, end + 1); } } return false; } /** * True when `sentence` names what was looked up: the declared argument or * value; with neither declared, a quoted span stands for the value. */ function namesTheLookup(sentence, subject) { const declared = [subject.argument, subject.value].filter((part) => typeof part === 'string' && part.trim() !== ''); if (declared.length === 0) return hasQuotedSpan(sentence); return declared.some((part) => namesWord(sentence, part)); } /** The sentences of a text — split at a sentence end or a line break. */ const sentencesOf = (text) => text.split(/(?<=[.!?])\s+|\n+/).filter((part) => part.trim() !== ''); /** * The first phrase of `pack` in a sentence of `text` that names the lookup, or * `undefined`. The pack decides what a sentence's kind exempts. */ function claimAboutTheLookup(text, subject, pack) { for (const sentence of sentencesOf(text)) { if (!namesTheLookup(sentence, subject)) continue; const phrase = pack(sentence); if (phrase !== undefined) return phrase; } return undefined; } /** A wrongness phrase, unless the sentence is a format, range, permission or transport message. */ const unexemptWrongness = (sentence) => isExemptMessage(sentence) ? undefined : wrongnessPhrase(sentence); /** A non-existence phrase — never exempt — or else an unexempt wrongness phrase. */ const absenceOrWrongness = (sentence) => nonExistencePhrase(sentence) ?? unexemptWrongness(sentence); /** * A refusal sample a catalog describes: the text, and what the refused call * looked up (`{ text, argument?, value? }`) — read when the object has those * keys only, so a refusal VALUE a tool returns is judged as the value it is. */ function describedSample(raw) { if (!isRecord(raw) || typeof raw.text !== 'string') return undefined; if (!Object.keys(raw).every((key) => key === 'text' || key === 'argument' || key === 'value')) { return undefined; } return { text: raw.text, ...(typeof raw.argument === 'string' && { argument: raw.argument }), ...(typeof raw.value === 'string' && { value: raw.value }), }; } const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); /** Every string leaf of `value` with its dot path, `seen`-guarded. */ function stringLeaves(value, path, out, seen) { if (typeof value === 'string') { out.push([path, value]); return; } if (typeof value !== 'object' || value === null || seen.has(value)) return; seen.add(value); const entries = Array.isArray(value) ? value.map((v, i) => [`${path}[${i}]`, v]) : Object.entries(value).map(([k, v]) => [path === '' ? k : `${path}.${k}`, v]); for (const [at, v] of entries) stringLeaves(v, at, out, seen); } /** The statuses that say a call read nothing. */ const NOT_READ_STATUSES = new Set(['invalid', 'denied', 'failure']); /** * True when `value` is a result that READ NOTHING by its own declaration — an * effects envelope whose `status` is `invalid`, `denied` or `failure`. */ function isNotReadResult(value) { const envelope = (0, toolEffects_js_1.readToolResultEnvelope)(value); return envelope?.status !== undefined && NOT_READ_STATUSES.has(envelope.status); } exports.isNotReadResult = isNotReadResult; /** * `refusal-asserts-absence` over one refusal — the text a tool throws, the * value it returns when it refuses, or a described sample * `{ text, argument?, value? }`. A refused call read nothing, so it may carry * no absence, and no sentence ABOUT what was looked up that says it does not * exist or that the input is wrong. A sentence about another argument, or a * format, range, permission or transport message, is not that claim. */ function refusalIssues(value, field) { const code = 'refusal-asserts-absence'; const described = describedSample(value); const subject = described ?? {}; const judged = described === undefined ? value : described.text; const text = typeof judged === 'string' ? judged : JSON.stringify(judged) ?? ''; if ((0, recognize_js_1.readAbsence)(judged) !== undefined || /"?af_absent"?\s*:/.test(text)) { return [ { code, field, message: 'a refusal carries an absence (`af_absent`) — a refused call read nothing, and an ' + 'absence says a search ran. Return one or the other.', }, ]; } const leaves = []; stringLeaves(judged, '', leaves, new Set()); for (const [, leaf] of leaves) { const phrase = claimAboutTheLookup(leaf, subject, absenceOrWrongness); if (phrase === undefined) continue; return [ { code, field, message: `a refusal says "${phrase}" about what was looked up — a refused call read nothing, ` + 'so it cannot say what does not exist or that the input is wrong. Say what was ' + 'refused and why ("array_id must start with PS-"); when a lookup ran and found ' + 'nothing, return absent({ what, among }) instead.', }, ]; } return []; } exports.refusalIssues = refusalIssues; /** The absence fields a wrongness phrase is looked for in, as `[path, text]`. */ function absenceTexts(absence) { const out = []; const own = absence; for (const [key, value] of Object.entries(own)) { // The library's own sentence is not the author's claim, and `looked_for` // IS the value asked for. if (key === 'note' || key === 'af_absent' || key === 'looked_for') continue; if (key === 'checked' || key === 'not_checked' || key === 'cannot_cover') { if (!Array.isArray(value)) continue; value.forEach((item, i) => { if (!isRecord(item)) return; for (const part of ['what', 'why']) { const text = item[part]; if (typeof text === 'string') out.push([`${key}[${i}].${part}`, text]); } }); continue; } stringLeaves(value, key, out, new Set()); } return out; } /** * `absence-asserts-wrongness` and `authoritative-names` over one absence. An * absence says what was searched; it never calls the input wrong — a name * missing from one list may exist elsewhere. */ function absenceIssues(absence) { const issues = []; const facet = (0, among_js_1.namesFacetOf)(absence); const subject = { value: absence.looked_for, ...(facet !== undefined && { argument: facet.argument }), }; for (const [field, text] of absenceTexts(absence)) { const phrase = claimAboutTheLookup(text, subject, unexemptWrongness); if (phrase === undefined) continue; issues.push({ code: 'absence-asserts-wrongness', field, message: `the absence's \`${field}\` says "${phrase}" — an absence says what was searched, not ` + 'that the input is wrong: a name missing from one list may exist elsewhere. Name the ' + 'list searched (absent({ what, among: { source, … } })) and let the gap "names outside ' + '<source>" stand.', }); } if (facet?.authoritative !== undefined) { const at = absence.checked.indexOf(facet); issues.push({ code: 'authoritative-names', field: `checked[${at}].authoritative`, message: `\`among.authoritative\` removes the gap "names outside ${facet.source}" — the tool ` + `says ${facet.source} holds every name there is (why: "${facet.authoritative.why}"). ` + 'Review that it holds for every caller this tool serves.', }); } return issues; } exports.absenceIssues = absenceIssues; /** * The absences a sample result carries — a bare one, or one a `coverage()` * ledger bounds, to any depth: the shapes the dispatch door recognizes. */ function absencesIn(value) { const out = []; const seen = new Set(); let at = value; while (typeof at === 'object' && at !== null && !seen.has(at)) { seen.add(at); const absence = (0, recognize_js_1.readAbsence)(at); if (absence !== undefined) { out.push(absence); break; } const covered = (0, recognize_js_1.readCoverageLedger)(at); if (covered === undefined) break; at = covered.result; } return out; } exports.absencesIn = absencesIn; //# sourceMappingURL=overclaim.js.map