feat: implement native hooks runtime

This commit is contained in:
jb
2026-09-01 16:43:46 -04:00
parent 3a9e61c22d
commit 73fa3299bf
59 changed files with 3392 additions and 8 deletions
+2
View File
@@ -0,0 +1,2 @@
import assert from "node:assert/strict"; import { test } from "node:test"; import { AnalysisCoordinator } from "../src/analysis.ts";
test("coalesces paths and invokes one adapter per analyzer key", async () => { let calls = 0; let received: readonly string[] = []; const coordinator = new AnalysisCoordinator(new Map([["same", { async analyze(paths: readonly string[], revision: number) { calls++; received = paths; return { analyzerKey: "same", workspaceRevision: revision, paths, findings: [], durationMs: 1 }; } }]])); const result = await coordinator.run([{ analyzerKey: "same", paths: ["b.ts", "a.ts"] }, { analyzerKey: "same", paths: ["a.ts", "c.ts"] }], 4); assert.equal(calls, 1); assert.deepEqual(received, ["a.ts", "b.ts", "c.ts"]); assert.equal(result.get("same")?.workspaceRevision, 4); assert.equal((result as Map<string, unknown>).set, undefined); });
+3
View File
@@ -0,0 +1,3 @@
import assert from "node:assert/strict"; import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { AstGrepAdapter } from "../src/analyzers/ast-grep.ts";
const run = promisify(execFile); const exec = async (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => { try { const result = await run(command, args, { encoding: "utf8", maxBuffer: 3_000_000, ...options }); return { stdout: result.stdout, stderr: result.stderr, code: 0 }; } catch (error) { const row = error as { stdout?: string; stderr?: string; code?: number; killed?: boolean }; return { stdout: row.stdout ?? "", stderr: row.stderr ?? "", code: row.code ?? 1, ...(row.killed === undefined ? {} : { killed: row.killed }) }; } };
test("one real ast-grep scan detects both TypeScript rules and honors named suppression", async (t) => { try { await run("ast-grep", ["--version"]); } catch { t.skip("ast-grep unavailable"); return; } const root = await mkdtemp(join(tmpdir(), "native-hooks-scan-")); const path = join(root, "sample.ts"); await writeFile(path, `const value: string | undefined = "x";\nconsole.log(value!);\nconst loaded = import("./x.js"); // ast-grep-ignore: typescript-no-dynamic-import-ts -- plugin\n`); const adapter = new AstGrepAdapter(exec, root, join(process.cwd(), "examples/personal-config/sgconfig.yml")); const snapshot = await adapter.analyze([path], 1); assert.equal(snapshot.failure, undefined); assert.deepEqual(snapshot.findings.map((finding) => finding.ruleId), ["typescript-no-non-null-assertion-ts"]); });
+4
View File
@@ -0,0 +1,4 @@
import assert from "node:assert/strict"; import { test } from "node:test"; import { ExecutionWaveCoordinator, type ObservedToolResult } from "../src/execution-wave.ts";
const result = (id: string): ObservedToolResult => ({ toolCallId: id, toolName: "write", input: { path: `${id}.ts` }, content: [], details: {}, isError: false });
test("groups parallel siblings, tolerates reverse completion, and picks source-order carrier", async () => { let finalized = 0; let carrier = ""; const waves = new ExecutionWaveCoordinator(async (wave) => { finalized++; carrier = wave.carrierId!; return new Map([[carrier, { content: [{ type: "text", text: "diagnostic" }] }]]); }, assert.fail); waves.start("first", "write", {}); waves.start("second", "write", {}); const second = waves.result(result("second")); const first = waves.result(result("first")); assert.deepEqual((await second)?.content, [{ type: "text", text: "diagnostic" }]); assert.equal((await first)?.content, undefined); assert.equal(finalized, 1); assert.equal(carrier, "second"); });
test("immediate end without a tool result releases the wave", async () => { let calls = 0; const waves = new ExecutionWaveCoordinator(async () => { calls++; return new Map(); }, assert.fail); waves.start("blocked", "write", {}); waves.end("blocked"); await new Promise((resolve) => setImmediate(resolve)); assert.equal(calls, 1); });
@@ -0,0 +1,6 @@
id: typescript-no-dynamic-import-ts
language: TypeScript
severity: warning
message: Avoid dynamic imports unless runtime loading is intentional.
rule:
pattern: import($MODULE)
@@ -0,0 +1,6 @@
id: typescript-no-dynamic-import-tsx
language: Tsx
severity: warning
message: Avoid dynamic imports unless runtime loading is intentional.
rule:
pattern: import($MODULE)
@@ -0,0 +1,6 @@
id: typescript-no-non-null-assertion-ts
language: TypeScript
severity: warning
message: Avoid TypeScript non-null assertions.
rule:
kind: non_null_expression
@@ -0,0 +1,6 @@
id: typescript-no-non-null-assertion-tsx
language: Tsx
severity: warning
message: Avoid TypeScript non-null assertions.
rule:
kind: non_null_expression
@@ -0,0 +1,8 @@
import type { VerificationPolicy } from "pi-native-hooks/policy";
const manifests = new Set(["package.json", "package-lock.json", "tsconfig.json"]);
const behavior = (path: string) => /^(extensions|scripts|services|skills|config)\//.test(path) || manifests.has(path);
const types = (path: string) => /\.tsx?$/.test(path) || manifests.has(path);
export default { kind: "verification", id: "harness-tools-verification", project: { matches: (project) => project.packageName === "harness-tools" }, requirements: [
{ id: "tests", command: "npm test", cwd: "project-root", affects: behavior },
{ id: "typecheck", command: "npm run typecheck", cwd: "project-root", affects: types },
] } satisfies VerificationPolicy;
@@ -0,0 +1,8 @@
import type { VerificationPolicy } from "pi-native-hooks/policy";
const manifests = new Set(["package.json", "package-lock.json", "tsconfig.json"]);
const behavior = (path: string) => /^(extensions|src|tests|examples|skills)\//.test(path) || manifests.has(path);
const types = (path: string) => /\.tsx?$/.test(path) || manifests.has(path);
export default { kind: "verification", id: "pi-native-hooks-verification", project: { matches: (project) => project.packageName === "pi-native-hooks" }, requirements: [
{ id: "tests", command: "npm test", cwd: "project-root", affects: behavior },
{ id: "typecheck", command: "npm run typecheck", cwd: "project-root", affects: types },
] } satisfies VerificationPolicy;
@@ -0,0 +1,7 @@
import type { DiagnosticPolicy } from "pi-native-hooks/policy";
const ruleIds = new Set(["typescript-no-dynamic-import-ts", "typescript-no-dynamic-import-tsx"]);
export default {
kind: "diagnostic", id: "typescript-no-dynamic-import", analyzerKeys: ["ast-grep:personal"],
appliesTo: (_project, paths) => paths.some((path) => /\.tsx?$/.test(path)),
evaluate: (analysis) => (analysis.get("ast-grep:personal")?.findings ?? []).filter((finding) => ruleIds.has(finding.ruleId)).map((finding) => ({ policyId: "typescript-no-dynamic-import", ruleId: finding.ruleId, path: finding.path, line: finding.range.start.line, column: finding.range.start.column, message: finding.message, guidance: "Prefer a static import unless lazy loading, code splitting, an optional dependency, or runtime module selection is intentional.", exception: `Use ast-grep-ignore: ${finding.ruleId} -- <short rationale> for an intentional dynamic import.`, matchedTextHash: finding.matchedTextHash })),
} satisfies DiagnosticPolicy;
@@ -0,0 +1,7 @@
import type { DiagnosticPolicy } from "pi-native-hooks/policy";
const ruleIds = new Set(["typescript-no-non-null-assertion-ts", "typescript-no-non-null-assertion-tsx"]);
export default {
kind: "diagnostic", id: "typescript-no-non-null-assertion", analyzerKeys: ["ast-grep:personal"],
appliesTo: (_project, paths) => paths.some((path) => /\.tsx?$/.test(path)),
evaluate: (analysis) => (analysis.get("ast-grep:personal")?.findings ?? []).filter((finding) => ruleIds.has(finding.ruleId)).map((finding) => ({ policyId: "typescript-no-non-null-assertion", ruleId: finding.ruleId, path: finding.path, line: finding.range.start.line, column: finding.range.start.column, message: finding.message, guidance: "Prefer a guard, refinement, assertion function, or corrected type.", exception: `Use ast-grep-ignore: ${finding.ruleId} -- <short rationale> when the invariant is intentional.`, matchedTextHash: finding.matchedTextHash })),
} satisfies DiagnosticPolicy;
+2
View File
@@ -0,0 +1,2 @@
ruleDirs:
- ast-grep/rules
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"strict": true,
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"paths": { "pi-native-hooks/policy": ["__PI_NATIVE_HOOKS_PACKAGE_ROOT__/src/policy.ts"] }
},
"include": ["policies/*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { test } from "node:test";
test("package exposes exactly one extension and the authoring skill", async () => { const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as { pi: { extensions: string[]; skills: string[] }; engines: { node: string } }; assert.deepEqual(pkg.pi.extensions, ["./extensions/pi-native-hooks/index.ts"]); assert.deepEqual(pkg.pi.skills, ["./skills/native-hook-authoring"]); assert.equal(pkg.engines.node, ">=22.19"); });
+20
View File
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { chmod, cp, mkdir, symlink, writeFile } from "node:fs/promises";
import { tmpdir, userInfo } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { mkdtemp } from "node:fs/promises";
import { loadPolicies } from "../src/policy-loader.ts";
const valid = `export default { kind: "diagnostic", id: "ok", analyzerKeys: [], appliesTo() { return true }, evaluate() { return [] } }`;
test("loads native erasable TypeScript deterministically and isolates failures", async () => {
const root = await mkdtemp(join(tmpdir(), "native-hooks-")); await chmod(root, 0o700); const policies = join(root, "policies"); await mkdir(policies, { mode: 0o700 });
await writeFile(join(policies, "b.ts"), `throw new Error("boom")`); await writeFile(join(policies, "a.ts"), `interface Marker { ok: true }; import type { PersonalPolicy } from "${join(process.cwd(), "src/policy.ts")}"; ${valid} satisfies PersonalPolicy`);
await chmod(join(policies, "a.ts"), 0o600); await chmod(join(policies, "b.ts"), 0o600);
const registry = await loadPolicies({ root }); assert.deepEqual(registry.policies.map((item) => item.policy.id), ["ok"]); assert.ok(registry.failures.some((failure) => /boom/.test(failure.error)));
});
test("rejects duplicate IDs, unsafe mode, and symlinks", async () => {
const root = await mkdtemp(join(tmpdir(), "native-hooks-")); await chmod(root, 0o700); const policies = join(root, "policies"); await mkdir(policies, { mode: 0o700 });
await writeFile(join(policies, "a.ts"), valid); await writeFile(join(policies, "b.ts"), valid.replace('id: "ok"', 'id: "ok"')); await writeFile(join(root, "target.ts"), valid); await symlink(join(root, "target.ts"), join(policies, "c.ts")); await writeFile(join(policies, "d.ts"), valid.replace('id: "ok"', 'id: "unsafe"'));
await Promise.all(["a.ts", "b.ts"].map((name) => chmod(join(policies, name), 0o600))); await chmod(join(policies, "d.ts"), 0o622);
const registry = await loadPolicies({ root, uid: userInfo().uid }); assert.equal(registry.policies.length, 1); assert.ok(registry.failures.some((failure) => /duplicate/.test(failure.error))); assert.ok(registry.failures.some((failure) => /symlink/.test(failure.error))); assert.ok(registry.failures.some((failure) => /group or others/.test(failure.error)));
});
+17
View File
@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { NativeHooksRuntime } from "../src/runtime.ts";
test("runtime registers lifecycle hooks and returns the current call's result patch", async () => {
const handlers = new Map<string, Array<(...args: unknown[]) => unknown>>(); const tools: string[] = []; const commands: string[] = [];
const pi = {
on(name: string, handler: (...args: unknown[]) => unknown) { const list = handlers.get(name) ?? []; list.push(handler); handlers.set(name, list); },
registerTool(tool: { name: string }) { tools.push(tool.name); }, registerCommand(name: string) { commands.push(name); },
} as unknown as ExtensionAPI;
const runtime = new NativeHooksRuntime(pi);
assert.deepEqual(tools, ["pi_hooks_waive_verification"]); assert.deepEqual(commands, ["hooks-status"]); assert.ok(handlers.has("agent_settled")); assert.ok(handlers.has("context"));
(runtime as unknown as { waves: { result(): Promise<{ content: Array<{ type: string; text: string }> }> } }).waves = { async result() { return { content: [{ type: "text", text: "augmented" }] }; } };
const handler = handlers.get("tool_result")![0]!; const patch = await handler({ type: "tool_result", toolCallId: "call", toolName: "write", input: { path: "a.ts" }, content: [{ type: "text", text: "original" }], details: undefined, isError: false }, {});
assert.deepEqual(patch, { content: [{ type: "text", text: "augmented" }] });
});
+2
View File
@@ -0,0 +1,2 @@
import assert from "node:assert/strict"; import { test } from "node:test"; import { latestState, STATE_TYPE } from "../src/state.ts";
test("restores latest valid branch state and ignores malformed newer entries", () => { const state = { version: 1 as const, policyDigest: "p", projectIdentity: "i", workspaceRevision: 2, workspaceFingerprint: "f", requirements: [], reason: "test", timestamp: new Date().toISOString() }; const restored = latestState([{ type: "custom", customType: STATE_TYPE, data: state }, { type: "custom", customType: STATE_TYPE, data: { version: 1 } }]); assert.deepEqual(restored.state, state); assert.equal(restored.malformed, 1); });
+4
View File
@@ -0,0 +1,4 @@
import assert from "node:assert/strict"; import { test } from "node:test"; import type { VerificationPolicy } from "../src/policy.ts"; import { VerificationTracker } from "../src/verification.ts";
const project = { root: "/repo", packageName: "x", identity: "identity", git: true } as const; const policy: VerificationPolicy = { kind: "verification", id: "verify", project: { matches: () => true }, requirements: [{ id: "tests", command: "npm test", cwd: "project-root", affects: (path) => path.startsWith("src/") }] };
test("arms, accepts only exact stable evidence, stales, and waives by revision", () => { const tracker = new VerificationTracker(project); tracker.configure([policy]); assert.equal(tracker.records()[0]!.status, "inactive"); tracker.observeMutation(["src/a.ts"], 1); assert.equal(tracker.records()[0]!.status, "not-run"); const altered = tracker.beginCheck("bad", "npm test -- --focus", "/repo"); assert.equal(altered.length, 0); const attempt = tracker.beginCheck("good", "npm test", "/repo"); tracker.finishCheck(attempt, true, true); assert.equal(tracker.records()[0]!.status, "passed"); tracker.observeMutation(["src/b.ts"], 2); assert.equal(tracker.records()[0]!.status, "stale"); assert.throws(() => tracker.waive(" ", "w"), /non-empty/); tracker.waive("deferred by user", "w"); assert.equal(tracker.records()[0]!.status, "waived"); tracker.observeMutation(["src/c.ts"], 3); assert.equal(tracker.records()[0]!.status, "not-run"); });
test("concurrent or wrong-cwd checks do not pass", () => { const tracker = new VerificationTracker(project); tracker.configure([policy]); tracker.observeMutation(["src/a.ts"], 1); tracker.finishCheck(tracker.beginCheck("wrong", "npm test", "/elsewhere"), true, true); assert.equal(tracker.records()[0]!.status, "not-run"); tracker.finishCheck(tracker.beginCheck("race", "npm test", "/repo"), true, false); assert.equal(tracker.records()[0]!.status, "not-run"); });
+4
View File
@@ -0,0 +1,4 @@
import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdtemp, rename, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { test } from "node:test"; import { WorkspaceInspector } from "../src/workspace.ts";
const run = promisify(execFile); const exec = async (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => { try { const value = await run(command, args, { encoding: "utf8", ...options }); return { stdout: value.stdout, stderr: value.stderr, code: 0 }; } catch (error) { const row = error as { stdout?: string; stderr?: string; code?: number }; return { stdout: row.stdout ?? "", stderr: row.stderr ?? "", code: row.code ?? 1 }; } };
async function git(root: string, ...args: string[]) { const result = await exec("git", ["-C", root, ...args]); assert.equal(result.code, 0, result.stderr); }
test("git snapshots detect second edits, staging, untracked, rename, deletion, and symlinks", async () => { const root = await mkdtemp(join(tmpdir(), "native-hooks-git-")); await git(root, "init", "-q"); await git(root, "config", "user.email", "test@example.invalid"); await git(root, "config", "user.name", "Test"); await writeFile(join(root, "a.ts"), "export const a = 1;\n"); await git(root, "add", "a.ts"); await git(root, "commit", "-qm", "initial"); const inspector = new WorkspaceInspector(exec); const clean = await inspector.capture(root); await writeFile(join(root, "a.ts"), "export const a = 2;\n"); const dirty = await inspector.capture(root); assert.deepEqual(inspector.diff(clean, dirty).changedPaths, ["a.ts"]); await writeFile(join(root, "a.ts"), "export const a = 3;\n"); const second = await inspector.capture(root); assert.notEqual(second.fingerprint, dirty.fingerprint); assert.deepEqual(inspector.diff(dirty, second).changedPaths, ["a.ts"]); await git(root, "add", "a.ts"); const staged = await inspector.capture(root); assert.deepEqual(inspector.diff(second, staged).changedPaths, ["a.ts"]); await writeFile(join(root, "new.ts"), "new\n"); const untracked = await inspector.capture(root); assert.ok(inspector.diff(staged, untracked).changedPaths.includes("new.ts")); await symlink("a.ts", join(root, "link.ts")); const linked = await inspector.capture(root); assert.ok(inspector.diff(untracked, linked).changedPaths.includes("link.ts")); await rename(join(root, "a.ts"), join(root, "moved.ts")); const renamed = await inspector.capture(root); assert.ok(inspector.diff(linked, renamed).changedPaths.includes("a.ts")); assert.ok(inspector.diff(linked, renamed).changedPaths.includes("moved.ts")); await rm(join(root, "moved.ts")); const deleted = await inspector.capture(root); assert.ok(inspector.diff(renamed, deleted).changedPaths.includes("moved.ts")); });