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
JavaScript
;
/**
* 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