diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index 2ad63e371..70b6ff619 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -17,7 +17,7 @@ Order: ceo → design → dx → eng. Design runs only with UI scope, DX only wi Scope flags on the first `next`: `--ui`, `--no-ui`, `--no-dx`, `--developer-tool`, `--agent-primary`, `--docs-only`. `--developer-tool` and `--agent-primary` only enable DX. The first `next` prints `SCOPE: ui=… (terms; visual; files) dx=… (detected) docs_only=yes|no () source=ui:,dx:` (each is `flag`, `marker` or `detected`). Docs-only (`--docs-only`, or every Implementation-plan path is README*, CHANGELOG*, -TODOS*, docs/** but not docs/*-policy.md, or other markdown outside a skill dir) runs CEO on the native voice only, then Eng. UI needs a rendered file path or a UI term next +TODOS*, docs/** but not docs/*-policy.md, or other markdown outside a skill dir) runs CEO on the native voice only, then Eng. UI needs a rendered file path (including a named non-test `.ts`/`.js` file that holds a tagged `html`, `css` or `svg` template, such as Lit or htm) or a UI term next to a visual noun in the Implementation plan. To keep flags for later runs on the same plan, add the `SCOPE_PIN:` marker it prints (``) as the plan's first line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** `--kind fix` reviews nothing: it prints `AUTOPLAN: not needed (bug fix; R65)`, a `PROOF:` line and `GSTACK_RESULT: skill=autoplan status=skipped run=-`, writes nothing and needs no `--out` or `--plan`; the PR still carries the bug's proof, a repro on current main or a failing test written first. `--kind feature`, the default for an unattended run on Capy (`GSTACK_CAPY=1`) that names no kind, is the lean run. Its `phase_order` is `eng` alone with one native reviewer, whose prompt merges the CEO and Eng lenses (Design or DX only when scope detects them), always asks "Any outside terms, licences or rights involved?" and shows the draft code. The draft is `git diff` from the merge base with `--base ` (default: `gstack-diff-scope`'s base, never an `origin/HEAD` naming the checked-out branch), untracked files included and the run directory left out, written to the snapshot as `draft.diff` and hashed; with no code yet it says `DRAFT: none ()`. The first `next` prints `LEAN:` and each opened phase `DRAFT:`; the reviewer's line after INPUT must be `DRAFT: ` or `DRAFT: none`, or submit refuses it (`RESULT_RECEIPT_MISSING`). No outside voice, `--max-rounds 1`, no carry check and one gate revision: `revise --incorporates` applies the accepted changes without re-review and approves. `--full` brings back every phase and both voices; off Capy, and in an interactive session, the default stays full. diff --git a/autoplan/DRIVER.md.tmpl b/autoplan/DRIVER.md.tmpl index 65b063d39..eef7e0c43 100644 --- a/autoplan/DRIVER.md.tmpl +++ b/autoplan/DRIVER.md.tmpl @@ -14,7 +14,7 @@ ends with `()`. {{AUTOPLAN_DRIVER_PHASES}} The first `next` prints `SCOPE: ui=… (terms; visual; files) dx=… (detected) docs_only=yes|no () source=ui:,dx:` (each is `flag`, `marker` or `detected`). Docs-only (`--docs-only`, or every Implementation-plan path is README*, CHANGELOG*, -TODOS*, docs/** but not docs/*-policy.md, or other markdown outside a skill dir) runs CEO on the native voice only, then Eng. UI needs a rendered file path or a UI term next +TODOS*, docs/** but not docs/*-policy.md, or other markdown outside a skill dir) runs CEO on the native voice only, then Eng. UI needs a rendered file path (including a named non-test `.ts`/`.js` file that holds a tagged `html`, `css` or `svg` template, such as Lit or htm) or a UI term next to a visual noun in the Implementation plan. To keep flags for later runs on the same plan, add the `SCOPE_PIN:` marker it prints (``) as the plan's first line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** `--kind fix` reviews nothing: it prints `AUTOPLAN: not needed (bug fix; R65)`, a `PROOF:` line and `GSTACK_RESULT: skill=autoplan status=skipped run=-`, writes nothing and needs no `--out` or `--plan`; the PR still carries the bug's proof, a repro on current main or a failing test written first. `--kind feature`, the default for an unattended run on Capy (`GSTACK_CAPY=1`) that names no kind, is the lean run. Its `phase_order` is `eng` alone with one native reviewer, whose prompt merges the CEO and Eng lenses (Design or DX only when scope detects them), always asks "Any outside terms, licences or rights involved?" and shows the draft code. The draft is `git diff` from the merge base with `--base ` (default: `gstack-diff-scope`'s base, never an `origin/HEAD` naming the checked-out branch), untracked files included and the run directory left out, written to the snapshot as `draft.diff` and hashed; with no code yet it says `DRAFT: none ()`. The first `next` prints `LEAN:` and each opened phase `DRAFT:`; the reviewer's line after INPUT must be `DRAFT: ` or `DRAFT: none`, or submit refuses it (`RESULT_RECEIPT_MISSING`). No outside voice, `--max-rounds 1`, no carry check and one gate revision: `revise --incorporates` applies the accepted changes without re-review and approves. `--full` brings back every phase and both voices; off Capy, and in an interactive session, the default stays full. diff --git a/bin/gstack-autoplan-snapshot.ts b/bin/gstack-autoplan-snapshot.ts index 9ae6effa4..0f7d4e031 100644 --- a/bin/gstack-autoplan-snapshot.ts +++ b/bin/gstack-autoplan-snapshot.ts @@ -26,7 +26,7 @@ export function detectDxScope(activePlan: string, developerTool = false, agentPr const content = extractImplementationPlan(readFileSync(source, 'utf8')); const terms = dxTermsFor(content); return { activePlan: source, sha256: sha256(content), ...terms, developerTool, agentPrimary, - dxRequired: terms.dxRequiredByTerms || developerTool || agentPrimary, ...(require('../lib/autoplan-scope') as typeof import('../lib/autoplan-scope')).detectUiScope(content) }; + dxRequired: terms.dxRequiredByTerms || developerTool || agentPrimary, ...(require('../lib/autoplan-scope') as typeof import('../lib/autoplan-scope')).detectUiScope(content, process.cwd()) }; } // The native dispatch payload is assembled from the same immutable bytes as diff --git a/changelog.d/gst-1750-autoplan-ui-scope.md b/changelog.d/gst-1750-autoplan-ui-scope.md new file mode 100644 index 000000000..ffb0fd2f5 --- /dev/null +++ b/changelog.d/gst-1750-autoplan-ui-scope.md @@ -0,0 +1 @@ +- **/autoplan now sees Lit and htm template UI, and a lean run no longer calls a skipped Design phase merged.** /autoplan's UI classifier counted a rendered file only by extension or directory, so a plan naming `plugins/web-ui/src/personas.ts` (Lit `html` templates in a `.ts` file) got no Design phase, and rows, pickers and menus were not UI words. A named non-test `.ts`/`.js` file under the repo root that holds a tagged `html`, `css` or `svg` template now counts as a rendered file, using /ship's template rule from `bin/gstack-diff-scope` (one regex, pinned byte for byte by a test), and row, list, picker, menu and section join the UI terms (near a visual noun they add Design; alone they only keep the UI checklist sections loaded). In a lean `--kind feature` run, a phase the one reviewer does not apply as a lens is now labelled with its real reason, such as `no UI scope detected (lean)`, instead of `lean run: merged into the one eng reviewer`. (GST-1750) diff --git a/lib/autoplan-run.ts b/lib/autoplan-run.ts index dc40829a1..0e78b501f 100644 --- a/lib/autoplan-run.ts +++ b/lib/autoplan-run.ts @@ -35,7 +35,7 @@ import { familyConflict, modelFamily } from './outside-voice-runner'; import { SpendCapExceeded, release, renderSpend, reserve, settle, summarize, readLedger } from './spend-ledger'; import { DEFAULT_MAX_ROUNDS, attemptEstimate, overCap, resumeStop, roundCap, roundOf, roundsLine, stopForBudget, stopLines } from './autoplan-budget'; import { annotateConfirm, confirmBlock } from './autoplan-confirm'; -import { LEAN_SKIPPED, captureDraft, checkDraftReceipt, draftLine, leanLine, leanMode, leanPrompt, type AutoplanKind } from './autoplan-lean'; +import { LEAN_SKIPPED, captureDraft, checkDraftReceipt, draftLine, leanLenses, leanLine, leanMode, leanPrompt, type AutoplanKind } from './autoplan-lean'; /** An unreadable run.lock younger than this is a lock some other gstack is still writing, never a stale one. */ const LOCK_WRITE_GRACE_MS = 30_000; @@ -120,7 +120,8 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState { const consent = (o.outsideEnabled ?? true) && !lean ? outsideConsent({ userInvoked: false }) : undefined; const consentMissing = consent && !consent.allowed ? consent.lines[0] : undefined; const phases: Record = {}; - for (const p of PHASES) if (!order.includes(p)) phases[p] = { skipped: o.ceoOnly ? 'ceo-only run' : lean ? LEAN_SKIPPED : scope.docs_only ? 'docs-only plan' : p === 'design' ? 'no UI scope detected' : 'no developer-facing scope detected', native: { status: 'skipped' }, outside: { status: 'skipped' } }; + const why = (p: Phase) => scope.docs_only ? 'docs-only plan' : p === 'design' ? 'no UI scope detected' : 'no developer-facing scope detected'; + for (const p of PHASES) if (!order.includes(p)) phases[p] = { skipped: o.ceoOnly ? 'ceo-only run' : !lean ? why(p) : leanLenses(scope).includes(p) ? LEAN_SKIPPED : `${why(p)} (lean)`, native: { status: 'skipped' }, outside: { status: 'skipped' } }; const state: RunState = { schema_version: 1, run, status: 'running', required_phases: order, phases, artifacts: {}, revision: 0, out, plan_source: init.sourcePlan, active_plan: init.activePlan, restore: init.restorePath, scope, phase_order: order, diff --git a/lib/autoplan-scope.ts b/lib/autoplan-scope.ts index 966810338..ee58bd889 100644 --- a/lib/autoplan-scope.ts +++ b/lib/autoplan-scope.ts @@ -4,31 +4,52 @@ * interactive path (`gstack-autoplan-snapshot.ts scope`, read by * autoplan/SKILL.md) take UI scope from `detectUiScope` over the * Implementation plan only. UI needs a rendered surface: a path to a rendered - * file, or a UI term next to a visual noun. Bare UI words are weak evidence: + * file, or a UI term next to a visual noun. A named .ts/.js file under the + * repo root that holds a tagged html, css or svg template (Lit, htm) is a + * rendered file: bin/gstack-diff-scope's content arm (GST-1718), read through + * lib/frontend-scope.ts. Bare UI words are weak evidence: * they keep the runner's conservative section fallback but do not add the * Design phase. The scope marker is the author's way to pin flags in the plan * itself; it is read, never written. `detectDocsOnly` classifies the paths * the Implementation plan names: a plan that touches only markdown no agent * or gate consumes is docs-only (CEO + Eng, outside voice on Eng only). */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { TEMPLATE_TAG, isTemplateCandidate } from './frontend-scope'; import { classifyPath } from './review-depth'; -export const UI_TERMS = ['component', 'screen', 'form', 'button', 'modal', 'layout', 'dashboard', 'sidebar', 'nav', 'dialog'] as const; +export const UI_TERMS = ['component', 'screen', 'form', 'button', 'modal', 'layout', 'dashboard', 'sidebar', 'nav', 'dialog', 'row', 'list', 'picker', 'menu', 'section'] as const; export const VISUAL_NOUNS = ['page', 'layout', 'style', 'pixel', 'view', 'browser', 'css'] as const; const VISUAL_WINDOW = 4; const RENDERED_EXT = /(?:^|\/)[^/.][^/]*\.(?:tsx|jsx|vue|svelte|css|scss|html|erb|hbs)$/i; const RENDERED_DIR = /(?:^|\/)(?:components|pages|views|styles)\//i; +const TEMPLATE_MAX_BYTES = 2_000_000; export interface UiEvidence { terms: Array<{ term: string; count: number }>; visual: string[]; files: string[] } export interface UiScope { uiRequired: boolean; uiMatches: number; uiEvidence: UiEvidence } const wordOf = (w: string, list: readonly string[]) => list.find(t => w === t || w === `${t}s` || (t === 'style' && w === 'stylesheet')); -/** UI scope from rendered surfaces: a rendered file path, or a UI term within a few words of a visual noun in the same sentence (paths are not prose). */ -export function detectUiScope(implementationPlan: string): UiScope { - const files = [...new Set([...implementationPlan.matchAll(/[\w@~$.\/-]+/g)] - .map(m => m[0].replace(/[.\-]+$/, '')) - .filter(t => t.length > 1 && (RENDERED_EXT.test(t) || RENDERED_DIR.test(t))))]; +/** A named non-test .ts/.js file inside root that holds a tagged html, css or svg template; missing, unreadable or outside root is no. */ +function holdsTemplate(file: string, root: string): boolean { + if (!isTemplateCandidate(file)) return false; + try { + const base = fs.realpathSync(root); + const real = fs.realpathSync(path.resolve(base, file)); + const rel = path.relative(base, real); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false; + const st = fs.statSync(real); + return st.isFile() && st.size <= TEMPLATE_MAX_BYTES && TEMPLATE_TAG.test(fs.readFileSync(real, 'utf8')); + } catch { + return false; + } +} + +/** UI scope from rendered surfaces: a rendered file path (with root, a named template file too), or a UI term within a few words of a visual noun in the same sentence (paths are not prose). */ +export function detectUiScope(implementationPlan: string, root?: string): UiScope { + const files = [...new Set([...implementationPlan.matchAll(/[\w@~$.\/-]+/g)].map(m => m[0].replace(/[.\-]+$/, '')))] + .filter(t => t.length > 1 && (RENDERED_EXT.test(t) || RENDERED_DIR.test(t) || (root !== undefined && holdsTemplate(t, root)))); const counts = new Map(); const visual: string[] = []; for (const sentence of implementationPlan.split(/[.!?;:](?:\s|$)|\n\s*\n/)) { diff --git a/lib/frontend-scope.ts b/lib/frontend-scope.ts index 19e1905d6..96c90806c 100644 --- a/lib/frontend-scope.ts +++ b/lib/frontend-scope.ts @@ -6,8 +6,10 @@ // sample paths so they cannot drift. bin/gstack-design-detect.ts uses this to // derive `scan --changed ` targets without consuming a shell-split list. // The bash script's template-UI content arm (GST-1718: Lit or htm templates in -// .ts/.js) reads file contents and is not mirrored here; the detector scans -// paths, so its targets stay path-only. +// .ts/.js) reads file contents, so isFrontendPath stays path-only and the +// detector's targets with it. TEMPLATE_TAG and isTemplateCandidate are that +// arm's regex and candidate rule, for callers that read the file themselves +// (lib/autoplan-scope.ts); test/frontend-scope.test.ts pins them to the script. const EXTENSIONS = new Set([ '.css', '.scss', '.less', '.sass', '.pcss', @@ -23,6 +25,22 @@ const EXTENSIONS = new Set([ // whole repo-relative path, so a nested `apps/web/tailwind.config.js` is not frontend there. const ROOT_CONFIG_PREFIXES = ['tailwind.config.', 'postcss.config.']; +/** gstack-diff-scope's TEMPLATE_TAG_RE, verbatim (POSIX ERE, matched per line). */ +export const TEMPLATE_TAG_ERE = '(^|[^A-Za-z0-9_$.])(html|css|svg)[[:space:]]*`'; +/** TEMPLATE_TAG_ERE over a whole file: per line, as grep reads it. */ +export const TEMPLATE_TAG = new RegExp(TEMPLATE_TAG_ERE.replace('[[:space:]]', '[^\\S\\n]'), 'm'); +/** gstack-diff-scope's TEST_PATH_RE, verbatim. */ +export const TEST_PATH_ERE = '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\\.py$|(^|/)test_[^/]+\\.py$|_test\\.(go|py|rb|ts|js|exs|dart)$|\\.(test|spec)\\.[jt]sx?$|_spec\\.rb$|Test\\.(java|kt)$'; +const TEST_PATH = new RegExp(TEST_PATH_ERE); +const TEMPLATE_EXT = /\.(ts|js|mjs|cjs|mts|cts)$/; +const TEMPLATE_SKIP = /\.(test|spec)\.|^(test|tests|spec|__tests__|cypress|e2e)\//; + +/** Could the content arm count this path: a non-test .ts/.js source file (gstack-diff-scope's TPL_CANDIDATES rule)? */ +export function isTemplateCandidate(relPath: string): boolean { + const rel = relPath.replace(/\\/g, '/').replace(/^\.\//, ''); + return TEMPLATE_EXT.test(rel) && !TEMPLATE_SKIP.test(rel) && !TEST_PATH.test(rel); +} + /** Repo-relative path (forward slashes) → is it a frontend file per gstack-diff-scope? */ export function isFrontendPath(relPath: string): boolean { const rel = relPath.replace(/\\/g, '/').replace(/^\.\//, ''); diff --git a/test/autoplan-lean.test.ts b/test/autoplan-lean.test.ts index c2d565c31..7dcb37a53 100644 --- a/test/autoplan-lean.test.ts +++ b/test/autoplan-lean.test.ts @@ -141,7 +141,9 @@ describe('next --kind feature: one merged reviewer over the plan and the draft c expect(first.status).toBe(0); expect(state.lean).toEqual({ kind: 'feature', source: 'flag', base: 'main' }); expect(state.phase_order).toEqual(['eng']); - for (const p of ['ceo', 'design', 'dx']) expect(state.phases[p].skipped).toBe('lean run: merged into the one eng reviewer (R65)'); + expect(state.phases.ceo.skipped).toBe('lean run: merged into the one eng reviewer (R65)'); + expect(state.phases.design.skipped).toBe('no UI scope detected (lean)'); + expect(state.phases.dx.skipped).toBe('no developer-facing scope detected (lean)'); expect(state.outside_enabled).toBe(false); expect(ps.outside).toMatchObject({ status: 'disabled', outcome: 'not_run', reason: 'lean' }); expect(state.max_rounds).toBe(1); @@ -247,6 +249,7 @@ describe('draft: none and scope lenses', () => { const r = run(['next', '--kind', 'feature', '--base', 'main', '--out', out, '--plan', plan('ui'), '--ui'], cwd); expect(r.status).toBe(0); expect(readRun(out).phase_order).toEqual(['eng']); + expect(readRun(out).phases.design.skipped).toBe('lean run: merged into the one eng reviewer (R65)'); expect(r.stdout).toContain('LEAN: one reviewer (ceo+eng+design lenses)'); const prompt = fs.readFileSync(path.join(out, 'eng-native-prompt.md'), 'utf8'); expect(prompt).toContain('### Design lens'); diff --git a/test/autoplan-scope.test.ts b/test/autoplan-scope.test.ts index 9ec86577f..c767c9085 100644 --- a/test/autoplan-scope.test.ts +++ b/test/autoplan-scope.test.ts @@ -41,8 +41,8 @@ describe('UI evidence is a rendered surface in the Implementation plan', () => { test('non-visual CLI prose with UI words is weak evidence: no Design phase, sections still load', () => { const r = detectUiScope(CLI_PROSE); expect(r.uiRequired).toBe(false); - expect(r.uiMatches).toBe(3); - expect(r.uiEvidence).toEqual({ terms: [{ term: 'component', count: 1 }, { term: 'screen', count: 1 }, { term: 'form', count: 1 }], visual: [], files: [] }); + expect(r.uiMatches).toBe(4); + expect(r.uiEvidence).toEqual({ terms: [{ term: 'component', count: 1 }, { term: 'screen', count: 1 }, { term: 'form', count: 1 }, { term: 'list', count: 1 }], visual: [], files: [] }); const scope = detectScope(planFile(CLI_PROSE), ROOT, {}); expect(scope.ui).toBe(false); expect(phaseOrder(scope)).not.toContain('design'); @@ -72,6 +72,47 @@ describe('UI evidence is a rendered surface in the Implementation plan', () => { }); }); +let repos = 0; +/** A repo root with Lit-style template files, plain .ts, test files and links out of the root. */ +const templateRepo = () => { + const root = path.join(host.base, `tpl-${++repos}`, 'repo'); + const write = (rel: string, text: string) => { fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); fs.writeFileSync(path.join(root, rel), text); }; + write('plugins/web-ui/src/personas.ts', 'export const row = (p) => html`
${p.name}
`;\n'); + write('src/spaced.js', 'const t = svg ``;\n'); + write('lib/pages.ts', 'export const pages = ["a.html"];\nconst xhtml`x` = 1;\n'); + write('src/split.ts', 'const t = html\n`

`;\n'); + write('test/view.ts', 'html`

`;\n'); + write('src/view.test.ts', 'html`

`;\n'); + write('src/__tests__/view.ts', 'html`

`;\n'); + write('../outside.ts', 'html`

`;\n'); + fs.symlinkSync(path.join(root, '..', 'outside.ts'), path.join(root, 'src', 'linked.ts')); + return root; +}; +const PERSONA_PROSE = 'Each grant is a permission row in plugins/web-ui/src/personas.ts with a role menu; the picker sits under the section.'; + +describe('template files and list-style terms (GST-1750)', () => { + test('a named .ts/.js file holding a tagged html, css or svg template is a rendered file', () => { + const root = templateRepo(); + expect(detectUiScope(PERSONA_PROSE, root)).toMatchObject({ uiRequired: true, uiEvidence: { files: ['plugins/web-ui/src/personas.ts'] } }); + expect(detectUiScope('Draw the icon in ./src/spaced.js.', root).uiEvidence.files).toEqual(['./src/spaced.js']); + }); + + test('without a root, or for a plain, test, missing or out-of-root file, a .ts path is not UI', () => { + const root = templateRepo(); + expect(detectUiScope(PERSONA_PROSE).uiRequired).toBe(false); + for (const file of ['lib/pages.ts', 'src/split.ts', 'test/view.ts', 'src/view.test.ts', 'src/__tests__/view.ts', 'src/missing.ts', '../outside.ts', 'src/linked.ts']) { + expect(detectUiScope(`Touch ${file} only.`, root).uiRequired, file).toBe(false); + } + }); + + test('row, list, picker, menu and section are UI terms: near a visual noun they add Design, bare they do not', () => { + for (const [prose, pair] of [['Show the grants as a list on the persona page.', 'list~page'], ['Add a role picker to the settings view.', 'picker~view'], ['Each row gets the shared row style.', 'row~style'], ['The page menu opens on hover.', 'menu~page'], ['Move the help section under the page header.', 'section~page']]) { + expect(detectUiScope(prose!).uiEvidence.visual, prose).toEqual([pair]); + } + expect(detectUiScope('List the files in each section and write one row per menu entry.')).toMatchObject({ uiRequired: false, uiMatches: 4 }); + }); +}); + describe('per-axis provenance, --no-dx and the plan marker', () => { test('--no-ui alone labels a detected DX as detected, not flag-forced', () => { const scope = detectScope(planFile(DX_PROSE), ROOT, { ui: false }); @@ -94,7 +135,7 @@ describe('per-axis provenance, --no-dx and the plan marker', () => { const out = path.join(host.base, 'run-pin'); const r = next(['--out', out, '--plan', file, '--no-ui', '--developer-tool']); expect(r.status, r.stderr).toBe(0); - expect(r.stdout).toMatch(/^SCOPE: ui=no \(terms: component×1, screen×1, form×1; visual: none; files: none\) dx=yes \(detected: [^)]*flag×2[^)]*\) docs_only=no \(detected\) source=ui:flag,dx:flag$/m); + expect(r.stdout).toMatch(/^SCOPE: ui=no \(terms: component×1, screen×1, form×1, list×1; visual: none; files: none\) dx=yes \(detected: [^)]*flag×2[^)]*\) docs_only=no \(detected\) source=ui:flag,dx:flag$/m); expect(r.stdout).toContain(`SCOPE_PIN: add as the first line of ${file} to keep these flags`); expect(fs.readFileSync(file).equals(before)).toBe(true); expect(readRun(out).scope).toMatchObject({ cli_flags: ['--no-ui', '--developer-tool'], marker: null, sources: { ui: 'flag', dx: 'flag' } }); @@ -120,7 +161,14 @@ describe('per-axis provenance, --no-dx and the plan marker', () => { describe('interactive parity: one detector', () => { test('snapshot scope returns uiRequired with its evidence', () => { expect(snapshotScope(planFile('Edit src/components/Settings.tsx.'))).toMatchObject({ uiRequired: true, uiEvidence: { files: ['src/components/Settings.tsx'] } }); - expect(snapshotScope(planFile(CLI_PROSE, 'The Settings page layout gets a new button.'))).toMatchObject({ uiRequired: false, uiMatches: 3 }); + expect(snapshotScope(planFile(CLI_PROSE, 'The Settings page layout gets a new button.'))).toMatchObject({ uiRequired: false, uiMatches: 4 }); + }); + + test('snapshot scope reads a named template file from the repo root it runs in', () => { + const root = templateRepo(); + const r = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-autoplan-snapshot.ts'), 'scope', planFile(PERSONA_PROSE)], { cwd: root, env: hostEnv(), encoding: 'utf8', timeout: 60_000 }); + expect(r.status, r.stderr).toBe(0); + expect(JSON.parse(r.stdout)).toMatchObject({ uiRequired: true, uiEvidence: { files: ['plugins/web-ui/src/personas.ts'], visual: [] } }); }); test('autoplan Step 2 takes UI scope from the returned uiRequired instead of a prose grep', () => { diff --git a/test/frontend-scope.test.ts b/test/frontend-scope.test.ts index 24977a7ce..147e7d420 100644 --- a/test/frontend-scope.test.ts +++ b/test/frontend-scope.test.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { spawnSync } from 'child_process'; -import { isFrontendPath } from '../lib/frontend-scope'; +import { TEMPLATE_TAG, TEMPLATE_TAG_ERE, TEST_PATH_ERE, isFrontendPath, isTemplateCandidate } from '../lib/frontend-scope'; const ROOT = path.join(import.meta.dir, '..'); const POSIX = process.platform !== 'win32'; @@ -60,6 +60,27 @@ describe('isFrontendPath', () => { }); }); +describe('template content arm (GST-1718 regex, reused by /autoplan in GST-1750)', () => { + test('TEMPLATE_TAG_ERE and TEST_PATH_ERE are byte-identical to the script', () => { + const script = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-diff-scope'), 'utf-8'); + expect(script.match(/^TEMPLATE_TAG_RE='([^']+)'$/m)?.[1]).toBe(TEMPLATE_TAG_ERE); + expect(script.match(/^TEST_PATH_RE='([^']+)'$/m)?.[1]).toBe(TEST_PATH_ERE); + }); + + test('TEMPLATE_TAG matches a tag before a backtick on one line, as grep -E does', () => { + for (const s of ['return html`

`', 'x = svg ``', ' css`a{}`', 'foo(html`x`)']) expect(TEMPLATE_TAG.test(s), s).toBe(true); + for (const s of ['xhtml`x`', 'a.html`x`', 'my_css`x`', 'html\n`x`', 'html("x")']) expect(TEMPLATE_TAG.test(s), s).toBe(false); + }); + + test.each([ + ['plugins/web-ui/src/personas.ts', true], ['src/a.mjs', true], ['a.cts', true], + ['src/a.test.ts', false], ['src/a.spec.js', false], ['test/a.ts', false], ['e2e/a.ts', false], ['cypress/a.js', false], + ['src/__tests__/a.ts', false], ['pkg/tests/a.ts', false], ['src/a_test.ts', false], ['src/A.tsx', false], ['src/a.TS', false], + ] as Array<[string, boolean]>)('isTemplateCandidate(%s) → %p', (p, expected) => { + expect(isTemplateCandidate(p)).toBe(expected); + }); +}); + describe.skipIf(!POSIX)('parity with bin/gstack-diff-scope', () => { test('SCOPE_FRONTEND agrees with isFrontendPath for every sample, one file per diff', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-scope-parity-'));