fix: harden new data-model eval regexes, dedupe E2E fixture setup

Ran /review on this branch's diff against main. The always-on testing
specialist found real correctness bugs in the two eval blocks added in
33fbb58b:

- pushesToSplitTheField (legitimate-JSON eval) was negation-blind: a
  CORRECT "I would NOT extract the payload into columns" answer contains
  the same verb+payload+column proximity as an incorrect one, so it could
  fail the test on a passing review. Also missing "normalize" from its verb
  list, so a real regression phrased as "normalize this payload into
  columns" would slip through undetected.
- rejectsAsUnjustified (measured-denorm eval) missed common rejection
  phrasings ("instead of denormalizing", "rather than snapshotting"), and
  its normali[sz]e-instead/back/it clause was loose enough to false-positive
  on approving language ("stays normalized elsewhere; keep it that way").
- recommendsSeparateModel (pre-existing data-model-bias eval, touched by
  this branch to fix its fixture) broke on backtick-wrapped model names, a
  common LLM markdown convention.

Added matchesUnnegated() to test/helpers/e2e-helpers.ts — walks every regex
match and checks the ~20 chars before it for a negation word before counting
it as a positive signal — and used it for the split-detector. Broadened the
rejection regex and tightened its false-positive-prone clause. Stripped
markdown formatting before the backtick-affected check. Verified all four
fixes against representative sentences (correct-negative, actual-positive,
previously-missed-regression, and previously-false-positive cases) before
committing — all passed as expected.

Also extracted the maintainability specialist's DRY finding: the three
data-model E2E blocks (bias, legitimate-json, measured-denorm) each
duplicated the same ~20-line mkdtemp/git-init/write-plan/copy-skill fixture
setup. Extracted into setupPlanEngReviewFixture() in e2e-helpers.ts.

bun test test/skill-validation.test.ts test/parity-suite.test.ts
test/touchfiles.test.ts test/gen-skill-docs.test.ts test/skill-e2e-plan.test.ts
— 768 pass, 39 skip (paid E2E), 0 fail.
This commit is contained in:
David Grant 2026-07-15 17:37:23 -07:00
parent d7567453c8
commit 09ad6fb1ef
2 changed files with 81 additions and 69 deletions

View File

@ -102,6 +102,58 @@ export function copyDirSync(src: string, dest: string) {
}
}
/**
* Set up a throwaway git repo containing plan.md + a copy of plan-eng-review's
* SKILL.md (+ sections/ if the skill has been carved) for standalone-plan E2E
* tests. Shared by the data-model-bias / legitimate-JSON / measured-denorm
* regression tests, which otherwise duplicated this ~20-line setup verbatim.
*/
export function setupPlanEngReviewFixture(tmpPrefix: string, planMarkdown: string): string {
const planDir = fs.mkdtempSync(path.join(os.tmpdir(), tmpPrefix));
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: planDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(planDir, 'plan.md'), planMarkdown);
run('git', ['add', '.']);
run('git', ['commit', '-m', 'add plan']);
fs.mkdirSync(path.join(planDir, 'plan-eng-review'), { recursive: true });
fs.copyFileSync(
path.join(ROOT, 'plan-eng-review', 'SKILL.md'),
path.join(planDir, 'plan-eng-review', 'SKILL.md'),
);
const sectionsDir = path.join(ROOT, 'plan-eng-review', 'sections');
if (fs.existsSync(sectionsDir)) {
fs.cpSync(sectionsDir, path.join(planDir, 'plan-eng-review', 'sections'), { recursive: true });
}
return planDir;
}
/**
* Case-insensitive check for `pattern` in `text` that ignores matches
* immediately preceded by a negation word (not/n't/no/never/without/...).
* Plain substring/regex matching can't tell "extract the payload into
* columns" from "would NOT extract the payload into columns" this walks
* every match and inspects the text just before it for a negation cue.
*/
export function matchesUnnegated(text: string, pattern: RegExp): boolean {
const NEGATION = /\b(not|n't|no|never|don't|doesn't|didn't|shouldn't|wouldn't|isn't|without|against)\b/i;
const flags = pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g';
const re = new RegExp(pattern.source, flags);
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
const windowStart = Math.max(0, m.index - 20);
const preceding = text.slice(windowStart, m.index);
if (!NEGATION.test(preceding)) return true;
if (re.lastIndex === m.index) re.lastIndex++; // avoid infinite loop on zero-width matches
}
return false;
}
/**
* Set up browse shims (binary symlink, find-browse, remote-slug) in a tmpDir.
*/

View File

@ -5,6 +5,7 @@ import {
describeIfSelected, testConcurrentIfSelected,
copyDirSync, setupBrowseShims, logCost, recordE2E,
createEvalCollector, finalizeEvalCollector,
setupPlanEngReviewFixture, matchesUnnegated,
} from './helpers/e2e-helpers';
import { judgePosture } from './helpers/llm-judge';
import { spawnSync } from 'child_process';
@ -380,20 +381,12 @@ describeIfSelected('Plan Eng Review Data-Model Bias E2E', ['plan-eng-review-data
let planDir: string;
beforeAll(() => {
planDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-plan-eng-dmb-'));
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: planDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
// Synthetic plan designed to trip the old bias: four clearly-polymorphic
// tier variants + feature-flag bag that the user proposes to inline onto
// User rather than create a new model. After the fix, the skill should
// recommend a separate SubscriptionTier model and push back on the
// JSONField for tier_features.
fs.writeFileSync(path.join(planDir, 'plan.md'), `# Plan: Add Subscription Tiers to User Model
planDir = setupPlanEngReviewFixture('skill-e2e-plan-eng-dmb-', `# Plan: Add Subscription Tiers to User Model
## Context
I want to add a 'subscription tier' feature to my Django app. Each user can
@ -423,18 +416,6 @@ column-add, no new FK navigation in the codebase.
- Should feature flags be a separate table or stay as JSONField?
- Any concerns with this approach?
`);
run('git', ['add', '.']);
run('git', ['commit', '-m', 'add plan']);
fs.mkdirSync(path.join(planDir, 'plan-eng-review'), { recursive: true });
fs.copyFileSync(
path.join(ROOT, 'plan-eng-review', 'SKILL.md'),
path.join(planDir, 'plan-eng-review', 'SKILL.md'),
);
// Carved skills (v2 plan T9): copy sections/ so the Architecture review +
// Data model review checklist (now carved out of SKILL.md) are present.
{ const _sec = path.join(ROOT, 'plan-eng-review', 'sections'); if (fs.existsSync(_sec)) fs.cpSync(_sec, path.join(planDir, 'plan-eng-review', 'sections'), { recursive: true }); }
});
afterAll(() => {
@ -469,14 +450,18 @@ Focus specifically on the data model design in the plan. Apply the data model re
// Behavioral assertions: the review should recommend a separate tier model
// and push back on JSONField for the feature set. Matching is deliberately
// loose (case-insensitive substrings) to tolerate wording variance while
// still catching the bias regression.
// still catching the bias regression. Strip markdown formatting chars
// (backticks, asterisks) first — LLMs commonly wrap identifiers like
// `SubscriptionTier` in backticks, which would otherwise break the \s*
// assumptions below.
const lower = review.toLowerCase();
const unformatted = review.replace(/[`*_]/g, '');
const recommendsSeparateModel =
/separate\s+(subscription\s*)?tier\s*model/i.test(review) ||
/new\s+(subscription\s*)?tier\s*model/i.test(review) ||
/subscriptiontier\s*model/i.test(review) ||
/extract.*tier.*model/i.test(review) ||
/tier\s*(?:table|model)\s*(?:with|per)/i.test(review);
/separate\s+(subscription\s*)?tier\s*model/i.test(unformatted) ||
/new\s+(subscription\s*)?tier\s*model/i.test(unformatted) ||
/subscriptiontier\s*model/i.test(unformatted) ||
/extract[\s\S]*tier[\s\S]*model/i.test(unformatted) ||
/tier\s*(?:table|model)\s*(?:with|per)/i.test(unformatted);
const pushesBackOnJsonField =
lower.includes('jsonfield') &&
(lower.includes('feature') || lower.includes('polymorph') || lower.includes('explicit'));
@ -518,18 +503,10 @@ describeIfSelected('Plan Eng Review Data-Model Legitimate JSON E2E', ['plan-eng-
let planDir: string;
beforeAll(() => {
planDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-plan-eng-json-'));
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: planDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
// Synthetic plan designed to NOT trip the JSONField guidance: the payload
// is a verbatim third-party webhook body, schema owned by Stripe (not us),
// never queried into by key — the textbook legitimate JSONField case.
fs.writeFileSync(path.join(planDir, 'plan.md'), `# Plan: Add Stripe Webhook Event Log
planDir = setupPlanEngReviewFixture('skill-e2e-plan-eng-json-', `# Plan: Add Stripe Webhook Event Log
## Context
We need to store incoming Stripe webhook events for audit and replay. Stripe
@ -554,16 +531,6 @@ response verbatim for audit/replay, not to model our own domain state.
- Is storing the whole event as JSONField the right call here, or should we
break out the commonly-used fields into columns?
`);
run('git', ['add', '.']);
run('git', ['commit', '-m', 'add plan']);
fs.mkdirSync(path.join(planDir, 'plan-eng-review'), { recursive: true });
fs.copyFileSync(
path.join(ROOT, 'plan-eng-review', 'SKILL.md'),
path.join(planDir, 'plan-eng-review', 'SKILL.md'),
);
{ const _sec = path.join(ROOT, 'plan-eng-review', 'sections'); if (fs.existsSync(_sec)) fs.cpSync(_sec, path.join(planDir, 'plan-eng-review', 'sections'), { recursive: true }); }
});
afterAll(() => {
@ -597,11 +564,16 @@ Focus specifically on the data model design in the plan. Apply the data model re
// Deliberately loose, case-insensitive matching (same tolerance as the
// bias-regression test above) to survive wording variance while still
// catching an over-eager JSONField warning.
// catching an over-eager JSONField warning. matchesUnnegated() ignores
// matches preceded by a negation word — otherwise a CORRECT "I would NOT
// extract the payload into columns" answer would trip this check, since
// it contains "extract...payload...column" just like an incorrect one.
// Includes normali[sz]e alongside promote/split/convert/extract/move since
// that's the exact vocabulary the companion template edits use.
const discussesThePayloadField = lower.includes('payload') || lower.includes('jsonfield');
const pushesToSplitTheField =
/(promote|split|convert|extract|move)[^.]{0,80}payload[^.]{0,80}(column|field)/i.test(review) ||
/payload[^.]{0,80}(promote|split|convert to (explicit )?column|explicit column)/i.test(review);
matchesUnnegated(review, /(promote|split|convert|extract|move|normali[sz]e)[^.]{0,80}payload[^.]{0,80}(column|field)/i) ||
matchesUnnegated(review, /payload[^.]{0,80}(promote|split|convert to (explicit )?column|explicit column|normali[sz]e)/i);
const acknowledgesLegitimateUse =
/legitimate|appropriate|reasonable|correct (choice|call|approach)|fine as[- ](is|it)|acceptable|(third[- ]party|external).{0,40}(payload|blob|response)|verbatim|schemaless/i.test(review);
@ -635,18 +607,10 @@ describeIfSelected('Plan Eng Review Data-Model Measured Denormalization E2E', ['
let planDir: string;
beforeAll(() => {
planDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-plan-eng-denorm-'));
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: planDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
// Synthetic plan designed to NOT trip the normalize-first guidance: the
// denormalization is scoped to one read path and backed by a stated
// measurement (APM p95 + load test), matching the fix's own counterexample.
fs.writeFileSync(path.join(planDir, 'plan.md'), `# Plan: Add OrderSummary Read Snapshot for Checkout Confirmation
planDir = setupPlanEngReviewFixture('skill-e2e-plan-eng-denorm-', `# Plan: Add OrderSummary Read Snapshot for Checkout Confirmation
## Context
The checkout confirmation page currently joins Order -> Customer ->
@ -667,16 +631,6 @@ not a general schema change.
- Is this the right call, or should we keep reading live via the FK chain
and instead add caching/indexing to fix the latency?
`);
run('git', ['add', '.']);
run('git', ['commit', '-m', 'add plan']);
fs.mkdirSync(path.join(planDir, 'plan-eng-review'), { recursive: true });
fs.copyFileSync(
path.join(ROOT, 'plan-eng-review', 'SKILL.md'),
path.join(planDir, 'plan-eng-review', 'SKILL.md'),
);
{ const _sec = path.join(ROOT, 'plan-eng-review', 'sections'); if (fs.existsSync(_sec)) fs.cpSync(_sec, path.join(planDir, 'plan-eng-review', 'sections'), { recursive: true }); }
});
afterAll(() => {
@ -709,8 +663,14 @@ Focus specifically on the data model design in the plan. Apply the data model re
const lower = review.toLowerCase();
const discussesTheDenormalization = lower.includes('denormal') || lower.includes('ordersummary');
// Broadened per review feedback: the original pattern missed common
// rejection phrasings ("instead of denormalizing", "cache/index instead")
// and its "normali[sz]e...instead|back|it" clause was loose enough to
// false-positive on approving language ("stays normalized elsewhere;
// keep it that way") — tightened to require an explicit join/read/query
// + instead framing.
const rejectsAsUnjustified =
/(premature|not[- ]yet justified|lacks?[- ]a measurement|no measurement|remove[^.]{0,40}(denormali[sz]ation|snapshot)|normali[sz]e[^.]{0,40}(instead|back|it)|should (read|query|join)[^.]{0,40}live)/i.test(review);
/(premature|not[- ]yet justified|lacks?[- ]a measurement|no measurement|remove[^.]{0,40}(denormali[sz]ation|snapshot)|normali[sz]e[^.]{0,20}(this|the)?[^.]{0,20}(join|read|query)[^.]{0,20}instead|instead of denormali[sz]ing|rather than (denormali[sz]ing|snapshot(ting)?)|(cache|index)[^.]{0,30}instead|should (read|query|join)[^.]{0,40}live)/i.test(review);
const acceptsMeasuredJustification =
/measur|profil|load test|p95|latency|query plan|justified|reasonable|legitimate|appropriate|scoped/i.test(review);