agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
281 lines • 14.6 kB
JavaScript
/**
* semantics/check — the `check:semantics` gate core (9.53.0).
*
* Pattern: humble shell (the tool-lint precedent) — ALL behavior lives here,
* unit-tested; `bin/agentfootprint-check-semantics.mjs` only resolves
* the built module and maps the exit code. Consumers wire the bin
* into their own `check:` scripts beside `check:tools`.
* Role: lib/ layer, pure. Judges SAMPLE RESULTS against the semantic
* vocabulary and the per-class rules; never executes a tool, never
* touches a network — the samples come from the consumer's own mock
* tools or fixtures, the same way `check:tools` receives a dumped
* catalog.
* Emits: N/A.
*
* ## Severity follows provability (the skillGraph check-up law)
*
* Only what the declaration PROVES wrong is an error:
*
* • a `'triage'` / `'inventory'` tool whose sample result declares no
* coverage — the CLASS declared the requirement, the sample violates it;
* • a marker-bearing envelope with faults (series without grain, data
* without provenance, a counter-looking aggregation with `is_counter`
* unstated, malformed shapes) — the marker declared the vocabulary, the
* shape violates it.
*
* Everything else warns: a classed tool with NO samples (the gate cannot
* check what it cannot see — but silence must not read as a pass), and an
* inventory whose facts carry no render hint (useful, not provably harmful).
*
* ## The overclaim rules (`overclaim.ts`) — warnings until a later major
*
* A declaration is a claim, so it is checked like one: a refusal that says
* what does not exist (`refusal-asserts-absence`), an absence that calls the
* input wrong (`absence-asserts-wrongness`), and every `among.authoritative`
* listed for review (`authoritative-names`). So no consumer's CI breaks on
* upgrade they WARN, and only for samples that use the new fields — a
* catalog's `refusals`, an absence minted with `among` — so an app using
* none of them sees no new finding. `strict: true` (the CLI's `--strict`)
* makes the first two errors; `rules: ['overclaim']` makes them errors AND
* judges every sample (any absence, any result whose status says it read
* nothing). `authoritative-names` is a review item and stays a warning.
*/
import { namesFacetOf } from './coverage/among.js';
import { readCoverageResult } from './coverage/read.js';
import { readDeclaredAbsence, readDeclaredLedger } from './coverage/recognize.js';
import { searchReviewItems } from './coverage/search/review.js';
import { explainSemantics, readSemantics } from './envelope.js';
import { absenceIssues, absencesIn, isNotReadResult, refusalIssues, SEMANTICS_RULE_SETS, } from './overclaim.js';
import { RESULT_CLASSES } from './types.js';
import { projectionSemanticsIssues, snapshotProjectionSemantics, } from './projection.js';
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
/** True when a result states its boundary through ANY recognized door: a
* semantic envelope with `coverage` or a `period` (honesty step 7b — a
* boundary in time, the same answer a `coverage()` carrying only a period
* gets), a `coverage()` ledger, or an `absent()` (whose `checked` IS its
* coverage). */
function declaresCoverage(result, search) {
// `checked: 'declared'` — the tool's search IS its coverage, when it has one.
if (readDeclaredAbsence(result) !== undefined || readDeclaredLedger(result) !== undefined) {
return search !== undefined;
}
const sem = readSemantics(result);
if (sem !== undefined)
return sem.coverage !== undefined || sem.period !== undefined;
return readCoverageResult(result) !== undefined;
}
function classCoverageFinding(entry, resultIndex) {
const cls = entry.resultClass;
return {
tool: entry.name,
code: cls === 'triage' ? 'triage-without-coverage' : 'inventory-without-coverage',
severity: 'error',
field: 'coverage',
resultIndex,
message: `tool '${entry.name}' is declared '${cls}' but sample result ${resultIndex + 1} ` +
`declares no coverage — field \`coverage\` is required on every ${cls} result. ` +
(cls === 'triage'
? `A triage that cannot say what it did NOT check turns "everything looks fine" into ` +
`a claim about ground it never stood on. `
: `An inventory that cannot say which population it covered ("4 of 5 clusters") reads ` +
`as the whole fleet. `) +
`Return describedResult({ …, coverage: { checked, notChecked, cannotCover } }), or coverage(result, …), ` +
`or absent({ … }) when nothing was found.`,
};
}
/**
* Judge a semantics catalog. Pure; throws only on a malformed CATALOG (a
* definition-side mistake — the CLI maps it to exit code 2), never on a
* malformed RESULT (that is a finding, the thing this gate exists to report).
*/
export function checkSemantics(entries, options = {}) {
if (!Array.isArray(entries)) {
throw new Error('checkSemantics: takes an array of { name, resultClass?, results } entries.');
}
const optedIn = ruleSetsOf(options.rules).includes('overclaim');
const overclaimSeverity = optedIn || options.strict === true ? 'error' : 'warning';
const findings = [];
let checkedResults = 0;
entries.forEach((raw, entryIndex) => {
if (!isPlainObject(raw)) {
throw new Error(`checkSemantics: entries[${entryIndex}] is not an object.`);
}
const entry = raw;
if (typeof entry.name !== 'string' || entry.name.trim().length === 0) {
throw new Error(`checkSemantics: entries[${entryIndex}] has no tool name.`);
}
const cls = entry.resultClass;
if (cls !== undefined && !RESULT_CLASSES.includes(cls)) {
throw new Error(`checkSemantics: tool '${entry.name}' declares resultClass '${String(cls)}', which is ` +
`not a class this library has. The classes are: ${RESULT_CLASSES.join(', ')}. ` +
`Omit the field for a tool with no class rules.`);
}
const results = entry.results;
if (!Array.isArray(results)) {
throw new Error(`checkSemantics: tool '${entry.name}' has no \`results\` array — the gate judges ` +
`sample results; give it at least one (your mock tool's return is the natural sample).`);
}
// A search's review items — warnings for a person, never a refusal.
if (entry.search !== undefined) {
for (const item of searchReviewItems(entry.search)) {
findings.push({
tool: entry.name,
code: item.code,
severity: 'warning',
field: item.field,
message: `tool '${entry.name}': ${item.field} — ${item.message}`,
});
}
}
if (cls !== undefined && results.length === 0) {
findings.push({
tool: entry.name,
code: 'unsampled-tool-class',
severity: 'warning',
field: 'results',
message: `tool '${entry.name}' is declared '${cls}' but no sample results were given — the ` +
`gate cannot check what it cannot see, and a class rule with no samples is a promise ` +
`nobody is keeping. Add one sample result (a mock return is enough).`,
});
}
if (entry.projections !== undefined) {
if (!Array.isArray(entry.projections))
throw new Error(`checkSemantics: tool '${entry.name}' projections must be an array of { before, after } fixtures.`);
entry.projections.forEach((pair, index) => {
if (!isPlainObject(pair) || !Object.hasOwn(pair, 'before') || !Object.hasOwn(pair, 'after'))
throw new Error(`checkSemantics: tool '${entry.name}' projection ${index + 1} requires before and after values.`);
for (const issue of projectionSemanticsIssues(snapshotProjectionSemantics(pair.before), pair.after))
findings.push({
tool: entry.name,
...issue,
field: `projections[${index}].${issue.field}`,
severity: 'error',
});
});
}
if (entry.refusals !== undefined) {
if (!Array.isArray(entry.refusals)) {
throw new Error(`checkSemantics: tool '${entry.name}' refusals must be an array of refusal samples ` +
'(the message the tool throws, or the value it returns, when it refuses a call).');
}
entry.refusals.forEach((refusal, index) => {
for (const issue of refusalIssues(refusal, `refusals[${index}]`)) {
findings.push(overclaimFinding(entry.name, issue, overclaimSeverity, `refusal sample ${index + 1}`));
}
});
}
results.forEach((result, resultIndex) => {
checkedResults += 1;
const sample = `sample result ${resultIndex + 1}`;
// ── The overclaim rules — before the envelope rules, which return early ──
for (const absence of absencesIn(result)) {
const usesAmong = namesFacetOf(absence) !== undefined;
for (const issue of absenceIssues(absence)) {
if (issue.code === 'authoritative-names') {
findings.push({
...overclaimFinding(entry.name, issue, 'warning', sample),
resultIndex,
});
}
else if (usesAmong || optedIn) {
findings.push({
...overclaimFinding(entry.name, issue, overclaimSeverity, sample),
resultIndex,
});
}
}
}
if (optedIn && isNotReadResult(result)) {
for (const issue of refusalIssues(result, 'content')) {
findings.push({
...overclaimFinding(entry.name, issue, overclaimSeverity, sample),
resultIndex,
});
}
}
// A marker-bearing value with faults: every fault becomes a finding
// under the recognizer's own code, naming the field.
const issues = explainSemantics(result);
if (issues !== undefined) {
for (const issue of issues) {
findings.push({
tool: entry.name,
code: issue.code,
severity: 'error',
field: issue.field,
resultIndex,
message: `tool '${entry.name}', sample result ${resultIndex + 1}: ${issue.message}`,
});
}
// An unrecognizable envelope cannot be judged for class rules — the
// faults above are already errors, and guessing at a broken shape
// would report the same problem twice under two names.
return;
}
const sem = readSemantics(result);
// The literal claims a declaration: without a search, the call is refused at run time.
if (entry.search === undefined &&
(readDeclaredAbsence(result) !== undefined || readDeclaredLedger(result) !== undefined)) {
findings.push({
tool: entry.name,
code: 'declared-without-search',
severity: 'error',
field: 'checked',
resultIndex,
message: `tool '${entry.name}', sample result ${resultIndex + 1}: checked: 'declared' says ` +
'the tool declared its search, and this tool declares no `search` — at run time the ' +
'call is refused. Add search: { source, subject, arguments, … } to its defineTool, ' +
'or write the checked list by hand.',
});
}
// ── The class rules ──
if ((cls === 'triage' || cls === 'inventory') && !declaresCoverage(result, entry.search)) {
findings.push(classCoverageFinding(entry, resultIndex));
}
if (cls === 'inventory' &&
sem !== undefined &&
sem.facts !== undefined &&
sem.render === undefined) {
findings.push({
tool: entry.name,
code: 'inventory-without-render',
severity: 'warning',
field: 'render',
resultIndex,
message: `tool '${entry.name}', sample result ${resultIndex + 1}: an inventory with ` +
`\`facts\` and no \`render\` hint — a UI can still draw the rows, but the tool ` +
`author knows the right columns and sort better than any consumer. Consider ` +
`render: { default: 'table', columns: […] }.`,
});
}
});
});
return {
ok: !findings.some((f) => f.severity === 'error'),
findings,
checkedTools: entries.length,
checkedResults,
};
}
/** One overclaim issue as a finding — names the tool, and which sample. */
function overclaimFinding(tool, issue, severity, sample) {
return {
tool,
code: issue.code,
severity,
field: issue.field,
message: `tool '${tool}', ${sample}: ${issue.message}`,
};
}
/** The opt-in rule sets, refused by name when one is not a set this gate has. */
function ruleSetsOf(rules) {
if (rules === undefined)
return [];
const known = SEMANTICS_RULE_SETS;
if (!Array.isArray(rules) || rules.some((r) => typeof r !== 'string' || !known.includes(r))) {
throw new Error(`checkSemantics: rules ${JSON.stringify(rules)} — the opt-in rule sets are: ` +
`${SEMANTICS_RULE_SETS.join(', ')}.`);
}
return rules;
}
//# sourceMappingURL=check.js.map