feat(optimize): add --subtract, the subtraction axis, behind a deterministic floor
Every command so far asked an addition question — what to add, what to move, what it costs. Nothing asked what is no longer earning its always-loaded rent. This adds that axis as a fourth lensCheck on the existing hybrid motor rather than a new scanner or a 22nd command: the measured payoff (~18% of one file) justifies a mode, not machinery. It is the only lens that proposes REMOVING config, so it carries a guarantee the others don't need: a load-bearing block is never a candidate. Precision is asymmetric — a missed dead line costs a few tokens per turn, a deleted one costs a wrong remote or a broken script — so the floor is decided in code (lib/floor-exclusion.mjs) before the opus judge sees anything, never in prose. Granularity is the leaf block, with two structural exceptions: a paragraph ending in ':' merges with the list it introduces, and an ordered list is a contract whose steps inherit floor from any sibling. Unordered lists deliberately do not inherit — a load-bearing bullet and a disposable one routinely share a list, and container-reasoning is the error the hand-built ground truth exists to catch. Verified against that ground truth (built before any classifier existed), with the comparison machine-checked rather than read by eye: zero load-bearing blocks proposed, 11/18 deletable groups surfaced, ~756 tok ~ 18% of a ~4300 token file — inside the pre-registered band. The first run found five floor violations the synthesized fixture missed; each got a structural rule and a fixture shape so it cannot regress. Three real bugs the dogfood run exposed, all now covered: - JS \b is ASCII-only, so /\bunngå\b/ never matches — every Norwegian keyword ending in æ/ø/å was silently dead. - A bare word/word is not a path; "pros/cons" vetoed the largest deletable block until PATH_RE was tightened to rooted paths and globs. - "Mid-sentence" must key on a preceding lowercase letter; the loose version read **bold labels:** and quoted openers as entities, costing 4 of 11 groups. BP-SUB-001 is grounded entirely in the Anthropic steering blog already cited by BP-MECH-001..004 and asserts nothing from the talk that motivated the feature — no "80%", no ablation figure. Suite 1365 -> 1382/0. Frozen v5.0.0 snapshots untouched; plain optimize output byte-identical on identical input (--subtract adds keys only when passed). knowledge-refresh-cli's reference date moved to 2026-08-01: its premise that every seed entry was verified 2026-06-20 expired when BP-SUB-001 got a genuine verification date, and backdating the entry to fit the test would have been a lie about when its source was checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW2haJXbxZpKivKHseSXNh
This commit is contained in:
parent
c0625c568f
commit
e9921d3c9d
13 changed files with 1080 additions and 32 deletions
126
scanners/lib/floor-exclusion.mjs
Normal file
126
scanners/lib/floor-exclusion.mjs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* floor-exclusion — the deterministic veto that runs BEFORE the subtraction
|
||||
* judge sees anything (v5.13, brief §6.0 / §7 q2).
|
||||
*
|
||||
* The subtraction lens asks "what no longer earns its always-loaded rent?", and
|
||||
* that question is only safe to ask because this module answers a prior one
|
||||
* first: **which blocks are not eligible to be asked about at all?**
|
||||
*
|
||||
* Brief §6.0 splits CLAUDE.md content on one axis:
|
||||
*
|
||||
* compensatory — corrects model *behaviour* ("think before you code").
|
||||
* A more capable model does it unprompted. Deletable.
|
||||
* load-bearing — a *local fact* no amount of intelligence derives from the
|
||||
* codebase ("only Forgejo at git.example.test", "system bash
|
||||
* is 3.2"). FLOOR. Never a deletion candidate.
|
||||
*
|
||||
* Why this is deterministic and not the judge's call: precision is asymmetric.
|
||||
* A missed dead line costs a few tokens per turn; a deleted load-bearing line
|
||||
* costs a wrong remote, a broken script, or a lost afternoon. A blocking
|
||||
* guarantee must not rest on a probabilistic prose judgement, so the floor is
|
||||
* decided here, in code, and the judge only ever ranks what survives.
|
||||
*
|
||||
* The veto keys on **underivable local literals** — the textual fingerprints of
|
||||
* a fact that came from this machine rather than from general engineering
|
||||
* knowledge: an inline code span, a path, a domain, a version pin, a concrete
|
||||
* filename. Plus §6.0's explicit carve-out: policy invariants (secrets,
|
||||
* credentials, production, destructive operations) are floor *by decision, not
|
||||
* by classification* — the model would probably honour them unprompted, but the
|
||||
* cost of being wrong is asymmetric and their token cost is trivial.
|
||||
*
|
||||
* Deliberately over-broad. A false veto costs recall (a dead line survives
|
||||
* another turn); a false clearance costs the guarantee. When in doubt: floor.
|
||||
*
|
||||
* Zero external dependencies. Pure: input text → boolean.
|
||||
*/
|
||||
|
||||
/** An inline code span — the single strongest local-literal signal. */
|
||||
const CODE_SPAN_RE = /`[^`\n]+`/;
|
||||
|
||||
/** A URL or a bare hostname. `.test`/`.local` included for fixtures. */
|
||||
const URL_RE = /https?:\/\/|\b[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)+\.(?:com|org|net|io|dev|sh|no|test|local|ai)\b/i;
|
||||
|
||||
/**
|
||||
* A rooted path (`~/x`, `./x`, `/Users/x`) or a glob. Deliberately does NOT
|
||||
* match a bare `word/word`: "pros/cons" is not a path, and treating it as one
|
||||
* vetoed the single largest deletable block in the dogfood run. Real filenames
|
||||
* are FILENAME_RE's job, and backticked paths are CODE_SPAN_RE's.
|
||||
*/
|
||||
const PATH_RE = /(?:^|[\s(«"'])(?:~|\.{1,2})?\/[\w.~/*-]+|\*\*?\//;
|
||||
|
||||
/** A concrete filename with a known extension (STATE.md, .zshenv, foo.sh). */
|
||||
const FILENAME_RE =
|
||||
/\b[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|md|json|ya?ml|toml|go|rs|java|rb|php|c|cpp|h|hpp|sh|sql|css|scss|html|env|template|lock)\b|(?:^|\s)\.\w+rc\b|\bzshenv\b/;
|
||||
|
||||
/**
|
||||
* A version pin — "bash 3.2", "Opus 4.8", "v5.12.5". A number that specific is
|
||||
* a fact about this machine's world, not general knowledge.
|
||||
*/
|
||||
const VERSION_RE = /\bv?\d+\.\d+(?:\.\d+)?\b/;
|
||||
|
||||
/**
|
||||
* §6.0's carve-out. These stay in the floor by decision: the downside is
|
||||
* asymmetric and the lines are cheap. Do not let a "the model knows this now"
|
||||
* argument reach them.
|
||||
*/
|
||||
const POLICY_RE =
|
||||
/\b(?:secret|secrets|credential|credentials|password|passphrase|api[\s-]?key|access[\s-]?token|keychain|\.env|production|prod|force[\s-]push|rm\s+-rf|destructive|hemmelighet|passord|untrusted|injection|prompt[\s-]injection|angrepsflate|attack[\s-]surface|exfiltrat\w*)\b/i;
|
||||
|
||||
/**
|
||||
* An unresolved local entity: a mixed-case capitalized word appearing
|
||||
* mid-sentence. In config prose that is almost always a product, service or
|
||||
* tool name — "push til deres egne Forgejo-remotes", "Bruk Explore for søk" —
|
||||
* i.e. exactly the local vocabulary that makes a line underivable, but with no
|
||||
* literal syntax for the other markers to key on.
|
||||
*
|
||||
* This marker is the CONSERVATIVE DEFAULT, and it is deliberately blunt: the
|
||||
* mechanism cannot tell "Forgejo" from an ordinary capitalized word without a
|
||||
* dictionary, so it declines to decide and keeps the block. Any finer rule
|
||||
* (lowercase-form-appears-elsewhere, curated entity lists) is a proxy for a
|
||||
* dictionary that would be tuned against one machine's config and fail silently
|
||||
* on the next one. Paying in recall is the direction brief §6.0 mandates.
|
||||
*
|
||||
* All-caps tokens are exempt: this config's emphasis convention is ALDRI /
|
||||
* ALLTID / FØR / MÅ, and acronyms like AI and TDD are generic, not local.
|
||||
*
|
||||
* "Mid-sentence" is keyed on a preceding LOWERCASE letter (or comma) — not on
|
||||
* "anything that is not a full stop". The looser version cost 4 of 11 deletable
|
||||
* groups in the dogfood run by firing on `**Bold labels:**` and on quoted
|
||||
* sentence starts (`"Som AI kan jeg ikke…"`), both of which are sentence
|
||||
* openings dressed in punctuation rather than local vocabulary.
|
||||
*/
|
||||
const ENTITY_RE = /[a-zæøå,;]\s+(?![A-ZÆØÅ]{2,}\b)[A-ZÆØÅ][a-zæøå][\wæøåÆØÅ-]*/;
|
||||
|
||||
/** The ordered veto table — exported so a finding can cite *why* it was floored. */
|
||||
export const FLOOR_MARKERS = Object.freeze([
|
||||
{ name: 'code-span', re: CODE_SPAN_RE, why: 'contains an inline code literal' },
|
||||
{ name: 'url', re: URL_RE, why: 'names a specific host or URL' },
|
||||
{ name: 'filename', re: FILENAME_RE, why: 'names a concrete file' },
|
||||
{ name: 'path', re: PATH_RE, why: 'names a concrete path' },
|
||||
{ name: 'version', re: VERSION_RE, why: 'pins a specific version' },
|
||||
{ name: 'policy', re: POLICY_RE, why: 'is a policy invariant (floor by decision, §6.0)' },
|
||||
{ name: 'unresolved-entity', re: ENTITY_RE, why: 'names a capitalized entity the mechanism cannot resolve' },
|
||||
]);
|
||||
|
||||
/**
|
||||
* The first floor marker present in `text`, or null if the text carries no
|
||||
* underivable local fact.
|
||||
* @param {string} text
|
||||
* @returns {{name:string, why:string}|null}
|
||||
*/
|
||||
export function floorMarker(text) {
|
||||
const s = String(text == null ? '' : text);
|
||||
for (const m of FLOOR_MARKERS) {
|
||||
if (m.re.test(s)) return { name: m.name, why: m.why };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the block must never be proposed for deletion.
|
||||
* @param {string} text
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLoadBearing(text) {
|
||||
return floorMarker(text) !== null;
|
||||
}
|
||||
350
scanners/lib/subtraction-prefilter.mjs
Normal file
350
scanners/lib/subtraction-prefilter.mjs
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
/**
|
||||
* subtraction-prefilter — deterministic candidate generator for the v5.13
|
||||
* subtraction lens (`/config-audit optimize --subtract`, BP-SUB-001).
|
||||
*
|
||||
* Every other command in this plugin asks an ADDITION question — what could you
|
||||
* add, what would fit a better mechanism, how expensive is what you have. This
|
||||
* module asks the inverse: **what is no longer earning its always-loaded rent?**
|
||||
*
|
||||
* It is the mirror image of `lens-prefilter` in one important way. That module
|
||||
* is recall-first, because a false candidate only costs the judge a moment's
|
||||
* thought. Here a false candidate is a proposal to DELETE something, so the
|
||||
* polarity flips: precision-first, and a hard deterministic floor
|
||||
* (`floor-exclusion`) that the judge is not allowed to override.
|
||||
*
|
||||
* ## Granularity: leaf blocks
|
||||
*
|
||||
* The one design choice the hand-built fasit deliberately left open. A block is
|
||||
* one markdown *leaf*: a list item including its wrapped continuation lines, or
|
||||
* a paragraph. Headings, table rows and fenced code are structural, never
|
||||
* candidates.
|
||||
*
|
||||
* Both halves of that choice are load-bearing, and the fasit tests both:
|
||||
* - It must SPLIT. A numbered list whose steps 2–3 are local facts and whose
|
||||
* steps 1 and 4 are filler is a mixed block; section granularity would have
|
||||
* to keep or drop all four.
|
||||
* - It must NOT split further. A bullet's load-bearing literal often sits on a
|
||||
* wrapped continuation line ("…— `coord-send` er mekanismen"). A
|
||||
* line-granular mechanism severs the first line from the fact that protects
|
||||
* it and proposes a floor block for deletion — the exact failure the gate
|
||||
* exists to prevent.
|
||||
*
|
||||
* ## Two independent guarantees, not one
|
||||
*
|
||||
* A load-bearing block fails to become a candidate for either of two reasons,
|
||||
* and both are needed:
|
||||
* 1. it is *declarative* — "Language: Norwegian for dialogue" states a fact
|
||||
* about the human and corrects no behaviour, so no detector fires; or
|
||||
* 2. `floor-exclusion` vetoes it for carrying an underivable local literal.
|
||||
* Group 1 never reaches the veto at all, which is why the contract is asserted
|
||||
* on the candidate list rather than on either mechanism alone.
|
||||
*
|
||||
* Norwegian and English are both first-class: the config this was designed
|
||||
* against is Norwegian prose carrying English identifiers.
|
||||
*
|
||||
* Zero external dependencies. Pure: input text → candidate array.
|
||||
*/
|
||||
|
||||
import { floorMarker } from './floor-exclusion.mjs';
|
||||
|
||||
/**
|
||||
* The subtraction detector, kept in its OWN table. `LENS_DETECTORS` drives the
|
||||
* plain `optimize` payload's register block, and the subtraction axis must not
|
||||
* fire on a plain run — it asks a different question and the operator has to
|
||||
* opt into it with `--subtract`.
|
||||
*/
|
||||
export const SUBTRACT_DETECTORS = Object.freeze([
|
||||
{ lensCheck: 'compensatory-instruction', registerId: 'BP-SUB-001', mechanism: 'deletion' },
|
||||
]);
|
||||
|
||||
/**
|
||||
* Absolute / insistent phrasing. An instruction that has to shout is usually
|
||||
* correcting behaviour rather than stating a fact.
|
||||
*/
|
||||
/**
|
||||
* Word boundaries that understand æ/ø/å.
|
||||
*
|
||||
* JavaScript's `\b` is ASCII-only, so `/\bunngå\b/` never matches "unngå " —
|
||||
* the trailing "å" is not a word character, so there is no boundary after it.
|
||||
* Every Norwegian keyword ending in æ/ø/å was silently dead until the dogfood
|
||||
* run surfaced it. Do not reintroduce `\b` around this vocabulary.
|
||||
*/
|
||||
const LB = '(?<![\\wæøåÆØÅ])';
|
||||
const RB = '(?![\\wæøåÆØÅ])';
|
||||
|
||||
const ABSOLUTE_RE = new RegExp(
|
||||
LB +
|
||||
'(?:never|always|avoid|don\'t|do not|must not|ensure|remember to|make sure|' +
|
||||
'aldri|alltid|unngå|husk|sørg for|ikke)' +
|
||||
RB,
|
||||
'i',
|
||||
);
|
||||
|
||||
/**
|
||||
* Imperative verbs — the grammatical signature of telling the model how to
|
||||
* behave. Matched anywhere in the block, since Norwegian list prose puts them
|
||||
* after a colon ("…oppgaver: forstå problemet, vurder alternativer").
|
||||
*
|
||||
* Word boundaries matter more than the list length: `\bdocument\b` must not
|
||||
* match "documentation", or the declarative language-preference fact — a floor
|
||||
* block with no local literal to veto it — would become a deletion candidate.
|
||||
*/
|
||||
const IMPERATIVE_RE = new RegExp(
|
||||
LB +
|
||||
'(?:' +
|
||||
// English
|
||||
'think|write|test|commit|use|read|check|ask|verify|stop|start|summarize|' +
|
||||
'wait|match|change|fix|present|identify|keep|prefer|declare|refactor|' +
|
||||
'document|explain|split|review|' +
|
||||
// Norwegian
|
||||
'tenk|skriv|test|commit|bruk|les|sjekk|spør|verifiser|dokumenter|stopp|' +
|
||||
'start|oppsummer|vent|gjør|match|endre|fiks|presenter|identifiser|forstå|' +
|
||||
'vurder|hold|siter|jobb|gjett|push|del|sett|forklar|utfør|følg' +
|
||||
')' +
|
||||
RB,
|
||||
'i',
|
||||
);
|
||||
|
||||
/**
|
||||
* Minimum words for a block whose ONLY signal is an absolute marker. A bare
|
||||
* "Haiku: aldri." is a declarative policy fact wearing the word "aldri", not an
|
||||
* instruction about how to behave — the same reason "Tone: direct and technical"
|
||||
* never fires. Blocks carrying a real imperative verb are exempt from the floor,
|
||||
* so "Test inkrementelt" still surfaces at two words.
|
||||
*/
|
||||
const ABSOLUTE_ONLY_MIN_WORDS = 6;
|
||||
|
||||
const HEADING_RE = /^\s*#{1,6}\s/;
|
||||
const TABLE_RE = /^\s*\|/;
|
||||
const FENCE_RE = /^\s*(?:```|~~~)/;
|
||||
const LIST_ITEM_RE = /^\s*(?:[-*+]\s+|\d+[.)]\s+)/;
|
||||
const ORDERED_ITEM_RE = /^\s*\d+[.)]\s+/;
|
||||
const CONTINUATION_RE = /^\s+\S/;
|
||||
/** A paragraph that introduces the list beneath it ("…tre lag med hver sin ene jobb:"). */
|
||||
const STEM_RE = /:\s*$/;
|
||||
|
||||
/**
|
||||
* Split markdown into leaf blocks.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Array<{startLine:number, endLine:number, text:string, type:string}>}
|
||||
* `type` is one of paragraph | list-item | heading | table | code.
|
||||
*/
|
||||
export function splitLeafBlocks(text) {
|
||||
const lines = String(text == null ? '' : text).split('\n');
|
||||
const blocks = [];
|
||||
let current = null;
|
||||
let inFence = false;
|
||||
|
||||
const flush = () => {
|
||||
if (current) blocks.push(current);
|
||||
current = null;
|
||||
};
|
||||
const indentOf = (line) => (line.match(/^\s*/) || [''])[0].length;
|
||||
const open = (type, i, line) => {
|
||||
current = { startLine: i + 1, endLine: i + 1, text: line, type, indent: indentOf(line) };
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (FENCE_RE.test(line)) {
|
||||
flush();
|
||||
inFence = !inFence;
|
||||
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'code' });
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'code' });
|
||||
continue;
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
if (HEADING_RE.test(line)) {
|
||||
flush();
|
||||
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'heading' });
|
||||
continue;
|
||||
}
|
||||
if (TABLE_RE.test(line)) {
|
||||
flush();
|
||||
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'table' });
|
||||
continue;
|
||||
}
|
||||
if (LIST_ITEM_RE.test(line)) {
|
||||
// Structural exception 1: a paragraph ending in ':' is this list's stem —
|
||||
// it introduces the items rather than standing alone, so it merges with
|
||||
// them. Deleting a stem without its list is meaningless, and a stem often
|
||||
// carries no literal of its own to protect it.
|
||||
//
|
||||
// A list item can be a stem too ("5. …alltid eksplisitt:" over its
|
||||
// sub-bullets) — but only when the following item is nested deeper, or
|
||||
// sibling bullets would glue together.
|
||||
const isStem =
|
||||
current &&
|
||||
STEM_RE.test(current.text) &&
|
||||
(current.type === 'paragraph' || indentOf(line) > current.indent);
|
||||
if (isStem) {
|
||||
current.type = 'list-item';
|
||||
if (current.ordered === undefined) current.ordered = ORDERED_ITEM_RE.test(line);
|
||||
current.endLine = i + 1;
|
||||
current.text += '\n' + line;
|
||||
current.stemMerged = true;
|
||||
current.stemIndent = indentOf(line);
|
||||
continue;
|
||||
}
|
||||
if (current && current.stemMerged && current.endLine === i && indentOf(line) >= current.stemIndent) {
|
||||
// Subsequent items of the same stemmed list join it too.
|
||||
current.endLine = i + 1;
|
||||
current.text += '\n' + line;
|
||||
continue;
|
||||
}
|
||||
// Otherwise a new list item always ends the previous block, even mid-list.
|
||||
flush();
|
||||
open('list-item', i, line);
|
||||
current.ordered = ORDERED_ITEM_RE.test(line);
|
||||
continue;
|
||||
}
|
||||
if (current && CONTINUATION_RE.test(line)) {
|
||||
// Indented wrap — belongs to the block it continues.
|
||||
current.endLine = i + 1;
|
||||
current.text += '\n' + line;
|
||||
continue;
|
||||
}
|
||||
if (current && current.type === 'paragraph') {
|
||||
current.endLine = i + 1;
|
||||
current.text += '\n' + line;
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
open('paragraph', i, line);
|
||||
}
|
||||
flush();
|
||||
|
||||
return blocks.sort((a, b) => a.startLine - b.startLine);
|
||||
}
|
||||
|
||||
/** Blocks that can carry a deletable instruction at all. */
|
||||
const isProse = (block) => block.type === 'paragraph' || block.type === 'list-item';
|
||||
|
||||
const wordCount = (s) => s.trim().split(/\s+/).filter(Boolean).length;
|
||||
|
||||
/**
|
||||
* Does the block instruct behaviour at all? A declarative fact does not, however
|
||||
* absolute its wording: "Haiku: aldri." and "Tone: direct and technical" both
|
||||
* state a decision rather than correcting how the model works.
|
||||
*/
|
||||
function correctsBehaviour(text) {
|
||||
if (IMPERATIVE_RE.test(text)) return true;
|
||||
return ABSOLUTE_RE.test(text) && wordCount(text) >= ABSOLUTE_ONLY_MIN_WORDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural exception 2: an ordered list is a CONTRACT. Numbered steps are a
|
||||
* sequence whose items reference each other, so a floor marker on any step
|
||||
* floors the whole run — deleting step 2 of a five-step session protocol is not
|
||||
* the same kind of act as deleting one bullet from a list of platitudes.
|
||||
*
|
||||
* Unordered lists deliberately do NOT inherit. B-32a and B-32b are opposite
|
||||
* calls inside one bullet list, and "the container decides" is precisely the
|
||||
* reasoning the fasit exists to refute.
|
||||
*
|
||||
* @returns {Set<number>} startLine of every block floored by inheritance
|
||||
*/
|
||||
function orderedContractFloor(blocks, floored) {
|
||||
const inherited = new Set();
|
||||
let run = [];
|
||||
const closeRun = () => {
|
||||
if (run.length > 1 && run.some((b) => floored.has(b.startLine))) {
|
||||
for (const b of run) inherited.add(b.startLine);
|
||||
}
|
||||
run = [];
|
||||
};
|
||||
for (const block of blocks) {
|
||||
const contiguous = run.length > 0 && block.startLine === run[run.length - 1].endLine + 1;
|
||||
if (block.ordered && (run.length === 0 || contiguous)) {
|
||||
run.push(block);
|
||||
} else {
|
||||
closeRun();
|
||||
if (block.ordered) run.push(block);
|
||||
}
|
||||
}
|
||||
closeRun();
|
||||
return inherited;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compensatory-phrasing candidates that survived floor-exclusion.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Array<{lensCheck:string, registerId:string, mechanism:string,
|
||||
* line:number, startLine:number, endLine:number, lineCount:number, text:string}>}
|
||||
*/
|
||||
export function subtractionCandidates(text) {
|
||||
const detector = SUBTRACT_DETECTORS[0];
|
||||
const out = [];
|
||||
const blocks = splitLeafBlocks(text).filter(isProse);
|
||||
|
||||
// 1. The blocking floor veto, evaluated over the WHOLE leaf block — so a
|
||||
// literal on a wrapped continuation line still protects its opening line.
|
||||
const floored = new Set();
|
||||
for (const block of blocks) {
|
||||
if (floorMarker(block.text)) floored.add(block.startLine);
|
||||
}
|
||||
// 2. …then propagated across ordered-list contracts.
|
||||
const inherited = orderedContractFloor(blocks, floored);
|
||||
|
||||
for (const block of blocks) {
|
||||
// 3. Does it correct behaviour at all? A declarative local fact does not.
|
||||
if (!correctsBehaviour(block.text)) continue;
|
||||
if (floored.has(block.startLine) || inherited.has(block.startLine)) continue;
|
||||
|
||||
out.push({
|
||||
lensCheck: detector.lensCheck,
|
||||
registerId: detector.registerId,
|
||||
mechanism: detector.mechanism,
|
||||
line: block.startLine,
|
||||
startLine: block.startLine,
|
||||
endLine: block.endLine,
|
||||
lineCount: block.endLine - block.startLine + 1,
|
||||
text: block.text.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostics for the floor gate: every prose block that fired the detector but
|
||||
* was vetoed, with the marker that saved it. Not user-facing — this is how a
|
||||
* later narrowing of the veto can be checked against the fasit.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Array<{startLine:number, endLine:number, marker:string, why:string}>}
|
||||
*/
|
||||
export function floorExcluded(text) {
|
||||
const blocks = splitLeafBlocks(text).filter(isProse);
|
||||
const floored = new Set();
|
||||
for (const block of blocks) {
|
||||
if (floorMarker(block.text)) floored.add(block.startLine);
|
||||
}
|
||||
const inherited = orderedContractFloor(blocks, floored);
|
||||
|
||||
const out = [];
|
||||
for (const block of blocks) {
|
||||
if (!correctsBehaviour(block.text)) continue;
|
||||
const marker = floorMarker(block.text);
|
||||
if (marker) {
|
||||
out.push({ startLine: block.startLine, endLine: block.endLine, marker: marker.name, why: marker.why });
|
||||
} else if (inherited.has(block.startLine)) {
|
||||
out.push({
|
||||
startLine: block.startLine,
|
||||
endLine: block.endLine,
|
||||
marker: 'ordered-contract',
|
||||
why: 'is a step of an ordered list whose sibling carries a local fact',
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import { resetCounter } from './lib/output.mjs';
|
|||
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { loadRegister, getEntry } from './lib/best-practices-register.mjs';
|
||||
import { prefilterClaudeMd, LENS_DETECTORS } from './lib/lens-prefilter.mjs';
|
||||
import { subtractionCandidates, SUBTRACT_DETECTORS } from './lib/subtraction-prefilter.mjs';
|
||||
import { scan as optScan } from './optimization-lens-scanner.mjs';
|
||||
|
||||
// Files under `.claude/plugins/` are shipped by an installed plugin — vendored
|
||||
|
|
@ -53,9 +54,11 @@ async function main() {
|
|||
let targetPath = '.';
|
||||
let outputFile = null;
|
||||
let includeGlobal = false;
|
||||
let subtract = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--global') includeGlobal = true;
|
||||
else if (args[i] === '--subtract') subtract = true;
|
||||
else if (args[i] === '--output-file' && args[i + 1]) outputFile = args[++i];
|
||||
else if (!args[i].startsWith('-')) targetPath = args[i];
|
||||
}
|
||||
|
|
@ -95,6 +98,11 @@ async function main() {
|
|||
// ── Recall half: prose-judgment candidates from the pre-filter ──
|
||||
const claudeMdFiles = (discovery.files || []).filter((f) => f.type === 'claude-md');
|
||||
const candidates = [];
|
||||
// Opt-in only: the subtraction axis asks a different question and must not
|
||||
// fire on a plain `/config-audit optimize` run (brief §7 q3).
|
||||
const subtractCands = [];
|
||||
const subtractEntry = subtract && register ? confirmedEntry(register, 'BP-SUB-001') : null;
|
||||
|
||||
for (const file of claudeMdFiles) {
|
||||
let content;
|
||||
try {
|
||||
|
|
@ -105,6 +113,28 @@ async function main() {
|
|||
const parsed = parseFrontmatter(content);
|
||||
const body = parsed.body || content;
|
||||
const bodyStartLine = parsed.bodyStartLine || 1;
|
||||
|
||||
if (subtractEntry) {
|
||||
for (const cand of subtractionCandidates(body)) {
|
||||
subtractCands.push({
|
||||
file: file.absPath,
|
||||
line: bodyStartLine - 1 + cand.startLine,
|
||||
endLine: bodyStartLine - 1 + cand.endLine,
|
||||
lineCount: cand.lineCount,
|
||||
lensCheck: cand.lensCheck,
|
||||
mechanism: cand.mechanism,
|
||||
signalText: cand.text,
|
||||
register: {
|
||||
id: subtractEntry.id,
|
||||
claim: subtractEntry.claim,
|
||||
recommendation: subtractEntry.recommendation || null,
|
||||
severity: subtractEntry.severity || 'low',
|
||||
source: subtractEntry.source,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const cand of prefilterClaudeMd(body)) {
|
||||
const entry = register ? confirmedEntry(register, cand.registerId) : null;
|
||||
if (!entry) continue; // never surface an unverifiable recommendation
|
||||
|
|
@ -161,6 +191,29 @@ async function main() {
|
|||
},
|
||||
};
|
||||
|
||||
// Additive ONLY under --subtract: a plain run's payload must stay byte-identical.
|
||||
if (subtract) {
|
||||
payload.subtract = {
|
||||
enabled: true,
|
||||
candidates: subtractCands,
|
||||
register: subtractEntry
|
||||
? [
|
||||
{
|
||||
id: subtractEntry.id,
|
||||
lensCheck: subtractEntry.lensCheck,
|
||||
claim: subtractEntry.claim,
|
||||
recommendation: subtractEntry.recommendation || null,
|
||||
mechanism: subtractEntry.mechanism || null,
|
||||
severity: subtractEntry.severity || 'low',
|
||||
source: subtractEntry.source,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
detectors: SUBTRACT_DETECTORS.map((d) => ({ ...d })),
|
||||
};
|
||||
payload.counts.subtractCandidates = subtractCands.length;
|
||||
}
|
||||
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
if (outputFile) {
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue