ndomo 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +4 -0
- package/README.es.md +29 -23
- package/README.md +64 -24
- package/bun.lock +447 -0
- package/docs/configuration.md +4 -4
- package/docs/installation.md +53 -34
- package/docs/installer.md +164 -0
- package/docs/integrations.md +1 -1
- package/docs/web-ui.md +124 -0
- package/package.json +43 -4
- package/scripts/install.sh +28 -0
- package/scripts/smoke-install.sh +47 -0
- package/scripts/smoke-web.sh +335 -0
- package/src/cli/__tests__/install.test.ts +733 -0
- package/src/cli/index.ts +8 -0
- package/src/cli/install.ts +1273 -0
- package/src/config/__tests__/schema.test.ts +223 -0
- package/src/config/schema.ts +129 -16
- package/src/http/__tests__/auth.test.ts +10 -10
- package/src/http/__tests__/spa.test.ts +296 -0
- package/src/http/auth.ts +8 -1
- package/src/http/server.ts +71 -2
- package/.bun-version +0 -1
- package/.dockerignore +0 -79
- package/.editorconfig +0 -18
- package/.github/CODEOWNERS +0 -8
- package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -62
- package/.github/ISSUE_TEMPLATE/config.yml +0 -2
- package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -34
- package/.github/dependabot.yml +0 -36
- package/.github/pull_request_template.md +0 -24
- package/.github/release.yml +0 -30
- package/.github/workflows/gitleaks.yml +0 -28
- package/.github/workflows/release-please.yml +0 -27
- package/.github/workflows/smoke.yml +0 -29
- package/.husky/commit-msg +0 -1
- package/CHANGELOG.md +0 -114
- package/Dockerfile +0 -32
- package/bin/ndomo-analyses.ts +0 -4
- package/bin/ndomo-status.ts +0 -4
- package/biome.json +0 -57
- package/commitlint.config.js +0 -3
- package/opencode.json +0 -5
- package/release-please-config.json +0 -11
- package/scripts/dev-bust-cache.sh +0 -164
- package/scripts/smoke-e2e.ts +0 -704
- package/scripts/smoke-hot.ts +0 -417
- package/scripts/smoke-v4.ts +0 -256
- package/scripts/smoke-v5.ts +0 -397
- package/scripts/uninstall.sh +0 -224
- package/src/index.ts +0 -37
- package/src/lib.ts +0 -65
- package/src/mem/scoped.ts +0 -65
- package/src/orchestrator/background.test.ts +0 -268
- package/src/orchestrator/background.ts +0 -293
- package/src/orchestrator/memory-hook.ts +0 -182
- package/src/orchestrator/reconciler.ts +0 -123
- package/src/orchestrator/scheduler.test.ts +0 -300
- package/src/orchestrator/scheduler.ts +0 -243
- package/src/plugin.test.ts +0 -2574
- package/src/plugin.ts +0 -1690
- package/src/worktrees/manager.ts +0 -236
- package/src/worktrees/state.ts +0 -87
- package/tests/integration/ranger-flow.test.ts +0 -257
- package/tsconfig.json +0 -31
package/src/worktrees/manager.ts
DELETED
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Git worktree manager for isolated coding lanes.
|
|
3
|
-
*
|
|
4
|
-
* Provides CRUD operations for git worktrees with persistent state tracking.
|
|
5
|
-
* All git operations use async exec to avoid blocking the event loop.
|
|
6
|
-
*
|
|
7
|
-
* @module worktrees/manager
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { exec as execCb } from "node:child_process";
|
|
11
|
-
import { existsSync } from "node:fs";
|
|
12
|
-
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
13
|
-
import { join } from "node:path";
|
|
14
|
-
import { promisify } from "node:util";
|
|
15
|
-
|
|
16
|
-
const exec = promisify(execCb);
|
|
17
|
-
|
|
18
|
-
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
19
|
-
|
|
20
|
-
export interface Worktree {
|
|
21
|
-
slug: string;
|
|
22
|
-
branch: string;
|
|
23
|
-
path: string;
|
|
24
|
-
createdAt: number;
|
|
25
|
-
status: "active" | "merged" | "abandoned";
|
|
26
|
-
agent?: string | undefined;
|
|
27
|
-
description?: string | undefined;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface WorktreeState {
|
|
31
|
-
worktrees: Worktree[];
|
|
32
|
-
updatedAt: number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
36
|
-
|
|
37
|
-
const STATE_FILE = ".slim/worktrees.json";
|
|
38
|
-
const WORKTREE_DIR = ".slim/worktrees";
|
|
39
|
-
|
|
40
|
-
/** Regex for valid slug/branch names — alphanumeric, hyphens, underscores, slashes */
|
|
41
|
-
const SAFE_NAME_RE = /^[a-zA-Z0-9_\-/]+$/;
|
|
42
|
-
|
|
43
|
-
// ─── Validation ──────────────────────────────────────────────────────────────
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Validate that a string is safe for use in git commands.
|
|
47
|
-
* Prevents shell injection via slug or branch parameters.
|
|
48
|
-
*/
|
|
49
|
-
function assertSafeName(value: string, label: string): void {
|
|
50
|
-
if (!value || typeof value !== "string") {
|
|
51
|
-
throw new Error(`${label} must be a non-empty string`);
|
|
52
|
-
}
|
|
53
|
-
if (!SAFE_NAME_RE.test(value)) {
|
|
54
|
-
throw new Error(
|
|
55
|
-
`${label} contains invalid characters: "${value}". Only alphanumeric, hyphens, underscores, and slashes allowed.`,
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
if (value.startsWith("-")) {
|
|
59
|
-
throw new Error(`${label} must not start with a hyphen`);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ─── State Persistence ───────────────────────────────────────────────────────
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Load worktree state from disk.
|
|
67
|
-
* Returns empty state if file doesn't exist or is malformed.
|
|
68
|
-
*/
|
|
69
|
-
export async function loadState(rootDir: string): Promise<WorktreeState> {
|
|
70
|
-
const statePath = join(rootDir, STATE_FILE);
|
|
71
|
-
try {
|
|
72
|
-
const raw = await readFile(statePath, "utf-8");
|
|
73
|
-
const parsed = JSON.parse(raw) as WorktreeState;
|
|
74
|
-
// Defensive: ensure worktrees array exists
|
|
75
|
-
if (!Array.isArray(parsed.worktrees)) {
|
|
76
|
-
return { worktrees: [], updatedAt: Date.now() };
|
|
77
|
-
}
|
|
78
|
-
return parsed;
|
|
79
|
-
} catch {
|
|
80
|
-
return { worktrees: [], updatedAt: Date.now() };
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Save worktree state to disk.
|
|
86
|
-
* Creates .slim/ directory if needed.
|
|
87
|
-
*/
|
|
88
|
-
export async function saveState(rootDir: string, state: WorktreeState): Promise<void> {
|
|
89
|
-
const slimDir = join(rootDir, ".slim");
|
|
90
|
-
if (!existsSync(slimDir)) {
|
|
91
|
-
await mkdir(slimDir, { recursive: true });
|
|
92
|
-
}
|
|
93
|
-
state.updatedAt = Date.now();
|
|
94
|
-
await writeFile(join(rootDir, STATE_FILE), JSON.stringify(state, null, 2));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ─── Worktree Operations ─────────────────────────────────────────────────────
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Create a new git worktree.
|
|
101
|
-
*
|
|
102
|
-
* @param rootDir - repository root directory
|
|
103
|
-
* @param slug - short identifier (e.g., "auth-refactor", "fix-login")
|
|
104
|
-
* @param branch - git branch name (created from current HEAD if doesn't exist)
|
|
105
|
-
* @param agent - which agent will work in this worktree (optional)
|
|
106
|
-
* @param description - human-readable description (optional)
|
|
107
|
-
* @returns path to the new worktree
|
|
108
|
-
* @throws if slug contains unsafe characters or worktree already exists
|
|
109
|
-
*/
|
|
110
|
-
export async function createWorktree(
|
|
111
|
-
rootDir: string,
|
|
112
|
-
slug: string,
|
|
113
|
-
branch: string,
|
|
114
|
-
agent?: string,
|
|
115
|
-
description?: string,
|
|
116
|
-
): Promise<string> {
|
|
117
|
-
assertSafeName(slug, "slug");
|
|
118
|
-
assertSafeName(branch, "branch");
|
|
119
|
-
|
|
120
|
-
const worktreePath = join(rootDir, WORKTREE_DIR, slug);
|
|
121
|
-
|
|
122
|
-
// Check if already exists
|
|
123
|
-
const state = await loadState(rootDir);
|
|
124
|
-
const existing = state.worktrees.find((w) => w.slug === slug && w.status === "active");
|
|
125
|
-
if (existing) {
|
|
126
|
-
throw new Error(`Worktree '${slug}' already exists at ${existing.path}`);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Create parent directory
|
|
130
|
-
await mkdir(join(rootDir, WORKTREE_DIR), { recursive: true });
|
|
131
|
-
|
|
132
|
-
// Git worktree add — try creating new branch first, fall back to existing branch
|
|
133
|
-
try {
|
|
134
|
-
await exec(`git worktree add -b ${branch} ${worktreePath}`, { cwd: rootDir });
|
|
135
|
-
} catch {
|
|
136
|
-
// Branch might already exist
|
|
137
|
-
await exec(`git worktree add ${worktreePath} ${branch}`, { cwd: rootDir });
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Update state
|
|
141
|
-
state.worktrees.push({
|
|
142
|
-
slug,
|
|
143
|
-
branch,
|
|
144
|
-
path: worktreePath,
|
|
145
|
-
createdAt: Date.now(),
|
|
146
|
-
status: "active",
|
|
147
|
-
agent,
|
|
148
|
-
description,
|
|
149
|
-
});
|
|
150
|
-
await saveState(rootDir, state);
|
|
151
|
-
|
|
152
|
-
return worktreePath;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Remove a worktree.
|
|
157
|
-
*
|
|
158
|
-
* @param rootDir - repository root directory
|
|
159
|
-
* @param slug - worktree identifier
|
|
160
|
-
* @param abandon - if true, marks as abandoned instead of merged
|
|
161
|
-
* @throws if no active worktree found with the given slug
|
|
162
|
-
*/
|
|
163
|
-
export async function removeWorktree(
|
|
164
|
-
rootDir: string,
|
|
165
|
-
slug: string,
|
|
166
|
-
abandon = false,
|
|
167
|
-
): Promise<void> {
|
|
168
|
-
assertSafeName(slug, "slug");
|
|
169
|
-
|
|
170
|
-
const state = await loadState(rootDir);
|
|
171
|
-
const wt = state.worktrees.find((w) => w.slug === slug && w.status === "active");
|
|
172
|
-
if (!wt) {
|
|
173
|
-
throw new Error(`No active worktree found with slug '${slug}'`);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Remove git worktree, force if regular remove fails
|
|
177
|
-
try {
|
|
178
|
-
await exec(`git worktree remove ${wt.path}`, { cwd: rootDir });
|
|
179
|
-
} catch {
|
|
180
|
-
await exec(`git worktree remove --force ${wt.path}`, { cwd: rootDir });
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
wt.status = abandon ? "abandoned" : "merged";
|
|
184
|
-
await saveState(rootDir, state);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* List all active worktrees.
|
|
189
|
-
*/
|
|
190
|
-
export async function listActive(rootDir: string): Promise<Worktree[]> {
|
|
191
|
-
const state = await loadState(rootDir);
|
|
192
|
-
return state.worktrees.filter((w) => w.status === "active");
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Get a specific worktree by slug.
|
|
197
|
-
* Returns undefined if not found.
|
|
198
|
-
*/
|
|
199
|
-
export async function getWorktree(rootDir: string, slug: string): Promise<Worktree | undefined> {
|
|
200
|
-
assertSafeName(slug, "slug");
|
|
201
|
-
|
|
202
|
-
const state = await loadState(rootDir);
|
|
203
|
-
return state.worktrees.find((w) => w.slug === slug);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Cleanup all abandoned/merged worktrees older than maxAge (ms).
|
|
208
|
-
*
|
|
209
|
-
* @param rootDir - repository root directory
|
|
210
|
-
* @param maxAge - max age in milliseconds (default: 7 days)
|
|
211
|
-
* @returns list of removed worktree slugs
|
|
212
|
-
*/
|
|
213
|
-
export async function cleanup(
|
|
214
|
-
rootDir: string,
|
|
215
|
-
maxAge: number = 7 * 24 * 60 * 60 * 1000,
|
|
216
|
-
): Promise<string[]> {
|
|
217
|
-
const state = await loadState(rootDir);
|
|
218
|
-
const now = Date.now();
|
|
219
|
-
const removed: string[] = [];
|
|
220
|
-
|
|
221
|
-
for (const wt of state.worktrees) {
|
|
222
|
-
if (wt.status !== "active" && now - wt.createdAt > maxAge) {
|
|
223
|
-
// Remove directory if it still exists
|
|
224
|
-
if (existsSync(wt.path)) {
|
|
225
|
-
await rm(wt.path, { recursive: true, force: true });
|
|
226
|
-
}
|
|
227
|
-
removed.push(wt.slug);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Filter out removed entries
|
|
232
|
-
state.worktrees = state.worktrees.filter((w) => !removed.includes(w.slug));
|
|
233
|
-
await saveState(rootDir, state);
|
|
234
|
-
|
|
235
|
-
return removed;
|
|
236
|
-
}
|
package/src/worktrees/state.ts
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Worktree integrity verification.
|
|
3
|
-
*
|
|
4
|
-
* Checks that active worktrees have valid directories, branches,
|
|
5
|
-
* and are properly registered with git.
|
|
6
|
-
*
|
|
7
|
-
* @module worktrees/state
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { exec as execCb } from "node:child_process";
|
|
11
|
-
import { existsSync } from "node:fs";
|
|
12
|
-
import { promisify } from "node:util";
|
|
13
|
-
import type { Worktree, WorktreeState } from "./manager.ts";
|
|
14
|
-
import { loadState } from "./manager.ts";
|
|
15
|
-
|
|
16
|
-
const exec = promisify(execCb);
|
|
17
|
-
|
|
18
|
-
export type { Worktree, WorktreeState };
|
|
19
|
-
|
|
20
|
-
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
21
|
-
|
|
22
|
-
export interface IntegrityReport {
|
|
23
|
-
slug: string;
|
|
24
|
-
pathExists: boolean;
|
|
25
|
-
branchValid: boolean;
|
|
26
|
-
gitWorktreeValid: boolean;
|
|
27
|
-
issues: string[];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// ─── Integrity Checks ────────────────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Verify integrity of all active worktrees.
|
|
34
|
-
*
|
|
35
|
-
* Checks per worktree:
|
|
36
|
-
* 1. Directory exists on disk
|
|
37
|
-
* 2. Branch exists in git
|
|
38
|
-
* 3. Worktree is registered in `git worktree list`
|
|
39
|
-
*
|
|
40
|
-
* @param rootDir - repository root directory
|
|
41
|
-
* @returns array of integrity reports (only for active worktrees)
|
|
42
|
-
*/
|
|
43
|
-
export async function verifyIntegrity(rootDir: string): Promise<IntegrityReport[]> {
|
|
44
|
-
const state = await loadState(rootDir);
|
|
45
|
-
const reports: IntegrityReport[] = [];
|
|
46
|
-
|
|
47
|
-
// Get git worktree list once (not per-worktree)
|
|
48
|
-
let gitWorktreeOutput = "";
|
|
49
|
-
try {
|
|
50
|
-
const { stdout } = await exec("git worktree list", { cwd: rootDir });
|
|
51
|
-
gitWorktreeOutput = stdout;
|
|
52
|
-
} catch {
|
|
53
|
-
// If this fails, all worktrees will report the issue
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
for (const wt of state.worktrees) {
|
|
57
|
-
if (wt.status !== "active") continue;
|
|
58
|
-
|
|
59
|
-
const issues: string[] = [];
|
|
60
|
-
const pathExists = existsSync(wt.path);
|
|
61
|
-
|
|
62
|
-
// Check if worktree path appears in git worktree list
|
|
63
|
-
const gitWorktreeValid = gitWorktreeOutput.includes(wt.path);
|
|
64
|
-
|
|
65
|
-
// Check branch exists
|
|
66
|
-
let branchValid = false;
|
|
67
|
-
try {
|
|
68
|
-
await exec(`git rev-parse --verify ${wt.branch}`, { cwd: rootDir });
|
|
69
|
-
branchValid = true;
|
|
70
|
-
} catch {
|
|
71
|
-
issues.push(`branch '${wt.branch}' not found`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (!pathExists) issues.push(`directory '${wt.path}' missing`);
|
|
75
|
-
if (!gitWorktreeValid) issues.push("worktree not registered with git");
|
|
76
|
-
|
|
77
|
-
reports.push({
|
|
78
|
-
slug: wt.slug,
|
|
79
|
-
pathExists,
|
|
80
|
-
branchValid,
|
|
81
|
-
gitWorktreeValid,
|
|
82
|
-
issues,
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return reports;
|
|
87
|
-
}
|
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Integration test — analyses (ranger) full flow end-to-end.
|
|
3
|
-
*
|
|
4
|
-
* Exercises create/get/list/search/update/archive/link/unlink
|
|
5
|
-
* using direct DB + CRUD calls (no tool runtime, no mocks).
|
|
6
|
-
* Each test gets a fresh in-memory DB with full schema via runMigrations.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { Database } from "bun:sqlite";
|
|
10
|
-
import { beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
-
import {
|
|
12
|
-
archiveAnalysis,
|
|
13
|
-
createAnalysis,
|
|
14
|
-
getAnalysis,
|
|
15
|
-
getAnalysisBySlug,
|
|
16
|
-
linkAnalysisToPlan,
|
|
17
|
-
listAnalyses,
|
|
18
|
-
searchAnalyses,
|
|
19
|
-
unlinkAnalysisFromPlan,
|
|
20
|
-
updateAnalysis,
|
|
21
|
-
} from "../../src/db/analyses.ts";
|
|
22
|
-
import { createPlan } from "../../src/db/plans.ts";
|
|
23
|
-
import { runMigrations } from "../../src/db/migrations.ts";
|
|
24
|
-
|
|
25
|
-
let db: Database;
|
|
26
|
-
|
|
27
|
-
/** Minimal plan stub for FK-dependent tests. */
|
|
28
|
-
function makePlan(id = "plan-1", slug = "test-plan") {
|
|
29
|
-
return createPlan(db, {
|
|
30
|
-
id,
|
|
31
|
-
slug,
|
|
32
|
-
title: "Test Plan",
|
|
33
|
-
status: "draft",
|
|
34
|
-
priority: 1,
|
|
35
|
-
overview: "test overview",
|
|
36
|
-
complexity: 3,
|
|
37
|
-
createdBy: "test",
|
|
38
|
-
updatedBy: "test",
|
|
39
|
-
sessionId: null,
|
|
40
|
-
approvedAt: null,
|
|
41
|
-
completedAt: null,
|
|
42
|
-
approach: null,
|
|
43
|
-
sourceSessionId: null,
|
|
44
|
-
sourceMessageId: null,
|
|
45
|
-
category: null,
|
|
46
|
-
metadata: {},
|
|
47
|
-
archivedAt: null,
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
beforeEach(() => {
|
|
52
|
-
db = new Database(":memory:");
|
|
53
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
54
|
-
runMigrations(db);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
describe("analyses integration — ranger flow", () => {
|
|
58
|
-
test("create + get roundtrips all fields", () => {
|
|
59
|
-
const created = createAnalysis(db, {
|
|
60
|
-
slug: "auth-review",
|
|
61
|
-
title: "Auth Module Review",
|
|
62
|
-
projectPath: "/home/user/project",
|
|
63
|
-
summary: "Reviewed auth module for vulnerabilities",
|
|
64
|
-
findingsJson: JSON.stringify([{ severity: "high", desc: "weak hash" }]),
|
|
65
|
-
agent: "ranger",
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
expect(created.id).toBeTruthy();
|
|
69
|
-
expect(created.slug).toBe("auth-review");
|
|
70
|
-
expect(created.title).toBe("Auth Module Review");
|
|
71
|
-
expect(created.projectPath).toBe("/home/user/project");
|
|
72
|
-
expect(created.summary).toBe("Reviewed auth module for vulnerabilities");
|
|
73
|
-
expect(created.agent).toBe("ranger");
|
|
74
|
-
expect(created.archivedAt).toBeNull();
|
|
75
|
-
|
|
76
|
-
const findings = JSON.parse(created.findingsJson);
|
|
77
|
-
expect(findings).toHaveLength(1);
|
|
78
|
-
expect(findings[0].severity).toBe("high");
|
|
79
|
-
|
|
80
|
-
// get by id returns same data
|
|
81
|
-
const fetched = getAnalysis(db, created.id);
|
|
82
|
-
expect(fetched).not.toBeNull();
|
|
83
|
-
expect(fetched!.id).toBe(created.id);
|
|
84
|
-
expect(fetched!.slug).toBe(created.slug);
|
|
85
|
-
expect(fetched!.title).toBe(created.title);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("agent defaults to 'ranger' when omitted", () => {
|
|
89
|
-
const a = createAnalysis(db, {
|
|
90
|
-
slug: "default-agent",
|
|
91
|
-
title: "Default Agent Test",
|
|
92
|
-
projectPath: "/p",
|
|
93
|
-
});
|
|
94
|
-
expect(a.agent).toBe("ranger");
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("slug uniqueness — same slug + projectPath throws", () => {
|
|
98
|
-
createAnalysis(db, {
|
|
99
|
-
slug: "dup-slug",
|
|
100
|
-
title: "First",
|
|
101
|
-
projectPath: "/p",
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
expect(() =>
|
|
105
|
-
createAnalysis(db, {
|
|
106
|
-
slug: "dup-slug",
|
|
107
|
-
title: "Second",
|
|
108
|
-
projectPath: "/p",
|
|
109
|
-
}),
|
|
110
|
-
).toThrow(/already exists/);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test("same slug different projectPath is OK", () => {
|
|
114
|
-
createAnalysis(db, { slug: "shared-slug", title: "A", projectPath: "/p1" });
|
|
115
|
-
const b = createAnalysis(db, { slug: "shared-slug", title: "B", projectPath: "/p2" });
|
|
116
|
-
expect(b.slug).toBe("shared-slug");
|
|
117
|
-
expect(b.projectPath).toBe("/p2");
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test("list filters by agent, sourcePlanId, archived", () => {
|
|
121
|
-
const plan = makePlan();
|
|
122
|
-
createAnalysis(db, { slug: "a1", title: "Alpha", projectPath: "/p", agent: "ranger" });
|
|
123
|
-
createAnalysis(db, { slug: "a2", title: "Beta", projectPath: "/p", agent: "sage" });
|
|
124
|
-
createAnalysis(db, { slug: "a3", title: "Gamma", projectPath: "/p", agent: "craftsman" });
|
|
125
|
-
const linked = getAnalysisBySlug(db, "a3", "/p")!;
|
|
126
|
-
linkAnalysisToPlan(db, linked.id, plan.id);
|
|
127
|
-
|
|
128
|
-
// filter by agent
|
|
129
|
-
const rangers = listAnalyses(db, { agent: "ranger" });
|
|
130
|
-
expect(rangers).toHaveLength(1);
|
|
131
|
-
expect(rangers[0].slug).toBe("a1");
|
|
132
|
-
|
|
133
|
-
// filter by sourcePlanId
|
|
134
|
-
const byPlan = listAnalyses(db, { sourcePlanId: plan.id });
|
|
135
|
-
expect(byPlan).toHaveLength(1);
|
|
136
|
-
expect(byPlan[0].slug).toBe("a3");
|
|
137
|
-
|
|
138
|
-
// archived filter
|
|
139
|
-
const all = listAnalyses(db, { archived: false });
|
|
140
|
-
expect(all.length).toBeGreaterThanOrEqual(3);
|
|
141
|
-
|
|
142
|
-
archiveAnalysis(db, linked.id);
|
|
143
|
-
const active = listAnalyses(db, { archived: false });
|
|
144
|
-
expect(active.find((a) => a.id === linked.id)).toBeUndefined();
|
|
145
|
-
|
|
146
|
-
const archived = listAnalyses(db, { archived: true });
|
|
147
|
-
expect(archived.find((a) => a.id === linked.id)).toBeDefined();
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test("search FTS — finds matching analysis by title", () => {
|
|
151
|
-
createAnalysis(db, { slug: "s1", title: "Database Security Audit", projectPath: "/p" });
|
|
152
|
-
createAnalysis(db, { slug: "s2", title: "Frontend Performance Review", projectPath: "/p" });
|
|
153
|
-
createAnalysis(db, { slug: "s3", title: "API Authentication Analysis", projectPath: "/p" });
|
|
154
|
-
|
|
155
|
-
const results = searchAnalyses(db, "security");
|
|
156
|
-
expect(results).toHaveLength(1);
|
|
157
|
-
expect(results[0].slug).toBe("s1");
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test("search FTS — finds matching analysis by summary", () => {
|
|
161
|
-
createAnalysis(db, {
|
|
162
|
-
slug: "s-summary",
|
|
163
|
-
title: "Generic Title",
|
|
164
|
-
projectPath: "/p",
|
|
165
|
-
summary: "Found critical XSS vulnerabilities in form handlers",
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
const results = searchAnalyses(db, "XSS vulnerabilities");
|
|
169
|
-
expect(results).toHaveLength(1);
|
|
170
|
-
expect(results[0].slug).toBe("s-summary");
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("update partial — only changed fields update + updated_at bumps", () => {
|
|
174
|
-
const a = createAnalysis(db, {
|
|
175
|
-
slug: "upd",
|
|
176
|
-
title: "Original Title",
|
|
177
|
-
projectPath: "/p",
|
|
178
|
-
summary: "original summary",
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
const updated = updateAnalysis(db, a.id, { title: "New Title" });
|
|
182
|
-
expect(updated.title).toBe("New Title");
|
|
183
|
-
expect(updated.summary).toBe("original summary"); // unchanged
|
|
184
|
-
expect(updated.slug).toBe("upd"); // unchanged
|
|
185
|
-
// updated_at is datetime('now') — same-second updates may have same value;
|
|
186
|
-
// just verify the field exists and is a string
|
|
187
|
-
expect(typeof updated.updatedAt).toBe("string");
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test("archive + list excludes archived", () => {
|
|
191
|
-
const a = createAnalysis(db, { slug: "to-archive", title: "Archive Me", projectPath: "/p" });
|
|
192
|
-
expect(a.archivedAt).toBeNull();
|
|
193
|
-
|
|
194
|
-
const archived = archiveAnalysis(db, a.id);
|
|
195
|
-
expect(archived.archivedAt).toBeTruthy();
|
|
196
|
-
|
|
197
|
-
// default list excludes archived
|
|
198
|
-
const active = listAnalyses(db);
|
|
199
|
-
expect(active.find((x) => x.id === a.id)).toBeUndefined();
|
|
200
|
-
|
|
201
|
-
// getAnalysis excludes archived by default
|
|
202
|
-
expect(getAnalysis(db, a.id)).toBeNull();
|
|
203
|
-
|
|
204
|
-
// getAnalysis with includeArchived returns it
|
|
205
|
-
const found = getAnalysis(db, a.id, { includeArchived: true });
|
|
206
|
-
expect(found).not.toBeNull();
|
|
207
|
-
expect(found!.id).toBe(a.id);
|
|
208
|
-
|
|
209
|
-
// idempotent — archiving again is a no-op
|
|
210
|
-
const again = archiveAnalysis(db, a.id);
|
|
211
|
-
expect(again.archivedAt).toBe(archived.archivedAt);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
test("linkAnalysisToPlan — sets sourcePlanId", () => {
|
|
215
|
-
const plan = makePlan();
|
|
216
|
-
const a = createAnalysis(db, { slug: "link-me", title: "Link Me", projectPath: "/p" });
|
|
217
|
-
expect(a.sourcePlanId).toBeNull();
|
|
218
|
-
|
|
219
|
-
const linked = linkAnalysisToPlan(db, a.id, plan.id);
|
|
220
|
-
expect(linked.sourcePlanId).toBe(plan.id);
|
|
221
|
-
|
|
222
|
-
// getAnalysisBySlug reflects the link
|
|
223
|
-
const bySlug = getAnalysisBySlug(db, "link-me", "/p");
|
|
224
|
-
expect(bySlug!.sourcePlanId).toBe(plan.id);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
test("linkAnalysisToPlan — FK validation: non-existent plan throws", () => {
|
|
228
|
-
const a = createAnalysis(db, { slug: "no-fk", title: "No FK", projectPath: "/p" });
|
|
229
|
-
expect(() => linkAnalysisToPlan(db, a.id, "non-existent-plan-id")).toThrow(/not found/);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
test("unlinkAnalysisFromPlan — sets sourcePlanId to null", () => {
|
|
233
|
-
const plan = makePlan();
|
|
234
|
-
const a = createAnalysis(db, { slug: "unlink", title: "Unlink", projectPath: "/p" });
|
|
235
|
-
linkAnalysisToPlan(db, a.id, plan.id);
|
|
236
|
-
|
|
237
|
-
const unlinked = unlinkAnalysisFromPlan(db, a.id);
|
|
238
|
-
expect(unlinked.sourcePlanId).toBeNull();
|
|
239
|
-
|
|
240
|
-
// idempotent — unlinking again is a no-op
|
|
241
|
-
const again = unlinkAnalysisFromPlan(db, a.id);
|
|
242
|
-
expect(again.sourcePlanId).toBeNull();
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
test("CASCADE plan deletion — sourcePlanId becomes NULL (ON DELETE SET NULL)", () => {
|
|
246
|
-
const plan = makePlan("plan-cascade", "cascade-plan");
|
|
247
|
-
const a = createAnalysis(db, { slug: "cascade", title: "Cascade", projectPath: "/p" });
|
|
248
|
-
linkAnalysisToPlan(db, a.id, plan.id);
|
|
249
|
-
|
|
250
|
-
// delete plan directly via SQL (simulates external deletion)
|
|
251
|
-
db.query("DELETE FROM plans WHERE id = ?").run("plan-cascade");
|
|
252
|
-
|
|
253
|
-
const after = getAnalysis(db, a.id);
|
|
254
|
-
expect(after).not.toBeNull();
|
|
255
|
-
expect(after!.sourcePlanId).toBeNull();
|
|
256
|
-
});
|
|
257
|
-
});
|
package/tsconfig.json
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "bundler",
|
|
6
|
-
"lib": ["ES2022"],
|
|
7
|
-
"types": ["bun-types"],
|
|
8
|
-
"allowImportingTsExtensions": true,
|
|
9
|
-
"verbatimModuleSyntax": true,
|
|
10
|
-
"noEmit": true,
|
|
11
|
-
"strict": true,
|
|
12
|
-
"noImplicitAny": true,
|
|
13
|
-
"strictNullChecks": true,
|
|
14
|
-
"strictFunctionTypes": true,
|
|
15
|
-
"noUnusedLocals": true,
|
|
16
|
-
"noUnusedParameters": true,
|
|
17
|
-
"noFallthroughCasesInSwitch": true,
|
|
18
|
-
"noUncheckedIndexedAccess": true,
|
|
19
|
-
"exactOptionalPropertyTypes": true,
|
|
20
|
-
"esModuleInterop": true,
|
|
21
|
-
"skipLibCheck": true,
|
|
22
|
-
"forceConsistentCasingInFileNames": true,
|
|
23
|
-
"resolveJsonModule": true,
|
|
24
|
-
"isolatedModules": true,
|
|
25
|
-
"paths": {
|
|
26
|
-
"@/*": ["./src/*"]
|
|
27
|
-
}
|
|
28
|
-
},
|
|
29
|
-
"include": ["src/**/*", "scripts/**/*", "build.ts"],
|
|
30
|
-
"exclude": ["node_modules", "dist", ".slim"]
|
|
31
|
-
}
|