chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
# `_parity/` — integration-demo parity tooling
|
||||
|
||||
Keeps `examples/integrations/*` demos aligned to a single north-star so
|
||||
drift doesn't pile up as the canonical demo evolves.
|
||||
|
||||
**North-star (v1):** `langgraph-python` — the richest demo (todos state, 5
|
||||
tools, polished prompt, full frontend canvas). Every other integration demo
|
||||
should track it.
|
||||
|
||||
## What gets tracked
|
||||
|
||||
Declared in [`manifest.json`](./manifest.json):
|
||||
|
||||
- **verbatim files** — copied byte-for-byte from north-star to each instance
|
||||
(frontend components, hooks, lib, public assets, shared Docker files,
|
||||
`docker-compose.test.yml`, `entrypoint.sh`, `postcss.config.mjs`, etc.)
|
||||
- **package.json keys** — tracked dependency versions and script names
|
||||
(`@copilotkit/*`, `next`, `react`, shared dev scripts). Per-instance
|
||||
overrides in manifest (`packageJsonOverrides`) win where the instance
|
||||
legitimately differs (e.g. `dev:agent` runs `npm install` in JS but
|
||||
`uv sync` in FastAPI).
|
||||
- **canonical prompt** — `_parity/canonical/PROMPT.md`. Each agent
|
||||
**inlines** the prompt as a string literal in its source (matching the
|
||||
north-star's `main.py` pattern). Verifier greps the canonical prompt's
|
||||
first non-blank line against instance agent source — drift = error.
|
||||
- **agent surface** — tool names + state keys expected to appear in each
|
||||
instance's agent source. Grep-level check — doesn't validate call-site
|
||||
correctness, that's the aimock fixture tests' job.
|
||||
|
||||
## What doesn't get tracked (allowed divergence)
|
||||
|
||||
Per-instance `allowedDivergence` list in `manifest.json`:
|
||||
|
||||
- `agent/**` — agents are written in different languages/runtimes (Python
|
||||
create_agent, TS StateGraph, Python StateGraph+FastAPI). Human-authored.
|
||||
- `src/app/api/copilotkit/**` — north-star uses `LangGraphAgent`, Docker
|
||||
instances use `HttpAgent`. Different routes.
|
||||
- `Dockerfile`, `docker/Dockerfile.agent`, `serve.py`, `scripts/**` —
|
||||
language-specific build/run tooling.
|
||||
|
||||
Anything outside both `tracked` and `allowedDivergence` is "no-op" — the
|
||||
verifier neither checks nor touches it.
|
||||
|
||||
## Commands
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
# Sync a single instance to north-star (copies verbatim files, rewrites
|
||||
# package.json keys, writes canonical prompt to agent/PROMPT.md)
|
||||
pnpm parity:sync --target=langgraph-js
|
||||
|
||||
# Dry-run: show what would change without writing
|
||||
pnpm parity:sync --target=langgraph-js --dry-run
|
||||
|
||||
# Sync every non-north-star instance
|
||||
pnpm parity:sync --all
|
||||
|
||||
# Verify — exits non-zero on unexpected drift
|
||||
pnpm parity:verify
|
||||
pnpm parity:verify --target=langgraph-js
|
||||
|
||||
# CI invocation (same as verify, no color)
|
||||
pnpm parity:check
|
||||
```
|
||||
|
||||
## Typical workflows
|
||||
|
||||
### North-star changed — sync instances
|
||||
|
||||
```bash
|
||||
pnpm parity:sync --all
|
||||
pnpm parity:verify
|
||||
# fix any agent-surface drift manually in the relevant agent/src/ files
|
||||
git add . && git commit
|
||||
```
|
||||
|
||||
### Adding a new instance
|
||||
|
||||
1. Create the new demo under `examples/integrations/<name>/` with a
|
||||
Next.js frontend at the root and an `agent/` dir.
|
||||
2. Add an entry to `manifest.json` under `instances`:
|
||||
```json
|
||||
"new-demo": {
|
||||
"role": "instance",
|
||||
"agent": { "language": "python", "runtime": "..." },
|
||||
"allowedDivergence": ["agent/**", "src/app/api/copilotkit/**",
|
||||
"Dockerfile", "docker/Dockerfile.agent",
|
||||
"serve.py", "scripts/**"],
|
||||
"packageJsonOverrides": { "scripts.dev:agent": "..." }
|
||||
}
|
||||
```
|
||||
3. Run `pnpm parity:sync --target=new-demo`.
|
||||
4. Hand-port the agent code (tools, state, prompt loading) — manifest
|
||||
`tracked.agentSurface` tells you what tool names and state keys are
|
||||
required.
|
||||
5. Run `pnpm parity:verify --target=new-demo` until green.
|
||||
|
||||
### North-star's agent surface changed
|
||||
|
||||
Edit `manifest.json` → `tracked.agentSurface.toolNames` / `stateKeys`.
|
||||
The verifier will then flag every instance that hasn't caught up.
|
||||
|
||||
### Canonical prompt changed
|
||||
|
||||
1. Edit `_parity/canonical/PROMPT.md`.
|
||||
2. Update north-star's `agent/main.py` to use the new prompt string
|
||||
(north-star is where humans read; canonical file is what the
|
||||
verifier reads).
|
||||
3. Port the new prompt into every instance's agent source (same
|
||||
manual-merge rules as any agent change).
|
||||
4. `pnpm parity:verify` — verifier greps first line of canonical against
|
||||
each instance's agent source. Passes when all instances inline it.
|
||||
|
||||
## CI
|
||||
|
||||
`.github/workflows/integrations_parity.yml` runs `pnpm parity:check` on
|
||||
every PR that touches `examples/integrations/**`. Failures link the
|
||||
contributor back to this README.
|
||||
|
||||
## Design notes
|
||||
|
||||
- **Declarative, not prescriptive.** The manifest says _what_ is tracked;
|
||||
the scripts just walk it. Adding a new tracked file = one line change,
|
||||
not a code change.
|
||||
- **Allowed-divergence is explicit, not implicit.** Everything is either
|
||||
tracked, declared-divergent, or ignored — no silent "probably different"
|
||||
state.
|
||||
- **Agent surface is grep-level, on purpose.** A real AST check would be
|
||||
three times the code and still miss semantic drift. The existing aimock
|
||||
fixture integration tests (under `fixtures/default.json` per instance)
|
||||
are the real correctness check; this is the "did someone rip out
|
||||
`manage_todos`" safety net.
|
||||
- **North-star is read-only to the scripts.** `sync.ts` refuses to write
|
||||
into the north-star directory even if asked. Verifier never touches
|
||||
anything.
|
||||
@@ -0,0 +1,11 @@
|
||||
You are a polished, professional demo assistant. Keep responses to 1-2 sentences.
|
||||
|
||||
Tool guidance:
|
||||
|
||||
- Flights: call search_flights to show flight cards with a pre-built schema.
|
||||
- Dashboards & rich UI: call generate_a2ui to create dashboard UIs with metrics,
|
||||
charts, tables, and cards. It handles rendering automatically.
|
||||
- Charts: call query_data first, then render with the chart component.
|
||||
- Todos: enable app mode first, then manage todos.
|
||||
- A2UI actions: when you see a log_a2ui_event result (e.g. "view_details"),
|
||||
respond with a brief confirmation. The UI already updated on the frontend.
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
|
||||
export function sha256(buf: Buffer | string): string {
|
||||
return createHash("sha256").update(buf).digest("hex");
|
||||
}
|
||||
|
||||
export function fileSha256(path: string): string {
|
||||
return sha256(readFileSync(path));
|
||||
}
|
||||
|
||||
export function fileExists(path: string): boolean {
|
||||
try {
|
||||
statSync(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getByPath(obj: unknown, path: string): unknown {
|
||||
const parts = path.split(".");
|
||||
let cur: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (cur == null || typeof cur !== "object") return undefined;
|
||||
cur = (cur as Record<string, unknown>)[part];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
export function setByPath(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
const parts = path.split(".");
|
||||
let cur: Record<string, unknown> = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const key = parts[i]!;
|
||||
const next = cur[key];
|
||||
if (next == null || typeof next !== "object" || Array.isArray(next)) {
|
||||
cur[key] = {};
|
||||
}
|
||||
cur = cur[key] as Record<string, unknown>;
|
||||
}
|
||||
cur[parts[parts.length - 1]!] = value;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export type InstanceRole = "north-star" | "instance";
|
||||
|
||||
export interface AgentSurface {
|
||||
toolNames: string[];
|
||||
stateKeys: string[];
|
||||
modelFamily: string;
|
||||
}
|
||||
|
||||
export interface TrackedSurface {
|
||||
verbatimFiles: string[];
|
||||
packageJsonPaths: string[];
|
||||
agentSurface: AgentSurface;
|
||||
}
|
||||
|
||||
export interface InstanceAgent {
|
||||
language: "python" | "typescript";
|
||||
runtime: string;
|
||||
}
|
||||
|
||||
export interface Instance {
|
||||
role: InstanceRole;
|
||||
agent: InstanceAgent;
|
||||
allowedDivergence: string[];
|
||||
packageJsonOverrides: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
version: number;
|
||||
northStar: string;
|
||||
canonicalPromptFile: string;
|
||||
tracked: TrackedSurface;
|
||||
instances: Record<string, Instance>;
|
||||
}
|
||||
|
||||
export interface ParityRoot {
|
||||
/** Absolute path to the parity dir, e.g. …/examples/integrations/_parity */
|
||||
parityDir: string;
|
||||
/** Absolute path to the integrations root, e.g. …/examples/integrations */
|
||||
integrationsDir: string;
|
||||
manifest: Manifest;
|
||||
}
|
||||
|
||||
export function loadManifest(parityDir: string): ParityRoot {
|
||||
const manifestPath = resolve(parityDir, "manifest.json");
|
||||
const raw = readFileSync(manifestPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Manifest;
|
||||
validate(parsed, manifestPath);
|
||||
return {
|
||||
parityDir,
|
||||
integrationsDir: resolve(parityDir, ".."),
|
||||
manifest: parsed,
|
||||
};
|
||||
}
|
||||
|
||||
function validate(m: Manifest, source: string): void {
|
||||
const err = (msg: string): never => {
|
||||
throw new Error(`[parity] invalid manifest at ${source}: ${msg}`);
|
||||
};
|
||||
if (m.version !== 1) err(`unsupported version ${m.version}`);
|
||||
if (!m.northStar) err("missing northStar");
|
||||
if (!m.instances?.[m.northStar])
|
||||
err(`northStar '${m.northStar}' not in instances`);
|
||||
if (m.instances[m.northStar].role !== "north-star")
|
||||
err(`northStar '${m.northStar}' must have role=north-star`);
|
||||
|
||||
const nonNorthStar = Object.entries(m.instances).filter(
|
||||
([n]) => n !== m.northStar,
|
||||
);
|
||||
for (const [name, inst] of nonNorthStar) {
|
||||
if (inst.role !== "instance")
|
||||
err(`instance '${name}' must have role=instance`);
|
||||
}
|
||||
|
||||
if (!m.tracked?.verbatimFiles?.length) err("tracked.verbatimFiles empty");
|
||||
if (!m.tracked?.packageJsonPaths?.length)
|
||||
err("tracked.packageJsonPaths empty");
|
||||
if (!m.tracked?.agentSurface?.toolNames?.length)
|
||||
err("tracked.agentSurface.toolNames empty");
|
||||
}
|
||||
|
||||
export function instanceDir(root: ParityRoot, name: string): string {
|
||||
return resolve(root.integrationsDir, name);
|
||||
}
|
||||
|
||||
export function northStarDir(root: ParityRoot): string {
|
||||
return instanceDir(root, root.manifest.northStar);
|
||||
}
|
||||
|
||||
export function listInstances(root: ParityRoot): string[] {
|
||||
return Object.keys(root.manifest.instances).filter(
|
||||
(n) => n !== root.manifest.northStar,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
export type Severity = "ok" | "warn" | "error";
|
||||
|
||||
export interface DriftItem {
|
||||
severity: Severity;
|
||||
instance: string;
|
||||
kind:
|
||||
| "verbatim-file"
|
||||
| "package-json"
|
||||
| "agent-tool"
|
||||
| "agent-state"
|
||||
| "prompt"
|
||||
| "missing-instance";
|
||||
subject: string;
|
||||
detail?: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
instance: string;
|
||||
items: DriftItem[];
|
||||
}
|
||||
|
||||
export function hasErrors(reports: Report[]): boolean {
|
||||
return reports.some((r) => r.items.some((i) => i.severity === "error"));
|
||||
}
|
||||
|
||||
const RESET = "\x1b[0m";
|
||||
const RED = "\x1b[31m";
|
||||
const YEL = "\x1b[33m";
|
||||
const GRN = "\x1b[32m";
|
||||
const BOLD = "\x1b[1m";
|
||||
const DIM = "\x1b[2m";
|
||||
|
||||
function color(enabled: boolean, code: string, s: string): string {
|
||||
return enabled ? `${code}${s}${RESET}` : s;
|
||||
}
|
||||
|
||||
export function printReports(reports: Report[], useColor = true): void {
|
||||
for (const r of reports) {
|
||||
const errors = r.items.filter((i) => i.severity === "error");
|
||||
const warns = r.items.filter((i) => i.severity === "warn");
|
||||
const oks = r.items.filter((i) => i.severity === "ok");
|
||||
|
||||
const header = color(
|
||||
useColor,
|
||||
BOLD,
|
||||
`\n${r.instance} — ${oks.length} ok, ${warns.length} warn, ${errors.length} error`,
|
||||
);
|
||||
process.stdout.write(header + "\n");
|
||||
|
||||
for (const item of [...errors, ...warns]) {
|
||||
const tag =
|
||||
item.severity === "error"
|
||||
? color(useColor, RED, " ✗")
|
||||
: color(useColor, YEL, " !");
|
||||
const kindStr = color(useColor, DIM, `[${item.kind}]`);
|
||||
process.stdout.write(`${tag} ${kindStr} ${item.subject}\n`);
|
||||
if (item.detail) process.stdout.write(` ${item.detail}\n`);
|
||||
if (item.expected !== undefined || item.actual !== undefined) {
|
||||
if (item.expected !== undefined)
|
||||
process.stdout.write(` expected: ${truncate(item.expected)}\n`);
|
||||
if (item.actual !== undefined)
|
||||
process.stdout.write(` actual: ${truncate(item.actual)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length === 0 && warns.length === 0) {
|
||||
process.stdout.write(color(useColor, GRN, " ✓ no drift\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(s: string, max = 120): string {
|
||||
if (s.length <= max) return s;
|
||||
return s.slice(0, max) + "…";
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
{
|
||||
"version": 1,
|
||||
"northStar": "langgraph-python",
|
||||
"canonicalPromptFile": "_parity/canonical/PROMPT.md",
|
||||
"tracked": {
|
||||
"verbatimFiles": [
|
||||
"postcss.config.mjs",
|
||||
"tsconfig.json",
|
||||
"next.config.ts",
|
||||
"next-env.d.ts",
|
||||
"public/copilotkit-logo-mark.svg",
|
||||
"public/copilotkit-logo.svg",
|
||||
"public/file.svg",
|
||||
"public/globe.svg",
|
||||
"public/next.svg",
|
||||
"public/vercel.svg",
|
||||
"public/window.svg",
|
||||
"src/app/favicon.ico",
|
||||
"src/app/globals.css",
|
||||
"src/app/layout.tsx",
|
||||
"src/app/page.tsx",
|
||||
"src/app/declarative-generative-ui/definitions.ts",
|
||||
"src/app/declarative-generative-ui/renderers.tsx",
|
||||
"src/components/example-canvas/index.tsx",
|
||||
"src/components/example-canvas/todo-card.tsx",
|
||||
"src/components/example-canvas/todo-column.tsx",
|
||||
"src/components/example-canvas/todo-list.tsx",
|
||||
"src/components/example-layout/index.tsx",
|
||||
"src/components/example-layout/mode-toggle.tsx",
|
||||
"src/components/generative-ui/charts/bar-chart.tsx",
|
||||
"src/components/generative-ui/charts/config.ts",
|
||||
"src/components/generative-ui/charts/pie-chart.tsx",
|
||||
"src/components/generative-ui/meeting-time-picker.tsx",
|
||||
"src/components/headless-chat.tsx",
|
||||
"src/components/tool-rendering.tsx",
|
||||
"src/components/threads-drawer/index.tsx",
|
||||
"src/components/threads-drawer/locked-state.tsx",
|
||||
"src/components/threads-drawer/threads-drawer.module.css",
|
||||
"src/components/threads-drawer/threads-drawer.tsx",
|
||||
"src/components/ui/badge.tsx",
|
||||
"src/components/ui/button.tsx",
|
||||
"src/components/ui/card.tsx",
|
||||
"src/components/ui/checkbox.tsx",
|
||||
"src/components/ui/input.tsx",
|
||||
"src/components/ui/separator.tsx",
|
||||
"src/components/ui/spinner.tsx",
|
||||
"src/hooks/index.ts",
|
||||
"src/hooks/use-example-suggestions.tsx",
|
||||
"src/hooks/use-generative-ui-examples.tsx",
|
||||
"src/hooks/use-theme.tsx",
|
||||
"src/lib/a2ui-theme.css",
|
||||
"src/lib/utils.ts",
|
||||
"Dockerfile",
|
||||
"docker/Dockerfile.app",
|
||||
"docker/Dockerfile.agent",
|
||||
"docker-compose.test.yml",
|
||||
"docker-route-override.ts",
|
||||
"entrypoint.sh",
|
||||
"serve.py",
|
||||
"showcase.json"
|
||||
],
|
||||
"packageJsonPaths": [
|
||||
"scripts.dev",
|
||||
"scripts.dev:debug",
|
||||
"scripts.dev:ui",
|
||||
"scripts.build",
|
||||
"scripts.start",
|
||||
"scripts.postinstall",
|
||||
"dependencies.@copilotkit/react-core",
|
||||
"dependencies.@copilotkit/react-ui",
|
||||
"dependencies.@copilotkit/runtime",
|
||||
"dependencies.@copilotkit/shared",
|
||||
"dependencies.@copilotkit/a2ui-renderer",
|
||||
"dependencies.@radix-ui/react-checkbox",
|
||||
"dependencies.@radix-ui/react-label",
|
||||
"dependencies.@radix-ui/react-separator",
|
||||
"dependencies.class-variance-authority",
|
||||
"dependencies.clsx",
|
||||
"dependencies.hono",
|
||||
"dependencies.lucide-react",
|
||||
"dependencies.next",
|
||||
"dependencies.react",
|
||||
"dependencies.react-dom",
|
||||
"dependencies.react-rnd",
|
||||
"dependencies.react18-json-view",
|
||||
"dependencies.recharts",
|
||||
"dependencies.tailwind-merge",
|
||||
"dependencies.zod",
|
||||
"devDependencies.@tailwindcss/postcss",
|
||||
"devDependencies.@types/node",
|
||||
"devDependencies.@types/react",
|
||||
"devDependencies.@types/react-dom",
|
||||
"devDependencies.concurrently",
|
||||
"devDependencies.tailwindcss",
|
||||
"devDependencies.typescript"
|
||||
],
|
||||
"agentSurface": {
|
||||
"toolNames": ["query_data", "generate_a2ui", "search_flights"],
|
||||
"stateKeys": ["todos", "messages"],
|
||||
"modelFamily": "openai"
|
||||
}
|
||||
},
|
||||
"instances": {
|
||||
"langgraph-python": {
|
||||
"role": "north-star",
|
||||
"agent": {
|
||||
"language": "python",
|
||||
"runtime": "langgraph-create-agent"
|
||||
},
|
||||
"allowedDivergence": [],
|
||||
"packageJsonOverrides": {}
|
||||
},
|
||||
"langgraph-js": {
|
||||
"role": "instance",
|
||||
"agent": {
|
||||
"language": "typescript",
|
||||
"runtime": "langgraph-stategraph"
|
||||
},
|
||||
"allowedDivergence": [
|
||||
"agent/**",
|
||||
"src/app/api/copilotkit/**",
|
||||
"Dockerfile",
|
||||
"docker/Dockerfile.agent",
|
||||
"docker-compose.test.yml",
|
||||
"entrypoint.sh",
|
||||
"serve.py",
|
||||
"scripts/**",
|
||||
"src/components/threads-drawer/**",
|
||||
"src/app/page.tsx",
|
||||
"next.config.ts"
|
||||
],
|
||||
"packageJsonOverrides": {
|
||||
"scripts.dev:agent": "./scripts/run-agent.sh || scripts\\run-agent.bat",
|
||||
"scripts.install:agent": "cd agent && npm install"
|
||||
}
|
||||
},
|
||||
"langgraph-fastapi": {
|
||||
"role": "instance",
|
||||
"agent": {
|
||||
"language": "python",
|
||||
"runtime": "fastapi-langgraph"
|
||||
},
|
||||
"allowedDivergence": [
|
||||
"agent/**",
|
||||
"src/app/api/copilotkit/**",
|
||||
"docker/Dockerfile.agent",
|
||||
"docker-compose.test.yml",
|
||||
"scripts/**"
|
||||
],
|
||||
"packageJsonOverrides": {
|
||||
"scripts.dev:agent": "cd agent && uv run main.py",
|
||||
"scripts.install:agent": "cd agent && uv sync"
|
||||
}
|
||||
},
|
||||
"strands-python": {
|
||||
"role": "instance",
|
||||
"agent": {
|
||||
"language": "python",
|
||||
"runtime": "strands-ag-ui"
|
||||
},
|
||||
"allowedDivergence": [
|
||||
"agent/**",
|
||||
"src/app/api/copilotkit/**",
|
||||
"Dockerfile",
|
||||
"docker/Dockerfile.agent",
|
||||
"docker-compose.test.yml",
|
||||
"entrypoint.sh",
|
||||
"serve.py",
|
||||
"scripts/**"
|
||||
],
|
||||
"packageJsonOverrides": {
|
||||
"scripts.dev:agent": "./scripts/run-agent.sh || scripts\\run-agent.bat",
|
||||
"scripts.install:agent": "./scripts/setup-agent.sh || scripts\\setup-agent.bat"
|
||||
}
|
||||
},
|
||||
"claude-sdk-python": {
|
||||
"role": "instance",
|
||||
"agent": {
|
||||
"language": "python",
|
||||
"runtime": "claude-agent-sdk"
|
||||
},
|
||||
"allowedDivergence": [
|
||||
"agent/**",
|
||||
"src/app/api/copilotkit/**",
|
||||
"Dockerfile",
|
||||
"docker/Dockerfile.agent",
|
||||
"docker-compose.test.yml",
|
||||
"entrypoint.sh",
|
||||
"serve.py",
|
||||
"scripts/**",
|
||||
"docker-route-override.ts",
|
||||
"src/app/declarative-generative-ui/renderers.tsx",
|
||||
"src/components/example-canvas/index.tsx",
|
||||
"src/components/example-canvas/todo-list.tsx",
|
||||
"src/components/example-layout/mode-toggle.tsx",
|
||||
"src/components/generative-ui/charts/bar-chart.tsx",
|
||||
"src/components/headless-chat.tsx",
|
||||
"src/components/tool-rendering.tsx",
|
||||
"src/components/ui/spinner.tsx"
|
||||
],
|
||||
"packageJsonOverrides": {
|
||||
"scripts.dev:agent": "./scripts/run-agent.sh || scripts\\run-agent.bat",
|
||||
"scripts.install:agent": "./scripts/setup-agent.sh || scripts\\setup-agent.bat"
|
||||
}
|
||||
},
|
||||
"claude-sdk-typescript": {
|
||||
"role": "instance",
|
||||
"agent": {
|
||||
"language": "typescript",
|
||||
"runtime": "claude-agent-sdk"
|
||||
},
|
||||
"allowedDivergence": [
|
||||
"agent/**",
|
||||
"src/app/api/copilotkit/**",
|
||||
"Dockerfile",
|
||||
"docker/Dockerfile.agent",
|
||||
"docker-compose.test.yml",
|
||||
"entrypoint.sh",
|
||||
"serve.py",
|
||||
"scripts/**",
|
||||
"docker-route-override.ts",
|
||||
"src/app/declarative-generative-ui/renderers.tsx",
|
||||
"src/components/example-canvas/index.tsx",
|
||||
"src/components/example-canvas/todo-list.tsx",
|
||||
"src/components/example-layout/mode-toggle.tsx",
|
||||
"src/components/generative-ui/charts/bar-chart.tsx",
|
||||
"src/components/headless-chat.tsx",
|
||||
"src/components/tool-rendering.tsx",
|
||||
"src/components/ui/spinner.tsx"
|
||||
],
|
||||
"packageJsonOverrides": {
|
||||
"scripts.dev:agent": "./scripts/run-agent.sh || scripts\\run-agent.bat",
|
||||
"scripts.install:agent": "./scripts/setup-agent.sh || scripts\\setup-agent.bat"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Integration-demo parity sync.
|
||||
*
|
||||
* Copies verbatim files from the north-star integration demo to one or more
|
||||
* instance demos, and rewrites tracked package.json keys so scripts and
|
||||
* dependency pins stay aligned. Does NOT touch agent code or the api route —
|
||||
* those are the manual-merge zones per `_parity/manifest.json`.
|
||||
*
|
||||
* Usage (from repo root or anywhere):
|
||||
* pnpm tsx examples/integrations/_parity/sync.ts --target=langgraph-js
|
||||
* pnpm tsx examples/integrations/_parity/sync.ts --target=langgraph-js --dry-run
|
||||
* pnpm tsx examples/integrations/_parity/sync.ts --all
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — sync applied (or dry-run reported only)
|
||||
* 1 — unexpected error (missing source file, unwritable target)
|
||||
* 2 — invalid CLI input
|
||||
*/
|
||||
|
||||
import {
|
||||
copyFileSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ParityRoot } from "./lib/manifest.js";
|
||||
import {
|
||||
loadManifest,
|
||||
instanceDir,
|
||||
northStarDir,
|
||||
listInstances,
|
||||
} from "./lib/manifest.js";
|
||||
import { fileExists, getByPath, setByPath } from "./lib/diff.js";
|
||||
|
||||
interface CliOpts {
|
||||
target?: string;
|
||||
all: boolean;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
function parseCli(argv: string[]): CliOpts {
|
||||
const opts: CliOpts = { all: false, dryRun: false };
|
||||
for (const arg of argv) {
|
||||
if (arg === "--all") opts.all = true;
|
||||
else if (arg === "--dry-run") opts.dryRun = true;
|
||||
else if (arg.startsWith("--target="))
|
||||
opts.target = arg.slice("--target=".length);
|
||||
else if (arg === "--help" || arg === "-h") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.stderr.write(`unknown arg: ${arg}\n`);
|
||||
printHelp();
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
if (!opts.all && !opts.target) {
|
||||
process.stderr.write("required: --target=<name> or --all\n");
|
||||
printHelp();
|
||||
process.exit(2);
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
process.stderr.write(
|
||||
[
|
||||
"sync integration demos to north-star.",
|
||||
"",
|
||||
" --target=<name> sync a single instance (e.g. langgraph-js)",
|
||||
" --all sync every non-north-star instance",
|
||||
" --dry-run print changes without writing",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveParityDir(): string {
|
||||
return dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
interface SyncResult {
|
||||
instance: string;
|
||||
filesCopied: number;
|
||||
filesSkipped: number;
|
||||
pkgKeysRewritten: string[];
|
||||
}
|
||||
|
||||
function syncInstance(
|
||||
root: ParityRoot,
|
||||
instance: string,
|
||||
dryRun: boolean,
|
||||
): SyncResult {
|
||||
const manifest = root.manifest;
|
||||
const from = northStarDir(root);
|
||||
const to = instanceDir(root, instance);
|
||||
const inst = manifest.instances[instance]!;
|
||||
|
||||
if (!fileExists(to)) {
|
||||
throw new Error(`target directory missing: ${to}`);
|
||||
}
|
||||
|
||||
let filesCopied = 0;
|
||||
let filesSkipped = 0;
|
||||
|
||||
for (const pattern of manifest.tracked.verbatimFiles) {
|
||||
const matches = expandPattern(from, pattern);
|
||||
if (matches.length === 0) {
|
||||
process.stderr.write(
|
||||
`[parity] warn: verbatim pattern matched nothing in north-star: ${pattern}\n`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const relPath of matches) {
|
||||
const src = join(from, relPath);
|
||||
const dst = join(to, relPath);
|
||||
if (isAllowedDivergence(relPath, inst.allowedDivergence)) {
|
||||
filesSkipped++;
|
||||
continue;
|
||||
}
|
||||
const changed = copyIfChanged(src, dst, dryRun);
|
||||
if (changed) filesCopied++;
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical prompt: not copied as a file. Each agent inlines the prompt
|
||||
// string in source. sync.ts used to write PROMPT.md per instance, but that
|
||||
// file was never loaded at runtime — it was cosmetic. Verifier now
|
||||
// grep-checks the canonical prompt's first line against instance source.
|
||||
|
||||
// package.json key rewrite
|
||||
const pkgSrcPath = join(from, "package.json");
|
||||
const pkgDstPath = join(to, "package.json");
|
||||
const pkgSrc = JSON.parse(readFileSync(pkgSrcPath, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const pkgDst = JSON.parse(readFileSync(pkgDstPath, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const rewritten: string[] = [];
|
||||
|
||||
for (const keyPath of manifest.tracked.packageJsonPaths) {
|
||||
const override = inst.packageJsonOverrides[keyPath];
|
||||
const value =
|
||||
override !== undefined ? override : getByPath(pkgSrc, keyPath);
|
||||
if (value === undefined) continue; // source doesn't declare it — leave instance alone
|
||||
const existing = getByPath(pkgDst, keyPath);
|
||||
if (existing !== value) {
|
||||
setByPath(pkgDst, keyPath, value);
|
||||
rewritten.push(keyPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply any override keys the source doesn't have (e.g. dev:agent differs
|
||||
// across instances and is NOT in packageJsonPaths)
|
||||
for (const [keyPath, override] of Object.entries(inst.packageJsonOverrides)) {
|
||||
if (manifest.tracked.packageJsonPaths.includes(keyPath)) continue;
|
||||
const existing = getByPath(pkgDst, keyPath);
|
||||
if (existing !== override) {
|
||||
setByPath(pkgDst, keyPath, override);
|
||||
rewritten.push(keyPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (rewritten.length > 0 && !dryRun) {
|
||||
writeFileSync(pkgDstPath, JSON.stringify(pkgDst, null, 2) + "\n");
|
||||
}
|
||||
|
||||
return {
|
||||
instance,
|
||||
filesCopied,
|
||||
filesSkipped,
|
||||
pkgKeysRewritten: rewritten,
|
||||
};
|
||||
}
|
||||
|
||||
function copyIfChanged(src: string, dst: string, dryRun: boolean): boolean {
|
||||
if (!fileExists(src)) {
|
||||
throw new Error(`missing source: ${src}`);
|
||||
}
|
||||
const srcBuf = readFileSync(src);
|
||||
if (fileExists(dst)) {
|
||||
const dstBuf = readFileSync(dst);
|
||||
if (srcBuf.equals(dstBuf)) return false;
|
||||
}
|
||||
if (dryRun) return true;
|
||||
mkdirSync(dirname(dst), { recursive: true });
|
||||
copyFileSync(src, dst);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal glob expansion. Supports literal paths, `dir/**` (recursive), and
|
||||
* `dir/**\/*.ext` (recursive with extension). Intentionally NOT a full glob —
|
||||
* manifest patterns are curated, not user input.
|
||||
*/
|
||||
export function expandPattern(baseDir: string, pattern: string): string[] {
|
||||
if (!pattern.includes("*")) {
|
||||
return fileExists(join(baseDir, pattern)) ? [pattern] : [];
|
||||
}
|
||||
if (pattern.endsWith("/**")) {
|
||||
const prefix = pattern.slice(0, -3);
|
||||
return walk(baseDir, prefix).map((p) => p);
|
||||
}
|
||||
const doubleStarExt = pattern.match(/^(.+)\/\*\*\/\*\.([a-zA-Z0-9]+)$/);
|
||||
if (doubleStarExt) {
|
||||
const prefix = doubleStarExt[1]!;
|
||||
const ext = doubleStarExt[2]!;
|
||||
return walk(baseDir, prefix).filter((p) => p.endsWith(`.${ext}`));
|
||||
}
|
||||
throw new Error(`unsupported pattern: ${pattern}`);
|
||||
}
|
||||
|
||||
function walk(baseDir: string, rel: string): string[] {
|
||||
const full = join(baseDir, rel);
|
||||
if (!fileExists(full)) return [];
|
||||
const out: string[] = [];
|
||||
const stack = [full];
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop()!;
|
||||
const entries = readdirSync(dir);
|
||||
for (const entry of entries) {
|
||||
const abs = join(dir, entry);
|
||||
const st = statSync(abs);
|
||||
if (st.isDirectory()) stack.push(abs);
|
||||
else out.push(relative(baseDir, abs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isAllowedDivergence(relPath: string, patterns: string[]): boolean {
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.endsWith("/**")) {
|
||||
const prefix = pattern.slice(0, -3);
|
||||
if (relPath === prefix || relPath.startsWith(prefix + "/")) return true;
|
||||
} else if (pattern === relPath) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function printSyncResult(r: SyncResult, dryRun: boolean): void {
|
||||
const verb = dryRun ? "would sync" : "synced";
|
||||
process.stdout.write(`\n${r.instance}: ${verb}\n`);
|
||||
process.stdout.write(` files copied: ${r.filesCopied}\n`);
|
||||
process.stdout.write(
|
||||
` files skipped: ${r.filesSkipped} (allowed divergence)\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
` package.json: ${r.pkgKeysRewritten.length} key(s) rewritten\n`,
|
||||
);
|
||||
if (r.pkgKeysRewritten.length > 0) {
|
||||
for (const k of r.pkgKeysRewritten) {
|
||||
process.stdout.write(` - ${k}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const opts = parseCli(process.argv.slice(2));
|
||||
const parityDir = resolveParityDir();
|
||||
const root = loadManifest(parityDir);
|
||||
|
||||
const targets = opts.all
|
||||
? listInstances(root)
|
||||
: opts.target
|
||||
? [opts.target]
|
||||
: [];
|
||||
|
||||
if (opts.target && !root.manifest.instances[opts.target]) {
|
||||
process.stderr.write(`unknown instance: ${opts.target}\n`);
|
||||
process.stderr.write(
|
||||
`known: ${Object.keys(root.manifest.instances).join(", ")}\n`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let totalFiles = 0;
|
||||
let totalKeys = 0;
|
||||
for (const t of targets) {
|
||||
if (t === root.manifest.northStar) continue; // paranoia: never write to north-star
|
||||
const result = syncInstance(root, t, opts.dryRun);
|
||||
printSyncResult(result, opts.dryRun);
|
||||
totalFiles += result.filesCopied;
|
||||
totalKeys += result.pkgKeysRewritten.length;
|
||||
}
|
||||
|
||||
const mode = opts.dryRun ? "(dry-run) " : "";
|
||||
process.stdout.write(
|
||||
`\n${mode}total: ${totalFiles} file change(s), ${totalKeys} package.json key(s) across ${targets.length} instance(s)\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`\nmanual-merge reminder: agent/ dir and api/copilotkit route are NOT synced.\n` +
|
||||
`run \`pnpm parity:verify\` to check agent-surface and prompt alignment.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Only run main() when invoked as script, not when imported by tests.
|
||||
const isMain =
|
||||
import.meta.url === `file://${process.argv[1]}` ||
|
||||
fileURLToPath(import.meta.url) === process.argv[1];
|
||||
if (isMain) {
|
||||
try {
|
||||
main();
|
||||
} catch (e) {
|
||||
process.stderr.write(`[parity] sync failed: ${(e as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Integration-demo parity verifier.
|
||||
*
|
||||
* Reads the parity manifest and checks each instance against the north-star:
|
||||
* - verbatim files byte-equal (modulo allowedDivergence)
|
||||
* - tracked package.json keys equal (or equal to instance override)
|
||||
* - canonical prompt present and byte-equal at <instance>/agent/PROMPT.md
|
||||
* - agent tool names + state keys declared in manifest present in the
|
||||
* instance's agent source (grep-level, not AST — see `scanAgentSurface`)
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — no errors
|
||||
* 1 — one or more errors (drift)
|
||||
* 2 — invalid CLI input
|
||||
* 3 — unreadable (missing file, unreadable dir)
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx examples/integrations/_parity/verify.ts
|
||||
* pnpm tsx examples/integrations/_parity/verify.ts --target=langgraph-js
|
||||
* pnpm tsx examples/integrations/_parity/verify.ts --json
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ParityRoot } from "./lib/manifest.js";
|
||||
import {
|
||||
loadManifest,
|
||||
instanceDir,
|
||||
northStarDir,
|
||||
listInstances,
|
||||
} from "./lib/manifest.js";
|
||||
import { fileExists, fileSha256, getByPath } from "./lib/diff.js";
|
||||
import type { DriftItem, Report } from "./lib/report.js";
|
||||
import { printReports, hasErrors } from "./lib/report.js";
|
||||
import { expandPattern } from "./sync.js";
|
||||
|
||||
interface CliOpts {
|
||||
target?: string;
|
||||
json: boolean;
|
||||
noColor: boolean;
|
||||
}
|
||||
|
||||
function parseCli(argv: string[]): CliOpts {
|
||||
const opts: CliOpts = { json: false, noColor: false };
|
||||
for (const arg of argv) {
|
||||
if (arg === "--json") opts.json = true;
|
||||
else if (arg === "--no-color") opts.noColor = true;
|
||||
else if (arg.startsWith("--target="))
|
||||
opts.target = arg.slice("--target=".length);
|
||||
else if (arg === "--help" || arg === "-h") {
|
||||
process.stderr.write(
|
||||
[
|
||||
"verify integration-demo parity.",
|
||||
"",
|
||||
" --target=<name> verify a single instance",
|
||||
" --json emit machine-readable report",
|
||||
" --no-color disable ANSI color",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.stderr.write(`unknown arg: ${arg}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function resolveParityDir(): string {
|
||||
return dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
function verifyInstance(root: ParityRoot, instance: string): Report {
|
||||
const items: DriftItem[] = [];
|
||||
const manifest = root.manifest;
|
||||
const from = northStarDir(root);
|
||||
const to = instanceDir(root, instance);
|
||||
const inst = manifest.instances[instance]!;
|
||||
|
||||
if (!fileExists(to)) {
|
||||
items.push({
|
||||
severity: "error",
|
||||
instance,
|
||||
kind: "missing-instance",
|
||||
subject: instance,
|
||||
detail: `directory not found: ${to}`,
|
||||
});
|
||||
return { instance, items };
|
||||
}
|
||||
|
||||
// Verbatim files
|
||||
for (const pattern of manifest.tracked.verbatimFiles) {
|
||||
const matches = expandPattern(from, pattern);
|
||||
if (matches.length === 0) {
|
||||
items.push({
|
||||
severity: "warn",
|
||||
instance,
|
||||
kind: "verbatim-file",
|
||||
subject: pattern,
|
||||
detail: "pattern matched no files in north-star",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const relPath of matches) {
|
||||
if (isAllowedDivergence(relPath, inst.allowedDivergence)) continue;
|
||||
const src = join(from, relPath);
|
||||
const dst = join(to, relPath);
|
||||
if (!fileExists(dst)) {
|
||||
items.push({
|
||||
severity: "error",
|
||||
instance,
|
||||
kind: "verbatim-file",
|
||||
subject: relPath,
|
||||
detail: "missing in instance",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const srcSha = fileSha256(src);
|
||||
const dstSha = fileSha256(dst);
|
||||
if (srcSha !== dstSha) {
|
||||
items.push({
|
||||
severity: "error",
|
||||
instance,
|
||||
kind: "verbatim-file",
|
||||
subject: relPath,
|
||||
detail: "content differs from north-star",
|
||||
expected: srcSha,
|
||||
actual: dstSha,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
severity: "ok",
|
||||
instance,
|
||||
kind: "verbatim-file",
|
||||
subject: relPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// package.json tracked keys
|
||||
const pkgSrc = JSON.parse(
|
||||
readFileSync(join(from, "package.json"), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const pkgDst = JSON.parse(
|
||||
readFileSync(join(to, "package.json"), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
for (const keyPath of manifest.tracked.packageJsonPaths) {
|
||||
const override = inst.packageJsonOverrides[keyPath];
|
||||
const expected =
|
||||
override !== undefined ? override : getByPath(pkgSrc, keyPath);
|
||||
if (expected === undefined) continue;
|
||||
const actual = getByPath(pkgDst, keyPath);
|
||||
if (actual === undefined) {
|
||||
items.push({
|
||||
severity: "error",
|
||||
instance,
|
||||
kind: "package-json",
|
||||
subject: keyPath,
|
||||
detail: "missing in instance package.json",
|
||||
expected: String(expected),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (actual !== expected) {
|
||||
items.push({
|
||||
severity: "error",
|
||||
instance,
|
||||
kind: "package-json",
|
||||
subject: keyPath,
|
||||
detail: "value differs from expected",
|
||||
expected: String(expected),
|
||||
actual: String(actual),
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
severity: "ok",
|
||||
instance,
|
||||
kind: "package-json",
|
||||
subject: keyPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical prompt: grep agent source for the first non-blank line of
|
||||
// the canonical prompt. Full-text match is too brittle across triple-quote
|
||||
// indentation; first line is a deterministic, high-signal marker.
|
||||
const promptSrc = resolve(root.integrationsDir, manifest.canonicalPromptFile);
|
||||
if (!fileExists(promptSrc)) {
|
||||
items.push({
|
||||
severity: "error",
|
||||
instance,
|
||||
kind: "prompt",
|
||||
subject: manifest.canonicalPromptFile,
|
||||
detail: "canonical prompt missing in repo",
|
||||
});
|
||||
} else {
|
||||
const canonicalFirstLine = readFirstNonBlankLine(promptSrc);
|
||||
if (canonicalFirstLine === null) {
|
||||
items.push({
|
||||
severity: "error",
|
||||
instance,
|
||||
kind: "prompt",
|
||||
subject: manifest.canonicalPromptFile,
|
||||
detail: "canonical prompt is empty",
|
||||
});
|
||||
} else {
|
||||
const agentTextForPrompt = readAgentText(to);
|
||||
if (agentTextForPrompt === null) {
|
||||
items.push({
|
||||
severity: "warn",
|
||||
instance,
|
||||
kind: "prompt",
|
||||
subject: "agent/",
|
||||
detail: "agent source not readable — skipping prompt check",
|
||||
});
|
||||
} else if (!agentTextForPrompt.includes(canonicalFirstLine)) {
|
||||
items.push({
|
||||
severity: "error",
|
||||
instance,
|
||||
kind: "prompt",
|
||||
subject: "canonical prompt",
|
||||
detail: `first line not found in agent source: "${truncate(canonicalFirstLine, 80)}"`,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
severity: "ok",
|
||||
instance,
|
||||
kind: "prompt",
|
||||
subject: "canonical prompt",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Agent surface: tool names + state keys grep-check.
|
||||
// Intentional shallow check: verifier confirms the declared identifiers
|
||||
// appear somewhere in the agent source. It does NOT validate call-site
|
||||
// correctness — that's the aimock fixture integration test's job.
|
||||
const agentText = readAgentText(to);
|
||||
if (agentText === null) {
|
||||
items.push({
|
||||
severity: "warn",
|
||||
instance,
|
||||
kind: "agent-tool",
|
||||
subject: "agent/",
|
||||
detail: "agent source not readable — skipping surface check",
|
||||
});
|
||||
} else {
|
||||
for (const tool of manifest.tracked.agentSurface.toolNames) {
|
||||
if (!agentText.includes(tool)) {
|
||||
items.push({
|
||||
severity: "error",
|
||||
instance,
|
||||
kind: "agent-tool",
|
||||
subject: tool,
|
||||
detail: "tool name not found in agent source",
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
severity: "ok",
|
||||
instance,
|
||||
kind: "agent-tool",
|
||||
subject: tool,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const key of manifest.tracked.agentSurface.stateKeys) {
|
||||
if (!agentText.includes(key)) {
|
||||
items.push({
|
||||
severity: "warn",
|
||||
instance,
|
||||
kind: "agent-state",
|
||||
subject: key,
|
||||
detail: "state key not found in agent source",
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
severity: "ok",
|
||||
instance,
|
||||
kind: "agent-state",
|
||||
subject: key,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { instance, items };
|
||||
}
|
||||
|
||||
function readAgentText(instanceRoot: string): string | null {
|
||||
const agentDir = join(instanceRoot, "agent");
|
||||
if (!fileExists(agentDir)) return null;
|
||||
// Recursively read .py, .ts, .js files and concatenate. Cheap enough; the
|
||||
// agent tree is small (< 1MB).
|
||||
const parts: string[] = [];
|
||||
const stack = [agentDir];
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = require("node:fs").readdirSync(dir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
entry === "node_modules" ||
|
||||
entry === ".venv" ||
|
||||
entry === ".langgraph_api" ||
|
||||
entry === "__pycache__" ||
|
||||
entry === "dist" ||
|
||||
entry === "build" ||
|
||||
entry === ".next"
|
||||
)
|
||||
continue;
|
||||
const abs = join(dir, entry);
|
||||
const st = require("node:fs").statSync(abs);
|
||||
if (st.isDirectory()) {
|
||||
stack.push(abs);
|
||||
} else if (/\.(py|ts|tsx|js|mjs)$/.test(entry)) {
|
||||
try {
|
||||
parts.push(readFileSync(abs, "utf8"));
|
||||
} catch {
|
||||
/* unreadable file — skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function readFirstNonBlankLine(path: string): string | null {
|
||||
const text = readFileSync(path, "utf8");
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length > 0) return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
if (s.length <= max) return s;
|
||||
return s.slice(0, max) + "…";
|
||||
}
|
||||
|
||||
function isAllowedDivergence(relPath: string, patterns: string[]): boolean {
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.endsWith("/**")) {
|
||||
const prefix = pattern.slice(0, -3);
|
||||
if (relPath === prefix || relPath.startsWith(prefix + "/")) return true;
|
||||
} else if (pattern === relPath) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const opts = parseCli(process.argv.slice(2));
|
||||
const parityDir = resolveParityDir();
|
||||
const root = loadManifest(parityDir);
|
||||
|
||||
const targets = opts.target ? [opts.target] : listInstances(root);
|
||||
|
||||
if (opts.target && !root.manifest.instances[opts.target]) {
|
||||
process.stderr.write(`unknown instance: ${opts.target}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const reports: Report[] = [];
|
||||
for (const t of targets) {
|
||||
if (t === root.manifest.northStar) continue;
|
||||
reports.push(verifyInstance(root, t));
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
process.stdout.write(JSON.stringify(reports, null, 2) + "\n");
|
||||
} else {
|
||||
printReports(reports, !opts.noColor);
|
||||
}
|
||||
|
||||
const failed = hasErrors(reports);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
const isMain =
|
||||
import.meta.url === `file://${process.argv[1]}` ||
|
||||
fileURLToPath(import.meta.url) === process.argv[1];
|
||||
if (isMain) {
|
||||
try {
|
||||
main();
|
||||
} catch (e) {
|
||||
process.stderr.write(`[parity] verify failed: ${(e as Error).message}\n`);
|
||||
process.exit(3);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user