Adds a region/function-scoped negative-grep idiom to the gsd-planner verification guidance plus a warn-only `validate_plan` check (`scanFileWideNegativeGateConflict`) that flags when a task's file-wide negative grep bans a construct a sibling task legitimately requires elsewhere in the same file. ReDoS-safe (linear, no RegExp on author patterns); region-scoped gates are exempt. Warn-only — never errors, never flips `valid`. Closes #968 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/zesty-badgers-caper.md
Normal file
5
.changeset/zesty-badgers-caper.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 1320
|
||||
---
|
||||
**`verify plan-structure` warns on cross-task region-scope conflicts (#968)** — when a plan task's file-wide negative grep (`! grep -Eq 'PAT' file` / `grep -c 'PAT' file == 0`) bans a construct a sibling task legitimately requires elsewhere in the same file, plan validation now surfaces a warning pointing to the new region/function-scoped negative-gate idiom (documented in the gsd-planner guidance and the planner-antipatterns reference, with a worked banned-in-X / required-in-Y example). Warn-only: it never errors and never changes `valid`. (#1320)
|
||||
@@ -198,6 +198,10 @@ Every task has four required fields:
|
||||
Full rules + worked examples: @gsd-core/references/planner-antipatterns.md ("Comment-Text Discipline").
|
||||
</comment_text_discipline>
|
||||
|
||||
<region_scoped_negative_gate>
|
||||
**Region-scoped negative gates (WARN, #968):** Region-scope a file-wide negative grep when a sibling task needs that construct elsewhere in the same file; `validate_plan` WARNS. See: @gsd-core/references/planner-antipatterns.md ("Region-Scoped Negative Gates").
|
||||
</region_scoped_negative_gate>
|
||||
|
||||
**<done>:** Acceptance criteria - measurable state of completion.
|
||||
- Good: "Valid credentials return 200 + JWT cookie, invalid credentials return 401"
|
||||
- Bad: "Authentication is complete"
|
||||
|
||||
@@ -187,7 +187,7 @@ Plans that contain any checkpoint task must set `autonomous: false` in frontmatt
|
||||
| `<read_first>` | Files the executor must read before touching anything — the file being modified, any source-of-truth pattern file, any file whose types or conventions must be replicated. |
|
||||
| `<action>` | Concrete instructions with exact identifiers, file paths, function signatures, and expected values. Never says "align X with Y" without specifying the target state. Never contains fenced code blocks or full implementations. |
|
||||
| `<verify>` | A runnable command or check that proves the task succeeded. Must distinguish pass from fail — `echo "done"` is not valid. |
|
||||
| `<acceptance_criteria>` | Verifiable conditions: grep-verifiable strings, command exit codes, observable behaviours. No subjective language ("looks correct", "properly configured"). |
|
||||
| `<acceptance_criteria>` | Verifiable conditions: grep-verifiable strings, command exit codes, observable behaviours. No subjective language ("looks correct", "properly configured"). Negative greps (`! grep -Eq 'PAT' file`) are file-scoped — region-scope them (`sed -n`/`awk` range, then grep) when a sibling task needs the construct elsewhere in the same file (#968). |
|
||||
| `<done>` | A short measurable statement of the completed outcome. |
|
||||
|
||||
---
|
||||
|
||||
@@ -128,3 +128,49 @@ When the literal MUST appear in the plan body verbatim — e.g. the plan documen
|
||||
```
|
||||
|
||||
One marker per literal. The marker exempts only the exact literal it names.
|
||||
|
||||
## Region-Scoped Negative Gates
|
||||
|
||||
> Surfaced at plan-write time by `verify.plan-structure` (the `validate_plan` step), WARN-level. Issue #968.
|
||||
|
||||
A **negative grep** — `! grep -Eq 'PAT' file` or `grep -c 'PAT' file == 0` — asserts a construct is absent. `grep` is file-scoped by nature: it has no notion of function or region. This breaks down when a phase splits one file across parallel tasks with legitimately opposite needs for the same construct in different regions:
|
||||
|
||||
- **Task A** bans the construct file-wide — a synchronous factory must not block on a refresh: `! grep -Eq 'await .*refresh' app/page.py`.
|
||||
- **Task B** legitimately requires it elsewhere in the same file — a post-reindex handler must `await bridge.refresh()` to repopulate state.
|
||||
|
||||
Both occurrences are real, correct production code in different functions of one file. A file-wide negative grep cannot say "absent in function X, present in function Y", so the two gates are mutually unsatisfiable with a direct call — the executor is pushed into an indirection whose only purpose is to relocate the matched string out of the file (pure gate-appeasement, zero behaviour change). This is distinct from Comment-Text Discipline (#429): there is no comment echo and no allowlist helps — the construct must genuinely be present in one region and absent in another.
|
||||
|
||||
**The fix:** region-scope the negative gate so "absent in region X" stops implying "absent file-wide."
|
||||
|
||||
### Bad — file-wide ban unsatisfiable against a sibling's real code
|
||||
|
||||
```xml
|
||||
<!-- Task A -->
|
||||
<verify><automated>! grep -Eq 'await .*refresh' app/page.py</automated></verify>
|
||||
<!-- Task B (same file, different function) -->
|
||||
<action>Add a post-reindex handler in app/page.py that awaits bridge.refresh().</action>
|
||||
<files>app/page.py</files>
|
||||
```
|
||||
|
||||
Task B writing `await bridge.refresh()` trips Task A's file-wide gate, though both are correct.
|
||||
|
||||
### Good — scope the gate to the factory region
|
||||
|
||||
```xml
|
||||
<!-- Task A: ban only inside the synchronous factory, not the whole file -->
|
||||
<verify><automated>! awk '/^def make_page/,/^def /' app/page.py | grep -Eq 'await .*refresh'</automated></verify>
|
||||
<!-- or a fixed line range -->
|
||||
<verify><automated>! sed -n '12,40p' app/page.py | grep -Eq 'await .*refresh'</automated></verify>
|
||||
```
|
||||
|
||||
The factory region is asserted clean; the reindex handler elsewhere in the same file keeps its required `await bridge.refresh()`. Both gates pass with no code restructuring. Prefer an AST/structural check or a focused unit test where region extraction is fragile.
|
||||
|
||||
### When the split is intentional and unavoidable
|
||||
|
||||
If region-scoping is genuinely impractical and the file split is intentional, suppress the warning with a marker naming the pattern:
|
||||
|
||||
```
|
||||
<!-- planner-region-allow: await .*refresh -->
|
||||
```
|
||||
|
||||
One marker per pattern. The marker exempts only the exact pattern it names. Prefer region-scoping over suppression.
|
||||
|
||||
308
src/verify.cts
308
src/verify.cts
@@ -253,6 +253,310 @@ function scanNegativeGrepCommentEcho(content: string): { errors: string[]; warni
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #968 — file-wide negative-grep sibling conflict detector.
|
||||
* A file-wide negative grep gate (! grep -Eq 'PAT' FILE or grep -c 'PAT' FILE == 0)
|
||||
* bans a construct across the WHOLE file. When a sibling task in the same plan
|
||||
* legitimately requires the same construct in the same file, the two gates are
|
||||
* mutually unsatisfiable. This is a WARN-only check (never changes valid:false).
|
||||
*/
|
||||
function scanFileWideNegativeGateConflict(content: string): { warnings: string[]; valid: true } {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Normalize newlines; join backslash line-continuations (same as #429).
|
||||
const text = (content || '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replace(/\\\n/g, ' ');
|
||||
|
||||
// Allowlisted patterns: <!-- planner-region-allow: PAT -->
|
||||
const allow = new Set<string>();
|
||||
const allowRe = /<!--\s*planner-region-allow:\s*(.+?)\s*-->/g;
|
||||
let am: RegExpExecArray | null;
|
||||
while ((am = allowRe.exec(text)) !== null) allow.add(am[1]);
|
||||
|
||||
// Helper predicates (reused from #429 style).
|
||||
// Zero-equality comparison: spaced == 0 or -eq 0.
|
||||
const zeroCmp = (s: string): boolean =>
|
||||
/\s==?\s*0\b/.test(s) || /-eq\s+0\b/.test(s) || /\bequals\s+0\b/.test(s);
|
||||
|
||||
// grep options include -c / --count
|
||||
const optsHaveCount = (opts: string): boolean =>
|
||||
/(?:^|\s)-[A-Za-z]*c[A-Za-z]*(?=\s|$)/.test(opts) || /--count\b/.test(opts);
|
||||
|
||||
// grep options include -v / --invert-match (inverted count is NOT a negative gate)
|
||||
const optsHaveInvert = (opts: string): boolean =>
|
||||
/(?:^|\s)-[A-Za-z]*v[A-Za-z]*(?=\s|$)/.test(opts) || /--invert-match\b/.test(opts);
|
||||
|
||||
// A bareword that is a plausible grep pattern (not a stray flag/number).
|
||||
const plausibleBare = (s: string): boolean => /[A-Za-z0-9_]/.test(s) && !/^[-=!<>0-9]+$/.test(s);
|
||||
|
||||
// Regex to extract grep arguments: opts run then PAT (quoted or bare).
|
||||
const grepArgRe =
|
||||
/grep((?:\s+-{1,2}[A-Za-z][A-Za-z-]*)+)\s+(?:'([^']*)'|"([^"]*)"|([^\s'"|>&;$()\[\]]+))/g;
|
||||
|
||||
// FIX 1 (ReDoS): Linear-time "does reqText satisfy the grep pattern" — no RegExp execution.
|
||||
// Never calls new RegExp, so no catastrophic backtracking is possible.
|
||||
//
|
||||
// Handles literal patterns and `.`/`.*/`.+`/`\s`-style wildcard gaps and `^`/`$` anchors.
|
||||
// Patterns using character classes (`[…]`), alternation (`a|b`), or other regex constructs
|
||||
// fall back to a conservative literal-substring check, so the detector may NOT warn on those
|
||||
// (false-negative is the safe direction for a warn-only advisory).
|
||||
const patternRequiredIn = (pat: string, reqText: string): boolean => {
|
||||
const hay = (reqText || '').slice(0, 8000); // bound the haystack
|
||||
if (!pat) return false;
|
||||
// Strip ERE anchors — position constraints don't change whether the construct is required.
|
||||
pat = pat.replace(/^\^/, '').replace(/\$$/, '');
|
||||
if (!pat) return false;
|
||||
// Pure literal (no regex metacharacters): direct substring.
|
||||
if (!/[.*+?^${}()|[\]\\]/.test(pat)) return hay.includes(pat);
|
||||
const SENT = ' ';
|
||||
// Replace simple wildcard gaps (\s* \w+ .* .+ .? bare .) with a sentinel.
|
||||
let work = pat
|
||||
.replace(/\\[sSwWdD][*+?]?/g, SENT)
|
||||
.replace(/\.[*+?]/g, SENT)
|
||||
.replace(/\./g, SENT);
|
||||
work = work.replace(/\\(.)/g, '$1'); // de-escape \( \. etc → literal char
|
||||
const joined = work.split(SENT).join('');
|
||||
// Unhandled regex constructs remain → safe literal-substring fallback on the raw pattern.
|
||||
if (/[*+?^${}()|[\]]/.test(joined)) return hay.includes(pat);
|
||||
const frags = work.split(SENT).filter(Boolean);
|
||||
if (!frags.length) return false; // all-wildcard pattern → no meaningful requirement
|
||||
let pos = 0;
|
||||
for (const f of frags) {
|
||||
const idx = hay.indexOf(f, pos);
|
||||
if (idx === -1) return false;
|
||||
pos = idx + f.length;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// FIX 2 (file basename over-match): exact normalized match; basename fallback ONLY for
|
||||
// unqualified gate files (no path separator).
|
||||
const normPath = (p: string): string => p.replace(/^\.\//, '').trim();
|
||||
|
||||
// File-wide discriminator: a token AFTER PAT that looks like a path.
|
||||
// Paths have /, a file extension, or match a known task <files> entry.
|
||||
// Globs (containing *) are excluded (unresolvable — no warn).
|
||||
const looksLikePath = (token: string): boolean =>
|
||||
!token.includes('*') &&
|
||||
(token.includes('/') || /\.[a-zA-Z]{1,6}$/.test(token));
|
||||
|
||||
// FIX 5 (hasLeadingNot): collapse to one command-boundary-anchored regex.
|
||||
// Negation at a command boundary: start of segment, or after ; & | ( newline / then / do.
|
||||
|
||||
// FIX 4 (isRegionScoped tightened): return true ONLY when grep is downstream of a
|
||||
// sed line-range or awk range producer. Other pipe sources (cat, tac, etc.) are file-wide.
|
||||
const isRegionScoped = (seg: string): boolean => {
|
||||
if (!seg.includes('|')) return false;
|
||||
const before = seg.slice(0, seg.lastIndexOf('|'));
|
||||
// sed -n line/range extraction, e.g. sed -n '12,40p' FILE or sed -n '/a/,/b/p' FILE
|
||||
if (/\bsed\s+-n\b/.test(before)) return true;
|
||||
// awk range pattern, e.g. awk '/start/,/end/' FILE
|
||||
if (/\bawk\b[^|]*\/[^/]*\/\s*,\s*\/[^/]*\//.test(before)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Parse all <task> blocks.
|
||||
interface TaskInfo {
|
||||
name: string;
|
||||
files: string[]; // entries from <files>
|
||||
gateText: string; // <verify>+<automated>+<acceptance_criteria> text
|
||||
reqText: string; // <action>+<acceptance_criteria> text (requirement side)
|
||||
}
|
||||
const taskRe = /<task[^>]*>([\s\S]*?)<\/task>/g;
|
||||
const tasks: TaskInfo[] = [];
|
||||
let tm: RegExpExecArray | null;
|
||||
while ((tm = taskRe.exec(text)) !== null) {
|
||||
const tc = tm[1];
|
||||
// Extract task name.
|
||||
const namem = tc.match(/<name>([\s\S]*?)<\/name>/);
|
||||
const name = namem ? namem[1].trim() : 'unnamed';
|
||||
// Extract <files> entries.
|
||||
const filesm = tc.match(/<files>([\s\S]*?)<\/files>/);
|
||||
const filesText = filesm ? filesm[1] : '';
|
||||
const files = filesText.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
|
||||
// Gate text: <verify>/<automated>/<acceptance_criteria>.
|
||||
const gateFragments: string[] = [];
|
||||
for (const tag of ['verify', 'automated', 'acceptance_criteria']) {
|
||||
const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g');
|
||||
let mm: RegExpExecArray | null;
|
||||
while ((mm = re.exec(tc)) !== null) gateFragments.push(mm[1]);
|
||||
}
|
||||
// Requirement text: <action>/<acceptance_criteria>.
|
||||
const reqFragments: string[] = [];
|
||||
for (const tag of ['action', 'acceptance_criteria']) {
|
||||
const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g');
|
||||
let mm: RegExpExecArray | null;
|
||||
while ((mm = re.exec(tc)) !== null) reqFragments.push(mm[1]);
|
||||
}
|
||||
// Strip XML tags from gate text so segments containing embedded
|
||||
// XML closing tags (e.g. <automated>cmd</automated> nested inside <verify>)
|
||||
// don't bleed into the file-path token extraction.
|
||||
const rawGateText = gateFragments.join('\n');
|
||||
const gateText = rawGateText.replace(/<[^>]+>/g, ' ');
|
||||
tasks.push({
|
||||
name,
|
||||
files,
|
||||
gateText,
|
||||
reqText: reqFragments.join('\n'),
|
||||
});
|
||||
}
|
||||
|
||||
if (tasks.length < 2) return { warnings, valid: true };
|
||||
|
||||
// FIX 3 (extensionless known files): build a normalized set of ALL tasks' <files> entries
|
||||
// so that extensionless filenames like Dockerfile are also recognized as valid file tokens.
|
||||
const knownFiles = new Set<string>();
|
||||
for (const t of tasks) {
|
||||
for (const f of t.files) knownFiles.add(normPath(f));
|
||||
}
|
||||
|
||||
// Extended looksLikePath: accepts known <files> entries even without an extension.
|
||||
const isFileLike = (token: string): boolean => {
|
||||
if (token.includes('*')) return false; // exclude globs
|
||||
if (looksLikePath(token)) return true;
|
||||
return knownFiles.has(normPath(token));
|
||||
};
|
||||
|
||||
// Dedup key: (taskAIdx, taskBIdx, pat, file)
|
||||
const seen = new Set<string>();
|
||||
|
||||
// For each task A, scan gate text for file-wide negative grep bans.
|
||||
for (let ai = 0; ai < tasks.length; ai++) {
|
||||
const taskA = tasks[ai];
|
||||
|
||||
// Split gate text into shell segments (split on && / || within lines).
|
||||
const segments = taskA.gateText.split('\n').flatMap(line =>
|
||||
line.split(/\s*(?:&&|\|\|)\s*/),
|
||||
);
|
||||
|
||||
for (const seg of segments) {
|
||||
if (!/grep/.test(seg)) continue;
|
||||
|
||||
// FIX 5: Negation at a command boundary: start of segment, or after ; & | ( newline / then / do.
|
||||
// Also handles ! negating an entire pipeline (e.g. ! cat FILE | grep ...).
|
||||
const hasLeadingNot =
|
||||
// Direct ! grep: negation immediately before grep keyword
|
||||
/(?:^|[\n;&|(]|\bthen\b|\bdo\b)\s*!\s*grep/.test(seg) ||
|
||||
// Pipeline negation: ! at command boundary, grep appears in pipeline after |
|
||||
(/(?:^|[\n;&|(]|\bthen\b|\bdo\b)\s*!\s*\w/.test(seg) && /\|\s*grep\b/.test(seg));
|
||||
|
||||
const hasCountZero = zeroCmp(seg);
|
||||
|
||||
// Extract grep invocation and check for count.
|
||||
grepArgRe.lastIndex = 0;
|
||||
let pat: string | null = null;
|
||||
let file: string | null = null;
|
||||
let isBan = false;
|
||||
|
||||
// FIX 4 helper: given a segment and the grep match end position, find the
|
||||
// file argument. First try the token immediately after PAT; if none qualifies,
|
||||
// try a cat/tac producer or < FILE redirect from the full segment.
|
||||
const resolveFileArg = (segment: string, afterPatStr: string): string | null => {
|
||||
// Primary: token immediately after PAT in the grep command
|
||||
const fileM = afterPatStr.match(/^\s+([^\s'"|>&;$()\[\]]+)/);
|
||||
const rawFile = fileM ? fileM[1] : null;
|
||||
if (rawFile && isFileLike(rawFile)) return rawFile;
|
||||
// FIX 4: For NON-region segments, also look for cat/tac producer or < FILE redirect
|
||||
const catM = segment.match(/\b(?:cat|tac)\s+([^\s'"|>&;()]+)/);
|
||||
if (catM && isFileLike(catM[1])) return catM[1];
|
||||
const redirM = segment.match(/<\s*([^\s'"|>&;()]+)/);
|
||||
if (redirM && isFileLike(redirM[1])) return redirM[1];
|
||||
return null;
|
||||
};
|
||||
|
||||
// If leading !, it might be a count or a direct !grep
|
||||
if (hasLeadingNot && !hasCountZero) {
|
||||
// Direct ! grep PAT FILE form: grep opts PAT FILE
|
||||
// Extract PAT and FILE from the grep invocation
|
||||
grepArgRe.lastIndex = 0;
|
||||
let gm: RegExpExecArray | null;
|
||||
while ((gm = grepArgRe.exec(seg)) !== null) {
|
||||
const opts = gm[1];
|
||||
if (optsHaveInvert(opts)) continue; // -v form: not a ban
|
||||
// PAT
|
||||
const rawPat = gm[2] !== undefined ? gm[2] :
|
||||
gm[3] !== undefined ? gm[3] :
|
||||
gm[4] !== undefined && plausibleBare(gm[4]) ? gm[4] : null;
|
||||
if (!rawPat) continue;
|
||||
// FILE: next non-option token after PAT (or cat/tac/redirect in segment)
|
||||
const afterPat = seg.slice((gm.index || 0) + gm[0].length);
|
||||
const rawFile = resolveFileArg(seg, afterPat);
|
||||
if (rawFile) {
|
||||
pat = rawPat;
|
||||
file = rawFile;
|
||||
isBan = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBan && hasCountZero) {
|
||||
// count grep form: grep -c PAT FILE == 0 or [ $(grep -c PAT FILE) -eq 0 ]
|
||||
grepArgRe.lastIndex = 0;
|
||||
let gm: RegExpExecArray | null;
|
||||
while ((gm = grepArgRe.exec(seg)) !== null) {
|
||||
const opts = gm[1];
|
||||
if (!optsHaveCount(opts) || optsHaveInvert(opts)) continue;
|
||||
const rawPat = gm[2] !== undefined ? gm[2] :
|
||||
gm[3] !== undefined ? gm[3] :
|
||||
gm[4] !== undefined && plausibleBare(gm[4]) ? gm[4] : null;
|
||||
if (!rawPat) continue;
|
||||
const afterPat = seg.slice((gm.index || 0) + gm[0].length);
|
||||
const rawFile = resolveFileArg(seg, afterPat);
|
||||
if (rawFile) {
|
||||
pat = rawPat;
|
||||
file = rawFile;
|
||||
isBan = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBan || !pat || !file) continue;
|
||||
if (allow.has(pat)) continue;
|
||||
// Skip if region-scoped (grep downstream of a sed/awk pipe — region extracted)
|
||||
if (isRegionScoped(seg)) continue;
|
||||
|
||||
// For each other task B: check if B's <files> includes FILE AND B's reqText contains PAT
|
||||
for (let bi = 0; bi < tasks.length; bi++) {
|
||||
if (bi === ai) continue;
|
||||
const taskB = tasks[bi];
|
||||
|
||||
// FIX 2: Exact normalized match; basename fallback ONLY for unqualified gate files.
|
||||
const gateFile = normPath(file);
|
||||
const bMatchesFile = taskB.files.some((bf) => {
|
||||
const nbf = normPath(bf);
|
||||
if (nbf === gateFile) return true;
|
||||
// basename fallback only when the gate file is an unqualified bare filename (no dir separator)
|
||||
if (!gateFile.includes('/') && path.basename(nbf) === gateFile) return true;
|
||||
return false;
|
||||
});
|
||||
if (!bMatchesFile) continue;
|
||||
|
||||
// FIX 1: Use linear-time patternRequiredIn instead of new RegExp (ReDoS-safe).
|
||||
const bRequiresPat = patternRequiredIn(pat, taskB.reqText);
|
||||
if (!bRequiresPat) continue;
|
||||
|
||||
const dedupeKey = `${ai}:${bi}:${pat}:${file}`;
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
|
||||
warnings.push(
|
||||
`Region-scope conflict (#968): task "${taskA.name}" negative-greps "${pat}" file-wide on ${file}, ` +
|
||||
`but sibling task "${taskB.name}" requires it in the same file. ` +
|
||||
`A file-wide ban is unsatisfiable when a sibling needs the construct elsewhere — ` +
|
||||
`region-scope task "${taskA.name}"'s gate (sed -n/awk range then grep) or use an AST/test check. ` +
|
||||
`See planner-antipatterns.md "Region-Scoped Negative Gates", or add ` +
|
||||
`<!-- planner-region-allow: ${pat} --> if intentional.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This detector is warn-only: it never sets valid=false.
|
||||
return { warnings, valid: true as const };
|
||||
}
|
||||
|
||||
function cmdVerifyPlanStructure(cwd: string, filePath: string, raw: boolean): void {
|
||||
if (!filePath) {
|
||||
error('file path required');
|
||||
@@ -315,6 +619,9 @@ function cmdVerifyPlanStructure(cwd: string, filePath: string, raw: boolean): vo
|
||||
errors.push(...echoScan.errors);
|
||||
warnings.push(...echoScan.warnings);
|
||||
|
||||
const conflictScan = scanFileWideNegativeGateConflict(content);
|
||||
warnings.push(...conflictScan.warnings);
|
||||
|
||||
output(
|
||||
{
|
||||
valid: errors.length === 0,
|
||||
@@ -1935,6 +2242,7 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void {
|
||||
|
||||
export = {
|
||||
scanNegativeGrepCommentEcho,
|
||||
scanFileWideNegativeGateConflict,
|
||||
cmdVerifySummary,
|
||||
cmdVerifyPlanStructure,
|
||||
cmdVerifyPhaseCompleteness,
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"gsd-pattern-mapper.md": 12487,
|
||||
"gsd-phase-researcher.md": 40638,
|
||||
"gsd-plan-checker.md": 42003,
|
||||
"gsd-planner.md": 48892,
|
||||
"gsd-planner.md": 49216,
|
||||
"gsd-project-researcher.md": 22014,
|
||||
"gsd-research-synthesizer.md": 13653,
|
||||
"gsd-roadmapper.md": 21781,
|
||||
|
||||
823
tests/enh-968-region-scoped-negative-grep.test.cjs
Normal file
823
tests/enh-968-region-scoped-negative-grep.test.cjs
Normal file
@@ -0,0 +1,823 @@
|
||||
// allow-test-rule: source-text-is-the-product #968
|
||||
// Enhancement #968: region-scoped negative gate detector + guidance docs.
|
||||
// Tests the pure function scanFileWideNegativeGateConflict exported from
|
||||
// verify.cjs, plus CLI integration and doc-contract assertions.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const os = require('node:os');
|
||||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
// Build path to built verify.cjs
|
||||
const VERIFY_CJS = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'verify.cjs');
|
||||
|
||||
// Build paths to doc files
|
||||
const PLANNER_MD = path.join(__dirname, '..', 'agents', 'gsd-planner.md');
|
||||
const ANTIPATTERNS_MD = path.join(__dirname, '..', 'gsd-core', 'references', 'planner-antipatterns.md');
|
||||
// PLAN_MD_REF removed — was unused (doc-contract cases test PLANNER_MD and ANTIPATTERNS_MD only)
|
||||
|
||||
// ─── Fixture helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a minimal two-task plan fixture.
|
||||
* taskA: file=app/page.py, gateText=the verify/acceptance_criteria block, actionText=action block
|
||||
* taskB: file=app/page.py (default) or otherFile, action text
|
||||
* allowlistMarker: optional HTML comment to insert at the top
|
||||
*/
|
||||
function makeTwoTaskPlan({
|
||||
taskAFile = 'app/page.py',
|
||||
taskAGate = '! grep -Eq \'await .*refresh\' app/page.py',
|
||||
taskAAction = 'Refactor the factory to be synchronous.',
|
||||
taskBFile = 'app/page.py',
|
||||
taskBAction = 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
allowlistMarker = '',
|
||||
} = {}) {
|
||||
const lines = [
|
||||
'---',
|
||||
'phase: 01-test',
|
||||
'plan: 01',
|
||||
'type: execute',
|
||||
'wave: 1',
|
||||
'depends_on: []',
|
||||
`files_modified: [${taskAFile}, ${taskBFile}]`,
|
||||
'autonomous: true',
|
||||
'must_haves:',
|
||||
' - AC1',
|
||||
'---',
|
||||
'',
|
||||
'# Test Plan',
|
||||
'',
|
||||
];
|
||||
|
||||
if (allowlistMarker) {
|
||||
lines.push(allowlistMarker, '');
|
||||
}
|
||||
|
||||
// Task A: the one with the negative gate
|
||||
lines.push('<task>');
|
||||
lines.push('<name>Task A: factory refactor</name>');
|
||||
lines.push(`<files>${taskAFile}</files>`);
|
||||
lines.push(`<action>${taskAAction}</action>`);
|
||||
lines.push(`<verify><automated>${taskAGate}</automated></verify>`);
|
||||
lines.push('<done>Factory is synchronous.</done>');
|
||||
lines.push('</task>');
|
||||
lines.push('');
|
||||
|
||||
// Task B: the sibling that requires the construct
|
||||
lines.push('<task>');
|
||||
lines.push('<name>Task B: reindex handler</name>');
|
||||
lines.push(`<files>${taskBFile}</files>`);
|
||||
lines.push(`<action>${taskBAction}</action>`);
|
||||
lines.push('<verify><automated>npm test</automated></verify>');
|
||||
lines.push('<done>Handler is in place.</done>');
|
||||
lines.push('</task>');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single-task plan (no sibling).
|
||||
*/
|
||||
function makeSingleTaskPlan({
|
||||
taskFile = 'app/page.py',
|
||||
taskGate = '! grep -Eq \'await .*refresh\' app/page.py',
|
||||
taskAction = 'Refactor the factory to be synchronous.',
|
||||
} = {}) {
|
||||
return [
|
||||
'---',
|
||||
'phase: 01-test',
|
||||
'plan: 01',
|
||||
'type: execute',
|
||||
'wave: 1',
|
||||
'depends_on: []',
|
||||
`files_modified: [${taskFile}]`,
|
||||
'autonomous: true',
|
||||
'must_haves:',
|
||||
' - AC1',
|
||||
'---',
|
||||
'',
|
||||
'# Test Plan',
|
||||
'',
|
||||
'<task>',
|
||||
'<name>Task A: factory refactor</name>',
|
||||
`<files>${taskFile}</files>`,
|
||||
`<action>${taskAction}</action>`,
|
||||
`<verify><automated>${taskGate}</automated></verify>`,
|
||||
'<done>Factory is synchronous.</done>',
|
||||
'</task>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Group 1: pure-function unit tests ────────────────────────────────────────
|
||||
|
||||
describe('scanFileWideNegativeGateConflict — pure unit tests', () => {
|
||||
let scan;
|
||||
|
||||
before(() => {
|
||||
const verify = require(VERIFY_CJS);
|
||||
scan = verify.scanFileWideNegativeGateConflict;
|
||||
assert.ok(typeof scan === 'function', 'scanFileWideNegativeGateConflict must be exported');
|
||||
});
|
||||
|
||||
// Case 1: basic WARN path — Task A bans PAT file-wide, Task B requires it in same file
|
||||
test('case 1 — file-wide ban + sibling requires → WARN', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(Array.isArray(result.warnings), 'must return { warnings: [] }');
|
||||
assert.ok(
|
||||
result.warnings.length >= 1,
|
||||
`expected at least 1 warning, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.warnings[0].includes('Region-scope conflict (#968)'),
|
||||
`warning must mention Region-scope conflict (#968), got: ${result.warnings[0]}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.warnings[0].includes('await .*refresh'),
|
||||
`warning must mention the PAT, got: ${result.warnings[0]}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.warnings[0].includes('app/page.py'),
|
||||
`warning must mention the file, got: ${result.warnings[0]}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 2: region-scoped via sed → NO warn
|
||||
test('case 2 — region-scoped via sed pipe → NO warn', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! sed -n '12,40p' app/page.py | grep -Eq 'await .*refresh'",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`sed-piped grep is region-scoped — must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 2b: region-scoped via awk → NO warn
|
||||
test('case 2b — region-scoped via awk pipe → NO warn', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! awk '/^def make_page/,/^def /' app/page.py | grep -Eq 'await .*refresh'",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`awk-piped grep is region-scoped — must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 3: single task file-wide ban, no sibling → NO warn
|
||||
test('case 3 — single task, no sibling → NO warn', () => {
|
||||
const content = makeSingleTaskPlan({
|
||||
taskGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`single task (no sibling) must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 4: sibling requires PAT but lists a different file → NO warn
|
||||
test('case 4 — sibling lists different file → NO warn', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAFile: 'app/page.py',
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBFile: 'app/other.py',
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`sibling with different file must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 5: sibling lists same file but action lacks PAT → NO warn
|
||||
test('case 5 — sibling lists same file but action lacks PAT → NO warn', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls bridge.sync() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`sibling with no PAT in action must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 6: positive grep (no !) + sibling → NO warn (positive requirement, not a ban)
|
||||
test('case 6 — positive grep (no !) + sibling → NO warn', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "grep -q 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`positive grep must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 7: inverted grep -v with ! + sibling → NO warn
|
||||
test('case 7 — inverted grep -vq with ! + sibling → NO warn', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -vq 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`inverted grep (-v) must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 8: allowlist marker present → NO warn
|
||||
test('case 8 — allowlist marker suppresses warn', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
allowlistMarker: '<!-- planner-region-allow: await .*refresh -->',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`allowlist marker must suppress warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 9: one task both bans and requires same PAT in same file (no second task) → NO warn
|
||||
test('case 9 — one task bans and requires PAT (no sibling) → NO warn', () => {
|
||||
const content = makeSingleTaskPlan({
|
||||
taskGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskAction: 'Refactor to avoid await refresh, but note that bridge.refresh() is used later.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`single task (no sibling B) must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 10: count form `grep -c 'PAT' FILE == 0` + sibling → WARN
|
||||
test('case 10 — count form (grep -c PAT FILE == 0) + sibling → WARN', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "grep -c 'await .*refresh' app/page.py == 0",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`count form (grep -c ... == 0) must warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 11: bracket form `[ $(grep -c PAT FILE) -eq 0 ]` + sibling → WARN
|
||||
test('case 11 — bracket form ([ $(grep -c PAT FILE) -eq 0 ]) + sibling → WARN', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "[ $(grep -c 'await .*refresh' app/page.py) -eq 0 ]",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`bracket form must warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 12: CRLF variant of case 1 → WARN
|
||||
test('case 12 — CRLF line endings → WARN', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const crlfContent = content.split('\n').join('\r\n');
|
||||
const result = scan(crlfContent);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`CRLF content must still warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 13: backslash line-continuation variant → WARN
|
||||
test('case 13 — backslash line continuation → WARN', () => {
|
||||
// Build manually to control exact line continuation
|
||||
const lines = [
|
||||
'---',
|
||||
'phase: 01-test',
|
||||
'plan: 01',
|
||||
'type: execute',
|
||||
'wave: 1',
|
||||
'depends_on: []',
|
||||
'files_modified: [app/page.py]',
|
||||
'autonomous: true',
|
||||
'must_haves:',
|
||||
' - AC1',
|
||||
'---',
|
||||
'',
|
||||
'<task>',
|
||||
'<name>Task A: factory refactor</name>',
|
||||
'<files>app/page.py</files>',
|
||||
'<action>Refactor the factory to be synchronous.</action>',
|
||||
// Gate split across lines with backslash continuation
|
||||
"<verify><automated>! grep -Eq 'await .*refresh' \\\napp/page.py</automated></verify>",
|
||||
'<done>Factory is synchronous.</done>',
|
||||
'</task>',
|
||||
'',
|
||||
'<task>',
|
||||
'<name>Task B: reindex handler</name>',
|
||||
'<files>app/page.py</files>',
|
||||
'<action>Add a post-reindex handler that calls await bridge.refresh() to repopulate state.</action>',
|
||||
'<verify><automated>npm test</automated></verify>',
|
||||
'<done>Handler is in place.</done>',
|
||||
'</task>',
|
||||
].join('\n');
|
||||
const result = scan(lines);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`backslash continuation must still warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 14: mixed line with positive gate AND a negative gate, sibling → WARN (on the negative only)
|
||||
test('case 14 — mixed positive+negative on one segment + sibling → WARN for negative', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "grep -c 'X' app/page.py == 1 && ! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`mixed line with negative gate + sibling must warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 15: glob file arg `app/*.py` → NO warn (unresolvable path)
|
||||
test('case 15 — glob file arg → NO warn (unresolvable)', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/*.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`glob file arg must not warn (unresolvable), got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 16: invalid-regex PAT literal fallback → WARN, no exception
|
||||
test('case 16 — invalid-regex PAT → literal fallback, WARN, no exception', () => {
|
||||
// "await (refresh" — unbalanced paren, invalid regex
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq 'await (refresh' app/page.py",
|
||||
taskBAction: 'The handler calls await (refresh on bridge to repopulate state.',
|
||||
});
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = scan(content);
|
||||
}, 'scan must not throw on invalid regex PAT');
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`invalid-regex PAT with literal match must warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 17: ReDoS-ish PAT (catastrophic backtracking) → no hang, no false warn.
|
||||
// The sibling action is 5000 'a's — classic ReDoS trigger if we call new RegExp('(a+)+$').
|
||||
// Proof-of-no-hang: the test runner's own timeout catches it; a hanging test fails here.
|
||||
// No timing assertion (flaky) — the linear patternRequiredIn implementation is microsecond-fast.
|
||||
test('case 17 — catastrophic ReDoS pattern is instant, no hang, no false warn', () => {
|
||||
const longAs = 'a'.repeat(5000);
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq '(a+)+$' app/page.py",
|
||||
taskBAction: `Reindex handler that processes ${longAs} records and calls bridge.refresh().`,
|
||||
});
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = scan(content);
|
||||
}, 'scan must not throw on ReDoS-ish PAT');
|
||||
// The literal '(a+)+$' is not present in the action text as a substring → no warn.
|
||||
// (If new RegExp were used, this test would hang before reaching this assertion.)
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`catastrophic PAT '(a+)+$' not literally in action — must not warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.ok(Array.isArray(result.warnings), 'valid result shape');
|
||||
});
|
||||
|
||||
// Case 23 (mutation-catching): cat producer = file-wide → WARN; sed producer = region-scoped → NO warn
|
||||
test('case 23a — cat pipe: ! cat app/page.py | grep -Eq PAT + sibling → WARN (file-wide via cat)', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! cat app/page.py | grep -Eq 'await .*refresh'",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`cat-piped grep is file-wide — must warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.ok(result.valid !== false, 'valid must remain true even when #968 warns');
|
||||
});
|
||||
|
||||
test('case 23b — sed pipe: ! sed -n "12,40p" app/page.py | grep -Eq PAT + sibling → NO warn (region-scoped)', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! sed -n '12,40p' app/page.py | grep -Eq 'await .*refresh'",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`sed-piped grep is region-scoped — must NOT warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 24: awk region → NO warn
|
||||
test('case 24 — awk region pipe: ! awk \'/^def make_page/,/^def /\' app/page.py | grep -Eq PAT + sibling → NO warn', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! awk '/^def make_page/,/^def /' app/page.py | grep -Eq 'await .*refresh'",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`awk-piped grep is region-scoped — must NOT warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 25: basename non-over-match — different dirs, same basename → NO warn
|
||||
test('case 25 — basename non-over-match: different dirs same filename → NO warn', () => {
|
||||
// Task A bans on apps/web/config.py; Task B lists apps/admin/config.py
|
||||
// Same basename "config.py" but different dirs → must NOT warn
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAFile: 'apps/web/config.py',
|
||||
taskAGate: "! grep -Eq 'await .*refresh' apps/web/config.py",
|
||||
taskBFile: 'apps/admin/config.py',
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`different dirs (apps/web/config.py vs apps/admin/config.py) — same basename but must NOT warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 26: extensionless known file (Dockerfile) recognized via knownFiles → WARN
|
||||
test('case 26 — extensionless known file (Dockerfile) via knownFiles → WARN', () => {
|
||||
// Task A has ! grep -Eq 'FROM scratch' Dockerfile
|
||||
// Dockerfile has no extension, so looksLikePath would miss it — but knownFiles should catch it
|
||||
// Task B lists Dockerfile in <files> and action requires 'FROM scratch'
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAFile: 'Dockerfile',
|
||||
taskAGate: "! grep -Eq 'FROM scratch' Dockerfile",
|
||||
taskBFile: 'Dockerfile',
|
||||
taskBAction: 'Update the image base: FROM scratch ensures minimal surface area.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`Dockerfile (extensionless, known via <files>) should be recognized — must warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.ok(result.valid !== false, 'valid must remain true');
|
||||
});
|
||||
|
||||
// Case 27: wildcard semantic match — patternRequiredIn handles .* correctly
|
||||
test('case 27 — wildcard semantic match: "await .*refresh" (gate) warns when action has "await bridge.refresh()"', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`patternRequiredIn must match "await .*refresh" against "await bridge.refresh()" — must warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 4b: same-file positive control — sibling lists the SAME banned file + requires PAT → WARN
|
||||
// Paired with case 4: proves the no-warn in case 4 is due to the file mismatch, not a dead detector.
|
||||
test('case 4b — same-file positive control: sibling lists same file → WARN (proves case 4 no-warn is file-mismatch)', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAFile: 'app/page.py',
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBFile: 'app/page.py',
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`same-file sibling must warn — proves case 4's no-warn is due to file mismatch, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 7b: non-inverted positive control — without -v the ban IS detected → WARN
|
||||
// Paired with case 7: proves the -v skip is what suppresses case 7.
|
||||
test('case 7b — non-inverted positive control: ! grep -q (no -v) + sibling → WARN (proves case 7 no-warn is -v skip)', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -q 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`non-inverted ! grep -q must warn — proves the -v flag is what suppresses case 7, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 25b: basename-fallback positive — bare unqualified filename matches sibling's qualified path → WARN
|
||||
// Paired with case 25: proves the bare-name basename fallback at src ~line 525 actually fires.
|
||||
// Case 25 only proves qualified paths don't over-match; this proves the bare fallback does fire.
|
||||
test('case 25b — basename-fallback positive: bare gate file matches sibling qualified path → WARN (proves basename fallback fires)', () => {
|
||||
// Task A gate uses bare "config.py" (no directory prefix — unqualified).
|
||||
// Task B lists "apps/admin/config.py" (qualified). basename("apps/admin/config.py") === "config.py".
|
||||
// The basename fallback (line 525) should match → WARN.
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAFile: 'config.py',
|
||||
taskAGate: "! grep -Eq 'await .*refresh' config.py",
|
||||
taskBFile: 'apps/admin/config.py',
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`bare gate file "config.py" must match sibling "apps/admin/config.py" via basename fallback — must warn, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 28: anchored pattern warns after ^ strip — proves anchor stripping works
|
||||
// Gate: ! grep -Eq '^FROM scratch' Dockerfile
|
||||
// Sibling B lists Dockerfile, action requires 'FROM scratch' (no anchor in prose).
|
||||
// Without anchor stripping, "^FROM scratch" would be treated as containing metacharacters
|
||||
// and fall back to literal-substring: "^FROM scratch" not in B's prose → no warn.
|
||||
// With anchor stripping, "FROM scratch" is the effective literal → found in B's prose → WARN.
|
||||
test('case 28 — anchored pattern warns: ! grep -Eq \'^FROM scratch\' Dockerfile + sibling → WARN (proves ^ strip)', () => {
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAFile: 'Dockerfile',
|
||||
taskAGate: "! grep -Eq '^FROM scratch' Dockerfile",
|
||||
taskBFile: 'Dockerfile',
|
||||
taskBAction: 'Update the image base: FROM scratch ensures minimal surface area.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.ok(
|
||||
result.warnings.filter(w => w.includes('#968')).length >= 1,
|
||||
`anchored pattern "^FROM scratch" must warn after ^ is stripped — "FROM scratch" is in sibling action, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true');
|
||||
});
|
||||
|
||||
// Case 29: alternation falls back conservatively — documents the known limitation.
|
||||
// Gate: ! grep -Eq 'debug|trace' src/logger.ts
|
||||
// Sibling B lists src/logger.ts, action says "remove debug calls" (contains "debug" but NOT "debug|trace").
|
||||
// patternRequiredIn sees unhandled `|` in joined frags → literal-substring fallback on raw pattern.
|
||||
// "debug|trace" is NOT literally in B's prose → conservative NO warn.
|
||||
// This is intentional: false-negative is the safe direction for a warn-only advisory.
|
||||
test('case 29 — alternation conservative fallback: "debug|trace" → NO warn (documents alternation limitation)', () => {
|
||||
// NOTE: This is intended conservative behavior, not a bug.
|
||||
// patternRequiredIn falls back to literal-substring for patterns containing `|` (alternation),
|
||||
// because safely expanding alternation without new RegExp would require a mini-parser.
|
||||
// The literal "debug|trace" is not present verbatim in the action, so no warn fires.
|
||||
// A planner who writes `debug|trace` gets no advisory — acceptable, since a false-negative
|
||||
// is always safer than a false-positive for a warn-only gate.
|
||||
const content = makeTwoTaskPlan({
|
||||
taskAFile: 'src/logger.ts',
|
||||
taskAGate: "! grep -Eq 'debug|trace' src/logger.ts",
|
||||
taskBFile: 'src/logger.ts',
|
||||
taskBAction: 'Remove debug calls from the logger module to reduce noise.',
|
||||
});
|
||||
const result = scan(content);
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
`alternation pattern "debug|trace" must conservatively NOT warn — literal "debug|trace" not in action, got: ${JSON.stringify(result.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Case 18: empty content → no crash, no #968 warn
|
||||
test('case 18 — empty content → no crash', () => {
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = scan('');
|
||||
});
|
||||
assert.ok(Array.isArray(result.warnings), 'must return { warnings: [] }');
|
||||
assert.strictEqual(
|
||||
result.warnings.filter(w => w.includes('#968')).length,
|
||||
0,
|
||||
'empty content must produce no #968 warn',
|
||||
);
|
||||
});
|
||||
|
||||
// Case 18b: no-task plan → no crash
|
||||
test('case 18b — no-task plan → no crash', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'phase: 01-test',
|
||||
'plan: 01',
|
||||
'type: execute',
|
||||
'wave: 1',
|
||||
'depends_on: []',
|
||||
'files_modified: []',
|
||||
'autonomous: true',
|
||||
'must_haves:',
|
||||
' - AC1',
|
||||
'---',
|
||||
'',
|
||||
'# No tasks here',
|
||||
].join('\n');
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = scan(content);
|
||||
});
|
||||
assert.strictEqual(result.warnings.filter(w => w.includes('#968')).length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group 2: end-to-end via runGsdTools ──────────────────────────────────────
|
||||
|
||||
describe('scanFileWideNegativeGateConflict — end-to-end via verify plan-structure', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// Case 19: integration — valid stays true despite warning (warn-only)
|
||||
test('case 19 — integration: valid===true despite #968 warning', () => {
|
||||
const planContent = makeTwoTaskPlan({
|
||||
taskAGate: "! grep -Eq 'await .*refresh' app/page.py",
|
||||
taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.',
|
||||
});
|
||||
const planDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||||
fs.mkdirSync(planDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(planDir, '01-01-PLAN.md'), planContent);
|
||||
|
||||
const result = runGsdTools(
|
||||
'verify plan-structure .planning/phases/01-test/01-01-PLAN.md',
|
||||
tmpDir,
|
||||
);
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.strictEqual(
|
||||
parsed.valid,
|
||||
true,
|
||||
`#968 is warn-only: valid must be true, got: ${JSON.stringify(parsed)}`,
|
||||
);
|
||||
assert.ok(
|
||||
parsed.warnings.some(w => w.includes('#968')),
|
||||
`must have a #968 warning, got: ${JSON.stringify(parsed.warnings)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group 3: doc-contract ────────────────────────────────────────────────────
|
||||
|
||||
describe('doc-contract: guidance prose is in place', () => {
|
||||
// Case 20: gsd-planner.md has the new guidance
|
||||
test('case 20 — gsd-planner.md contains Region-scoped negative gates + reference', () => {
|
||||
const content = fs.readFileSync(PLANNER_MD, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('Region-scoped negative gates'),
|
||||
'gsd-planner.md must include "Region-scoped negative gates"',
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('planner-antipatterns.md'),
|
||||
'gsd-planner.md must reference planner-antipatterns.md',
|
||||
);
|
||||
});
|
||||
|
||||
// Case 21: planner-antipatterns.md has the new section
|
||||
test('case 21 — planner-antipatterns.md has ## Region-Scoped Negative Gates + examples', () => {
|
||||
const content = fs.readFileSync(ANTIPATTERNS_MD, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('## Region-Scoped Negative Gates'),
|
||||
'planner-antipatterns.md must include "## Region-Scoped Negative Gates"',
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('await .*refresh'),
|
||||
'planner-antipatterns.md must include the worked example pattern "await .*refresh"',
|
||||
);
|
||||
// Verify sed or awk region example is present
|
||||
const hasSedOrAwk = content.includes('sed -n') || content.includes('awk ');
|
||||
assert.ok(
|
||||
hasSedOrAwk,
|
||||
'planner-antipatterns.md must include sed-n or awk region example',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group 4: AC3 executable proof ───────────────────────────────────────────
|
||||
|
||||
describe('AC3: executable proof — file-wide ban vs region-scoped simultaneously satisfiable', () => {
|
||||
test('case 22 — grep/sed proof: both gates simultaneously satisfiable', () => {
|
||||
// Check if grep and sed are available
|
||||
const grepAvail = spawnSync('grep', ['--version']).status === 0;
|
||||
const sedAvail = spawnSync('sed', ['--version']).status === 0 ||
|
||||
spawnSync('sed', ['-n', '1p', '/dev/null']).status === 0;
|
||||
|
||||
if (!grepAvail || !sedAvail) {
|
||||
// Skip gracefully if tools are unavailable
|
||||
return;
|
||||
}
|
||||
|
||||
// Write a temp Python file with:
|
||||
// def make_page(): — no await refresh
|
||||
// async def reindex_handler(): — awaits bridge.refresh()
|
||||
const tmpFile = path.join(os.tmpdir(), `gsd-968-proof-${process.pid}.py`);
|
||||
const pyContent = [
|
||||
'def make_page():',
|
||||
' """Synchronous factory — must not block on a refresh."""',
|
||||
' return {"title": "My Page"}',
|
||||
'',
|
||||
'',
|
||||
'async def reindex_handler():',
|
||||
' """Post-reindex callback — must await bridge.refresh() to repopulate state."""',
|
||||
' await bridge.refresh()',
|
||||
' return True',
|
||||
].join('\n');
|
||||
fs.writeFileSync(tmpFile, pyContent);
|
||||
|
||||
try {
|
||||
// (a) File-wide: grep -Eq 'await .*refresh' <file> — should EXIT 0 (pattern found)
|
||||
// This means a file-wide ban (! grep -Eq ...) WOULD FAIL
|
||||
const fileWide = spawnSync('grep', ['-Eq', 'await .*refresh', tmpFile]);
|
||||
assert.strictEqual(
|
||||
fileWide.status,
|
||||
0,
|
||||
'grep file-wide should find the pattern (exits 0) — proving the file-wide ban would fail',
|
||||
);
|
||||
|
||||
// (b) Region-scoped (make_page only): sed extracts lines 1-3, piped to grep → pattern NOT found
|
||||
// The factory region is clean: ban PASSES
|
||||
const makePageLines = spawnSync('sed', ['-n', '1,3p', tmpFile]);
|
||||
assert.strictEqual(makePageLines.status, 0, 'sed should succeed');
|
||||
const makePageRegion = makePageLines.stdout.toString();
|
||||
|
||||
// Write to a temp file and grep it
|
||||
const regionFile = path.join(os.tmpdir(), `gsd-968-region-${process.pid}.py`);
|
||||
fs.writeFileSync(regionFile, makePageRegion);
|
||||
try {
|
||||
const regionBan = spawnSync('grep', ['-Eq', 'await .*refresh', regionFile]);
|
||||
assert.strictEqual(
|
||||
regionBan.status,
|
||||
1,
|
||||
'grep in make_page region should NOT find pattern (exits 1) — ban PASSES in factory region',
|
||||
);
|
||||
|
||||
// (c) Region-scoped (reindex_handler): grep should FIND the pattern → requirement met
|
||||
const reindexLines = spawnSync('sed', ['-n', '6,9p', tmpFile]);
|
||||
const reindexRegion = reindexLines.stdout.toString();
|
||||
const reindexFile = path.join(os.tmpdir(), `gsd-968-reindex-${process.pid}.py`);
|
||||
fs.writeFileSync(reindexFile, reindexRegion);
|
||||
try {
|
||||
const reindexCheck = spawnSync('grep', ['-Eq', 'await .*refresh', reindexFile]);
|
||||
assert.strictEqual(
|
||||
reindexCheck.status,
|
||||
0,
|
||||
'grep in reindex_handler region MUST find pattern (exits 0) — requirement met',
|
||||
);
|
||||
} finally {
|
||||
try { fs.unlinkSync(reindexFile); } catch { /* ignore */ }
|
||||
}
|
||||
} finally {
|
||||
try { fs.unlinkSync(regionFile); } catch { /* ignore */ }
|
||||
}
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpFile); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user