Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions .agents/scripts/docs-surface-check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env node
// `node .agents/scripts/docs-surface-check.mjs [--base <ref>] [--head <ref>]`: the mechanical
// half of documentation rule 9. Since <head> (default HEAD) left <base> (default origin/main),
// a change to the public surface goes with a change to a page under website/src/content/docs/:
// one without the other prints the surfaces it changed and the exit code is 1. The surface is:
//
// cli src/cli/tree.ts, the command tree
// config src/core/config.ts, the config type, defaults and validator
// errors the ERROR_TABLE object in src/contract/errors.ts
// mcp each `registerTool(` name and definition object in src/mcp/server.ts, and the
// tool schemas in src/mcp/contracts.ts
// http each `declareRoute(...)` call in src/http/*.ts
// client src/client/index.ts and src/admin/index.ts, the package's two exports
// events the EventMap interface in src/bus/index.ts, which EventName is the keys of
//
// A part counts as changed when its code differs once comments and whitespace are dropped, so
// a comment or a reflow is no change. A commit in the range with the trailer `Docs: none
// (<reason>)` lets a surface change through (a refactor, an internal rename); whether the
// reason holds is the review's question.
import { execFileSync } from "node:child_process";

import { option } from "./cli-args.mjs";

const TRAILER = /^Docs: none \(.+\)\s*$/m;
const DOCS = "website/src/content/docs/";
const QUOTES = ["'", '"', "`"];

const git = (...a) =>
execFileSync("git", a, {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
const head = option("--head") ?? "HEAD";
const base = git("merge-base", option("--base") ?? "origin/main", head).trim();

const whole = (text) => [text];
const calls = (pattern) => (text) =>
[...text.matchAll(pattern)].map((m) => balanced(text, m.index + m[0].length - 1));
const block = (pattern) => (text) => {
const m = pattern.exec(text);
return m === null ? [] : [balanced(text, m.index + m[0].length - 1)];
};
const tools = (text) =>
[...text.matchAll(/registerTool\(\s*("[^"]*")/g)].map((m) => {
const open = text.indexOf("{", m.index + m[0].length);
return `${m[1]} ${balanced(text, open)}`;
});

const SURFACES = [
{ name: "cli", files: ["src/cli/tree.ts"], parts: whole },
{ name: "config", files: ["src/core/config.ts"], parts: whole },
{
name: "errors",
files: ["src/contract/errors.ts"],
parts: block(/export const ERROR_TABLE\b[^=]*=\s*\{/),
},
{ name: "mcp", files: ["src/mcp/server.ts"], parts: tools },
{ name: "mcp", files: ["src/mcp/contracts.ts"], parts: whole },
{ name: "http", files: httpFiles(), parts: calls(/declareRoute\(/g) },
{ name: "client", files: ["src/client/index.ts", "src/admin/index.ts"], parts: whole },
{ name: "events", files: ["src/bus/index.ts"], parts: block(/export interface EventMap \{/) },
];

const changed = [...new Set(SURFACES.filter(changes).map((s) => s.name))];
const documented = git("diff", "--name-only", base, head, "--", DOCS).trim() !== "";
const waived = TRAILER.test(git("log", "--format=%B", `${base}..${head}`));
if (changed.length > 0 && !documented && !waived) {
console.log(
`The public surface changed (${changed.join(", ")}), but no page under ${DOCS} did. ` +
`Update the pages that describe it (documentation rule 9), or add the trailer "Docs: none (<reason>)" to a commit.`,
);
process.exit(1);
}

function httpFiles() {
return [base, head]
.flatMap((rev) => git("ls-tree", "--name-only", `${rev}:src/http/`).split("\n"))
.filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"))
.map((f) => `src/http/${f}`)
.filter((f, i, all) => all.indexOf(f) === i);
}

function show(rev, file) {
try {
return git("show", `${rev}:${file}`);
} catch {
return "";
}
}

// why: a string or a comment may hold an unmatched bracket; the scan skips both, so a call
// or a block ends at its own closing bracket.
function balanced(text, open) {
const pair = { "(": ")", "{": "}", "[": "]" };
const stack = [];
for (let i = open; i < text.length; i++) {
const skip = skipped(text, i);
if (skip > i) {
i = skip - 1;
} else if (pair[text[i]] !== undefined) {
stack.push(pair[text[i]]);
} else if (text[i] === stack.at(-1)) {
stack.pop();
if (stack.length === 0) return text.slice(open, i + 1);
}
}
return text.slice(open);
}

function skipped(text, i) {
const two = text.slice(i, i + 2);
if (two === "//") return endOf(text, "\n", i);
if (two === "/*") return endOf(text, "*/", i);
const quote = text[i];
if (!QUOTES.includes(quote)) return i;
let j = i + 1;
while (j < text.length && text[j] !== quote) j += text[j] === "\\" ? 2 : 1;
return j + 1;
}

function endOf(text, marker, from) {
const at = text.indexOf(marker, from + 2);
return at === -1 ? text.length : at + marker.length;
}

function code(text) {
return text
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:"'`\\])\/\/.*$/gm, "$1")
.replace(/\s+/g, "");
}

function changes(surface) {
return surface.files.some((file) => {
const before = surface.parts(show(base, file)).map(code);
const after = surface.parts(show(head, file)).map(code);
return before.join("\n") !== after.join("\n");
});
}
73 changes: 73 additions & 0 deletions .agents/scripts/runnable.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env node
// `node .agents/scripts/runnable.mjs <feature> [--world <f>]`: which of a feature's tasks the
// deliver skill may start now (delivery rule 1), so nobody reads the Depends on by hand. It
// prints the Depends on facts of the open sub-issues, each as `B <n> Depends on: ...` or `N
// ...` (as spec-lint.mjs does): a number that does not exist and a cycle block; a closed issue
// is a note; an open issue outside the feature is `depends-outside #<d> (open, feature #<m>)`.
// Then `chain <k> of <t> tasks` (the longest Depends on path, counted in tasks, over every
// sub-issue), one `waiting #<t>: <why>` line per open task it leaves out, and last `runnable:
// #<a> #<b>` (or `runnable: none`).
//
// A task is runnable when it is open, `task:ready` and unassigned, and every issue under its
// Depends on is closed, or is a sub-issue of this feature with an open PR out of draft that
// closes it. Exit 1 on a cycle or a missing number, 2 on a usage error. `--world <f>` reads
// the issues from a JSON file (see spec-body.mjs) instead of GitHub. The repo is GH_REPO, else
// the origin remote.
import { dependsOn, graph, issues, printFinding, readWorld } from "./spec-body.mjs";
import { option } from "./cli-args.mjs";

const args = process.argv.slice(2);
const feature = Number(args[0]);
if (!/^\d+$/.test(args[0] ?? "")) {
console.error("usage: runnable.mjs <feature> [--world <f>]");
process.exit(2);
}
const world = readWorld(option("--world"));

const root = issues([feature], world).get(feature);
if (root === undefined) {
console.error(`runnable: #${feature} does not exist`);
process.exit(2);
}
const tasks = [...issues(root.subIssues, world).values()];
const open = tasks.filter((t) => t.state === "open");
const known = issues(
open.flatMap((t) => dependsOn(t.body)),
world,
);
const result = graph(tasks, known);
for (const f of result.findings) printFinding(f);
if (result.chain !== null) console.log(`chain ${result.chain} of ${tasks.length} tasks`);

const subs = new Map(tasks.map((t) => [t.number, t]));
const ready = [];
for (const task of open) {
const why = blocker(task);
if (why === null) ready.push(`#${task.number}`);
else console.log(`waiting #${task.number}: ${why}`);
}
console.log(`runnable: ${result.cycle === null && ready.length > 0 ? ready.join(" ") : "none"}`);
process.exit(result.findings.some((f) => f.level === "B") ? 1 : 0);

function blocker(task) {
if (!task.labels.includes("task:ready")) {
return `labelled ${task.labels.filter((l) => l.startsWith("task:")).join(", ") || "no task label"}`;
}
if (task.assignees.length > 0) return `assigned to ${task.assignees.join(", ")}`;
const waits = dependsOn(task.body).filter((d) => !settled(d));
return waits.length === 0 ? null : `on ${waits.map(describe).join(", ")}`;
}

function settled(dep) {
const target = subs.get(dep) ?? known.get(dep);
if (target === undefined) return false;
if (target.state === "closed") return true;
return subs.has(dep) && target.prs.some((p) => !p.draft);
}

function describe(dep) {
const target = subs.get(dep) ?? known.get(dep);
if (target === undefined) return `#${dep} (does not exist)`;
if (!subs.has(dep)) return `#${dep} (open, outside this feature)`;
return `#${dep} (${target.prs.length === 0 ? "no PR" : "PR in draft"})`;
}
Loading
Loading