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
16 changes: 10 additions & 6 deletions plugins/thread-briefs/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@ import {
useRealtime,
useRpc,
} from "@get-bb/plugin-sdk/app";
// Types only: `contract.ts` reaches the SDK root, which the app build cannot
// resolve, so this import must erase. Runtime values come from `shared.ts`.
import type {
BriefState,
ResolvedBrief,
RowSignal,
rpcContract,
} from "./contract.js";
import {
BRIEFS_CHANGED_CHANNEL,
type BriefState,
BRIEF_STAGES,
type BriefStage,
type ResolvedBrief,
type RowSignal,
type rpcContract,
} from "./contract.js";
import { BRIEF_STAGES } from "./contract.js";
} from "./shared.js";
import { rowDecoration, STAGE_LABELS, STATUS_LABELS } from "./brief.js";

type Decoration = {
Expand Down
88 changes: 88 additions & 0 deletions plugins/thread-briefs/bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

/**
* The frontend bundle is built by the *server*, against a production install
* where `@get-bb/plugin-sdk` has been pruned as a devDependency — and only
* `@get-bb/plugin-sdk/app` is shimmed for the frontend, never the root. So any
* runtime import that reaches the SDK root breaks the app build in the image
* while passing every local check, because local installs keep dev deps.
*
* That shipped once: `app.tsx` imported two constants from `contract.ts`, which
* imports `defineRpcContract` from the SDK root, and the image failed with
* `Could not resolve "@get-bb/plugin-sdk"`. These tests encode the rule so it
* cannot happen again without a local failure first.
*/

const read = (name: string) =>
readFileSync(join(import.meta.dirname, name), "utf8");

/** Modules the frontend bundle is allowed to pull in at runtime. */
const FRONTEND_MODULES = ["app.tsx", "shared.ts", "brief.ts"];

/**
* Strip comments first: the word "import" inside prose would otherwise anchor a
* match that runs on into the next real statement.
*/
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//gu, "")
.replace(/^[^\n]*?\/\/[^\n]*$/gmu, "");
}

/** `import ... from "<spec>"` statements that are not `import type`. */
function runtimeImports(rawSource: string): string[] {
const source = stripComments(rawSource);
const specs: string[] = [];
const pattern = /import\s+(type\s+)?([\s\S]*?)\s+from\s+["']([^"']+)["']/gu;
for (const match of source.matchAll(pattern)) {
const isTypeOnly = match[1] !== undefined;
const clause = match[2] ?? "";
const spec = match[3] ?? "";
if (isTypeOnly) continue;
// A clause whose every binding is `type X` also erases entirely.
const bindings = clause
.replace(/[{}]/gu, "")
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry !== "");
const allTypes =
bindings.length > 0 && bindings.every((entry) => entry.startsWith("type "));
if (allTypes) continue;
specs.push(spec);
}
return specs;
}

describe("frontend bundle graph", () => {
it.each(FRONTEND_MODULES)(
"%s does not import the SDK root or zod at runtime",
(name) => {
const imports = runtimeImports(read(name));
expect(imports).not.toContain("@get-bb/plugin-sdk");
expect(imports).not.toContain("zod");
},
);

it("app.tsx imports contract.ts for types only", () => {
// contract.ts reaches the SDK root, so a runtime import of it would drag
// the SDK into the app bundle.
expect(runtimeImports(read("app.tsx"))).not.toContain("./contract.js");
});

it("app.tsx gets its runtime constants from shared.ts", () => {
const source = read("app.tsx");
expect(source).toMatch(/BRIEFS_CHANGED_CHANNEL[\s\S]*?from "\.\/shared\.js"/u);
});

it("shared.ts has no imports at all, so it can never reach the SDK", () => {
expect(read("shared.ts")).not.toMatch(/^\s*import\s/mu);
});

it("the stages have one definition", () => {
// contract.ts builds its zod enum from shared.ts's list rather than
// repeating it, so the two can never disagree.
expect(read("contract.ts")).toMatch(/z\.enum\(BRIEF_STAGES\)/u);
});
});
18 changes: 7 additions & 11 deletions plugins/thread-briefs/contract.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import { defineRpcContract } from "@get-bb/plugin-sdk";
import { z } from "zod";
import { BRIEF_STAGES, type BriefStage } from "./shared.js";

/**
* Where the thread is in its arc. A semantic judgement the summarizer makes
* from the transcript, which the user can override by hand.
* from the transcript, which the user can override by hand. Built from the
* plain list in `shared.ts` so the stages have one definition.
*/
export const briefStageSchema = z.enum([
"discovery",
"planning",
"implementation",
"review",
]);
export type BriefStage = z.infer<typeof briefStageSchema>;
export const briefStageSchema = z.enum(BRIEF_STAGES);
export type { BriefStage };

/**
* Who the thread is waiting on. Derived mechanically rather than asked of the
Expand All @@ -25,7 +22,7 @@ export const briefStatusSchema = z.enum([
]);
export type BriefStatus = z.infer<typeof briefStatusSchema>;

export const BRIEF_STAGES: readonly BriefStage[] = briefStageSchema.options;
export { BRIEF_STAGES };

/**
* The five prose fields, exactly as the summarizer is asked to return them.
Expand Down Expand Up @@ -127,8 +124,7 @@ export const rowSignalSchema = z
.strict();
export type RowSignal = z.infer<typeof rowSignalSchema>;

/** Realtime channel the server pokes when any brief changes. */
export const BRIEFS_CHANGED_CHANNEL = "briefs-changed";
export { BRIEFS_CHANGED_CHANNEL } from "./shared.js";

export const rpcContract = defineRpcContract({
/** The brief for one thread, for the thread-header popover. */
Expand Down
24 changes: 24 additions & 0 deletions plugins/thread-briefs/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Values both the server and the frontend need at runtime, with no imports.
*
* This module exists to keep `@get-bb/plugin-sdk` and zod out of the frontend
* bundle's dependency graph. `contract.ts` imports `defineRpcContract` from the
* SDK root, which the app build cannot resolve: the SDK is a devDependency, so
* a production install prunes it, and only `@get-bb/plugin-sdk/app` is shimmed
* for the frontend. Any runtime value `app.tsx` needs therefore lives here
* rather than beside the schemas, so the app's imports from `contract.ts` stay
* type-only and erase completely.
*/

/** Where the thread is in its arc, in display order. */
export const BRIEF_STAGES = [
"discovery",
"planning",
"implementation",
"review",
] as const;

export type BriefStage = (typeof BRIEF_STAGES)[number];

/** Realtime channel the server pokes when any brief changes. */
export const BRIEFS_CHANGED_CHANNEL = "briefs-changed";
1 change: 1 addition & 0 deletions plugins/thread-briefs/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"skipLibCheck": false
},
"include": [
"shared.ts",
"contract.ts",
"brief.ts",
"transcript.ts",
Expand Down
Loading