FN-8103: enforce PostgreSQL-only production data access
Require production paths to use PostgreSQL-aware stores and prevent new unrestricted database access. - Add a checked allowlist that bans production getDatabase() calls by default. - Route Quality plugin persistence through an async PostgreSQL-aware store and add Drizzle ORM. - Document backend-safe plugin storage patterns and cover guarded access behavior. Files changed: docs/PLUGIN_AUTHORING.md | 20 +++ package.json | 6 +- .../src/__tests__/agent-logs-backend-mode.test.ts | 7 + packages/core/src/store.ts | 7 +- packages/core/src/task-store/remaining-ops-5.ts | 8 +- plugins/fusion-plugin-quality/package.json | 1 + .../src/__tests__/async-quality-store.pg.test.ts | 36 +++++ .../src/__tests__/cancel-and-plans.test.ts | 8 +- .../src/__tests__/experimental-gate.test.ts | 1 + .../src/routes/create-routes.ts | 50 +++---- .../src/runner/command-runner.ts | 17 ++- .../src/store/async-quality-store.ts | 34 +++++ pnpm-lock.yaml | 3 + scripts/__tests__/check-no-getdatabase.test.mjs | 90 ++++++++++++ scripts/check-no-getdatabase.mjs | 159 +++++++++++++++++++++ scripts/lib/getdatabase-allowlist.json | 18 +++ 16 files changed, 422 insertions(+), 43 deletions(-) Fusion-Task-Id: FN-8103 Fusion-Task-Lineage: ff17bcb2-5341-4c6c-a5c4-993580539676 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -997,6 +997,26 @@ interface PluginContext {
|
||||
| `emitEvent` | `(event, data) => void` | Emit custom events |
|
||||
| `createAiSession` | `CreateAiSessionFactory \| undefined` | Engine-injected AI session factory (undefined when engine isn't loaded) |
|
||||
|
||||
### Durable data access: PostgreSQL / `AsyncDataLayer`
|
||||
|
||||
Production Fusion hosts are PostgreSQL-only. **Plugin routes, hooks, and dashboard-backed feature paths must not call `ctx.taskStore.getDatabase()`**: it is the legacy synchronous SQLite accessor and throws in backend mode. Use a project-bound `AsyncDataLayer` and make store methods asynchronous instead.
|
||||
|
||||
```typescript
|
||||
function getWidgetStore(ctx: PluginContext): AsyncWidgetStore {
|
||||
const asyncLayer = ctx.taskStore.getAsyncLayer();
|
||||
if (!asyncLayer) {
|
||||
throw new Error("Widget plugin requires PostgreSQL AsyncDataLayer");
|
||||
}
|
||||
return new AsyncWidgetStore(asyncLayer);
|
||||
}
|
||||
|
||||
const widget = await getWidgetStore(ctx).getWidget(widgetId);
|
||||
```
|
||||
|
||||
Use a direct `drizzle-orm` dependency for plugin-owned PostgreSQL tables and scope every query by `asyncLayer.projectId`. See Reports' `getReportStore` / `ReportStore` async siblings and Quality's `AsyncQualityStore` for production patterns. SQLite/`DatabaseSync` is permitted only inside intentional unit harnesses, never as a production fallback.
|
||||
|
||||
The repository gate `scripts/check-no-getdatabase.mjs` scans tracked plugin, dashboard, engine, and core paths. A legitimate transitional exception must be reviewed in `scripts/lib/getdatabase-allowlist.json` and pin the exact `file`, one-based `line`, and trimmed `snippet`; there is no file-level or inline exemption. New plugin feature code is not eligible for that allowlist.
|
||||
|
||||
### Logger Methods
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
"pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs",
|
||||
"pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs",
|
||||
"pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs",
|
||||
"pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs",
|
||||
"check:line-count": "node scripts/check-file-line-count.mjs",
|
||||
"check:changesets": "node scripts/check-changeset-format.mjs",
|
||||
"check:quarantine-ledger": "node scripts/check-quarantine-ledger.mjs",
|
||||
"check:mock-completeness": "node scripts/check-mock-completeness.mjs",
|
||||
"test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @fusion/core test:pg-gate && pnpm --filter @runfusion/fusion test:ci-shape",
|
||||
"test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @fusion/core test:pg-gate && pnpm --filter @runfusion/fusion test:ci-shape",
|
||||
"smoke:boot": "node scripts/boot-smoke.mjs",
|
||||
"local": "node scripts/start-local.mjs",
|
||||
"dev": "node scripts/dev-with-memory.mjs",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { appendAgentLogBatchImpl, flushAgentLogBufferImpl } from "../task-store/agent-logs.js";
|
||||
import { appendAgentLogImpl } from "../task-store/workflow-integrity.js";
|
||||
import { getAgentLogCountImpl, getAgentLogsImpl } from "../task-store/remaining-ops-7.js";
|
||||
import { dbImpl } from "../task-store/remaining-ops-5.js";
|
||||
import { readAgentLogEntries } from "../agent-log-file-store.js";
|
||||
|
||||
/**
|
||||
@@ -73,6 +74,12 @@ function makeBackendStore(fusionDir: string): { store: any; dbTouched: () => boo
|
||||
return { store, dbTouched: () => touched };
|
||||
}
|
||||
|
||||
describe("backend-mode SQLite access guidance", () => {
|
||||
it("directs backend callers to AsyncDataLayer plugin authoring guidance", () => {
|
||||
expect(() => dbImpl({ backendMode: true } as never)).toThrow(/getAsyncLayer\(\).*docs\/PLUGIN_AUTHORING\.md/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent-log read filtering", () => {
|
||||
it("filters before pagination and counts the filtered result", async () => {
|
||||
const dir = tmp();
|
||||
|
||||
@@ -2478,9 +2478,10 @@ Issue #2149 requires read-only type filtering to occur in the file-store before
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:AsyncDataLayer 2026-06-24-11:00: CONTRACT CHANGE (U4, VAL-DATA-001): Returns synchronous Database during migration (U12-U15).
|
||||
* U15 flips to AsyncDataLayer. New code should target AsyncDataLayer (transactionImmediate, transaction, recordRunAuditEventWithinTransaction).
|
||||
* Async foundation in packages/core/src/postgres/data-layer.ts preserves BEGIN IMMEDIATE atomicity (VAL-DATA-002/003) and no partial writes (VAL-DATA-004).
|
||||
* FNXC:PostgresOnlyDataAccess 2026-07-16-10:20:
|
||||
* This legacy synchronous SQLite accessor is unavailable in backend mode and
|
||||
* must not be used by plugin, dashboard, engine, or feature data paths.
|
||||
* Durable production access uses getAsyncLayer() and an async store.
|
||||
*/
|
||||
getDatabase(): Database {
|
||||
return this.db;
|
||||
|
||||
@@ -47,10 +47,16 @@ export function trackDeferredTaskCreatedWorkImpl(store: TaskStore, work: () => P
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PostgresOnlyDataAccess 2026-07-16-10:20:
|
||||
Backend mode intentionally has no synchronous SQLite escape hatch. Name the
|
||||
AsyncDataLayer route and authoring guide in this failure so plugin authors fix
|
||||
the durable-data boundary rather than adding a backend-specific fallback.
|
||||
*/
|
||||
export function dbImpl(store: TaskStore): Database {
|
||||
if (store.backendMode) {
|
||||
throw new Error(
|
||||
"TaskStore.db: SQLite Database is not available in backend mode (AsyncDataLayer injected)",
|
||||
"TaskStore.db: SQLite Database is not available in backend mode (PostgreSQL/AsyncDataLayer injected). Use ctx.taskStore.getAsyncLayer() / an async store — see docs/PLUGIN_AUTHORING.md",
|
||||
);
|
||||
}
|
||||
if (!store._db) {
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"dependencies": {
|
||||
"@fusion/core": "workspace:*",
|
||||
"@fusion/plugin-sdk": "workspace:*",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"lucide-react": "^0.542.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
FNXC:QualityPostgresDurability 2026-07-16-10:30:
|
||||
This behavioral test applies the plugin's declarative PostgreSQL schema itself:
|
||||
core's harness supplies only baseline tables. It proves Quality CRUD uses the
|
||||
project-bound AsyncDataLayer rather than the SQLite route that failed Task QA.
|
||||
*/
|
||||
import { expect, it } from "vitest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { AsyncDataLayer } from "@fusion/core";
|
||||
import { createTaskStoreForTest, pgDescribe } from "../../../../packages/core/src/__test-utils__/pg-test-harness.js";
|
||||
import { qualityPostgresSchema } from "../quality-schema.js";
|
||||
import { AsyncQualityStore } from "../store/async-quality-store.js";
|
||||
|
||||
function projectLayer(layer: AsyncDataLayer, projectId: string): AsyncDataLayer { return { ...layer, projectId }; }
|
||||
|
||||
pgDescribe("AsyncQualityStore (PostgreSQL / backend mode)", () => {
|
||||
it("persists Quality lifecycle data and isolates projects", async () => {
|
||||
const h = await createTaskStoreForTest({ prefix: "fusion_quality_async" });
|
||||
try {
|
||||
for (const statement of qualityPostgresSchema.statements) await h.adminDb.execute(sql.raw(statement));
|
||||
const projectA = new AsyncQualityStore(projectLayer(h.layer, "quality-a"));
|
||||
const projectB = new AsyncQualityStore(projectLayer(h.layer, "quality-b"));
|
||||
const created = await projectA.createRun({ projectId: "quality-a", source: "hub", command: "pnpm verify:fast", cwd: "/repo", cwdKind: "project-root", timeoutMs: 1_000, triggeredBy: "test" });
|
||||
const updated = await projectA.updateRun("quality-a", created.id, { status: "passed", exitCode: 0, finishedAt: new Date().toISOString(), durationMs: 1 });
|
||||
expect(updated).toMatchObject({ id: created.id, status: "passed", exitCode: 0 });
|
||||
expect(await projectA.listRuns("quality-a")).toHaveLength(1);
|
||||
expect(await projectB.getRun("quality-b", created.id)).toBeNull();
|
||||
|
||||
const createdPlan = await projectA.createPlan({ projectId: "quality-a", name: "Fast gate", steps: ["verify-fast"] });
|
||||
expect((await projectA.getPlan("quality-a", createdPlan.id))?.steps).toEqual(["verify-fast"]);
|
||||
await projectA.saveSuggestedCases({ projectId: "quality-a", taskId: "FN-8103", cases: [{ id: "case", text: "uses async data layer", done: false, source: "heuristic" }], generatedAt: new Date().toISOString(), method: "heuristic" });
|
||||
expect((await projectA.getSuggestedCases("quality-a", "FN-8103"))?.cases).toHaveLength(1);
|
||||
expect(await projectB.getSuggestedCases("quality-b", "FN-8103")).toBeNull();
|
||||
} finally { await h.teardown(); }
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ describe("cancelQualityRun", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("kills the supervised child and marks queued/running runs cancelled", () => {
|
||||
it("kills the supervised child and marks queued/running runs cancelled", async () => {
|
||||
__clearActiveQualityRunsForTests();
|
||||
const db = new DatabaseSync(":memory:");
|
||||
ensureQualitySchema(db as never);
|
||||
@@ -35,12 +35,12 @@ describe("cancelQualityRun", () => {
|
||||
store.updateRun("p1", run.id, { status: "running", startedAt: new Date().toISOString() });
|
||||
const kill = vi.fn();
|
||||
__registerActiveQualityRunForTests("p1", run.id, { kill });
|
||||
const cancelled = cancelQualityRun(store, "p1", run.id);
|
||||
const cancelled = await cancelQualityRun(store, "p1", run.id);
|
||||
expect(kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(cancelled?.status).toBe("cancelled");
|
||||
expect(cancelled?.errorMessage).toMatch(/Cancelled/);
|
||||
|
||||
const again = cancelQualityRun(store, "p1", run.id);
|
||||
const again = await cancelQualityRun(store, "p1", run.id);
|
||||
expect(again?.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("cancelQualityRun", () => {
|
||||
timeoutMs: 1_000,
|
||||
logTruncateKb: 1,
|
||||
});
|
||||
cancelQualityRun(store, "p1", run.id);
|
||||
await cancelQualityRun(store, "p1", run.id);
|
||||
|
||||
await expect(execution).resolves.toMatchObject({ status: "cancelled", errorMessage: "Cancelled by operator" });
|
||||
expect(kill).toHaveBeenCalledWith("SIGTERM");
|
||||
|
||||
@@ -13,6 +13,7 @@ function makeCtx(getSettings?: () => unknown) {
|
||||
return {
|
||||
taskStore: {
|
||||
getDatabase: () => db,
|
||||
getAsyncLayer: () => ({ projectId: "proj-1", db: { execute: vi.fn().mockResolvedValue([]) } }),
|
||||
getSettings: getSettings ?? (() => Promise.resolve({})),
|
||||
getRootDir: () => "/tmp",
|
||||
getTask: vi.fn(),
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { PluginContext, PluginRouteDefinition } from "@fusion/plugin-sdk";
|
||||
import type { Database } from "@fusion/core";
|
||||
import { ensureQualitySchema } from "../quality-schema.js";
|
||||
import { QualityStore } from "../store/quality-store.js";
|
||||
import { AsyncQualityStore } from "../store/async-quality-store.js";
|
||||
import { isQualityPresetId, listPresetCatalog, resolvePresetCommand } from "../runner/command-presets.js";
|
||||
import { cancelQualityRun, defaultTimeoutMs, executeQualityRun } from "../runner/command-runner.js";
|
||||
import { getAllowRootFallback, getDefaultPreviewScript, getLogTruncateKb, getRunRetentionCount } from "../settings.js";
|
||||
@@ -40,15 +38,17 @@ function requireProjectId(req: Req): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
function getDb(ctx: PluginContext): Database {
|
||||
// Prefer sync database when available (SQLite / sync facade).
|
||||
return ctx.taskStore.getDatabase();
|
||||
}
|
||||
const qualityStoreCache = new WeakMap<object, AsyncQualityStore>();
|
||||
|
||||
function getStore(ctx: PluginContext): QualityStore {
|
||||
const db = getDb(ctx);
|
||||
ensureQualitySchema(db);
|
||||
return new QualityStore(db);
|
||||
function getStore(ctx: PluginContext): AsyncQualityStore {
|
||||
const key = ctx.taskStore as object;
|
||||
const cached = qualityStoreCache.get(key);
|
||||
if (cached) return cached;
|
||||
const asyncLayer = ctx.taskStore.getAsyncLayer();
|
||||
if (!asyncLayer) throw new Error("Quality plugin requires ctx.taskStore.getAsyncLayer() / PostgreSQL AsyncDataLayer");
|
||||
const store = new AsyncQualityStore(asyncLayer);
|
||||
qualityStoreCache.set(key, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
function httpError(status: number, message: string): never {
|
||||
@@ -139,7 +139,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
|
||||
const store = getStore(ctx);
|
||||
const taskId = typeof r.query?.taskId === "string" ? r.query.taskId : undefined;
|
||||
const limit = typeof r.query?.limit === "string" ? Number(r.query.limit) : 50;
|
||||
return { runs: store.listRuns(projectId, { taskId, limit }) };
|
||||
return { runs: await store.listRuns(projectId, { taskId, limit }) };
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -151,7 +151,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
|
||||
const projectId = requireProjectId(r);
|
||||
const runId = r.params?.runId;
|
||||
if (!runId) httpError(400, "runId required");
|
||||
const run = getStore(ctx).getRun(projectId, runId);
|
||||
const run = await getStore(ctx).getRun(projectId, runId);
|
||||
if (!run) httpError(404, "Run not found");
|
||||
return { run };
|
||||
},
|
||||
@@ -177,7 +177,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
|
||||
const source = body.source === "hub" ? "hub" : "task-tab";
|
||||
|
||||
const store = getStore(ctx);
|
||||
const active = store.findActiveRun(projectId, taskId);
|
||||
const active = await store.findActiveRun(projectId, taskId);
|
||||
if (active) {
|
||||
httpError(409, `A run is already active (${active.id})`);
|
||||
}
|
||||
@@ -263,7 +263,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
|
||||
}
|
||||
|
||||
const timeoutMs = defaultTimeoutMs(verificationCommandTimeoutMs);
|
||||
const run = store.createRun({
|
||||
const run = await store.createRun({
|
||||
projectId,
|
||||
taskId,
|
||||
source,
|
||||
@@ -285,14 +285,14 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
|
||||
timeoutMs,
|
||||
logTruncateKb: getLogTruncateKb(ctx.settings as Record<string, unknown>),
|
||||
})
|
||||
.then(() => {
|
||||
store.pruneRuns(projectId, getRunRetentionCount(ctx.settings as Record<string, unknown>));
|
||||
.then(async () => {
|
||||
await store.pruneRuns(projectId, getRunRetentionCount(ctx.settings as Record<string, unknown>));
|
||||
})
|
||||
.catch((err) => {
|
||||
.catch(async (err) => {
|
||||
ctx.logger?.warn?.(
|
||||
`Quality run ${run.id} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
store.updateRun(projectId, run.id, {
|
||||
await store.updateRun(projectId, run.id, {
|
||||
status: "error",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
finishedAt: new Date().toISOString(),
|
||||
@@ -312,12 +312,12 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
|
||||
const runId = r.params?.runId;
|
||||
if (!runId) httpError(400, "runId required");
|
||||
const store = getStore(ctx);
|
||||
const run = store.getRun(projectId, runId);
|
||||
const run = await store.getRun(projectId, runId);
|
||||
if (!run) httpError(404, "Run not found");
|
||||
if (run.status !== "queued" && run.status !== "running") {
|
||||
return { run };
|
||||
}
|
||||
const updated = cancelQualityRun(store, projectId, runId);
|
||||
const updated = await cancelQualityRun(store, projectId, runId);
|
||||
return { run: updated };
|
||||
},
|
||||
},
|
||||
@@ -328,7 +328,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
|
||||
handler: async (req, ctx) => {
|
||||
const r = req as Req;
|
||||
const projectId = requireProjectId(r);
|
||||
return { plans: getStore(ctx).listPlans(projectId) };
|
||||
return { plans: await getStore(ctx).listPlans(projectId) };
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -342,7 +342,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
|
||||
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||
if (!name) httpError(400, "name is required");
|
||||
const steps = validatePlanSteps(Array.isArray(body.steps) ? body.steps : []);
|
||||
const plan = getStore(ctx).createPlan({ projectId, name, steps });
|
||||
const plan = await getStore(ctx).createPlan({ projectId, name, steps });
|
||||
return { plan };
|
||||
},
|
||||
},
|
||||
@@ -355,7 +355,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
|
||||
const projectId = requireProjectId(r);
|
||||
const taskId = r.params?.taskId;
|
||||
if (!taskId) httpError(400, "taskId required");
|
||||
const existing = getStore(ctx).getSuggestedCases(projectId, taskId);
|
||||
const existing = await getStore(ctx).getSuggestedCases(projectId, taskId);
|
||||
return { suggestions: existing };
|
||||
},
|
||||
},
|
||||
@@ -391,7 +391,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
|
||||
? task.modifiedFiles.filter((p): p is string => typeof p === "string")
|
||||
: [],
|
||||
});
|
||||
const snapshot = getStore(ctx).saveSuggestedCases({
|
||||
const snapshot = await getStore(ctx).saveSuggestedCases({
|
||||
projectId,
|
||||
taskId,
|
||||
cases,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { superviseSpawn } from "@fusion/core";
|
||||
import type { QualityStore } from "../store/quality-store.js";
|
||||
import type { AsyncQualityStore } from "../store/async-quality-store.js";
|
||||
|
||||
type QualityPersistence = QualityStore | AsyncQualityStore;
|
||||
import type { TestRun, TestRunStatus } from "../store/quality-types.js";
|
||||
|
||||
/*
|
||||
@@ -24,12 +27,12 @@ Keep each live supervisor by project/run so the cancel route can terminate its
|
||||
process group, while the runner's final write preserves the cancelled terminal
|
||||
state if the child closes after that request.
|
||||
*/
|
||||
export function cancelQualityRun(store: QualityStore, projectId: string, runId: string): TestRun | null {
|
||||
const current = store.getRun(projectId, runId);
|
||||
export async function cancelQualityRun(store: QualityPersistence, projectId: string, runId: string): Promise<TestRun | null> {
|
||||
const current = await store.getRun(projectId, runId);
|
||||
if (!current || (current.status !== "queued" && current.status !== "running")) return current;
|
||||
|
||||
activeQualityRuns.get(activeRunKey(projectId, runId))?.kill("SIGTERM");
|
||||
return store.updateRun(projectId, runId, {
|
||||
return await store.updateRun(projectId, runId, {
|
||||
status: "cancelled",
|
||||
finishedAt: new Date().toISOString(),
|
||||
errorMessage: "Cancelled by operator",
|
||||
@@ -45,7 +48,7 @@ export function __registerActiveQualityRunForTests(projectId: string, runId: str
|
||||
}
|
||||
|
||||
export interface RunCommandOptions {
|
||||
store: QualityStore;
|
||||
store: QualityPersistence;
|
||||
projectId: string;
|
||||
runId: string;
|
||||
command: string;
|
||||
@@ -65,7 +68,7 @@ export async function executeQualityRun(opts: RunCommandOptions): Promise<TestRu
|
||||
const { store, projectId, runId, command, cwd } = opts;
|
||||
const timeoutMs = Math.min(Math.max(opts.timeoutMs, 1_000), HARD_TIMEOUT_MS);
|
||||
const startedAt = new Date().toISOString();
|
||||
store.updateRun(projectId, runId, { status: "running", startedAt });
|
||||
await store.updateRun(projectId, runId, { status: "running", startedAt });
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
@@ -128,9 +131,9 @@ export async function executeQualityRun(opts: RunCommandOptions): Promise<TestRu
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
const durationMs = Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt));
|
||||
const current = store.getRun(projectId, runId);
|
||||
const current = await store.getRun(projectId, runId);
|
||||
const wasCancelled = current?.status === "cancelled";
|
||||
const updated = store.updateRun(projectId, runId, {
|
||||
const updated = await store.updateRun(projectId, runId, {
|
||||
status: wasCancelled ? "cancelled" : status,
|
||||
exitCode,
|
||||
errorMessage: wasCancelled ? current.errorMessage ?? "Cancelled by operator" : errorMessage,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AsyncDataLayer } from "@fusion/core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { CreateTestPlanInput, CreateTestRunInput, QualityPresetId, SuggestedCase, SuggestedCasesSnapshot, TestPlan, TestPlanStatus, TestRun, TestRunStatus } from "./quality-types.js";
|
||||
|
||||
/*
|
||||
FNXC:QualityPostgresDurability 2026-07-16-10:15:
|
||||
Task QA previously called TaskStore.getDatabase(), which throws when production
|
||||
injects PostgreSQL AsyncDataLayer. This store owns durable Quality data through
|
||||
the project-bound async layer only, so every plugin route remains available in
|
||||
backend mode and every query explicitly scopes project_id.
|
||||
*/
|
||||
type RunRow = { id: string; project_id: string; task_id: string | null; plan_id: string | null; source: string; preset_id: string | null; command: string; cwd: string; cwd_kind: string; status: string; exit_code: number | null; error_message: string | null; timeout_ms: number; started_at: string | null; finished_at: string | null; duration_ms: number | null; stdout: string; stderr: string; triggered_by: string; created_at: string; updated_at: string };
|
||||
type PlanRow = { id: string; project_id: string; name: string; status: string; steps_json: string; created_at: string; updated_at: string };
|
||||
|
||||
function run(row: RunRow): TestRun { return { id: row.id, projectId: row.project_id, taskId: row.task_id ?? undefined, planId: row.plan_id ?? undefined, source: row.source as TestRun["source"], presetId: (row.preset_id as QualityPresetId | null) ?? undefined, command: row.command, cwd: row.cwd, cwdKind: row.cwd_kind as TestRun["cwdKind"], status: row.status as TestRunStatus, exitCode: row.exit_code ?? undefined, errorMessage: row.error_message ?? undefined, timeoutMs: row.timeout_ms, startedAt: row.started_at ?? undefined, finishedAt: row.finished_at ?? undefined, durationMs: row.duration_ms ?? undefined, stdout: row.stdout ?? "", stderr: row.stderr ?? "", triggeredBy: row.triggered_by, createdAt: row.created_at, updatedAt: row.updated_at }; }
|
||||
function plan(row: PlanRow): TestPlan { let steps: QualityPresetId[] = []; try { const parsed = JSON.parse(row.steps_json); if (Array.isArray(parsed)) steps = parsed.filter((value): value is QualityPresetId => typeof value === "string"); } catch { /* malformed legacy data is empty */ } return { id: row.id, projectId: row.project_id, name: row.name, status: row.status as TestPlanStatus, steps, createdAt: row.created_at, updatedAt: row.updated_at }; }
|
||||
|
||||
export class AsyncQualityStore {
|
||||
private readonly projectId: string;
|
||||
constructor(private readonly layer: AsyncDataLayer) { if (!layer.projectId) throw new Error("Quality plugin requires a project-bound PostgreSQL AsyncDataLayer"); this.projectId = layer.projectId; }
|
||||
private async runs(query: ReturnType<typeof sql>): Promise<RunRow[]> { return await this.layer.db.execute(query) as unknown as RunRow[]; }
|
||||
async createRun(input: CreateTestRunInput): Promise<TestRun> { const now = new Date().toISOString(); const id = `qrun_${randomUUID()}`; await this.layer.db.execute(sql`INSERT INTO project.quality_test_runs (project_id,id,task_id,plan_id,source,preset_id,command,cwd,cwd_kind,status,timeout_ms,stdout,stderr,triggered_by,created_at,updated_at) VALUES (${this.projectId},${id},${input.taskId ?? null},${input.planId ?? null},${input.source},${input.presetId ?? null},${input.command},${input.cwd},${input.cwdKind},'queued',${input.timeoutMs},'','',${input.triggeredBy},${now},${now})`); return (await this.getRun(input.projectId, id))!; }
|
||||
async getRun(projectId: string, id: string): Promise<TestRun | null> { const rows = await this.runs(sql`SELECT * FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} AND id=${id} LIMIT 1`); return rows[0] ? run(rows[0]) : null; }
|
||||
async listRuns(projectId: string, opts?: { taskId?: string; limit?: number }): Promise<TestRun[]> { const limit = opts?.limit && opts.limit > 0 ? Math.min(opts.limit, 200) : 50; const rows = opts?.taskId ? await this.runs(sql`SELECT * FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} AND task_id=${opts.taskId} ORDER BY created_at DESC,id DESC LIMIT ${limit}`) : await this.runs(sql`SELECT * FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} ORDER BY created_at DESC,id DESC LIMIT ${limit}`); return rows.map(run); }
|
||||
async updateRun(projectId: string, id: string, patch: Partial<{ status: TestRunStatus; exitCode: number | null; errorMessage: string | null; startedAt: string | null; finishedAt: string | null; durationMs: number | null; stdout: string; stderr: string }>): Promise<TestRun | null> { const current = await this.getRun(projectId, id); if (!current) return null; const now = new Date().toISOString(); await this.layer.db.execute(sql`UPDATE project.quality_test_runs SET status=${patch.status ?? current.status},exit_code=${patch.exitCode !== undefined ? patch.exitCode : current.exitCode ?? null},error_message=${patch.errorMessage !== undefined ? patch.errorMessage : current.errorMessage ?? null},started_at=${patch.startedAt !== undefined ? patch.startedAt : current.startedAt ?? null},finished_at=${patch.finishedAt !== undefined ? patch.finishedAt : current.finishedAt ?? null},duration_ms=${patch.durationMs !== undefined ? patch.durationMs : current.durationMs ?? null},stdout=${patch.stdout !== undefined ? patch.stdout : current.stdout},stderr=${patch.stderr !== undefined ? patch.stderr : current.stderr},updated_at=${now} WHERE project_id=${this.projectId} AND id=${id}`); return this.getRun(projectId, id); }
|
||||
async pruneRuns(projectId: string, retention: number): Promise<number> { if (retention <= 0) return 0; const rows = await this.runs(sql`DELETE FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} AND status NOT IN ('queued','running') AND id NOT IN (SELECT id FROM project.quality_test_runs WHERE project_id=${this.projectId} AND status NOT IN ('queued','running') ORDER BY created_at DESC,id DESC LIMIT ${retention}) RETURNING id`); return rows.length; }
|
||||
async findActiveRun(projectId: string, taskId?: string): Promise<TestRun | null> { const rows = taskId ? await this.runs(sql`SELECT * FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} AND task_id=${taskId} AND status IN ('queued','running') ORDER BY created_at DESC LIMIT 1`) : await this.runs(sql`SELECT * FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} AND task_id IS NULL AND status IN ('queued','running') ORDER BY created_at DESC LIMIT 1`); return rows[0] ? run(rows[0]) : null; }
|
||||
async createPlan(input: CreateTestPlanInput): Promise<TestPlan> { const now = new Date().toISOString(); const id = `qplan_${randomUUID()}`; await this.layer.db.execute(sql`INSERT INTO project.quality_test_plans(project_id,id,name,status,steps_json,created_at,updated_at) VALUES(${this.projectId},${id},${input.name},${input.status ?? "active"},${JSON.stringify(input.steps)},${now},${now})`); return (await this.getPlan(input.projectId,id))!; }
|
||||
async getPlan(projectId: string, id: string): Promise<TestPlan | null> { const rows = await this.layer.db.execute(sql`SELECT * FROM project.quality_test_plans WHERE project_id=${this.projectId} AND project_id=${projectId} AND id=${id} LIMIT 1`) as unknown as PlanRow[]; return rows[0] ? plan(rows[0]) : null; }
|
||||
async listPlans(projectId: string, opts?: { includeArchived?: boolean }): Promise<TestPlan[]> { const rows = await this.layer.db.execute(opts?.includeArchived ? sql`SELECT * FROM project.quality_test_plans WHERE project_id=${this.projectId} AND project_id=${projectId} ORDER BY updated_at DESC,id DESC` : sql`SELECT * FROM project.quality_test_plans WHERE project_id=${this.projectId} AND project_id=${projectId} AND status != 'archived' ORDER BY updated_at DESC,id DESC`) as unknown as PlanRow[]; return rows.map(plan); }
|
||||
async getSuggestedCases(projectId: string, taskId: string): Promise<SuggestedCasesSnapshot | null> { const rows = await this.layer.db.execute(sql`SELECT * FROM project.quality_suggested_cases WHERE project_id=${this.projectId} AND project_id=${projectId} AND task_id=${taskId} LIMIT 1`) as unknown as Array<{project_id:string;task_id:string;cases_json:string;generated_at:string;method:string}>; if (!rows[0]) return null; let cases: SuggestedCase[]=[]; try { const parsed=JSON.parse(rows[0].cases_json); if(Array.isArray(parsed)) cases=parsed as SuggestedCase[]; } catch { /* malformed legacy data is empty */ } return {projectId:rows[0].project_id,taskId:rows[0].task_id,cases,generatedAt:rows[0].generated_at,method:rows[0].method as SuggestedCasesSnapshot["method"]}; }
|
||||
async saveSuggestedCases(snapshot: SuggestedCasesSnapshot): Promise<SuggestedCasesSnapshot> { await this.layer.db.execute(sql`INSERT INTO project.quality_suggested_cases(project_id,task_id,cases_json,generated_at,method) VALUES(${this.projectId},${snapshot.taskId},${JSON.stringify(snapshot.cases)},${snapshot.generatedAt},${snapshot.method}) ON CONFLICT(project_id,task_id) DO UPDATE SET cases_json=excluded.cases_json,generated_at=excluded.generated_at,method=excluded.method`); return snapshot; }
|
||||
}
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -1149,6 +1149,9 @@ importers:
|
||||
'@fusion/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.2
|
||||
version: 0.45.2(@opentelemetry/api@1.9.0)(better-sqlite3@12.9.0)(pg@8.22.0)(postgres@3.4.9)
|
||||
lucide-react:
|
||||
specifier: ^0.542.0
|
||||
version: 0.542.0(react@19.2.4)
|
||||
|
||||
90
scripts/__tests__/check-no-getdatabase.test.mjs
Normal file
90
scripts/__tests__/check-no-getdatabase.test.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
scanFileContent,
|
||||
scanTrackedFiles,
|
||||
validateAllowlistEntries,
|
||||
} from "../check-no-getdatabase.mjs";
|
||||
|
||||
const file = "plugins/example/src/route.ts";
|
||||
const line = " return ctx.taskStore.getDatabase();";
|
||||
const pinned = { file, line: 2, snippet: line.trim(), reason: "FN-9999: temporary backend-guarded shim; remove during migration.", allowlistedAt: "2026-07-16" };
|
||||
|
||||
function invocations(content, entries = []) {
|
||||
return scanFileContent(content, file, { allowlistEntries: entries }).filter((match) => match.type === "invocation");
|
||||
}
|
||||
|
||||
describe("check-no-getdatabase", () => {
|
||||
it("flags executable invocations and exempts only the exact pinned occurrence", () => {
|
||||
const content = `const ignored = 1;\n${line}`;
|
||||
assert.equal(invocations(content).length, 1);
|
||||
assert.equal(scanFileContent(content, file, { allowlistEntries: [pinned] }).length, 0);
|
||||
});
|
||||
|
||||
it("does not turn an invocation pin into a file-level exemption", () => {
|
||||
const content = `const ignored = 1;\n${line}\n return ctx.taskStore.getDatabase();`;
|
||||
assert.equal(invocations(content, [pinned]).length, 1);
|
||||
});
|
||||
|
||||
it("does not exempt identical source text on a different line", () => {
|
||||
const content = `const ignored = 1;\n${line}\n${line}`;
|
||||
assert.equal(invocations(content, [pinned]).length, 1);
|
||||
});
|
||||
|
||||
it("flags a moved call and reports the former pin as stale", () => {
|
||||
const content = `\n\n${line}`;
|
||||
const matches = scanFileContent(content, file, { allowlistEntries: [pinned] });
|
||||
assert.equal(matches.filter((match) => match.type === "invocation").length, 1);
|
||||
assert.equal(matches.filter((match) => match.type === "stale-allowlist").length, 1);
|
||||
});
|
||||
|
||||
it("fails stale allowlist fingerprints", () => {
|
||||
const content = `const ignored = 1;\n return ctx.taskStore.getDatabase ( );`;
|
||||
const matches = scanFileContent(content, file, { allowlistEntries: [pinned] });
|
||||
assert.equal(matches.some((match) => match.type === "stale-allowlist"), true);
|
||||
assert.equal(matches.some((match) => match.type === "invocation"), true);
|
||||
});
|
||||
|
||||
it("ignores comments, strings, literal templates, declarations, and near-misses", () => {
|
||||
const content = [
|
||||
"// getDatabase()",
|
||||
"/** getDatabase( */",
|
||||
"const quoted = 'getDatabase('; const double = \"call getDatabase()\";",
|
||||
"const literal = `call getDatabase() only`;",
|
||||
"getDatabase(): Database { return database; }",
|
||||
"public getDatabase(): Database { return database; }",
|
||||
"async getDatabase ( ) { return database; }",
|
||||
"interface Store { getDatabase(): Database; }",
|
||||
"const testDouble = { getDatabase() { return database; } };",
|
||||
"getDatabaseHealth(); getDatabasePath(); refreshDatabaseHealth();",
|
||||
].join("\n");
|
||||
assert.deepEqual(invocations(content), []);
|
||||
});
|
||||
|
||||
it("scans template interpolation expressions but not template literal text", () => {
|
||||
assert.equal(invocations("const text = `literal getDatabase()`;").length, 0);
|
||||
assert.equal(invocations("const text = `${ctx.taskStore.getDatabase()}`;").length, 1);
|
||||
assert.equal(invocations("const text = `${`nested ${ctx.taskStore.getDatabase()}`}`;").length, 1);
|
||||
});
|
||||
|
||||
it("allows explicit backend-guarded legacy pins", () => {
|
||||
const core = "packages/core/src/store.ts";
|
||||
const legacyLine = " await this.getDatabase().runPluginSchemaInits(";
|
||||
const entry = { file: core, line: 1, snippet: legacyLine.trim(), reason: "FN-8104: remove legacy SQLite fallback.", allowlistedAt: "2026-07-16" };
|
||||
assert.deepEqual(scanFileContent(legacyLine, core, { allowlistEntries: [entry] }), []);
|
||||
});
|
||||
|
||||
it("skips deleted tracked files and rethrows non-ENOENT read failures", () => {
|
||||
const matches = scanTrackedFiles(["deleted.ts"], { allowlistEntries: [], readFile: () => { const error = new Error("gone"); error.code = "ENOENT"; throw error; } });
|
||||
assert.deepEqual(matches, []);
|
||||
assert.throws(() => scanTrackedFiles(["denied.ts"], { allowlistEntries: [], readFile: () => { throw new Error("denied"); } }), /denied/);
|
||||
});
|
||||
|
||||
it("validates every required allowlist field", () => {
|
||||
for (const key of ["file", "line", "snippet", "reason", "allowlistedAt"]) {
|
||||
const candidate = { ...pinned };
|
||||
delete candidate[key];
|
||||
assert.throws(() => validateAllowlistEntries([candidate]));
|
||||
}
|
||||
});
|
||||
});
|
||||
159
scripts/check-no-getdatabase.mjs
Normal file
159
scripts/check-no-getdatabase.mjs
Normal file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
FNXC:PostgresOnlyDataAccess 2026-07-16-10:00:
|
||||
Production plugin, dashboard, engine, and core access must use PostgreSQL through
|
||||
AsyncDataLayer, after a Quality route reached SQLite in backend mode and crashed.
|
||||
This scanner bans executable getDatabase() calls. Exceptions live only in the dated,
|
||||
invocation-pinned JSON allowlist keyed by file+line+snippet: no file-level,
|
||||
identical-line, or inline-marker bypass exists. Template ${...} expressions remain
|
||||
executable and are scanned while literal template text is ignored.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const ALLOWLIST_PATH = "scripts/lib/getdatabase-allowlist.json";
|
||||
export const SCAN_ROOTS = ["plugins", "packages/dashboard", "packages/engine", "packages/core"];
|
||||
|
||||
function listTrackedTargets() {
|
||||
const result = spawnSync("git", ["ls-files", "--", ...SCAN_ROOTS], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
||||
if (result.status !== 0) throw new Error(result.stderr?.trim() || "git ls-files failed");
|
||||
return result.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function validateAllowlistEntries(entries) {
|
||||
if (!Array.isArray(entries)) throw new Error(`${ALLOWLIST_PATH} must contain an entries array`);
|
||||
return entries.map((entry, index) => {
|
||||
const prefix = `${ALLOWLIST_PATH} entries[${index}]`;
|
||||
if (!entry || typeof entry.file !== "string" || !entry.file.trim()) throw new Error(`${prefix} must include a non-empty file`);
|
||||
if (!Number.isInteger(entry.line) || entry.line < 1) throw new Error(`${prefix} must include a 1-based integer line`);
|
||||
if (typeof entry.snippet !== "string" || !entry.snippet.trim()) throw new Error(`${prefix} must include a non-empty snippet`);
|
||||
if (typeof entry.reason !== "string" || !entry.reason.trim()) throw new Error(`${prefix} must include a non-empty reason`);
|
||||
if (typeof entry.allowlistedAt !== "string" || Number.isNaN(Date.parse(entry.allowlistedAt))) throw new Error(`${prefix} must include an ISO-8601 allowlistedAt date`);
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
function loadAllowlistEntries(path = ALLOWLIST_PATH) {
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(readFileSync(path, "utf8")); }
|
||||
catch (error) { throw new Error(`Failed to read ${path}: ${error instanceof Error ? error.message : String(error)}`); }
|
||||
return validateAllowlistEntries(parsed.entries);
|
||||
}
|
||||
|
||||
/** Replace non-code with spaces while retaining line/column offsets. */
|
||||
function codeMask(source) {
|
||||
const out = source.split("");
|
||||
const blank = (start, end) => { for (let i = start; i < end; i++) if (out[i] !== "\n" && out[i] !== "\r") out[i] = " "; };
|
||||
const scan = (start, end, templateExpression = false) => {
|
||||
for (let i = start; i < end;) {
|
||||
if (source.startsWith("//", i)) { const close = source.indexOf("\n", i + 2); blank(i, close < 0 ? end : close); i = close < 0 ? end : close; continue; }
|
||||
if (source.startsWith("/*", i)) { const close = source.indexOf("*/", i + 2); const until = close < 0 ? end : close + 2; blank(i, until); i = until; continue; }
|
||||
const quote = source[i];
|
||||
if (quote === "'" || quote === '"') {
|
||||
let j = i + 1;
|
||||
while (j < end) { if (source[j] === "\\") { j += 2; continue; } if (source[j] === quote) { j++; break; } j++; }
|
||||
blank(i, j); i = j; continue;
|
||||
}
|
||||
if (quote === "`") {
|
||||
let j = i + 1; blank(i, i + 1);
|
||||
while (j < end) {
|
||||
if (source[j] === "\\") { blank(j, Math.min(j + 2, end)); j += 2; continue; }
|
||||
if (source[j] === "`") { blank(j, j + 1); j++; break; }
|
||||
if (source[j] === "$" && source[j + 1] === "{") {
|
||||
blank(j, j + 2); let depth = 1; const exprStart = j + 2; j += 2;
|
||||
while (j < end && depth) { if (source[j] === "{") depth++; else if (source[j] === "}") depth--; j++; }
|
||||
const exprEnd = depth === 0 ? j - 1 : end;
|
||||
scan(exprStart, exprEnd, true); if (depth === 0) blank(j - 1, j); continue;
|
||||
}
|
||||
blank(j, j + 1); j++;
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
};
|
||||
scan(0, source.length);
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function closingParen(source, open) {
|
||||
let depth = 0;
|
||||
for (let i = open; i < source.length; i++) {
|
||||
if (source[i] === "(") depth++;
|
||||
else if (source[i] === ")" && --depth === 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function isDeclaration(mask, index, openParen) {
|
||||
const before = mask.slice(Math.max(0, index - 100), index);
|
||||
if (/\.\s*$/.test(before)) return false;
|
||||
const close = closingParen(mask, openParen);
|
||||
if (close < 0) return false;
|
||||
const after = mask.slice(close + 1, Math.min(mask.length, close + 240));
|
||||
const precedingDeclarationToken = /\b(?:function|public|private|protected|static|async|abstract|readonly|declare)\s*$/.test(before);
|
||||
const signature = /^\s*(?:\??\s*)?(?::[^\n{;=]+)?\s*(?:\{|;)/.test(after);
|
||||
return precedingDeclarationToken || signature;
|
||||
}
|
||||
|
||||
function lineAt(content, index) {
|
||||
const lineNumber = content.slice(0, index).split("\n").length;
|
||||
const start = content.lastIndexOf("\n", index - 1) + 1;
|
||||
const end = content.indexOf("\n", index);
|
||||
return { lineNumber, line: content.slice(start, end < 0 ? content.length : end) };
|
||||
}
|
||||
|
||||
export function scanFileContent(content, filePath, options = {}) {
|
||||
const entries = validateAllowlistEntries(options.allowlistEntries ?? []);
|
||||
const mask = codeMask(content);
|
||||
const matches = [];
|
||||
const pinned = new Set();
|
||||
const pattern = /\bgetDatabase\s*\(/g;
|
||||
for (let found; (found = pattern.exec(mask));) {
|
||||
const openParen = mask.indexOf("(", found.index);
|
||||
if (isDeclaration(mask, found.index, openParen)) continue;
|
||||
const { lineNumber, line } = lineAt(content, found.index);
|
||||
const exact = entries.find((entry) => entry.file === filePath && entry.line === lineNumber && entry.snippet === line.trim());
|
||||
if (exact) { pinned.add(exact); continue; }
|
||||
matches.push({ type: "invocation", filePath, lineNumber, line });
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.file === filePath && !pinned.has(entry)) matches.push({ type: "stale-allowlist", filePath, lineNumber: entry.line, line: entry.snippet });
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function scanTrackedFiles(files = listTrackedTargets(), options = {}) {
|
||||
const entries = validateAllowlistEntries(options.allowlistEntries ?? loadAllowlistEntries(options.allowlistPath));
|
||||
const readFile = options.readFile ?? readFileSync;
|
||||
const matches = [];
|
||||
const seen = new Set(files);
|
||||
for (const entry of entries) if (!seen.has(entry.file)) matches.push({ type: "stale-allowlist", filePath: entry.file, lineNumber: entry.line, line: entry.snippet });
|
||||
for (const filePath of files) {
|
||||
let content;
|
||||
try { content = readFile(filePath, "utf8"); }
|
||||
catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") continue; throw error; }
|
||||
matches.push(...scanFileContent(content, filePath, { allowlistEntries: entries }));
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function formatFailureMessage(matches) {
|
||||
return [
|
||||
"[check-no-getdatabase] found non-PostgreSQL durable-data access or a stale exemption.",
|
||||
"Use ctx.taskStore.getAsyncLayer() with an async store; see docs/PLUGIN_AUTHORING.md.",
|
||||
`Legitimate transitional exemptions must be invocation-pinned in ${ALLOWLIST_PATH} by file+line+snippet (all three required).`,
|
||||
...matches.map(({ type, filePath, lineNumber, line }) => `${type}: ${filePath}:${lineNumber}: ${line.trim()}`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function main() {
|
||||
const matches = scanTrackedFiles();
|
||||
if (!matches.length) return 0;
|
||||
console.error(formatFailureMessage(matches));
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) process.exitCode = main();
|
||||
18
scripts/lib/getdatabase-allowlist.json
Normal file
18
scripts/lib/getdatabase-allowlist.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"file": "packages/core/src/store.ts",
|
||||
"line": 2604,
|
||||
"snippet": "await this.getDatabase().runPluginSchemaInits(",
|
||||
"reason": "FN-8104: Backend-guarded SQLite plugin-schema fallback; remove with the coordinated SQLite/U15 retirement.",
|
||||
"allowlistedAt": "2026-07-16"
|
||||
},
|
||||
{
|
||||
"file": "packages/engine/src/self-healing.ts",
|
||||
"line": 5358,
|
||||
"snippet": "const db = this.store.getDatabase();",
|
||||
"reason": "FN-8104: Backend-guarded SQLite self-healing fallback; remove with the coordinated SQLite/U15 retirement.",
|
||||
"allowlistedAt": "2026-07-16"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user