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
|
|
@ -15,7 +15,7 @@ Per-command flags, patterns, and feature lists live in `README.md` and `/config-
|
|||
| `/config-audit tokens` | Prompt-cache-aware token hotspots, each tagged with its load pattern; cache-aware |
|
||||
| `/config-audit manifest` | Ranked table of every token source + always-loaded subtotal |
|
||||
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact |
|
||||
| `/config-audit optimize` | Mechanism-fit lens (procedure→skill, lifecycle→hook, path→rule, never→permission). Agent-driven, **not byte-stable** |
|
||||
| `/config-audit optimize` | Mechanism-fit lens (procedure→skill, lifecycle→hook, path→rule, never→permission). Agent-driven, **not byte-stable**. `--subtract` adds the subtraction axis (what no longer earns its always-loaded rent, `BP-SUB-001`) — opt-in, proposes only |
|
||||
| `/config-audit fix` | Auto-fix deterministic issues with backup + verification |
|
||||
| `/config-audit rollback` | Restore configuration from backup |
|
||||
| `/config-audit plan` | Create action plan from findings |
|
||||
|
|
@ -87,6 +87,8 @@ Enforced conventions live in `.claude/rules/` (auto-loaded as project instructio
|
|||
|
||||
Coding style: scanners are zero-dependency Node ESM; new findings use the `CA-{SCANNER}-{NNN}` ID format; byte-stable CLIs are verified against frozen `tests/snapshots/v5.0.0/` baselines.
|
||||
|
||||
**Subtraction floor (invariant).** `optimize --subtract` is the only lens that proposes removing config, so `scanners/lib/floor-exclusion.mjs` runs as a deterministic pre-step *before* the judge — a load-bearing block is never a candidate, and that guarantee must not be moved into the agent prompt. Two rules follow from it: (1) **staleness is not a deletion signal** — an outdated version pin inside a floor block is a `drift`/`CA-CML` dead-reference concern; (2) **tier 2 ≠ tier 3** — a compensatory block that keeps earning its place returns, and reporting it as dead weight is wrong even when the label matches. Norwegian keywords need the Unicode boundaries in `subtraction-prefilter.mjs`; JS `\b` is ASCII-only, so `/\bunngå\b/` silently never matches.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
|
|
|
|||
26
README.md
26
README.md
|
|
@ -279,6 +279,7 @@ Your team configuration changes over time. Track it:
|
|||
| `/config-audit manifest` | Ranked table of every token source (CLAUDE.md, rules, agents, skills, output styles, MCP, hooks) sorted by estimated tokens — each tagged with its **load pattern** (always-loaded / on-demand / external) plus an **always-loaded subtotal** ("≈X tokens enter context every turn before you type"). Component-level: no coarse plugin roll-up (it would double-count) |
|
||||
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact |
|
||||
| `/config-audit optimize` | Optimization lens (mechanism-fit): config that works but fits a better mechanism — procedure→skill, lifecycle→hook, unscoped path→rule, "never"→permission. Hybrid motor (deterministic pre-filter + opus precision gate), every finding cites a best-practices-register rule |
|
||||
| `/config-audit optimize --subtract` | **Subtraction lens** — the inverse question no other command asks: what no longer earns its always-loaded rent? Ranks CLAUDE.md blocks that correct general model *behaviour* rather than stating a local fact, split into **dead** (never missed) and **earned** (returns if the model stumbles), with the token payoff (`BP-SUB-001`). **Load-bearing local facts are excluded deterministically before the judge sees anything** — remotes, versions, paths, filenames, policy invariants and unresolvable entity names are never candidates, and an ordered list is treated as a contract. Opt-in, proposes only, never writes. Pair with `--global` to reach the user-level CLAUDE.md, where the always-loaded cost actually sits |
|
||||
| `/config-audit fix` | Auto-fix deterministic issues with backup + verification |
|
||||
| `/config-audit rollback` | Restore configuration from a previous backup |
|
||||
| `/config-audit plan` | Generate prioritized action plan from audit findings |
|
||||
|
|
@ -595,6 +596,31 @@ date, and a `confidence`. It is the source of truth for the optimization lens (O
|
|||
`scanners/lib/best-practices-register.mjs` (zero-dependency, native JSON). See
|
||||
`docs/v5.7-optimization-lens-plan.md`.
|
||||
|
||||
### The subtraction floor
|
||||
|
||||
`optimize --subtract` is the only lens that proposes *removing* configuration, so it carries a
|
||||
guarantee the others do not need: **a load-bearing block is never a candidate.** Precision here
|
||||
is asymmetric — a missed dead line costs a few tokens per turn, while a deleted load-bearing
|
||||
line costs a wrong remote or a broken script — so the floor is decided in code
|
||||
(`scanners/lib/floor-exclusion.mjs`), before the opus judge sees anything, rather than being
|
||||
left to prose judgement.
|
||||
|
||||
A block is floored when it carries an underivable local literal (inline code span, rooted path,
|
||||
domain, concrete filename, version pin), when it states a policy invariant (secrets,
|
||||
credentials, production, prompt-injection boundaries — floor *by decision*, not by
|
||||
classification), or when it names a capitalized entity the mechanism cannot resolve without a
|
||||
dictionary. That last rule is a deliberate conservative default: it declines to decide and
|
||||
keeps the block, paying in recall rather than risk.
|
||||
|
||||
Granularity is the **leaf block** — one list item including its wrapped continuation lines, or
|
||||
one paragraph — with two structural exceptions: a paragraph ending in `:` merges with the list
|
||||
it introduces, and an *ordered* list is treated as a contract whose steps inherit floor from
|
||||
any sibling. Unordered lists deliberately do not inherit, so a load-bearing bullet and a
|
||||
disposable one can coexist in the same list. Measured against a hand-built ground truth
|
||||
(35 blocks, 13 genuinely ambiguous): zero load-bearing blocks proposed, 11 of 18 deletable
|
||||
groups surfaced, ≈18 % of an always-loaded file. On a well-maintained config this axis is
|
||||
mostly a no-op — which is itself the finding.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
|
|
|||
|
|
@ -33,6 +33,39 @@ whether the line is *really* that kind of instruction:
|
|||
| `claude-md-lifecycle-phrasing` | BP-MECH-001 | a recurring automation the model is *told* to perform ("after every commit, run X") — something that should happen deterministically, not at the model's discretion | a **hook** (PreToolUse / PostToolUse / Stop) |
|
||||
| `unscoped-path-specific-instruction` | BP-MECH-002 | a constraint that only applies when a *specific* file/path/glob is touched, sitting in root CLAUDE.md where it loads every turn regardless | a **path-scoped rule** (`.claude/rules/` with `paths:` frontmatter) |
|
||||
| `never-instruction` | BP-MECH-004 | an *absolute* prohibition — something that must NEVER happen, where relying on the model to remember is the wrong guarantee | a **permission deny rule** or PreToolUse hook |
|
||||
| `compensatory-instruction` | BP-SUB-001 | **`--subtract` mode only.** an instruction that corrects general model *behaviour* rather than stating a local fact — so it pays an always-loaded token cost without telling the model anything it could not work out | **removal**, re-added only if the model actually stumbles |
|
||||
|
||||
## The subtraction lens (`--subtract` only)
|
||||
|
||||
Present only when the payload has a `subtract` block. It asks the inverse of
|
||||
every other lens: *what is no longer earning its always-loaded rent?* Three
|
||||
things make it different, and all three are non-negotiable.
|
||||
|
||||
**1. The floor is not yours to decide.** A deterministic pre-step has already
|
||||
excluded every block carrying a local fact — a code span, path, domain, version
|
||||
pin, policy invariant, or an unresolved capitalized entity — plus the steps of
|
||||
any ordered list whose siblings carry one. You never see those blocks, and you
|
||||
must not reason about whether some *other* block ought to be deleted. Judge only
|
||||
what you are given. 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.
|
||||
|
||||
**2. Staleness is NOT a deletion signal.** A block that pins an outdated version
|
||||
("use Opus 4.8") is a *dead-reference* problem for `drift` / `CA-CML`, not a
|
||||
subtraction finding. The instruction is still load-bearing — it encodes a
|
||||
decision only the operator can make; it is merely out of date. Recommending
|
||||
deletion because content looks stale is a category error. Say "this looks
|
||||
outdated" if you must, but never as a removal candidate.
|
||||
|
||||
**3. Tier 2 is not tier 3.** Deletable splits into *earned* (compensatory, but
|
||||
this model still stumbles on it, so it returns) and *dead* (never missed). Sort
|
||||
every candidate into one of the two and say which. A block that has visibly
|
||||
earned its place — its subject matter recurs in the repo's own history — is tier
|
||||
2 even when its classification is "compensatory". Reporting it as dead weight is
|
||||
wrong even though the label matches.
|
||||
|
||||
Rank kept candidates by always-loaded token cost, and state the total payoff.
|
||||
Frame it as *rent*, never as a mistake: this config was correct when written.
|
||||
|
||||
## Input
|
||||
|
||||
|
|
@ -45,6 +78,10 @@ You receive an `optimize-lens` payload (JSON) with:
|
|||
`recommendation`, `severity`, `source`). Only CONFIRMED register rules reach
|
||||
you.
|
||||
- `register` — the full confirmed prose-judgment entries, for reference.
|
||||
- `subtract` — **present only under `--subtract`.** `{ enabled, candidates,
|
||||
register, detectors }`. Each candidate spans `line`–`endLine` (a whole leaf
|
||||
block, not one line) and carries `signalText` plus the BP-SUB-001 register
|
||||
block. Everything load-bearing was already removed before you saw this.
|
||||
|
||||
Always **Read the actual CLAUDE.md file(s)** named in the candidates before
|
||||
judging — `signalText` is one line out of context; the surrounding lines decide
|
||||
|
|
@ -104,6 +141,15 @@ Write `optimization-lens-report.md` to the session directory (≤120 lines).
|
|||
{Brief, honest: candidates you dropped and why — "line 22 mentions a path but is
|
||||
a cross-reference, not an instruction." This is the precision gate showing its
|
||||
work. Keep to a few lines.}
|
||||
|
||||
## No longer earning its rent (--subtract only)
|
||||
|
||||
{Omit entirely unless the payload has a `subtract` block. Two sub-lists —
|
||||
**Dead** (tier 3, out and never missed) and **Earned** (tier 2, out but likely
|
||||
to return) — ranked by token cost, with a payoff total. For each:}
|
||||
**{file}:{line}-{endLine}** — {what the block says, in one line} · ~{N} tok/turn
|
||||
Tier: {dead | earned — and why}
|
||||
Source: {register.source.url}
|
||||
```
|
||||
|
||||
Omit any section with zero kept findings (except keep the "left alone" note when
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ is hybrid: a cheap deterministic pre-filter finds candidates, then the opus
|
|||
- **Lifecycle phrasing → hooks** (BP-MECH-001)
|
||||
- **Unscoped path-specific instructions → path-scoped rules** (BP-MECH-002)
|
||||
- **Absolute "never" prohibitions → permissions / hooks** (BP-MECH-004)
|
||||
- **`--subtract`:** instructions that no longer earn their always-loaded rent (BP-SUB-001)
|
||||
|
||||
Each finding cites its register rule + source URL. A clean CLAUDE.md returns "no
|
||||
opportunities" — that is a good result, not a failure.
|
||||
|
|
@ -34,7 +35,21 @@ opportunities" — that is a good result, not a failure.
|
|||
|
||||
Split `$ARGUMENTS` into a path (first non-flag argument; default: current working
|
||||
directory) and flags. Recognized flags: `--global` (include the user `~/.claude`
|
||||
cascade in discovery).
|
||||
cascade in discovery) and `--subtract` (add the subtraction axis, below).
|
||||
|
||||
**`--subtract` — the inverse question.** Every other lens asks what to *add* or
|
||||
*move*; this one asks what no longer earns its always-loaded rent. It is opt-in
|
||||
because it asks something different, and because deleting is not undoable by
|
||||
reading. Pair it with `--global` to reach the user-level CLAUDE.md, where the
|
||||
always-loaded cost actually sits (it loads in every repo, every session).
|
||||
|
||||
If `--subtract` is present, say so up front:
|
||||
|
||||
```
|
||||
Also running the subtraction axis — instructions that cost tokens every turn
|
||||
without telling me anything I couldn't work out. Load-bearing local facts
|
||||
(remotes, versions, paths, policy) are excluded before anything is judged.
|
||||
```
|
||||
|
||||
Tell the user:
|
||||
|
||||
|
|
@ -52,7 +67,9 @@ Generate a session ID (`YYYYMMDD_HHmmss`) if no active session exists.
|
|||
mkdir -p ~/.claude/config-audit/sessions/{session-id} 2>/dev/null
|
||||
GLOBAL_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--global"; then GLOBAL_FLAG="--global"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/optimize-lens-cli.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/optimize-lens.json $GLOBAL_FLAG 2>/dev/null; echo $?
|
||||
SUBTRACT_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--subtract"; then SUBTRACT_FLAG="--subtract"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/optimize-lens-cli.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/optimize-lens.json $GLOBAL_FLAG $SUBTRACT_FLAG 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit code 0 is normal. Only exit code 3 is a real error → "The lens couldn't run.
|
||||
|
|
@ -64,8 +81,13 @@ Read `~/.claude/config-audit/sessions/{session-id}/optimize-lens.json` with the
|
|||
Read tool. It has `deterministic` (already-confirmed OPT findings), `candidates`
|
||||
(pre-filter candidates with register provenance), `register`, and `counts`.
|
||||
|
||||
**Early exit:** if `counts.deterministic === 0` and `counts.candidates === 0`,
|
||||
skip the agent and tell the user plainly:
|
||||
Under `--subtract` it also has a `subtract` block (`candidates`, `register`) and
|
||||
`counts.subtractCandidates`. Each subtraction candidate spans `line`–`endLine`
|
||||
(a whole block). Include the whole `subtract` block when spawning the agent.
|
||||
|
||||
**Early exit:** if `counts.deterministic === 0` and `counts.candidates === 0`
|
||||
(and, under `--subtract`, `counts.subtractCandidates === 0`), skip the agent and
|
||||
tell the user plainly:
|
||||
|
||||
```
|
||||
✓ No mechanism-fit opportunities found.
|
||||
|
|
@ -117,6 +139,11 @@ End with context-sensitive next steps, explaining WHY each is useful:
|
|||
|
||||
- This command is **agent-driven and not byte-stable** — its output is a
|
||||
human-facing report, deliberately outside the deterministic snapshot suite.
|
||||
- `--subtract` **proposes, never writes.** Nothing is deleted; act on a finding
|
||||
via `/config-audit plan` → `/config-audit implement` (backup + rollback).
|
||||
- The subtraction floor is deterministic and runs *before* the agent, so a
|
||||
load-bearing block is never a candidate. It errs toward keeping: on a
|
||||
well-maintained config this axis is mostly a no-op, and that is a good result.
|
||||
- The deterministic half (CA-OPT-001) also rides in the normal orchestrated
|
||||
audit; this command adds the prose-judgment half on top.
|
||||
- No files are modified. To act on a finding, use `/config-audit plan` →
|
||||
|
|
|
|||
|
|
@ -322,11 +322,21 @@ bash script, or a lost afternoon.
|
|||
|
||||
- [x] **DONE 2026-07-31 (#40).** `grep -rniE 'blame|mtime|birthtime' scanners/` returns
|
||||
zero hits → §3.1 confirmed still true at build time.
|
||||
- [ ] `node scanners/drift-cli.mjs <path> --save --name pre-subtraction` succeeds and
|
||||
`lib/baseline.mjs` round-trips → confirms §4 reuse is real, not assumed.
|
||||
**Still open** — not blocking the fasit, blocking any code.
|
||||
- [ ] If any claim from §1 is to be written into the register: a primary source exists
|
||||
(URL to the talk or an Anthropic doc). Otherwise the entry is not written.
|
||||
- [x] **DONE 2026-07-31 (#41).** `node scanners/drift-cli.mjs . --save --name pre-subtraction`
|
||||
succeeds and `lib/baseline.mjs` round-trips → §4 reuse is real, not assumed.
|
||||
Written envelope is `{meta, scanners[16], aggregate, _baseline}` with
|
||||
`_baseline.target_path` = the repo (15 findings, score 55). Run with no flags
|
||||
beyond `--save --name`, since M-BUG-21 makes an unknown flag's value silently
|
||||
become the scan target.
|
||||
- [x] **RESOLVED 2026-07-31 (#41) by not needing it.** `BP-SUB-001` was written
|
||||
`confirmed`, and asserts **nothing** from §1 — no "80 %", no ablation figure, no
|
||||
Cherny attribution. Its claim is grounded entirely in the Anthropic steering blog
|
||||
already cited by BP-MECH-001–004, re-verified the same day: *"Every line loads into
|
||||
every session for every engineer working in the repo, whether it's relevant to their
|
||||
task or not. This consumes tokens and dilutes adherence"*, *"Build commands,
|
||||
directory layout, monorepo structure, coding conventions, and team norms all fit
|
||||
naturally here"*, and *"Keep CLAUDE.md under 200 lines"*. The anecdote motivated the
|
||||
feature; it is not a premise of the shipped rule.
|
||||
- [x] **DONE 2026-07-31 (#40) — THE AGE SIGNAL DOES NOT EXIST. Collapse condition met.**
|
||||
Per-line `git blame` (not `git log`; per-line is the question) over four real
|
||||
instruction files:
|
||||
|
|
@ -359,25 +369,32 @@ bash script, or a lost afternoon.
|
|||
per §7 q3; the two struck items below were premised on a new standalone scanner, which is
|
||||
no longer the shape):
|
||||
|
||||
- [ ] Red tests exist and fail before implementation.
|
||||
- [ ] `node --test 'tests/**/*.test.mjs'` green, count strictly greater than 1365.
|
||||
- [ ] `git diff --stat tests/snapshots/v5.0.0/` is empty (frozen baselines untouched).
|
||||
**Still applies, and matters more as a mode:** the new lens class must not alter any
|
||||
existing `optimize` output, so a plain `/config-audit optimize` run has to be diffed
|
||||
against its current output as well.
|
||||
- [x] **DONE (#41).** Red tests written first against a synthesized fixture and confirmed
|
||||
failing (module not found) before either module existed.
|
||||
- [x] **DONE (#41).** Suite green at **1382/0** (was 1365).
|
||||
- [x] **DONE (#41).** `git diff --stat tests/snapshots/v5.0.0/` empty, **and** a plain
|
||||
`optimize-lens-cli.mjs` run diffed byte-identical against its pre-change output
|
||||
(`--subtract` adds keys only when the flag is present; `LENS_DETECTORS` still
|
||||
exposes exactly 3 detectors, asserted in the suite).
|
||||
- [ ] ~~`node scanners/self-audit.mjs --check-readme` passes (badge counts updated:
|
||||
scanners 16 → 17).~~ **No badge bump — no new scanner.** `--check-readme` must still
|
||||
pass, and README/CLAUDE.md still need the docs-gate diffs for a `feat:` commit
|
||||
([[docs-gate-feat-requires-readme-claudemd]]).
|
||||
- [ ] Every new finding renders with a non-`Other` `userImpactCategory` and a
|
||||
non-`_default` action language → humanizer wiring correct (M-16/M-17).
|
||||
- [ ] **Floor test (§6.0), the blocking one.** Build a fasit by hand first — per
|
||||
[[judge-the-judge-build-fasit-first]] — over the operator's own global CLAUDE.md,
|
||||
labelling each block compensatory or load-bearing. Then run the tool. Required
|
||||
result: **zero load-bearing blocks proposed for deletion.** Non-negotiable; the
|
||||
known-correct floor items to check for explicitly are "never GitHub, only Forgejo",
|
||||
"system bash is 3.2", the test command, and "`~/.claude` is not git-tracked". One
|
||||
of those in the delete list fails the gate outright, whatever the recall.
|
||||
- [ ] Recall, secondary: at least one candidate the operator agrees is genuinely dead
|
||||
weight. Precision over recall throughout — a missed dead line costs a few tokens
|
||||
per turn, a deleted load-bearing line costs a wrong remote or a broken script.
|
||||
- [x] **PASSED 2026-07-31 (#41) — the blocking floor test.** Fasit built first (#40), tool
|
||||
run after, and the comparison **machine-checked**: a script asserts the intersection
|
||||
of the candidate list with every FLOOR / POLICY-FLOOR line range in the fasit is
|
||||
empty. Result: **zero load-bearing blocks proposed for deletion.** Both §8 anchors
|
||||
present in the subject file (B-25 "Aldri GitHub. Kun Forgejo", B-26 "System bash er
|
||||
3.2") are excluded, as are B-09, B-13, B-17, B-23, B-27, B-29, B-32b and B-38b.
|
||||
The first run found **five** violations the synthesized fixture missed; each was
|
||||
fixed with a structural rule (list-stem merge, ordered-list-as-contract, security
|
||||
terms, `unresolved-entity`, declarative guard) and a fixture shape added so it
|
||||
cannot regress.
|
||||
- [x] **MET (#41), with the number stated honestly.** 11 of 18 deletable groups surfaced,
|
||||
≈756 always-loaded tokens ≈ **18 %** of the ~4 300-token file — inside the
|
||||
pre-registered ~850 / ~20 % band. The 7 misses are the conservative default working
|
||||
as intended (unresolvable entity names, code spans, and declarative/infinitive
|
||||
phrasing carrying no imperative). Precision over recall held throughout: every fix
|
||||
in this session traded recall away, never the gate.
|
||||
|
|
|
|||
|
|
@ -499,6 +499,56 @@ orchestrates pre-filter→agent→report. **Agent-driven → deliberately NOT by
|
|||
outside the snapshot suite); the pre-filter lib *is* unit-tested (13 tests). No new orchestrated
|
||||
scanner → scanner count stays 15; agents 6→7, commands 18→19, suite 1055→1068.
|
||||
|
||||
### Subtraction lens (`optimize --subtract`, `BP-SUB-001`) — the inverse axis
|
||||
|
||||
**Why a mode, not a command or scanner.** Every other command asks an addition question; nothing
|
||||
asked what is no longer earning its rent. A hand-built ground truth over a real 250-line global
|
||||
CLAUDE.md put the honest payoff at ~850–1400 always-loaded tokens of ~4300 (~20 %, not the source
|
||||
anecdote's 80 %), with 26 of 34 blocks load-bearing. That proportion justifies a fourth `lensCheck`
|
||||
on the existing hybrid motor — not a new scanner (no badge bump 16→17, no frozen-snapshot risk) and
|
||||
not a 22nd command. Cross-session state is what would have forced a command, and the earned-re-add
|
||||
*ledger* is deliberately out of v1.
|
||||
|
||||
**Polarity is flipped from `lens-prefilter`.** That module is recall-first because a false candidate
|
||||
only costs the judge a moment. Here a false candidate is a proposal to *delete*, so
|
||||
`subtraction-prefilter.mjs` is precision-first and carries a blocking deterministic guarantee.
|
||||
|
||||
**Floor-exclusion is a separate module on purpose** (`lib/floor-exclusion.mjs`). §6.0's asymmetry —
|
||||
a missed dead line costs a few tokens per turn, a deleted load-bearing line costs a wrong remote —
|
||||
means the guarantee must not rest on a probabilistic judge, so the floor runs *before* the agent
|
||||
and is legible as its own unit. Markers: code span, URL/host, filename, rooted path, version pin,
|
||||
policy invariant, and `unresolved-entity` (a mixed-case capitalized word mid-sentence). The last is
|
||||
a deliberate conservative default — resolving "Forgejo" from an ordinary capitalized word needs a
|
||||
dictionary, so the mechanism declines and keeps the block.
|
||||
|
||||
**Granularity: leaf block + two structural exceptions.** (1) A paragraph ending in `:` merges with
|
||||
the list it introduces — a stem often carries no literal of its own, and deleting it without its
|
||||
list is meaningless. (2) An *ordered* list is a contract: steps inherit floor from any sibling,
|
||||
because deleting step 2 of a five-step protocol is not like dropping one platitude. Unordered lists
|
||||
do **not** inherit — a load-bearing bullet and a disposable one routinely share a list, and
|
||||
container-reasoning is exactly the error the ground truth was built to catch.
|
||||
|
||||
**Three lessons from the dogfood run**, all invisible to the synthesized fixture and worth keeping:
|
||||
- **JS `\b` is ASCII-only.** `/\bunngå\b/` never matches — the trailing `å` is not a word character,
|
||||
so there is no boundary after it. Every Norwegian keyword ending in æ/ø/å was silently dead. Use
|
||||
the `LB`/`RB` lookaround constants, never `\b`, around that vocabulary.
|
||||
- **A bare `word/word` is not a path.** `pros/cons` vetoed the single largest deletable block until
|
||||
`PATH_RE` was tightened to rooted paths and globs; real filenames are `FILENAME_RE`'s job.
|
||||
- **"Mid-sentence" must key on a preceding lowercase letter**, not on "anything that is not a full
|
||||
stop". The loose version read `**Bold labels:**` and quoted openers (`"Som AI kan jeg ikke…"`) as
|
||||
entities and cost 4 of 11 deletable groups.
|
||||
|
||||
**Measured against the ground truth:** zero load-bearing blocks proposed (the blocking §8 gate),
|
||||
11/18 deletable groups surfaced, ≈756 tok ≈ 18 % of the file — inside the pre-registered band. The
|
||||
misses are all the conservative default working as designed (entity names, code spans, and
|
||||
declarative/infinitive phrasing that carries no imperative). No new scanner: scanners stay 16,
|
||||
commands 21, agents 7; suite 1365→1382.
|
||||
|
||||
**Note for a future narrowing of the veto:** the two failure modes the agent prompt hardens against
|
||||
— staleness-is-not-deletion and tier-2-is-not-tier-3 — are currently *also* covered by exclusion
|
||||
(both example blocks carry code spans/version pins and never reach the judge). The prompt language
|
||||
is the only protection if those markers are ever loosened.
|
||||
|
||||
**Test-isolation fix (this session):** `token-hotspots.test.mjs` `runScanner` now wraps `scan()` in
|
||||
the shared `withHermeticHome` helper — the suite is green on BOTH a real and a clean `HOME` (the OPT
|
||||
section's old «run with clean HOME» caveat is resolved). Snapshot/byte tests were already hermetic.
|
||||
|
|
|
|||
|
|
@ -136,6 +136,18 @@
|
|||
"category": "size-budget",
|
||||
"lensCheck": "CA-SKL-002",
|
||||
"source": { "url": "https://code.claude.com/docs/en/skills", "title": "Skills", "verified": "2026-06-20" }
|
||||
},
|
||||
{
|
||||
"id": "BP-SUB-001",
|
||||
"claim": "Every line of CLAUDE.md loads into every session whether or not it is relevant, which consumes tokens and dilutes adherence. A line that states a local fact the model cannot derive (build commands, directory layout, conventions, team norms) earns that cost; a line that only restates general engineering behaviour pays it without being the kind of content CLAUDE.md is for, and is a candidate for removal.",
|
||||
"mechanism": "deletion",
|
||||
"appliesTo": "claude-md",
|
||||
"recommendation": "Review the block for removal, then re-add it only if the model actually stumbles on it repeatedly. Local facts (remotes, versions, paths, conventions) and policy invariants are the floor and are never removal candidates.",
|
||||
"confidence": "confirmed",
|
||||
"severity": "low",
|
||||
"category": "subtraction",
|
||||
"lensCheck": "compensatory-instruction",
|
||||
"source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-07-31" }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
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');
|
||||
|
|
|
|||
98
tests/fixtures/subtraction/shapes-claude-md.md
vendored
Normal file
98
tests/fixtures/subtraction/shapes-claude-md.md
vendored
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Subtraction shapes — synthesized fixture
|
||||
|
||||
This fixture reproduces the *discriminating shapes* from the hand-built subtraction fasit
|
||||
(økt #40) without reproducing any of the operator's private config text. Every block below
|
||||
exists to make one classification call hard in the same way a real block was hard.
|
||||
|
||||
Both languages appear on purpose: the config this lens was designed against is Norwegian
|
||||
prose with English identifiers, and a detector that only speaks English would score well
|
||||
here and fail in the field.
|
||||
|
||||
## Preferences
|
||||
|
||||
- Language: Norwegian for dialogue, English for code and documentation.
|
||||
- Tone: direct and technical, minimal ceremony.
|
||||
- Format: be concrete and action-oriented, avoid unnecessary text.
|
||||
- Never say "as an AI I cannot"; skip apologies and over-explanation.
|
||||
|
||||
## Kodepreferanser
|
||||
|
||||
- TypeScript > JavaScript
|
||||
- Readability > cleverness
|
||||
- Conventional Commits: `type(scope): description`
|
||||
|
||||
## Oppstart
|
||||
|
||||
1. Run `pwd` to see where you are.
|
||||
2. Read the nearest STATE.md and confirm the active task.
|
||||
3. Sjekk `git status` mot det STATE.md hevder.
|
||||
4. Summarize before you do anything.
|
||||
|
||||
## Arbeidsregler
|
||||
|
||||
- Commit often with descriptive messages.
|
||||
- Test incrementally, and ask when blocked.
|
||||
- Tenk før du koder — uttal antakelser eksplisitt og spør ved tvetydighet.
|
||||
- Aldri gjett på rotårsak; undersøk først.
|
||||
|
||||
## Defensive shell-scripting
|
||||
|
||||
- Alltid siter variabel-ekspansjoner (`"$var"`) — en usitert ekspansjon brøt en zsh-loop.
|
||||
- Hold testkode ASCII-ren; et multibyte-tegn krasjet bash 3.2 under `set -u`.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Do not one-shot large features.
|
||||
- Never declare "done" without running the tests.
|
||||
- Aldri jobb i et annet repo enn det økten står i. Hvert repo eier sin egen
|
||||
versjonsplan og sin egen tilstand. Skriver du der, tar du beslutninger på
|
||||
deres vegne uten deres kontekst — `coord-send` er mekanismen, send arbeidet
|
||||
dit i stedet.
|
||||
- Do not refactor as part of a bugfix without approval.
|
||||
|
||||
## Git
|
||||
|
||||
- Aldri GitHub. Kun Forgejo (`git.example-forge.test`). Aldri `gh` CLI.
|
||||
- Etter `git commit`: push til Forgejo umiddelbart.
|
||||
|
||||
## Miljø
|
||||
|
||||
- System bash er **3.2**: ingen `declare -A`, ingen `readarray`.
|
||||
- Alle hook-scripts må være 3.2-kompatible.
|
||||
|
||||
## Modellvalg
|
||||
|
||||
Default for alt arbeid er Opus 4.8 med xhigh effort. Ingen annen modell skal brukes.
|
||||
Sett `model: "opus"` på hver spawn. Haiku: aldri.
|
||||
|
||||
## Secrets
|
||||
|
||||
- ALDRI commit secrets. Bruk `.env`/`.template`-mønsteret.
|
||||
|
||||
### The Iron Law: NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
|
||||
|
||||
## Arbeidsflyt
|
||||
|
||||
Default for alle oppgaver: forstå problemet, vurder alternativer, presenter dem,
|
||||
og vent på bekreftelse før du implementerer. Ved usikkerhet om en oppgave faller
|
||||
i unntakene: den gjør det ikke.
|
||||
|
||||
**Viktig:** `/harness continue` og andre skills styrer sin egen workflow.
|
||||
|
||||
## Kodeblokk
|
||||
|
||||
```bash
|
||||
# Never run this in production, and always check every time first.
|
||||
echo "avoid unnecessary output"
|
||||
```
|
||||
|
||||
## Kontinuitet
|
||||
|
||||
Konteksten mellom sesjoner hviler på tre lag med hver sin ene jobb:
|
||||
- **`STATE.md`** — current state-of-play der du jobber. Overskrives alltid ved sesjonsslutt.
|
||||
- **`MEMORY.md`** — varige fakta om bruker. Hold den under 200 linjer.
|
||||
|
||||
## Verktøy
|
||||
|
||||
- Deleger arbeid til subagenter, og bruk Explore for søk.
|
||||
- Skriv alltid kortest mulig kode.
|
||||
228
tests/lib/subtraction-prefilter.test.mjs
Normal file
228
tests/lib/subtraction-prefilter.test.mjs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* subtraction-prefilter tests — the deterministic half of the v5.13 subtraction
|
||||
* lens (`/config-audit optimize --subtract`, CA-OPT / BP-SUB-001).
|
||||
*
|
||||
* The subtraction axis asks the inverse of every other command: *what is no
|
||||
* longer earning its always-loaded rent?* Precision here is ASYMMETRIC — a
|
||||
* missed dead line costs a few tokens per turn, a deleted load-bearing line
|
||||
* costs a wrong remote or a broken script (brief §6.0). So unlike
|
||||
* `lens-prefilter` (recall-first, agent as the gate), this module is
|
||||
* precision-first and carries a BLOCKING deterministic guarantee:
|
||||
*
|
||||
* **a load-bearing block never reaches the judge at all.**
|
||||
*
|
||||
* That guarantee is delivered by two independent mechanisms, and the contract is
|
||||
* therefore asserted on the CANDIDATE LIST rather than on either mechanism:
|
||||
* 1. the compensatory detector does not fire on declarative local facts
|
||||
* ("Language: Norwegian for dialogue") — they are never generated; and
|
||||
* 2. floor-exclusion vetoes any leaf block carrying an underivable local
|
||||
* literal (code span, path, domain, version pin) or a policy invariant.
|
||||
* A test written against `isLoadBearing()` alone would miss group 1 entirely.
|
||||
*
|
||||
* GRANULARITY (the one design choice the fasit left open, §5): leaf-block — one
|
||||
* markdown list item including its wrapped continuation lines, or one paragraph.
|
||||
* It must SPLIT a mixed numbered list and three consecutive bullets, and must
|
||||
* KEEP WHOLE a bullet whose load-bearing literal sits on a continuation line.
|
||||
*
|
||||
* FIXTURE, not the real subject file. The fasit was built against the operator's
|
||||
* private `~/.claude/CLAUDE.md`; that file is machine-dependent, edited between
|
||||
* sessions, and this repo's only remote is a PUBLIC mirror. So the committed
|
||||
* suite runs against a synthesized fixture that reproduces the discriminating
|
||||
* *shapes*, and the run against the real file is the manual dogfood gate (brief
|
||||
* §8), recorded in the worklist — not a suite test.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
splitLeafBlocks,
|
||||
subtractionCandidates,
|
||||
floorExcluded,
|
||||
SUBTRACT_DETECTORS,
|
||||
} from '../../scanners/lib/subtraction-prefilter.mjs';
|
||||
import { isLoadBearing } from '../../scanners/lib/floor-exclusion.mjs';
|
||||
|
||||
const FIXTURE = readFileSync(
|
||||
fileURLToPath(new URL('../fixtures/subtraction/shapes-claude-md.md', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const candidateLines = (text) => subtractionCandidates(text).map((c) => c.line);
|
||||
const covers = (cands, line) => cands.some((c) => line >= c.startLine && line <= c.endLine);
|
||||
|
||||
/**
|
||||
* Every line of the fixture that the fasit labels FLOOR or POLICY-FLOOR. If any
|
||||
* of these is covered by a candidate block, the gate fails outright — whatever
|
||||
* the recall. (brief §8, "zero load-bearing blocks proposed for deletion")
|
||||
*/
|
||||
const FLOOR_LINES = [
|
||||
13, 14, // declarative preference facts (B-04 / B-05 shape) — must never fire
|
||||
22, // format contract in a code span (B-30c) — adjacent to two other calls
|
||||
26, 27, 28, 29, // ordered list = contract (B-16 / B-17): siblings 27-28 floor the whole run
|
||||
91, 92, 93, // list stem + its floor items (B-09) — the stem carries no literal of its own
|
||||
97, // "bruk Explore for søk" — an entity the mechanism cannot resolve, so it keeps it
|
||||
40, 41, // generic-sounding advice wrapped around a local incident (B-27 — THE trap)
|
||||
47, 48, 49, 50, // floor bullet inside a compensatory list (B-32b), literal on a continuation line
|
||||
55, 56, // §8 named anchor: the remote (B-25) + the push workflow (B-23)
|
||||
60, 61, // §8 named anchor: system bash version (B-26)
|
||||
65, 66, // stale-in-content but load-bearing (B-29) — staleness is NOT a deletion signal
|
||||
70, // policy invariant, floor by decision not classification (B-34)
|
||||
72, // absolute policy prohibition as a heading (B-37)
|
||||
80, // local fact about how skills behave (B-38b)
|
||||
85, 86, // inside a fenced code block — never a candidate
|
||||
];
|
||||
|
||||
/** Lines the fasit labels DELETABLE that the mechanism must actually surface. */
|
||||
const MUST_SURFACE = [
|
||||
15, // "avoid unnecessary text" — the B-06 half of the B-05/B-06 adjacent pair
|
||||
16, // "Never say 'as an AI I cannot'" — textbook tier-3 compensatory
|
||||
33, // "Commit often with descriptive messages" (B-19b) — the named pure-behaviour case
|
||||
36, // "Aldri gjett på rotårsak" — Norwegian imperative
|
||||
46, // "Never declare done without running the tests"
|
||||
76, // the workflow paragraph (B-38a) — split from its floor line at 80
|
||||
98, // "Skriv alltid kortest mulig kode" — survives next to an entity-vetoed sibling
|
||||
];
|
||||
|
||||
describe('subtraction-prefilter — detector table', () => {
|
||||
it('registers the subtraction class against BP-SUB-001, separately from LENS_DETECTORS', async () => {
|
||||
const map = new Map(SUBTRACT_DETECTORS.map((d) => [d.lensCheck, d]));
|
||||
assert.ok(map.has('compensatory-instruction'), 'subtraction lensCheck must exist');
|
||||
assert.equal(map.get('compensatory-instruction').registerId, 'BP-SUB-001');
|
||||
|
||||
// Byte-stability: the three mechanism-fit detectors drive the plain
|
||||
// `optimize` payload's `register` block. Adding the subtraction class there
|
||||
// would change plain-optimize output, which the brief forbids.
|
||||
const { LENS_DETECTORS } = await import('../../scanners/lib/lens-prefilter.mjs');
|
||||
assert.equal(LENS_DETECTORS.length, 3, 'plain optimize must still expose exactly 3 detectors');
|
||||
assert.ok(
|
||||
!LENS_DETECTORS.some((d) => d.lensCheck === 'compensatory-instruction'),
|
||||
'the subtraction class must not leak into the plain-optimize detector table',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subtraction-prefilter — leaf-block granularity (the declared contract)', () => {
|
||||
it('keeps a wrapped bullet whole, so a continuation-line literal still protects it', () => {
|
||||
const blocks = splitLeafBlocks(FIXTURE);
|
||||
const b32b = blocks.find((b) => b.startLine === 47);
|
||||
assert.ok(b32b, 'the wrapped bullet must start its own block at line 47');
|
||||
assert.equal(b32b.endLine, 50, 'its continuation lines belong to the same block');
|
||||
});
|
||||
|
||||
it('splits three consecutive bullets into three blocks (three different calls)', () => {
|
||||
const blocks = splitLeafBlocks(FIXTURE);
|
||||
for (const line of [20, 21, 22]) {
|
||||
assert.ok(
|
||||
blocks.some((b) => b.startLine === line && b.endLine === line),
|
||||
`line ${line} must be its own leaf block`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats an ordered list as a contract: a floor sibling floors the whole run', () => {
|
||||
// Steps 2-3 name the file to read and the check to run; steps 1 and 4 are
|
||||
// filler. Splitting them would propose deleting step 4 of a five-step
|
||||
// protocol — so the run inherits floor. (Structural exception 2.)
|
||||
const cands = subtractionCandidates(FIXTURE);
|
||||
for (const line of [26, 27, 28, 29]) {
|
||||
assert.ok(!covers(cands, line), `ordered step at line ${line} must inherit floor`);
|
||||
}
|
||||
const reasons = floorExcluded(FIXTURE);
|
||||
assert.ok(
|
||||
reasons.some((r) => r.startLine === 29 && r.marker === 'ordered-contract'),
|
||||
'step 4 must be recorded as floored by contract inheritance, not by its own literal',
|
||||
);
|
||||
});
|
||||
|
||||
it('merges a list stem with the items it introduces (structural exception 1)', () => {
|
||||
const blocks = splitLeafBlocks(FIXTURE);
|
||||
const stem = blocks.find((b) => b.startLine === 91);
|
||||
assert.ok(stem, 'the stem paragraph must open a block at line 91');
|
||||
assert.equal(stem.endLine, 93, 'the stem absorbs the list it introduces');
|
||||
// The stem itself carries no literal — it is protected only by the merge.
|
||||
assert.equal(isLoadBearing('Konteksten mellom sesjoner hviler på tre lag med hver sin ene jobb:'), false);
|
||||
});
|
||||
|
||||
it('never emits a heading as a candidate block', () => {
|
||||
const cands = subtractionCandidates(FIXTURE);
|
||||
assert.ok(!covers(cands, 72), 'the Iron Law heading must not be a deletion candidate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subtraction-prefilter — THE GATE (brief §8, blocking)', () => {
|
||||
it('proposes ZERO load-bearing lines for deletion', () => {
|
||||
const cands = subtractionCandidates(FIXTURE);
|
||||
const violations = FLOOR_LINES.filter((line) => covers(cands, line));
|
||||
assert.deepEqual(
|
||||
violations,
|
||||
[],
|
||||
`load-bearing lines proposed for deletion: ${violations.join(', ')} — gate fails outright`,
|
||||
);
|
||||
});
|
||||
|
||||
it('still surfaces genuinely compensatory blocks (recall is secondary, not zero)', () => {
|
||||
const cands = subtractionCandidates(FIXTURE);
|
||||
const missed = MUST_SURFACE.filter((line) => !covers(cands, line));
|
||||
assert.deepEqual(missed, [], `expected these to be candidates: ${missed.join(', ')}`);
|
||||
});
|
||||
|
||||
it('separates the adjacent near-identical preference bullets (B-05 vs B-06)', () => {
|
||||
const lines = candidateLines(FIXTURE);
|
||||
assert.ok(!lines.includes(14), 'the tone preference is a fact about the human — keep');
|
||||
assert.ok(lines.includes(15), 'the "avoid unnecessary text" nag is compensatory — surface');
|
||||
});
|
||||
|
||||
it('does not fire inside fenced code blocks', () => {
|
||||
const cands = subtractionCandidates(FIXTURE);
|
||||
assert.ok(!covers(cands, 85) && !covers(cands, 86), 'fenced code is not config prose');
|
||||
});
|
||||
});
|
||||
|
||||
describe('floor-exclusion — the deterministic veto', () => {
|
||||
it('vetoes underivable local literals', () => {
|
||||
assert.ok(isLoadBearing('Aldri GitHub. Kun Forgejo (`git.example.test`).'), 'code span + domain');
|
||||
assert.ok(isLoadBearing('System bash er **3.2**: ingen declare -A.'), 'version pin');
|
||||
assert.ok(isLoadBearing('Read the nearest STATE.md before starting.'), 'concrete filename');
|
||||
assert.ok(isLoadBearing('Tokens live in ~/.zshenv on this machine.'), 'home path');
|
||||
});
|
||||
|
||||
it('vetoes policy invariants regardless of phrasing (§6.0 carve-out)', () => {
|
||||
assert.ok(isLoadBearing('ALDRI commit secrets.'), 'secrets are floor by decision');
|
||||
});
|
||||
|
||||
it('does not veto generic behaviour correction carrying no local fact', () => {
|
||||
assert.equal(isLoadBearing('Commit often with descriptive messages.'), false);
|
||||
assert.equal(isLoadBearing('Think before you code and avoid unnecessary text.'), false);
|
||||
});
|
||||
|
||||
it('vetoes an unresolvable mid-sentence entity, but not a bold label or a quoted opener', () => {
|
||||
// The conservative default: a product/tool name is local vocabulary the
|
||||
// mechanism cannot resolve without a dictionary, so it keeps the block.
|
||||
assert.ok(isLoadBearing('push til deres egne Forgejo-remotes også'), 'mid-sentence entity');
|
||||
// …but these are sentence openings dressed in punctuation. Reading them as
|
||||
// entities cost 4 of 11 deletable groups in the dogfood run.
|
||||
assert.equal(isLoadBearing('- **Format:** Konkret og handlingsorientert'), false, 'bold label');
|
||||
assert.equal(isLoadBearing('- **Aldri:** "Som AI kan jeg ikke..."'), false, 'quoted opener + acronym');
|
||||
});
|
||||
|
||||
it('does not treat a bare word/word as a path (it is not one)', () => {
|
||||
// "pros/cons" vetoed the largest deletable block until PATH_RE was tightened.
|
||||
assert.equal(isLoadBearing('Presenter alternativene med pros/cons og foreslå ett valg.'), false);
|
||||
assert.ok(isLoadBearing('Store baselines under ~/.config-audit/baselines/.'), 'a real path still vetoes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subtraction-prefilter — Unicode word boundaries', () => {
|
||||
// JS `\b` is ASCII-only, so /\bunngå\b/ never matches: the trailing "å" is
|
||||
// not a word character. Every Norwegian keyword ending in æ/ø/å was silently
|
||||
// dead until the dogfood run surfaced it.
|
||||
it('matches Norwegian keywords ending in æ/ø/å', () => {
|
||||
const c = subtractionCandidates('- Vær konkret og handlingsorientert, unngå overflødig tekst her.');
|
||||
assert.equal(c.length, 1, '"unngå" must be detected despite the trailing å');
|
||||
});
|
||||
|
||||
it('still requires a whole-word match', () => {
|
||||
assert.deepEqual(subtractionCandidates('- Systemet er uunngåelig komplekst og stort.'), []);
|
||||
});
|
||||
});
|
||||
|
|
@ -9,10 +9,18 @@ import { tmpdir } from 'node:os';
|
|||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const CLI = resolve(__dirname, '../../scanners/knowledge-refresh-cli.mjs');
|
||||
|
||||
// The bundled register's seed entries are all verified 2026-06-20, so a reference
|
||||
// date of 2026-06-21 makes every entry exactly 1 day old — deterministic regardless
|
||||
// of the real clock. We exploit that to exercise both the fresh and stale branches.
|
||||
const REF = '2026-06-21';
|
||||
// The reference date must sit AFTER the newest entry's `verified` date, so every
|
||||
// entry is at least one day old and both the fresh and stale branches are
|
||||
// reachable deterministically, regardless of the real clock.
|
||||
//
|
||||
// It used to be 2026-06-21, on the premise that every seed entry was verified
|
||||
// 2026-06-20. That premise expired when BP-SUB-001 was added with a genuine
|
||||
// 2026-07-31 verification date — an entry verified *after* the reference date has
|
||||
// a negative age and counts as fresh, so `--stale-after 0` no longer emptied the
|
||||
// fresh bucket. Backdating the entry to fit the test would have been a lie about
|
||||
// when its source was checked; moving the reference date is the honest fix.
|
||||
// Bump this again when a newer entry lands.
|
||||
const REF = '2026-08-01';
|
||||
|
||||
function runCli(extraArgs) {
|
||||
try {
|
||||
|
|
@ -56,7 +64,7 @@ describe('knowledge-refresh-cli — payload shape', () => {
|
|||
const out = JSON.parse(stdout);
|
||||
assert.equal(out.status, 'ok');
|
||||
assert.match(out.registerPath, /knowledge\/best-practices\.json$/);
|
||||
assert.equal(out.referenceDate, '2026-06-21');
|
||||
assert.equal(out.referenceDate, REF);
|
||||
assert.equal(out.staleAfterDays, 90);
|
||||
assert.ok(Array.isArray(out.stale));
|
||||
assert.ok(Array.isArray(out.fresh));
|
||||
|
|
@ -78,7 +86,12 @@ describe('knowledge-refresh-cli — payload shape', () => {
|
|||
const s = out.stale[0];
|
||||
assert.match(s.id, /^BP-[A-Z]+-\d{3}$/);
|
||||
assert.match(s.verified, /^\d{4}-\d{2}-\d{2}$/);
|
||||
assert.equal(s.ageDays, 1);
|
||||
// Was `=== 1`, which only held while every entry shared one verified date.
|
||||
// Assert the invariant that actually matters: ageDays is a positive integer
|
||||
// and agrees with this entry's own `verified` date against the reference.
|
||||
const expectedAge = Math.round((Date.parse(REF) - Date.parse(s.verified)) / 86400000);
|
||||
assert.equal(s.ageDays, expectedAge);
|
||||
assert.ok(s.ageDays >= 1, 'a stale entry is at least a day old');
|
||||
assert.ok(s.url && s.claim);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue