feat: implement native hooks runtime
This commit is contained in:
@@ -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); });
|
||||
@@ -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"]); });
|
||||
@@ -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); });
|
||||
+6
@@ -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)
|
||||
+6
@@ -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
@@ -0,0 +1,2 @@
|
||||
ruleDirs:
|
||||
- ast-grep/rules
|
||||
+11
@@ -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"]
|
||||
}
|
||||
@@ -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"); });
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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" }] });
|
||||
});
|
||||
@@ -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); });
|
||||
@@ -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"); });
|
||||
@@ -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")); });
|
||||
Reference in New Issue
Block a user