Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions dashboard/src/engine/__tests__/nlxErrorSanitizer.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
87 changes: 87 additions & 0 deletions dashboard/src/engine/__tests__/worktreeContext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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 (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("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("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";
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);
});
});
64 changes: 64 additions & 0 deletions dashboard/src/engine/nlxErrorSanitizer.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
18 changes: 17 additions & 1 deletion dashboard/src/engine/nlxService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -69,14 +70,29 @@ async function listTasksInternal(signal?: AbortSignal): Promise<{ taskNames: str
}

function toResponse(commandId: string, result: Awaited<ReturnType<typeof runCommandArgv>>): 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,
exitCode: result.exitCode,
timedOut: result.timedOut,
errorType: result.errorType,
stdout: redactOutput(result.stdout),
stderr: redactOutput(result.stderr),
stderr: rawStderr,
};
}

Expand Down
59 changes: 59 additions & 0 deletions dashboard/src/engine/worktreeContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { execSync } from "node:child_process";
import { realpathSync } from "node:fs";
import { normalize, resolve } from "node:path";

export interface WorktreeContext {
cwd: string;
gitTopLevel: string | null;
isWorktree: boolean;
interpreterPath: string | null;
nlxAvailable: boolean;
}

export type ShellFn = (cmd: string) => 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;
}
}

/** 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(),
): 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");

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");

const nlxAvailable = safeShell(shell, "command -v nlx") !== null;

return { cwd, gitTopLevel, isWorktree, interpreterPath, nlxAvailable };
}
2 changes: 1 addition & 1 deletion docs/backlog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/) |
Expand Down
2 changes: 1 addition & 1 deletion docs/sprints/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Loading