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
11 changes: 9 additions & 2 deletions .github/workflows/base-std-docs-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ jobs:
authorize:
name: Authorize trigger
if: github.event.repository.fork == false
runs-on: ubuntu-latest
# Keep every job in this workflow on BaseRunnerGroup. Besides providing a
# consistent trusted execution environment, the downstream apply job uses
# Base's internal LLM Gateway.
runs-on:
group: BaseRunnerGroup
permissions:
contents: read
steps:
Expand Down Expand Up @@ -117,7 +121,10 @@ jobs:
# above. Flipping vars.DISABLE_BASE_SYNC to 'true' skips this job
# without affecting authorize (so the banner still fires).
if: github.event.repository.fork == false && vars.DISABLE_BASE_SYNC != 'true'
runs-on: ubuntu-latest
# Run where Base's internal LLM Gateway is available. The corresponding
# Base gateway integration documents BaseRunnerGroup as a requirement.
runs-on:
group: BaseRunnerGroup
permissions:
contents: write
pull-requests: write
Expand Down
82 changes: 81 additions & 1 deletion scripts/sync-from-base-std/__tests__/base-std-routing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,44 @@ import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { buildProvenanceComment, routeCodeChange } from "../index.mjs";
import { fileURLToPath } from "node:url";
import {
buildProvenanceComment,
loadStyleGuide,
routeCodeChange,
} from "../index.mjs";

const REPO_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"..",
);
const B20_REFERENCE_GLOB = "docs/base-chain/specs/reference/b20/**/*.mdx";
const B20_STANDALONE_PAGES = [
"docs/base-chain/network-information/b20-token-standard.mdx",
"docs/apps/guides/accept-b20-payments.mdx",
"docs/get-started/launch-b20-token.mdx",
];
const B20_MANUAL_UPDATE_PAGES = [
"docs/base-chain/specs/reference/b20/index.mdx",
...B20_STANDALONE_PAGES,
];

async function listMdxFiles(root) {
const out = [];
async function walk(dir) {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) await walk(abs);
else if (entry.isFile() && entry.name.endsWith(".mdx")) {
out.push(path.relative(REPO_ROOT, abs));
}
}
}
await walk(root);
return out.sort();
}

test("routeCodeChange expands page_globs only to existing docs pages", async () => {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "base-std-routing-"));
Expand Down Expand Up @@ -40,6 +77,49 @@ test("routeCodeChange expands page_globs only to existing docs pages", async ()
}
});

test("B20 source changes route to the complete current B20 documentation set", async () => {
const routeTable = JSON.parse(
await fs.readFile(
path.join(REPO_ROOT, "scripts/sync-from-base-std/route-table.json"),
"utf8",
),
);
const expected = [
...(await listMdxFiles(
path.join(REPO_ROOT, "docs/base-chain/specs/reference/b20"),
)),
...B20_STANDALONE_PAGES,
].sort();

for (const rule of routeTable.code_changes) {
assert.deepEqual(rule.pages, B20_STANDALONE_PAGES);
assert.deepEqual(rule.page_globs, [B20_REFERENCE_GLOB]);
}
assert.deepEqual(routeTable.manual_update.allowed_pages, B20_MANUAL_UPDATE_PAGES);

const work = await routeCodeChange(
routeTable,
["src/interfaces/IB20Asset.sol"],
{ repoRoot: REPO_ROOT },
);
const routed = work.map((item) => item.page).sort();

assert.deepEqual(routed, expected);
assert.doesNotMatch(
JSON.stringify(routeTable),
/specs\/upgrades\/beryl\/b20\/specification|specs\/upgrades\/cobalt\/eip-8130|specs\/upgrades\/beryl\/b20\/demos/,
);
});

test("loadStyleGuide reads the root content instructions", async () => {
const expected = (
await fs.readFile(path.join(REPO_ROOT, "content-instructions.md"), "utf8")
).trim();

assert.ok(expected.length > 0);
assert.equal(await loadStyleGuide({ repoRoot: REPO_ROOT }), expected);
});

test("buildProvenanceComment cannot inject a second HTML comment boundary", () => {
const comment = buildProvenanceComment("manual-update", {
intent: "Update docs --> <script>alert(1)</script> --!>",
Expand Down
18 changes: 10 additions & 8 deletions scripts/sync-from-base-std/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const ROUTE_TABLE_PATH = path.join(__dirname, "route-table.json");
const STYLE_GUIDE_FILE = "content-instructions.md";
const DRY_RUN = process.env.DRY_RUN === "1";
const DOCS_ROOT = process.env.DOCS_CONTENT_ROOT || "docs";

Expand Down Expand Up @@ -847,22 +848,22 @@ async function loadKnownRoutes() {
}

/**
* Read the house writing-style guide (global-tone-voice.mdx at repo root) once at
* Read the house writing-style guide (content-instructions.md at repo root) once at
* script start. Returned content gets embedded in every Claude prompt as a
* <style_guide>...</style_guide> block so the model writes in the
* documented Mintlify-style voice.
*
* Missing-file is non-fatal: returns "" and the prompts include an empty
* A missing or empty file is non-fatal: returns "" and the prompts omit the
* style-guide section. Keeps the script usable in test contexts and
* avoids hard-coupling routing to a docs-side filename.
*
* @returns {Promise<string>}
*/
async function loadStyleGuide() {
const stylePath = path.join(REPO_ROOT, "global-tone-voice.mdx");
export async function loadStyleGuide({ repoRoot = REPO_ROOT } = {}) {
const stylePath = path.join(repoRoot, STYLE_GUIDE_FILE);
if (!existsSync(stylePath)) return "";
try {
return await fs.readFile(stylePath, "utf8");
return (await fs.readFile(stylePath, "utf8")).trim();
} catch {
return "";
}
Expand Down Expand Up @@ -1153,12 +1154,13 @@ async function main() {
const knownRoutes = await loadKnownRoutes();
console.log(`[sync] loaded ${knownRoutes.size} known doc route(s) for link validation`);
// Read the house writing-style guide once. Threaded into every Claude
// prompt via ctx.styleGuide. Empty string when global-tone-voice.mdx is missing.
// prompt via ctx.styleGuide. Empty string when content-instructions.md is
// missing or empty.
const styleGuide = await loadStyleGuide();
if (styleGuide) {
console.log(`[sync] loaded global-tone-voice.mdx style guide (${styleGuide.length} chars)`);
console.log(`[sync] loaded ${STYLE_GUIDE_FILE} style guide (${styleGuide.length} chars)`);
} else {
console.log(`[sync] no global-tone-voice.mdx found at repo root — prompts will ship without a style guide section`);
console.log(`[sync] no usable ${STYLE_GUIDE_FILE} found at repo root — prompts will ship without a style guide section`);
}

const kind = payload.kind || (payload.tag ? "release" : "code-change");
Expand Down
12 changes: 6 additions & 6 deletions scripts/sync-from-base-std/llm/prompts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -134,16 +134,16 @@ const SHARED_RULES = `Hard requirements for your output:

/**
* Block embedded after SHARED_RULES in every prompt. Points the model at the
* house writing style maintained in `global-tone-voice.mdx` at the repo root. Empty
* house writing style maintained in `content-instructions.md` at the repo root. Empty
* `styleGuide` falls back to a neutral note that doesn't add tokens.
*
* Note on size: global-tone-voice.mdx is ~4kb, ~1100 input tokens. Cost per dispatch is
* Note on size: content-instructions.md is ~4kb, ~1100 input tokens. Cost per dispatch is
* meaningful but acceptable (~$0.03 extra on a 9-page run). Brainstorm A
* captures the option to distill this further if cost grows.
*/
function styleGuideSection(styleGuide) {
if (!styleGuide || !styleGuide.trim()) {
return ""; // global-tone-voice.mdx missing — skip section entirely.
return ""; // content-instructions.md missing or empty — skip section entirely.
}
return `

Expand Down Expand Up @@ -203,7 +203,7 @@ ${lines.join("\n")}
* before, after, summary}. Empty/missing → section is
* omitted and the model falls back to scanning the diff.
* @param {string} ctx.current — the current content of the page being edited
* @param {string=} ctx.styleGuide — full contents of global-tone-voice.mdx, embedded as a <style_guide> block
* @param {string=} ctx.styleGuide — full contents of content-instructions.md, embedded as a <style_guide> block
* @returns {string} prompt as a single string
*/
export function codeChangePrompt(ctx) {
Expand Down Expand Up @@ -279,7 +279,7 @@ ${lines}${more}
* @param {boolean=} ctx.diff_truncated — true if the upstream diff was capped before manifest extraction
* @param {string} ctx.current — current page content (already version-bumped)
* @param {number} ctx.bumpCount — how many version tokens the regex pass replaced
* @param {string=} ctx.styleGuide — full contents of global-tone-voice.mdx, embedded as a <style_guide> block
* @param {string=} ctx.styleGuide — full contents of content-instructions.md, embedded as a <style_guide> block
* @returns {string}
*/
export function releasePrompt(ctx) {
Expand Down Expand Up @@ -379,7 +379,7 @@ Output ONLY a JSON array of page path strings, each drawn EXACTLY from the candi
* @param {string} ctx.intent — maintainer's intent text (free-form)
* @param {string[]=} ctx.source_refs — optional list of source-of-truth URLs
* @param {string} ctx.current — current page content
* @param {string=} ctx.styleGuide — full contents of global-tone-voice.mdx, embedded as a <style_guide> block
* @param {string=} ctx.styleGuide — full contents of content-instructions.md, embedded as a <style_guide> block
* @returns {string}
*/
export function manualUpdatePrompt(ctx) {
Expand Down
Loading
Loading