From 295469644ba51db2bea69707f4de13c23670661a Mon Sep 17 00:00:00 2001 From: Doogie201 Date: Tue, 24 Feb 2026 18:50:01 -0500 Subject: [PATCH 1/4] [S19] devops : worktree + poetry guardrails v2 Add worktree context detection, Python traceback suppression with canonical fix path, and dev-setup.sh hardening for offline/worktree use. - worktreeContext.ts: DI-testable git worktree/interpreter detection - nlxErrorSanitizer.ts: replaces raw tracebacks with controlled message + "bash scripts/dev-setup.sh" canonical remediation - nlxService.ts: wires sanitizer into toResponse() for missing_nlx and traceback-containing nonzero_exit errors - dev-setup.sh: worktree detection, --offline flag, context logging - 13 new tests (195 total, 42 files) AT-S19-01: worktree context detection (DI fixture proof) AT-S19-02: traceback suppression (no raw stack frames leak) AT-S19-03: dev-setup idempotent + offline-friendly AT-S19-04: error context attached to sanitized result Co-Authored-By: Claude --- .../__tests__/nlxErrorSanitizer.test.ts | 94 +++++++++++++++++++ .../engine/__tests__/worktreeContext.test.ts | 52 ++++++++++ dashboard/src/engine/nlxErrorSanitizer.ts | 64 +++++++++++++ dashboard/src/engine/nlxService.ts | 18 +++- dashboard/src/engine/worktreeContext.ts | 40 ++++++++ docs/backlog/README.md | 2 +- docs/sprints/README.md | 2 +- docs/sprints/S19/README.md | 70 +++++++++++--- .../evidence/at-s19-01-worktree-context.json | 21 +++++ .../at-s19-02-traceback-suppression.json | 45 +++++++++ .../S19/evidence/at-s19-03-dev-setup.json | 30 ++++++ .../S19/evidence/at-s19-04-error-context.json | 20 ++++ docs/sprints/S19/evidence/gates.json | 24 +++++ scripts/dev-setup.sh | 50 ++++++++-- 14 files changed, 504 insertions(+), 28 deletions(-) create mode 100644 dashboard/src/engine/__tests__/nlxErrorSanitizer.test.ts create mode 100644 dashboard/src/engine/__tests__/worktreeContext.test.ts create mode 100644 dashboard/src/engine/nlxErrorSanitizer.ts create mode 100644 dashboard/src/engine/worktreeContext.ts create mode 100644 docs/sprints/S19/evidence/at-s19-01-worktree-context.json create mode 100644 docs/sprints/S19/evidence/at-s19-02-traceback-suppression.json create mode 100644 docs/sprints/S19/evidence/at-s19-03-dev-setup.json create mode 100644 docs/sprints/S19/evidence/at-s19-04-error-context.json create mode 100644 docs/sprints/S19/evidence/gates.json diff --git a/dashboard/src/engine/__tests__/nlxErrorSanitizer.test.ts b/dashboard/src/engine/__tests__/nlxErrorSanitizer.test.ts new file mode 100644 index 0000000..b05ecdc --- /dev/null +++ b/dashboard/src/engine/__tests__/nlxErrorSanitizer.test.ts @@ -0,0 +1,94 @@ +import { containsTraceback, sanitizeNlxError, TRACEBACK_PATTERNS } from "../nlxErrorSanitizer"; +import type { ShellFn } from "../worktreeContext"; + +const WORKTREE_SHELL: ShellFn = (cmd) => { + if (cmd.includes("--show-toplevel")) return "/home/user/project"; + if (cmd.includes("--git-common-dir")) return "/home/user/project/.git"; + if (cmd.includes("--git-dir")) return "/home/user/.worktrees/project/wt/.git"; + if (cmd.includes("command -v python3")) return "/usr/bin/python3"; + if (cmd.includes("command -v nlx")) throw new Error("not found"); + if (cmd.includes("command -v python")) return "/usr/bin/python"; + return ""; +}; + +const NON_WORKTREE_SHELL: ShellFn = (cmd) => { + if (cmd.includes("--show-toplevel")) return "/home/user/project"; + if (cmd.includes("--git-common-dir")) return ".git"; + if (cmd.includes("--git-dir")) return ".git"; + if (cmd.includes("command -v python3")) return "/usr/bin/python3"; + if (cmd.includes("command -v nlx")) return "/usr/local/bin/nlx"; + return ""; +}; + +describe("containsTraceback", () => { + it("detects Python traceback header", () => { + expect(containsTraceback("Traceback (most recent call last):")).toBe(true); + }); + + it("detects ModuleNotFoundError", () => { + expect(containsTraceback("ModuleNotFoundError: No module named 'typer'")).toBe(true); + }); + + it("detects File line pattern", () => { + expect(containsTraceback(' File "/usr/lib/python3.11/site.py", line 42')).toBe(true); + }); + + it("returns false for clean stderr", () => { + expect(containsTraceback("Command completed successfully.")).toBe(false); + }); +}); + +describe("TRACEBACK_PATTERNS", () => { + it("includes all expected patterns", () => { + expect(TRACEBACK_PATTERNS.length).toBeGreaterThanOrEqual(6); + }); +}); + +describe("sanitizeNlxError", () => { + it("AT-S19-02: suppresses traceback and provides canonical fix for missing_nlx", () => { + const result = sanitizeNlxError("missing_nlx", "", WORKTREE_SHELL); + + expect(result.originalSuppressed).toBe(true); + expect(result.message).toContain("nlx is not installed"); + expect(result.message).toContain("git worktree"); + expect(result.fixCommand).toBe("bash scripts/dev-setup.sh"); + expect(result.context.isWorktree).toBe(true); + }); + + it("AT-S19-02: suppresses Python traceback in stderr for nonzero_exit", () => { + const traceback = + "Traceback (most recent call last):\n" + + ' File "/usr/lib/python3.11/runpy.py", line 198\n' + + "ModuleNotFoundError: No module named 'nextlevelapex'"; + + const result = sanitizeNlxError("nonzero_exit", traceback, WORKTREE_SHELL); + + expect(result.originalSuppressed).toBe(true); + expect(result.message).toContain("Python error"); + expect(result.message).toContain("bash scripts/dev-setup.sh"); + expect(result.message).not.toContain("Traceback"); + expect(result.message).not.toContain("File \"/usr/lib"); + }); + + it("passes through clean stderr without suppression", () => { + const result = sanitizeNlxError("nonzero_exit", "DNS check failed.", NON_WORKTREE_SHELL); + + expect(result.originalSuppressed).toBe(false); + expect(result.message).toBe("DNS check failed."); + }); + + it("AT-S19-04: includes worktree context in sanitized result", () => { + const result = sanitizeNlxError("missing_nlx", "", WORKTREE_SHELL); + + expect(result.context.cwd).toBeDefined(); + expect(result.context.isWorktree).toBe(true); + expect(result.context.interpreterPath).toBe("/usr/bin/python3"); + }); + + it("omits worktree note when not in a worktree", () => { + const result = sanitizeNlxError("missing_nlx", "", NON_WORKTREE_SHELL); + + expect(result.message).not.toContain("worktree"); + expect(result.message).toContain("bash scripts/dev-setup.sh"); + }); +}); diff --git a/dashboard/src/engine/__tests__/worktreeContext.test.ts b/dashboard/src/engine/__tests__/worktreeContext.test.ts new file mode 100644 index 0000000..e8a05a3 --- /dev/null +++ b/dashboard/src/engine/__tests__/worktreeContext.test.ts @@ -0,0 +1,52 @@ +import { detectWorktreeContext, type ShellFn } from "../worktreeContext"; + +describe("detectWorktreeContext", () => { + it("AT-S19-01: detects worktree when git-common-dir differs from git-dir", () => { + const shell: ShellFn = (cmd) => { + if (cmd.includes("--show-toplevel")) return "/home/user/project"; + if (cmd.includes("--git-common-dir")) return "/home/user/project/.git"; + if (cmd.includes("--git-dir")) return "/home/user/.worktrees/project/wt1/.git"; + if (cmd.includes("command -v python3")) return "/usr/bin/python3"; + if (cmd.includes("command -v nlx")) return "/usr/local/bin/nlx"; + return ""; + }; + + const ctx = detectWorktreeContext(shell, "/home/user/.worktrees/project/wt1"); + + expect(ctx.cwd).toBe("/home/user/.worktrees/project/wt1"); + expect(ctx.gitTopLevel).toBe("/home/user/project"); + expect(ctx.isWorktree).toBe(true); + expect(ctx.interpreterPath).toBe("/usr/bin/python3"); + expect(ctx.nlxAvailable).toBe(true); + }); + + it("detects non-worktree when git-common-dir equals git-dir", () => { + const shell: ShellFn = (cmd) => { + if (cmd.includes("--show-toplevel")) return "/home/user/project"; + if (cmd.includes("--git-common-dir")) return ".git"; + if (cmd.includes("--git-dir")) return ".git"; + if (cmd.includes("command -v python3")) return "/usr/bin/python3"; + if (cmd.includes("command -v nlx")) throw new Error("not found"); + if (cmd.includes("command -v python")) return "/usr/bin/python"; + return ""; + }; + + const ctx = detectWorktreeContext(shell, "/home/user/project"); + + expect(ctx.isWorktree).toBe(false); + expect(ctx.nlxAvailable).toBe(false); + }); + + it("handles missing git gracefully", () => { + const shell: ShellFn = () => { + throw new Error("command not found"); + }; + + const ctx = detectWorktreeContext(shell, "/tmp/no-git"); + + expect(ctx.gitTopLevel).toBeNull(); + expect(ctx.isWorktree).toBe(false); + expect(ctx.interpreterPath).toBeNull(); + expect(ctx.nlxAvailable).toBe(false); + }); +}); diff --git a/dashboard/src/engine/nlxErrorSanitizer.ts b/dashboard/src/engine/nlxErrorSanitizer.ts new file mode 100644 index 0000000..e21bacf --- /dev/null +++ b/dashboard/src/engine/nlxErrorSanitizer.ts @@ -0,0 +1,64 @@ +import { detectWorktreeContext, type WorktreeContext, type ShellFn } from "./worktreeContext"; + +/** Patterns that indicate a raw Python traceback in stderr. */ +export const TRACEBACK_PATTERNS: RegExp[] = [ + /Traceback \(most recent call last\)/i, + /^\s+File ".*", line \d+/m, + /ModuleNotFoundError:/, + /ImportError:/, + /FileNotFoundError:.*poetry/i, + /No module named/, +]; + +const CANONICAL_FIX = "bash scripts/dev-setup.sh"; + +export function containsTraceback(stderr: string): boolean { + return TRACEBACK_PATTERNS.some((pattern) => pattern.test(stderr)); +} + +export interface SanitizedError { + message: string; + fixCommand: string; + context: WorktreeContext; + originalSuppressed: boolean; +} + +export function sanitizeNlxError( + errorType: string, + stderr: string, + shell?: ShellFn, +): SanitizedError { + const context = detectWorktreeContext(shell); + const hasTraceback = containsTraceback(stderr); + + if (errorType === "missing_nlx") { + return { + message: + "nlx is not installed or not on PATH." + + (context.isWorktree ? " You are running from a git worktree." : "") + + ` Run: ${CANONICAL_FIX}`, + fixCommand: CANONICAL_FIX, + context, + originalSuppressed: true, + }; + } + + if (hasTraceback) { + return { + message: + "nlx encountered a Python error." + + (context.isWorktree ? " Worktree virtualenvs may need setup." : "") + + ` Run: ${CANONICAL_FIX}`, + fixCommand: CANONICAL_FIX, + context, + originalSuppressed: true, + }; + } + + return { + message: stderr, + fixCommand: CANONICAL_FIX, + context, + originalSuppressed: false, + }; +} diff --git a/dashboard/src/engine/nlxService.ts b/dashboard/src/engine/nlxService.ts index 0e7189c..3db120d 100644 --- a/dashboard/src/engine/nlxService.ts +++ b/dashboard/src/engine/nlxService.ts @@ -6,6 +6,7 @@ import { validateTaskNameFormat, } from "./allowlist"; import { classifyDiagnose, parseDiagnoseLine, type DiagnoseSummary, type HealthBadge } from "./diagnose"; +import { sanitizeNlxError } from "./nlxErrorSanitizer"; import { redactOutput } from "./redaction"; import { runCommandArgv, type CommandErrorType } from "./runner"; import { parseTaskResults, type TaskResult } from "./taskResults"; @@ -69,6 +70,21 @@ async function listTasksInternal(signal?: AbortSignal): Promise<{ taskNames: str } function toResponse(commandId: string, result: Awaited>): NlxCommandResponse { + const rawStderr = redactOutput(result.stderr); + + if (result.exitCode !== 0 && (result.errorType === "missing_nlx" || result.errorType === "nonzero_exit")) { + const sanitized = sanitizeNlxError(result.errorType, result.stderr); + return { + ok: false, + commandId, + exitCode: result.exitCode, + timedOut: result.timedOut, + errorType: result.errorType, + stdout: redactOutput(result.stdout), + stderr: sanitized.originalSuppressed ? sanitized.message : rawStderr, + }; + } + return { ok: result.exitCode === 0, commandId, @@ -76,7 +92,7 @@ function toResponse(commandId: string, result: Awaited string; + +const defaultShell: ShellFn = (cmd) => + execSync(cmd, { encoding: "utf-8", timeout: 3000 }).trim(); + +function safeShell(shell: ShellFn, cmd: string): string | null { + try { + return shell(cmd); + } catch { + return null; + } +} + +export function detectWorktreeContext( + shell: ShellFn = defaultShell, + cwd: string = process.cwd(), +): WorktreeContext { + const gitTopLevel = safeShell(shell, "git rev-parse --show-toplevel"); + + const commonDir = safeShell(shell, "git rev-parse --git-common-dir"); + const gitDir = safeShell(shell, "git rev-parse --git-dir"); + const isWorktree = commonDir !== null && gitDir !== null && commonDir !== gitDir; + + const interpreterPath = + safeShell(shell, "command -v python3") ?? safeShell(shell, "command -v python"); + + const nlxAvailable = safeShell(shell, "command -v nlx") !== null; + + return { cwd, gitTopLevel, isWorktree, interpreterPath, nlxAvailable }; +} diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 5d636f9..4e26425 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -28,7 +28,7 @@ See [milestones.md](milestones.md) for milestone definitions and mapping rules. | S16 | Operator-Grade QA Megasuite | backlog | test | M3 | — | [S16](../sprints/S16/) | | S17 | URL Sync Loop Hardening | done | bug | M3 | [#126](https://github.com/Doogie201/NextLevelApex/pull/126) | [S17](../sprints/S17/) | | S18 | Release Certification v2 | done | chore | M3 | [#128](https://github.com/Doogie201/NextLevelApex/pull/128) | [S18](../sprints/S18/) | -| S19 | Worktree + Poetry Guardrails v2 | backlog | chore | M3 | — | [S19](../sprints/S19/) | +| S19 | Worktree + Poetry Guardrails v2 | in-progress | devops | M3 | — | [S19](../sprints/S19/) | | S20 | Governance: DoD + Stop Conditions | backlog | docs | M4 | — | [S20](../sprints/S20/) | | S21 | Operator Execution Safety System (OESS) | backlog | security | M4 | — | [S21](../sprints/S21/) | | S22 | Backlog Index Layout | done | docs | M1 | [#124](https://github.com/Doogie201/NextLevelApex/pull/124) | [S22](../sprints/S22/) | diff --git a/docs/sprints/README.md b/docs/sprints/README.md index c901a97..100b44d 100644 --- a/docs/sprints/README.md +++ b/docs/sprints/README.md @@ -31,7 +31,7 @@ Quick links to each sprint's documentation folder. | S16 | [S16/](S16/) | backlog | | S17 | [S17/](S17/) | done | | S18 | [S18/](S18/) | done | -| S19 | [S19/](S19/) | backlog | +| S19 | [S19/](S19/) | in-progress | | S20 | [S20/](S20/) | backlog | | S21 | [S21/](S21/) | backlog | | S22 | [S22/](S22/) | done | diff --git a/docs/sprints/S19/README.md b/docs/sprints/S19/README.md index 9a06850..d03fa9a 100644 --- a/docs/sprints/S19/README.md +++ b/docs/sprints/S19/README.md @@ -2,33 +2,73 @@ | Field | Value | |-------|-------| -| Sprint ID | `S19` | +| Sprint ID | `S19-worktree-poetry-guardrails-v2` | | Name | Worktree + Poetry Guardrails v2 | -| Status | backlog | -| Category | chore | +| Status | in-progress | +| Category | devops | | Milestone | M3 | -| Baseline SHA | — | -| Branch | — | +| Baseline SHA | `339338f` | +| Branch | `sprint/S19-worktree-poetry-guardrails-v2` | | PR | — | ## Objective -Extend S08 guardrails with automated detection of stale venvs, cross-worktree dependency drift, and CI enforcement. +Make diagnose work from any git worktree by capturing invocation context (cwd/interpreter/env), replacing raw tracebacks with a controlled message + one canonical fix path, and optionally offering a safe setup button that requires explicit confirmation; ensure dev setup is idempotent and offline-friendly. -## Work Plan / Scope +## Approach -TBD — to be defined at sprint start. +1. **`worktreeContext.ts`** — DI-testable module that detects git worktree status, Python interpreter path, and nlx availability via shell commands. +2. **`nlxErrorSanitizer.ts`** — Intercepts raw Python tracebacks and `missing_nlx` errors, replacing them with controlled messages containing exactly one canonical remediation: `bash scripts/dev-setup.sh`. +3. **`nlxService.ts` wiring** — The `toResponse()` function routes error states through the sanitizer before returning to the API layer. +4. **`scripts/dev-setup.sh` hardening** — Worktree detection, `--offline` flag, context logging. -## Acceptance Tests +## Work Plan -- [ ] AT-S19-01 TBD +1. **Create `worktreeContext.ts`** — DI-testable context detection (cwd, gitTopLevel, isWorktree, interpreterPath, nlxAvailable) +2. **Create `nlxErrorSanitizer.ts`** — Traceback pattern matching + controlled message generation +3. **Wire sanitizer into `nlxService.ts`** — Replace raw stderr on `missing_nlx` and traceback-containing `nonzero_exit` +4. **Harden `scripts/dev-setup.sh`** — Worktree detection, `--offline` flag, idempotency +5. **Write unit tests** — DI fixtures for worktree context, traceback suppression, passthrough -## Evidence Paths +## Acceptance Tests -No evidence yet (backlog). +- [x] **AT-S19-01** — Worktree context detection: `detectWorktreeContext()` returns `{cwd, gitTopLevel, isWorktree, interpreterPath, nlxAvailable}` with deterministic values via DI shell mock. Worktree detected when `git-common-dir` differs from `git-dir`. +- [x] **AT-S19-02** — Traceback suppression: `sanitizeNlxError()` returns controlled message with exactly one canonical fix path (`bash scripts/dev-setup.sh`) when stderr contains Python traceback patterns or errorType is `missing_nlx`. No raw stack frames leak. +- [x] **AT-S19-03** — Dev-setup is idempotent and offline-friendly: `scripts/dev-setup.sh` detects worktree context, supports `--offline` flag to skip network-dependent steps, and exits 0 on repeated runs. +- [x] **AT-S19-04** — Error context attached: sanitized error includes `WorktreeContext` object with `isWorktree`, `interpreterPath`, and `nlxAvailable` fields. API envelope receives controlled stderr instead of raw traceback. ## Definition of Done -- [ ] All ATs pass with receipts. -- [ ] Gates pass (build/lint/test EXIT 0). -- [ ] PR merged via squash merge. +- All 4 ATs checked +- No raw Python tracebacks in error responses +- Tests: 195 passed (42 files) +- Lint: clean +- Build: clean (`/` is `○ Static`) +- No files outside whitelist touched +- Maintainability budgets within limits + +## Traceback Patterns (suppressed) + +``` +Traceback (most recent call last) +File "...", line N +ModuleNotFoundError: +ImportError: +FileNotFoundError:.*poetry +No module named +``` + +## Evidence + +See `docs/sprints/S19/evidence/` for JSON receipts. + +## Files Touched + +| File | Before | After | Net New | +|------|--------|-------|---------| +| `dashboard/src/engine/worktreeContext.ts` | 0 | 40 | +40 (new) | +| `dashboard/src/engine/nlxErrorSanitizer.ts` | 0 | 64 | +64 (new) | +| `dashboard/src/engine/nlxService.ts` | 129 | 144 | +15 | +| `dashboard/src/engine/__tests__/worktreeContext.test.ts` | 0 | 52 | +52 (new) | +| `dashboard/src/engine/__tests__/nlxErrorSanitizer.test.ts` | 0 | 94 | +94 (new) | +| `scripts/dev-setup.sh` | 34 | 63 | +29 | diff --git a/docs/sprints/S19/evidence/at-s19-01-worktree-context.json b/docs/sprints/S19/evidence/at-s19-01-worktree-context.json new file mode 100644 index 0000000..c268cc1 --- /dev/null +++ b/docs/sprints/S19/evidence/at-s19-01-worktree-context.json @@ -0,0 +1,21 @@ +{ + "AT": "AT-S19-01", + "description": "Worktree context detection via DI shell mock", + "test": { + "name": "AT-S19-01: detects worktree when git-common-dir differs from git-dir", + "file": "dashboard/src/engine/__tests__/worktreeContext.test.ts", + "assertions": [ + "ctx.cwd equals injected cwd", + "ctx.gitTopLevel equals mocked --show-toplevel", + "ctx.isWorktree === true (common-dir !== git-dir)", + "ctx.interpreterPath equals mocked python3 path", + "ctx.nlxAvailable === true (command -v nlx succeeds)" + ], + "result": "PASSED" + }, + "supplementary_tests": [ + "detects non-worktree when git-common-dir equals git-dir", + "handles missing git gracefully (all null/false)" + ], + "verdict": "PASS" +} diff --git a/docs/sprints/S19/evidence/at-s19-02-traceback-suppression.json b/docs/sprints/S19/evidence/at-s19-02-traceback-suppression.json new file mode 100644 index 0000000..9ddb4b5 --- /dev/null +++ b/docs/sprints/S19/evidence/at-s19-02-traceback-suppression.json @@ -0,0 +1,45 @@ +{ + "AT": "AT-S19-02", + "description": "Traceback suppression: raw Python stack frames replaced with controlled message + canonical fix", + "tests": [ + { + "name": "AT-S19-02: suppresses traceback and provides canonical fix for missing_nlx", + "assertions": [ + "result.originalSuppressed === true", + "result.message contains 'nlx is not installed'", + "result.message contains 'git worktree' (when in worktree)", + "result.fixCommand === 'bash scripts/dev-setup.sh'" + ], + "result": "PASSED" + }, + { + "name": "AT-S19-02: suppresses Python traceback in stderr for nonzero_exit", + "assertions": [ + "result.originalSuppressed === true", + "result.message contains 'Python error'", + "result.message contains 'bash scripts/dev-setup.sh'", + "result.message does NOT contain 'Traceback'", + "result.message does NOT contain 'File \"/usr/lib'" + ], + "result": "PASSED" + }, + { + "name": "passes through clean stderr without suppression", + "assertions": [ + "result.originalSuppressed === false", + "result.message === original stderr" + ], + "result": "PASSED" + } + ], + "traceback_patterns": [ + "Traceback (most recent call last)", + "File \"...\", line N", + "ModuleNotFoundError:", + "ImportError:", + "FileNotFoundError:.*poetry", + "No module named" + ], + "canonical_fix": "bash scripts/dev-setup.sh", + "verdict": "PASS" +} diff --git a/docs/sprints/S19/evidence/at-s19-03-dev-setup.json b/docs/sprints/S19/evidence/at-s19-03-dev-setup.json new file mode 100644 index 0000000..8bcc4dc --- /dev/null +++ b/docs/sprints/S19/evidence/at-s19-03-dev-setup.json @@ -0,0 +1,30 @@ +{ + "AT": "AT-S19-03", + "description": "Dev-setup script is idempotent, worktree-aware, and offline-friendly", + "changes": { + "before": { + "lines": 34, + "features": ["poetry install", "npm ci", "idempotent"] + }, + "after": { + "lines": 63, + "features": [ + "poetry install", + "npm ci", + "idempotent", + "worktree detection (git-common-dir vs git-dir)", + "--offline flag skips network-dependent steps", + "context logging (repo root, worktree status, offline mode)" + ] + } + }, + "offline_flag": { + "usage": "bash scripts/dev-setup.sh --offline", + "behavior": "Skips poetry install and npm ci; exits 0" + }, + "worktree_detection": { + "method": "git rev-parse --git-common-dir vs --git-dir", + "output": "IS_WORKTREE=true/false logged at startup" + }, + "verdict": "PASS" +} diff --git a/docs/sprints/S19/evidence/at-s19-04-error-context.json b/docs/sprints/S19/evidence/at-s19-04-error-context.json new file mode 100644 index 0000000..9fdc24d --- /dev/null +++ b/docs/sprints/S19/evidence/at-s19-04-error-context.json @@ -0,0 +1,20 @@ +{ + "AT": "AT-S19-04", + "description": "Error context attached: sanitized error includes WorktreeContext; API envelope receives controlled stderr", + "test": { + "name": "AT-S19-04: includes worktree context in sanitized result", + "file": "dashboard/src/engine/__tests__/nlxErrorSanitizer.test.ts", + "assertions": [ + "result.context.cwd is defined", + "result.context.isWorktree === true", + "result.context.interpreterPath === '/usr/bin/python3'" + ], + "result": "PASSED" + }, + "integration_wiring": { + "file": "dashboard/src/engine/nlxService.ts", + "function": "toResponse()", + "behavior": "When exitCode !== 0 and errorType is missing_nlx or nonzero_exit, stderr is replaced with sanitized.message if originalSuppressed is true" + }, + "verdict": "PASS" +} diff --git a/docs/sprints/S19/evidence/gates.json b/docs/sprints/S19/evidence/gates.json new file mode 100644 index 0000000..aaf0681 --- /dev/null +++ b/docs/sprints/S19/evidence/gates.json @@ -0,0 +1,24 @@ +{ + "gates": { + "test": { "result": "PASS", "files": 42, "tests": 195, "exit_code": 0 }, + "lint": { "result": "PASS", "exit_code": 0 }, + "build": { "result": "PASS", "route_slash": "○ (Static)", "exit_code": 0 } + }, + "maintainability": { + "nlxService.ts": { "before": 129, "after": 144, "net_new": 15, "budget": 120, "within": true }, + "dev-setup.sh": { "before": 34, "after": 63, "net_new": 29, "budget": 120, "within": true }, + "worktreeContext.ts": { "before": 0, "after": 40, "new_file": true }, + "nlxErrorSanitizer.ts": { "before": 0, "after": 64, "new_file": true } + }, + "scope": { + "files_touched": [ + "dashboard/src/engine/worktreeContext.ts", + "dashboard/src/engine/nlxErrorSanitizer.ts", + "dashboard/src/engine/nlxService.ts", + "dashboard/src/engine/__tests__/worktreeContext.test.ts", + "dashboard/src/engine/__tests__/nlxErrorSanitizer.test.ts", + "scripts/dev-setup.sh" + ], + "all_within_whitelist": true + } +} diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh index 5042fd1..3735b75 100755 --- a/scripts/dev-setup.sh +++ b/scripts/dev-setup.sh @@ -5,26 +5,56 @@ # Installs Python (Poetry) and dashboard (npm) dependencies. # # Usage: -# bash scripts/dev-setup.sh +# bash scripts/dev-setup.sh # full setup (network required for first run) +# bash scripts/dev-setup.sh --offline # skip network-dependent steps set -euo pipefail -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OFFLINE=false +for arg in "$@"; do + case "$arg" in + --offline) OFFLINE=true ;; + esac +done + +# Resolve repo root even inside a git worktree +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Detect worktree context +IS_WORKTREE=false +if command -v git >/dev/null 2>&1; then + GIT_COMMON="$(git -C "$REPO_ROOT" rev-parse --git-common-dir 2>/dev/null || true)" + GIT_DIR="$(git -C "$REPO_ROOT" rev-parse --git-dir 2>/dev/null || true)" + if [ -n "$GIT_COMMON" ] && [ -n "$GIT_DIR" ] && [ "$GIT_COMMON" != "$GIT_DIR" ]; then + IS_WORKTREE=true + fi +fi echo "=== NextLevelApex dev-setup ===" -echo "Repo root: $REPO_ROOT" +echo "Repo root: $REPO_ROOT" +echo "Worktree: $IS_WORKTREE" +echo "Offline: $OFFLINE" # 1) Poetry: install Python deps + register nlx entrypoint echo "" -echo "[1/2] Installing Python dependencies (poetry install)..." -cd "$REPO_ROOT" -poetry install -echo " Poetry install complete." +if [ "$OFFLINE" = true ]; then + echo "[1/2] Skipping Poetry install (--offline)." +else + echo "[1/2] Installing Python dependencies (poetry install)..." + cd "$REPO_ROOT" + poetry install + echo " Poetry install complete." +fi # 2) Dashboard: install Node dependencies echo "" -echo "[2/2] Installing dashboard dependencies (npm ci)..." -npm --prefix "$REPO_ROOT/dashboard" ci -echo " Dashboard install complete." +if [ "$OFFLINE" = true ]; then + echo "[2/2] Skipping npm ci (--offline)." +else + echo "[2/2] Installing dashboard dependencies (npm ci)..." + npm --prefix "$REPO_ROOT/dashboard" ci + echo " Dashboard install complete." +fi # Summary echo "" From b0ac36f16c0f89c4986f219f93f52ae333f38099 Mon Sep 17 00:00:00 2001 From: Doogie201 Date: Wed, 25 Feb 2026 07:30:57 -0500 Subject: [PATCH 2/4] chore(s19): add preflight prune + worktree sanity check receipts Durable evidence for all 7 required preflight commands: - git fetch --all --prune --tags - git worktree list --porcelain - git rev-parse --show-toplevel - git rev-parse --is-inside-work-tree - git rev-parse --abbrev-ref HEAD - git status --porcelain=v1 --branch - git branch -vv Co-Authored-By: Claude --- .../abbrev-ref-head.json | 9 ++++ .../preflight_worktree_prune/branch-vv.json | 54 +++++++++++++++++++ .../preflight_worktree_prune/fetch-prune.json | 7 +++ .../is-inside-work-tree.json | 5 ++ .../show-toplevel.json | 5 ++ .../status-porcelain.json | 6 +++ .../worktree-list.json | 14 +++++ 7 files changed, 100 insertions(+) create mode 100644 docs/sprints/S19/evidence/preflight_worktree_prune/abbrev-ref-head.json create mode 100644 docs/sprints/S19/evidence/preflight_worktree_prune/branch-vv.json create mode 100644 docs/sprints/S19/evidence/preflight_worktree_prune/fetch-prune.json create mode 100644 docs/sprints/S19/evidence/preflight_worktree_prune/is-inside-work-tree.json create mode 100644 docs/sprints/S19/evidence/preflight_worktree_prune/show-toplevel.json create mode 100644 docs/sprints/S19/evidence/preflight_worktree_prune/status-porcelain.json create mode 100644 docs/sprints/S19/evidence/preflight_worktree_prune/worktree-list.json diff --git a/docs/sprints/S19/evidence/preflight_worktree_prune/abbrev-ref-head.json b/docs/sprints/S19/evidence/preflight_worktree_prune/abbrev-ref-head.json new file mode 100644 index 0000000..2a57071 --- /dev/null +++ b/docs/sprints/S19/evidence/preflight_worktree_prune/abbrev-ref-head.json @@ -0,0 +1,9 @@ +{ + "command": "bash -lc \"git rev-parse --abbrev-ref HEAD\"", + "output": "sprint/S19-worktree-poetry-guardrails-v2", + "EXIT_CODE": 0, + "validation": { + "sprint_regex": "^sprint/S19-", + "match": true + } +} diff --git a/docs/sprints/S19/evidence/preflight_worktree_prune/branch-vv.json b/docs/sprints/S19/evidence/preflight_worktree_prune/branch-vv.json new file mode 100644 index 0000000..75bd253 --- /dev/null +++ b/docs/sprints/S19/evidence/preflight_worktree_prune/branch-vv.json @@ -0,0 +1,54 @@ +{ + "command": "bash -lc \"git branch -vv | sed -n '1,120p'\"", + "output": [ + " chore/s17-evidence-durability-and-playwright-note 649dd73 [origin/chore/s17-evidence-durability-and-playwright-note: gone] chore: persist S17 cert evidence + document Playwright substitution", + " chore/s24-done ca896c0 [origin/chore/s24-done: gone] chore: mark S24 done after PR #130 merge", + " codex/gui-audit-existing e36e276 feat(dns): add one-line diagnose summary command (#75)", + " codex/gui-canonicalization-v1 c3ef706 [origin/codex/gui-canonicalization-v1: gone] gui: canonicalize dashboard with read-only nlx bridge", + " codex/gui-phase1-scaffold c39968e [origin/codex/gui-phase1-scaffold] fix(gui): move bridge modules to tracked src/engine", + " codex/gui-phase10-operator-ux 62806dc [origin/codex/gui-phase10-operator-ux: gone] feat(gui): harden operator empty states and console diagnostics", + " codex/gui-phase11-timeline-compare 05619f2 [origin/codex/gui-phase11-timeline-compare: gone] feat(gui): timeline intelligence and session comparison", + " codex/gui-phase12-presets 797acf7 [origin/codex/gui-phase12-presets: gone] feat(gui): command runner presets and repeatable runs", + " codex/gui-phase13-run-center 4652fb3 [origin/codex/gui-phase13-run-center: gone] feat(gui): guided run flow and safety rails", + " codex/gui-phase14-saved-views 0c7aa2c [origin/codex/gui-phase14-saved-views: gone] feat(gui): saved views and workspace layout", + " codex/gui-phase15-bundle-export a5de4c8 [origin/codex/gui-phase15-bundle-export: gone] feat(gui): preset and session share-safe bundle export", + " codex/gui-phase16-bundle-import 6d9b9ce [origin/codex/gui-phase16-bundle-import: gone] feat(gui): bundle import validation and rehydrate", + " codex/gui-phase17-bundle-explorer e9e93c9 [origin/codex/gui-phase17-bundle-explorer: gone] chore(repo): ignore root vite cache directory", + " codex/gui-phase18-run-history-replay bda05f3 [origin/codex/gui-phase18-run-history-replay: gone] feat(gui): run history and replay (share-safe)", + " codex/gui-phase19-run-history-ux-hardening 13aefda [origin/codex/gui-phase19-run-history-ux-hardening: gone] feat(gui): run history UX hardening (operator-grade)", + " codex/gui-phase2-readonly-outputviewer c06f643 [origin/codex/gui-phase2-readonly-outputviewer: gone] feat(gui): remove mutate route and add read-only output viewer", + " codex/gui-phase20-run-details-share-safe 7866169 [origin/codex/gui-phase20-run-details-share-safe: gone] feat(gui): run details + share-safe export (operator-grade)", + " codex/gui-phase21-run-history-persistence 2d84f83 [origin/codex/gui-phase21-run-history-persistence: gone] feat(gui): durable run history persistence (share-safe)", + " codex/gui-phase22-run-history-search-filter 12a7088 [origin/codex/gui-phase22-run-history-search-filter: gone] feat(gui): run history search + filters (operator-grade)", + " codex/gui-phase23-run-compare-diff d90e02f [origin/codex/gui-phase23-run-compare-diff: gone] feat(gui): compare runs with share-safe diff (operator-grade)", + " codex/gui-phase24-case-bundle-export-import a572da7 [origin/codex/gui-phase24-case-bundle-export-import: gone] feat(gui): share-safe case bundle export/import (operator-grade)", + " codex/gui-phase25-case-library-notes b9a6983 [origin/codex/gui-phase25-case-library-notes: gone] feat(gui): case library + private notes (operator-grade)", + " codex/gui-phase26-export-preview-provenance c66adcf [origin/codex/gui-phase26-export-preview-provenance: gone] feat(gui): export preview + provenance (operator-grade)", + " codex/gui-phase27-page-tsx-decomposition 6015311 [origin/codex/gui-phase27-page-tsx-decomposition: gone] refactor(gui): decompose app page entry and modal sections", + " codex/gui-phase28-homepage-decomposition-ceilings 7400ae8 [origin/codex/gui-phase28-homepage-decomposition-ceilings: gone] [S01] nav : nav history back/forward determinism", + " codex/gui-phase3-premium-ux 22c902e [origin/codex/gui-phase3-premium-ux: gone] feat(gui): upgrade premium read-only observability workspace", + " codex/gui-phase4-ci-gates 1abc212 [origin/codex/gui-phase4-ci-gates: gone] ci(gui): add dashboard test/build/lint gates", + " codex/gui-phase5a-doc-artifacts 3c6c91b [origin/codex/gui-phase5a-doc-artifacts: gone] docs(gui): add phase5a screenshots and output viewer notes", + " codex/gui-phase5a-output-polish 8346d17 [origin/codex/gui-phase5a-output-polish: gone] feat(gui): polish read-only output timeline and exports", + " codex/gui-phase5b-security-hardening 629ec0c [origin/codex/gui-phase5b-security-hardening: gone] hardening(gui): enforce single-flight runs and localhost safety warnings", + " codex/gui-phase5c-contract-determinism 999f905 [origin/codex/gui-phase5c-contract-determinism: gone] hardening(gui): deterministic /api/nlx/run response envelope", + " codex/gui-phase6b-a11y-keyboard 8b29334 [origin/codex/gui-phase6b-a11y-keyboard: gone] feat(gui): a11y keyboard navigation and reduced motion", + " codex/gui-phase7-deeplinks-state 89df1a3 [origin/codex/gui-phase7-deeplinks-state: gone] feat(gui): deep links and state restoration", + " codex/gui-phase8-tasks-polish b755b9d [origin/codex/gui-phase8-tasks-polish: gone] feat(gui): tasks view polish and keyboard selection", + " codex/gui-phase9-sessions-export cc9a778 [origin/codex/gui-phase9-sessions-export: gone] feat(gui): run sessions and unified export center", + " main 339338f [origin/main] chore: mark S24 done after PR #130 merge (#131)", + " quizzical-wilbur aa79c67 [S05] ux : export import mvp (#112)", + " sprint/S02-run-exec-lifecycle f43aa6a [origin/sprint/S02-run-exec-lifecycle: gone] [S02] chore : sprint 2 evidence-only closeout", + " sprint/S03-run-history-replay-mvp 5910b3f [origin/sprint/S03-run-history-replay-mvp: gone] [S03] ux : run history replay mvp closeout", + "+ sprint/S06-hardening-operator-polish aa79c67 (/Users/marcussmith/Projects/NextLevelApex) [S05] ux : export import mvp (#112)", + " sprint/S06-hardening-operator-polish-wt 7c676d2 [origin/sprint/S06-hardening-operator-polish-wt: gone] [S06] docs : add sprint log (evidence-only)", + " sprint/S07-tooltip-system-global-coverage 4e14d71 [origin/sprint/S07-tooltip-system-global-coverage: gone] [S07] ux : global tooltips (hover + focus parity)", + " sprint/S08-dev-setup-guardrails 2c247cd [origin/sprint/S08-dev-setup-guardrails: gone] [S08] docs : dev setup guardrails (poetry + worktrees)", + "* sprint/S19-worktree-poetry-guardrails-v2 2954696 [origin/sprint/S19-worktree-poetry-guardrails-v2] [S19] devops : worktree + poetry guardrails v2", + " sprint/S22-backlog-index-layout 12ac8b3 [origin/sprint/S22-backlog-index-layout: gone] chore: trigger CI re-run for codecov status check", + " sprint/S23-governance-gates-v1 40fe3c6 [origin/sprint/S23-governance-gates-v1: gone] [S23] devops : governance gates (codecov + automation exception)", + " sprint/S24-s18-validation 8d99498 [origin/sprint/S24-s18-validation: gone] fix(cert): fail-closed log collection when serverLogPath is unreadable" + ], + "EXIT_CODE": 0, + "notes": "47 local branches total; 41 tracking gone upstreams (orphaned). Current sprint branch (*) is sprint/S19-worktree-poetry-guardrails-v2, tracking remote in sync." +} diff --git a/docs/sprints/S19/evidence/preflight_worktree_prune/fetch-prune.json b/docs/sprints/S19/evidence/preflight_worktree_prune/fetch-prune.json new file mode 100644 index 0000000..ae99331 --- /dev/null +++ b/docs/sprints/S19/evidence/preflight_worktree_prune/fetch-prune.json @@ -0,0 +1,7 @@ +{ + "command": "bash -lc \"git fetch --all --prune --tags\"", + "output": "", + "EXIT_CODE": 0, + "timestamp": "2026-02-25T00:00:00Z", + "notes": "Fetch completed; no new refs or pruned refs (already pruned in prior session)" +} diff --git a/docs/sprints/S19/evidence/preflight_worktree_prune/is-inside-work-tree.json b/docs/sprints/S19/evidence/preflight_worktree_prune/is-inside-work-tree.json new file mode 100644 index 0000000..c890999 --- /dev/null +++ b/docs/sprints/S19/evidence/preflight_worktree_prune/is-inside-work-tree.json @@ -0,0 +1,5 @@ +{ + "command": "bash -lc \"git rev-parse --is-inside-work-tree\"", + "output": "true", + "EXIT_CODE": 0 +} diff --git a/docs/sprints/S19/evidence/preflight_worktree_prune/show-toplevel.json b/docs/sprints/S19/evidence/preflight_worktree_prune/show-toplevel.json new file mode 100644 index 0000000..9c73d43 --- /dev/null +++ b/docs/sprints/S19/evidence/preflight_worktree_prune/show-toplevel.json @@ -0,0 +1,5 @@ +{ + "command": "bash -lc \"git rev-parse --show-toplevel\"", + "output": "/Users/marcussmith/.claude-worktrees/NextLevelApex/quizzical-wilbur", + "EXIT_CODE": 0 +} diff --git a/docs/sprints/S19/evidence/preflight_worktree_prune/status-porcelain.json b/docs/sprints/S19/evidence/preflight_worktree_prune/status-porcelain.json new file mode 100644 index 0000000..0cfd434 --- /dev/null +++ b/docs/sprints/S19/evidence/preflight_worktree_prune/status-porcelain.json @@ -0,0 +1,6 @@ +{ + "command": "bash -lc \"git status --porcelain=v1 --branch\"", + "output": "## sprint/S19-worktree-poetry-guardrails-v2...origin/sprint/S19-worktree-poetry-guardrails-v2", + "EXIT_CODE": 0, + "notes": "Clean working tree, tracking remote branch, in sync" +} diff --git a/docs/sprints/S19/evidence/preflight_worktree_prune/worktree-list.json b/docs/sprints/S19/evidence/preflight_worktree_prune/worktree-list.json new file mode 100644 index 0000000..697d090 --- /dev/null +++ b/docs/sprints/S19/evidence/preflight_worktree_prune/worktree-list.json @@ -0,0 +1,14 @@ +{ + "command": "bash -lc \"git worktree list --porcelain\"", + "output": [ + "worktree /Users/marcussmith/Projects/NextLevelApex", + "HEAD aa79c671d2f843856ecc720b0c2ae336de30cd3b", + "branch refs/heads/sprint/S06-hardening-operator-polish", + "", + "worktree /Users/marcussmith/.claude-worktrees/NextLevelApex/quizzical-wilbur", + "HEAD 295469644ba51db2bea69707f4de13c23670661a", + "branch refs/heads/sprint/S19-worktree-poetry-guardrails-v2" + ], + "EXIT_CODE": 0, + "notes": "Two worktrees: main repo (pinned to stale S06 branch) and this worktree (S19 sprint branch)" +} From 283bb716a72c7c5bda08c1ec71ea6bd27a1897fa Mon Sep 17 00:00:00 2001 From: Doogie201 Date: Wed, 25 Feb 2026 09:19:23 -0500 Subject: [PATCH 3/4] chore(s19): add primary worktree preflight receipts - New receipt: primary-worktree.json with all 8 required commands from ~/Projects/NextLevelApex (now on main, synced) - Updated worktree-list.json to reflect corrected primary worktree state (main @ 339338f, no longer stale S06 branch) Co-Authored-By: Claude --- .../primary-worktree.json | 63 +++++++++++++++++++ .../worktree-list.json | 8 +-- 2 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 docs/sprints/S19/evidence/preflight_worktree_prune/primary-worktree.json diff --git a/docs/sprints/S19/evidence/preflight_worktree_prune/primary-worktree.json b/docs/sprints/S19/evidence/preflight_worktree_prune/primary-worktree.json new file mode 100644 index 0000000..f19dfa5 --- /dev/null +++ b/docs/sprints/S19/evidence/preflight_worktree_prune/primary-worktree.json @@ -0,0 +1,63 @@ +{ + "description": "Primary worktree preflight receipts — ~/Projects/NextLevelApex", + "timestamp": "2026-02-25", + "hard_stop_check": "PRIMARY WORKTREE ON MAIN AND SYNCED — CLEARED", + "commands": [ + { + "command": "bash -lc \"cd ~/Projects/NextLevelApex && pwd\"", + "output": "/Users/marcussmith/Projects/NextLevelApex", + "EXIT_CODE": 0 + }, + { + "command": "bash -lc \"cd ~/Projects/NextLevelApex && git rev-parse --show-toplevel\"", + "output": "/Users/marcussmith/Projects/NextLevelApex", + "EXIT_CODE": 0 + }, + { + "command": "bash -lc \"cd ~/Projects/NextLevelApex && git worktree list --porcelain\"", + "output": [ + "worktree /Users/marcussmith/Projects/NextLevelApex", + "HEAD 339338fc6126584ddc3f4c4b9f16cd387a4fff14", + "branch refs/heads/main", + "", + "worktree /Users/marcussmith/.claude-worktrees/NextLevelApex/quizzical-wilbur", + "HEAD b0ac36f16c0f89c4986f219f93f52ae333f38099", + "branch refs/heads/sprint/S19-worktree-poetry-guardrails-v2" + ], + "EXIT_CODE": 0 + }, + { + "command": "bash -lc \"cd ~/Projects/NextLevelApex && git status --porcelain=v1 --branch\"", + "output": "## main...origin/main", + "EXIT_CODE": 0, + "interpretation": "Clean working tree, tracking origin/main, in sync" + }, + { + "command": "bash -lc \"cd ~/Projects/NextLevelApex && git fetch --all --prune --tags\"", + "output": "(no output — already up to date)", + "EXIT_CODE": 0 + }, + { + "command": "bash -lc \"cd ~/Projects/NextLevelApex && git rev-list --left-right --count HEAD...origin/main\"", + "output": "0\t0", + "EXIT_CODE": 0, + "interpretation": "HEAD == origin/main (IN_SYNC)" + }, + { + "command": "bash -lc \"cd ~/Projects/NextLevelApex && git rev-parse --abbrev-ref HEAD\"", + "output": "main", + "EXIT_CODE": 0 + }, + { + "command": "bash -lc \"cd ~/Projects/NextLevelApex && git log --oneline -5\"", + "output": [ + "339338f chore: mark S24 done after PR #130 merge (#131)", + "51497b7 [S24] test : s18 cert validation hardening (harness-based) (#130)", + "17371f5 chore: mark S18 done after PR #128 merge (#129)", + "6ef500f [S18] chore : release certification v2 (#128)", + "c21bf51 chore: persist S17 cert evidence + document Playwright substitution (#127)" + ], + "EXIT_CODE": 0 + } + ] +} diff --git a/docs/sprints/S19/evidence/preflight_worktree_prune/worktree-list.json b/docs/sprints/S19/evidence/preflight_worktree_prune/worktree-list.json index 697d090..3666dd0 100644 --- a/docs/sprints/S19/evidence/preflight_worktree_prune/worktree-list.json +++ b/docs/sprints/S19/evidence/preflight_worktree_prune/worktree-list.json @@ -2,13 +2,13 @@ "command": "bash -lc \"git worktree list --porcelain\"", "output": [ "worktree /Users/marcussmith/Projects/NextLevelApex", - "HEAD aa79c671d2f843856ecc720b0c2ae336de30cd3b", - "branch refs/heads/sprint/S06-hardening-operator-polish", + "HEAD 339338fc6126584ddc3f4c4b9f16cd387a4fff14", + "branch refs/heads/main", "", "worktree /Users/marcussmith/.claude-worktrees/NextLevelApex/quizzical-wilbur", - "HEAD 295469644ba51db2bea69707f4de13c23670661a", + "HEAD b0ac36f16c0f89c4986f219f93f52ae333f38099", "branch refs/heads/sprint/S19-worktree-poetry-guardrails-v2" ], "EXIT_CODE": 0, - "notes": "Two worktrees: main repo (pinned to stale S06 branch) and this worktree (S19 sprint branch)" + "notes": "Two worktrees: primary (~/Projects/NextLevelApex on main, synced) and sprint worktree (S19 branch)" } From c00dc455429bdd86d5eba0703f1ea6ddfdc4da28 Mon Sep 17 00:00:00 2001 From: Doogie201 Date: Wed, 25 Feb 2026 09:45:08 -0500 Subject: [PATCH 4/4] fix(s19): normalize git paths to prevent worktree false-positive detectWorktreeContext() compared git-common-dir and git-dir outputs as raw strings. When invoked from a subdirectory, git may return these in different formats (relative ".git" vs absolute "/path/to/project/.git"), causing a false isWorktree=true. Fix: normalizeGitPath() resolves both paths to absolute form via path.resolve() + fs.realpathSync (with path.normalize fallback), then compares the normalized results. New test: verifies no false worktree when relative and absolute paths resolve to the same .git directory. Co-Authored-By: Claude --- .../engine/__tests__/worktreeContext.test.ts | 43 ++++++++++++-- dashboard/src/engine/worktreeContext.ts | 21 ++++++- docs/sprints/S19/README.md | 12 ++-- .../evidence/worktree-false-positive-fix.json | 58 +++++++++++++++++++ 4 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 docs/sprints/S19/evidence/worktree-false-positive-fix.json diff --git a/dashboard/src/engine/__tests__/worktreeContext.test.ts b/dashboard/src/engine/__tests__/worktreeContext.test.ts index e8a05a3..811e00b 100644 --- a/dashboard/src/engine/__tests__/worktreeContext.test.ts +++ b/dashboard/src/engine/__tests__/worktreeContext.test.ts @@ -1,11 +1,28 @@ -import { detectWorktreeContext, type ShellFn } from "../worktreeContext"; +import { + detectWorktreeContext, + normalizeGitPath, + type ShellFn, +} from "../worktreeContext"; + +describe("normalizeGitPath", () => { + it("resolves relative path against base", () => { + const result = normalizeGitPath(".git", "/home/user/project"); + expect(result).toContain("home/user/project/.git"); + }); + + it("returns absolute path unchanged (modulo normalize)", () => { + const result = normalizeGitPath("/home/user/project/.git", "/ignored"); + expect(result).toContain("home/user/project/.git"); + }); +}); describe("detectWorktreeContext", () => { - it("AT-S19-01: detects worktree when git-common-dir differs from git-dir", () => { + it("AT-S19-01: detects worktree when git-common-dir differs from git-dir (after normalization)", () => { const shell: ShellFn = (cmd) => { if (cmd.includes("--show-toplevel")) return "/home/user/project"; if (cmd.includes("--git-common-dir")) return "/home/user/project/.git"; - if (cmd.includes("--git-dir")) return "/home/user/.worktrees/project/wt1/.git"; + if (cmd.includes("--git-dir")) + return "/home/user/.worktrees/project/wt1/.git"; if (cmd.includes("command -v python3")) return "/usr/bin/python3"; if (cmd.includes("command -v nlx")) return "/usr/local/bin/nlx"; return ""; @@ -20,7 +37,25 @@ describe("detectWorktreeContext", () => { expect(ctx.nlxAvailable).toBe(true); }); - it("detects non-worktree when git-common-dir equals git-dir", () => { + it("AT-S19-01: no false worktree when relative and absolute paths resolve to the same .git", () => { + // Simulates running from a subdirectory of a normal checkout where + // git-common-dir returns ".git" (relative) and git-dir returns the + // absolute path to the same .git directory. + const shell: ShellFn = (cmd) => { + if (cmd.includes("--show-toplevel")) return "/home/user/project"; + if (cmd.includes("--git-common-dir")) return ".git"; + if (cmd.includes("--git-dir")) return "/home/user/project/.git"; + if (cmd.includes("command -v python3")) return "/usr/bin/python3"; + if (cmd.includes("command -v nlx")) return "/usr/local/bin/nlx"; + return ""; + }; + + const ctx = detectWorktreeContext(shell, "/home/user/project/src"); + + expect(ctx.isWorktree).toBe(false); + }); + + it("detects non-worktree when git-common-dir equals git-dir (both relative)", () => { const shell: ShellFn = (cmd) => { if (cmd.includes("--show-toplevel")) return "/home/user/project"; if (cmd.includes("--git-common-dir")) return ".git"; diff --git a/dashboard/src/engine/worktreeContext.ts b/dashboard/src/engine/worktreeContext.ts index 515715c..082e2f7 100644 --- a/dashboard/src/engine/worktreeContext.ts +++ b/dashboard/src/engine/worktreeContext.ts @@ -1,4 +1,6 @@ import { execSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { normalize, resolve } from "node:path"; export interface WorktreeContext { cwd: string; @@ -21,6 +23,16 @@ function safeShell(shell: ShellFn, cmd: string): string | null { } } +/** Resolve a possibly-relative git path to an absolute normalized form. */ +export function normalizeGitPath(raw: string, base: string): string { + const abs = resolve(base, raw); + try { + return realpathSync(abs); + } catch { + return normalize(abs); + } +} + export function detectWorktreeContext( shell: ShellFn = defaultShell, cwd: string = process.cwd(), @@ -29,7 +41,14 @@ export function detectWorktreeContext( const commonDir = safeShell(shell, "git rev-parse --git-common-dir"); const gitDir = safeShell(shell, "git rev-parse --git-dir"); - const isWorktree = commonDir !== null && gitDir !== null && commonDir !== gitDir; + + let isWorktree = false; + if (commonDir !== null && gitDir !== null) { + const base = gitTopLevel ?? cwd; + const normCommon = normalizeGitPath(commonDir, base); + const normGit = normalizeGitPath(gitDir, base); + isWorktree = normCommon !== normGit; + } const interpreterPath = safeShell(shell, "command -v python3") ?? safeShell(shell, "command -v python"); diff --git a/docs/sprints/S19/README.md b/docs/sprints/S19/README.md index d03fa9a..8a88832 100644 --- a/docs/sprints/S19/README.md +++ b/docs/sprints/S19/README.md @@ -32,7 +32,7 @@ Make diagnose work from any git worktree by capturing invocation context (cwd/in ## Acceptance Tests -- [x] **AT-S19-01** — Worktree context detection: `detectWorktreeContext()` returns `{cwd, gitTopLevel, isWorktree, interpreterPath, nlxAvailable}` with deterministic values via DI shell mock. Worktree detected when `git-common-dir` differs from `git-dir`. +- [x] **AT-S19-01** — Worktree context detection: `detectWorktreeContext()` returns `{cwd, gitTopLevel, isWorktree, interpreterPath, nlxAvailable}` with deterministic values via DI shell mock. Worktree detected when `git-common-dir` differs from `git-dir` **after path normalization**. No false-positive when relative and absolute paths resolve to the same `.git` directory (e.g., subdirectory invocation where `--git-common-dir` returns `.git` and `--git-dir` returns the absolute path). - [x] **AT-S19-02** — Traceback suppression: `sanitizeNlxError()` returns controlled message with exactly one canonical fix path (`bash scripts/dev-setup.sh`) when stderr contains Python traceback patterns or errorType is `missing_nlx`. No raw stack frames leak. - [x] **AT-S19-03** — Dev-setup is idempotent and offline-friendly: `scripts/dev-setup.sh` detects worktree context, supports `--offline` flag to skip network-dependent steps, and exits 0 on repeated runs. - [x] **AT-S19-04** — Error context attached: sanitized error includes `WorktreeContext` object with `isWorktree`, `interpreterPath`, and `nlxAvailable` fields. API envelope receives controlled stderr instead of raw traceback. @@ -41,7 +41,7 @@ Make diagnose work from any git worktree by capturing invocation context (cwd/in - All 4 ATs checked - No raw Python tracebacks in error responses -- Tests: 195 passed (42 files) +- Tests: 198 passed (42 files) - Lint: clean - Build: clean (`/` is `○ Static`) - No files outside whitelist touched @@ -62,13 +62,17 @@ No module named See `docs/sprints/S19/evidence/` for JSON receipts. +### Follow-up: Worktree False-Positive Fix + +The original `detectWorktreeContext()` compared `git rev-parse --git-common-dir` and `--git-dir` outputs as raw strings. When invoked from a subdirectory of a normal checkout, git may return these in different formats (relative vs absolute), causing a false `isWorktree=true`. Fixed by normalizing both paths via `path.resolve()` + `fs.realpathSync()` (with `path.normalize()` fallback) before comparison. See `worktree-false-positive-fix.json` for evidence. + ## Files Touched | File | Before | After | Net New | |------|--------|-------|---------| -| `dashboard/src/engine/worktreeContext.ts` | 0 | 40 | +40 (new) | +| `dashboard/src/engine/worktreeContext.ts` | 0 | 59 | +59 (new) | | `dashboard/src/engine/nlxErrorSanitizer.ts` | 0 | 64 | +64 (new) | | `dashboard/src/engine/nlxService.ts` | 129 | 144 | +15 | -| `dashboard/src/engine/__tests__/worktreeContext.test.ts` | 0 | 52 | +52 (new) | +| `dashboard/src/engine/__tests__/worktreeContext.test.ts` | 0 | 87 | +87 (new) | | `dashboard/src/engine/__tests__/nlxErrorSanitizer.test.ts` | 0 | 94 | +94 (new) | | `scripts/dev-setup.sh` | 34 | 63 | +29 | diff --git a/docs/sprints/S19/evidence/worktree-false-positive-fix.json b/docs/sprints/S19/evidence/worktree-false-positive-fix.json new file mode 100644 index 0000000..d4f5c16 --- /dev/null +++ b/docs/sprints/S19/evidence/worktree-false-positive-fix.json @@ -0,0 +1,58 @@ +{ + "description": "Worktree false-positive fix: path normalization prevents misclassification when git-common-dir and git-dir differ by format (relative vs absolute) but resolve to the same directory", + "problem": { + "symptom": "isWorktree reported true in a normal (non-worktree) checkout when invoked from a subdirectory", + "root_cause": "Raw string inequality between git-common-dir output (e.g., '.git') and git-dir output (e.g., '/home/user/project/.git') — same directory, different string representations", + "affected_function": "detectWorktreeContext() in dashboard/src/engine/worktreeContext.ts:32" + }, + "fix": { + "approach": "Added normalizeGitPath() that resolves both paths to absolute form via path.resolve(base, raw), then applies fs.realpathSync with fallback to path.normalize for offline/test environments", + "files_changed": [ + "dashboard/src/engine/worktreeContext.ts", + "dashboard/src/engine/__tests__/worktreeContext.test.ts" + ], + "new_imports": ["node:fs (realpathSync)", "node:path (normalize, resolve)"], + "new_deps": "none (Node built-ins only)" + }, + "tests": { + "total_after_fix": "198 passed / 42 files", + "new_tests": [ + { + "name": "AT-S19-01: no false worktree when relative and absolute paths resolve to the same .git", + "scenario": "git-common-dir returns '.git', git-dir returns '/home/user/project/.git', toplevel is '/home/user/project'", + "expected": "isWorktree === false", + "result": "PASSED" + }, + { + "name": "normalizeGitPath: resolves relative path against base", + "result": "PASSED" + }, + { + "name": "normalizeGitPath: returns absolute path unchanged", + "result": "PASSED" + } + ], + "preserved_tests": [ + { + "name": "AT-S19-01: detects worktree when git-common-dir differs from git-dir (after normalization)", + "scenario": "git-common-dir returns '/home/user/project/.git', git-dir returns '/home/user/.worktrees/project/wt1/.git'", + "expected": "isWorktree === true", + "result": "PASSED" + }, + { + "name": "detects non-worktree when git-common-dir equals git-dir (both relative)", + "result": "PASSED" + }, + { + "name": "handles missing git gracefully", + "result": "PASSED" + } + ] + }, + "gates": { + "tests": { "result": "198 passed / 42 files", "EXIT_CODE": 0 }, + "lint": { "result": "clean", "EXIT_CODE": 0 }, + "build": { "result": "static export success", "EXIT_CODE": 0 } + }, + "verdict": "PASS" +}