mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
89f0b6628e
Phase 2 of live app annotation: full parity on Pi over one shared implementation instead of drifting copies. - Extract every proxy decision into packages/shared/live-proxy-core.ts (HTML injector state machine, loopback/Host/Origin predicates, CSP/X-Frame-Options policy, redirect rewrite, WS origin gate, bridge assembly, liveAppDraftIdentity) and the CLI probe + live-mode messages into packages/shared/live-probe.ts. packages/server/live-proxy.ts is now a thin Bun transport over the core; its test suite passes unmodified. - Add packages/shared/live-proxy-node.ts, the node:http transport the Pi extension runs: streaming request/response piping through the shared injector, and WebSocket (HMR) passthrough that replays the client's handshake upstream over raw TCP and pipes the sockets byte-for-byte. Transport tests run the proxy in a real node child process, because Bun's node:http shim drops writes to an upgrade event's socket. - Wire Pi: /plannotator-annotate probes loopback URLs live-first with the shared probe (same 3s timeout, same <500 gate, same messages), recognizes --app/--static via parseAnnotateArgs's liveFlags opt-in (OpenCode deliberately does not opt in), and serves mode annotate-app from serverAnnotate.ts with the shared per-target draft identity, live sessions excluded from history/submissions, the remote hard-off throw, and guarded live-proxy shutdown. - Vendor live-proxy-core/live-probe/live-proxy-node plus the dependency-free bridge-script constants to generated/. - Docs: AGENTS.md phase-gate passages, marketing annotate page, Pi README.
531 lines
14 KiB
TypeScript
531 lines
14 KiB
TypeScript
import { describe, test, expect } from "bun:test";
|
|
import { parseAnnotateArgs } from "./annotate-args";
|
|
|
|
describe("parseAnnotateArgs", () => {
|
|
test("path only", () => {
|
|
expect(parseAnnotateArgs("spec.md")).toEqual({
|
|
filePath: "spec.md",
|
|
rawFilePath: "spec.md",
|
|
gate: false,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("path with --gate at end", () => {
|
|
expect(parseAnnotateArgs("spec.md --gate")).toEqual({
|
|
filePath: "spec.md",
|
|
rawFilePath: "spec.md",
|
|
gate: true,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("--gate before path", () => {
|
|
expect(parseAnnotateArgs("--gate spec.md")).toEqual({
|
|
filePath: "spec.md",
|
|
rawFilePath: "spec.md",
|
|
gate: true,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("path with both flags", () => {
|
|
expect(parseAnnotateArgs("spec.md --gate --json")).toEqual({
|
|
filePath: "spec.md",
|
|
rawFilePath: "spec.md",
|
|
gate: true,
|
|
json: true,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("flags only, no path", () => {
|
|
expect(parseAnnotateArgs("--gate --json")).toEqual({
|
|
filePath: "",
|
|
rawFilePath: "",
|
|
gate: true,
|
|
json: true,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("path with spaces rejoins with single space", () => {
|
|
expect(parseAnnotateArgs("my file.md --gate")).toEqual({
|
|
filePath: "my file.md",
|
|
rawFilePath: "my file.md",
|
|
gate: true,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
// `@` is the reference-mode marker (Claude Code / OpenCode / Pi convention),
|
|
// not part of the filename. The parser strips it on `filePath` as the primary
|
|
// behavior — that's the common case. `rawFilePath` preserves the original
|
|
// for callers that want to try the literal form as a fallback (scoped-package-
|
|
// style names). See at-reference.ts for the combined helper.
|
|
|
|
test("leading @ is stripped (reference-mode primary) and rawFilePath preserves it", () => {
|
|
expect(parseAnnotateArgs("@spec.md --gate")).toEqual({
|
|
filePath: "spec.md",
|
|
rawFilePath: "@spec.md",
|
|
gate: true,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("scoped-package-style path: filePath stripped, rawFilePath literal", () => {
|
|
expect(parseAnnotateArgs("@plannotator/ui/README.md")).toEqual({
|
|
filePath: "plannotator/ui/README.md",
|
|
rawFilePath: "@plannotator/ui/README.md",
|
|
gate: false,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("@ stripped on filePath when combined with --gate --json, raw preserved", () => {
|
|
expect(parseAnnotateArgs("@docs/spec.md --gate --json")).toEqual({
|
|
filePath: "docs/spec.md",
|
|
rawFilePath: "@docs/spec.md",
|
|
gate: true,
|
|
json: true,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("URL passes through", () => {
|
|
expect(parseAnnotateArgs("https://example.com/docs --gate")).toEqual({
|
|
filePath: "https://example.com/docs",
|
|
rawFilePath: "https://example.com/docs",
|
|
gate: true,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("--no-jina is stripped from URL args", () => {
|
|
expect(parseAnnotateArgs("https://example.com/docs --no-jina --gate")).toEqual({
|
|
filePath: "https://example.com/docs",
|
|
rawFilePath: "https://example.com/docs",
|
|
gate: true,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: true,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("--no-jina before path is stripped", () => {
|
|
expect(parseAnnotateArgs("--no-jina https://example.com/docs")).toEqual({
|
|
filePath: "https://example.com/docs",
|
|
rawFilePath: "https://example.com/docs",
|
|
gate: false,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: true,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("extra whitespace is collapsed", () => {
|
|
expect(parseAnnotateArgs(" spec.md --gate ")).toEqual({
|
|
filePath: "spec.md",
|
|
rawFilePath: "spec.md",
|
|
gate: true,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("empty string produces empty result", () => {
|
|
expect(parseAnnotateArgs("")).toEqual({
|
|
filePath: "",
|
|
rawFilePath: "",
|
|
gate: false,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("nullish input is tolerated", () => {
|
|
expect(parseAnnotateArgs(undefined as unknown as string)).toEqual({
|
|
filePath: "",
|
|
rawFilePath: "",
|
|
gate: false,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("folder path with trailing slash", () => {
|
|
expect(parseAnnotateArgs("./specs/ --gate --json")).toEqual({
|
|
filePath: "./specs/",
|
|
rawFilePath: "./specs/",
|
|
gate: true,
|
|
json: true,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
// Regressions from the initial parser: the tokenize-and-rejoin approach
|
|
// collapsed consecutive whitespace in file paths. Before this branch,
|
|
// OpenCode and Pi passed the raw args string straight through, so files
|
|
// with double-spaces or tabs in their names worked fine. These tests pin
|
|
// that behavior so we don't regress it again.
|
|
|
|
test("double-space inside a file path is preserved (flag at end)", () => {
|
|
expect(parseAnnotateArgs("My Notes.md --gate")).toEqual({
|
|
filePath: "My Notes.md",
|
|
rawFilePath: "My Notes.md",
|
|
gate: true,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("double-space inside a file path is preserved (flag at start)", () => {
|
|
expect(parseAnnotateArgs("--gate My Notes.md")).toEqual({
|
|
filePath: "My Notes.md",
|
|
rawFilePath: "My Notes.md",
|
|
gate: true,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("tab inside a file path is preserved", () => {
|
|
expect(parseAnnotateArgs("My\tNotes.md --gate")).toEqual({
|
|
filePath: "My\tNotes.md",
|
|
rawFilePath: "My\tNotes.md",
|
|
gate: true,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("multi-whitespace path with no flags passes through untouched", () => {
|
|
expect(parseAnnotateArgs("/tmp/My Notes.md")).toEqual({
|
|
filePath: "/tmp/My Notes.md",
|
|
rawFilePath: "/tmp/My Notes.md",
|
|
gate: false,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
// OpenCode and Pi don't go through a shell, so users who quote paths
|
|
// (shell muscle memory, copy-paste from docs) have literal quote
|
|
// characters reach the parser. Strip them at the tokenization layer
|
|
// so downstream callers don't have to reason about quoting.
|
|
|
|
test("wrapping double quotes are stripped from both filePath and rawFilePath", () => {
|
|
expect(parseAnnotateArgs(`"@foo.md" --gate`)).toEqual({
|
|
filePath: "foo.md",
|
|
rawFilePath: "@foo.md",
|
|
gate: true,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("wrapping single quotes are stripped", () => {
|
|
expect(parseAnnotateArgs(`'@foo.md' --gate`)).toEqual({
|
|
filePath: "foo.md",
|
|
rawFilePath: "@foo.md",
|
|
gate: true,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("wrapping quotes around a path with spaces", () => {
|
|
expect(parseAnnotateArgs(`"@My Notes.md" --gate`)).toEqual({
|
|
filePath: "My Notes.md",
|
|
rawFilePath: "@My Notes.md",
|
|
gate: true,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("wrapping quotes without @ still get stripped", () => {
|
|
expect(parseAnnotateArgs(`"My Notes.md"`)).toEqual({
|
|
filePath: "My Notes.md",
|
|
rawFilePath: "My Notes.md",
|
|
gate: false,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
// --hook emits hook-native JSON ({"decision":"block","reason":"..."}) for
|
|
// annotations and empty stdout for approve/close. It implies --gate in the
|
|
// binary, but the parser is a pure tokenizer — it reports which flags were
|
|
// present without applying implication logic.
|
|
|
|
test("--hook alongside --gate", () => {
|
|
expect(parseAnnotateArgs("spec.md --gate --hook")).toEqual({
|
|
filePath: "spec.md",
|
|
rawFilePath: "spec.md",
|
|
gate: true,
|
|
json: false,
|
|
hook: true,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("--hook with all three flags", () => {
|
|
expect(parseAnnotateArgs("spec.md --gate --json --hook")).toEqual({
|
|
filePath: "spec.md",
|
|
rawFilePath: "spec.md",
|
|
gate: true,
|
|
json: true,
|
|
hook: true,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("--hook alone implies gate", () => {
|
|
expect(parseAnnotateArgs("spec.md --hook")).toEqual({
|
|
filePath: "spec.md",
|
|
rawFilePath: "spec.md",
|
|
gate: true,
|
|
json: false,
|
|
hook: true,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("--hook before path", () => {
|
|
expect(parseAnnotateArgs("--hook --gate spec.md")).toEqual({
|
|
filePath: "spec.md",
|
|
rawFilePath: "spec.md",
|
|
gate: true,
|
|
json: false,
|
|
hook: true,
|
|
renderHtml: false,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("--render-html with HTML file and --gate", () => {
|
|
expect(parseAnnotateArgs("plan.html --render-html --gate")).toEqual({
|
|
filePath: "plan.html",
|
|
rawFilePath: "plan.html",
|
|
gate: true,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: true,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("--render-html alone", () => {
|
|
expect(parseAnnotateArgs("plan.html --render-html")).toEqual({
|
|
filePath: "plan.html",
|
|
rawFilePath: "plan.html",
|
|
gate: false,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: true,
|
|
renderMarkdown: false,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("--markdown with HTML file", () => {
|
|
expect(parseAnnotateArgs("plan.html --markdown")).toEqual({
|
|
filePath: "plan.html",
|
|
rawFilePath: "plan.html",
|
|
gate: false,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: false,
|
|
renderMarkdown: true,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
|
|
test("--markdown and legacy --render-html both parse", () => {
|
|
expect(parseAnnotateArgs("plan.html --render-html --markdown")).toEqual({
|
|
filePath: "plan.html",
|
|
rawFilePath: "plan.html",
|
|
gate: false,
|
|
json: false,
|
|
hook: false,
|
|
renderHtml: true,
|
|
renderMarkdown: true,
|
|
noJina: false,
|
|
app: false,
|
|
static: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("parseAnnotateArgs: live flags (--app / --static)", () => {
|
|
test("recognized only when the host opts in via liveFlags", () => {
|
|
const parsed = parseAnnotateArgs("http://localhost:5173 --app", { liveFlags: true });
|
|
expect(parsed.filePath).toBe("http://localhost:5173");
|
|
expect(parsed.app).toBe(true);
|
|
expect(parsed.static).toBe(false);
|
|
|
|
const staticParsed = parseAnnotateArgs("--static http://localhost:5173", { liveFlags: true });
|
|
expect(staticParsed.filePath).toBe("http://localhost:5173");
|
|
expect(staticParsed.static).toBe(true);
|
|
expect(staticParsed.app).toBe(false);
|
|
});
|
|
|
|
test("without liveFlags the tokens stay in the path (honest unsupported-flag error downstream)", () => {
|
|
// A host that cannot act on --app must not silently strip it: leaving the
|
|
// token in the path preserves the legacy "File not found: --app ..."
|
|
// failure instead of quietly ignoring the user's flag.
|
|
const parsed = parseAnnotateArgs("http://localhost:5173 --app");
|
|
expect(parsed.filePath).toBe("http://localhost:5173 --app");
|
|
expect(parsed.app).toBe(false);
|
|
expect(parsed.static).toBe(false);
|
|
});
|
|
});
|