From 5217d50baf6fd358bddcea83dcaa7a644f76fdb2 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Tue, 14 Jul 2026 00:25:24 +0900 Subject: [PATCH 1/2] feat(preset): localize generated instruction files via the declared language Completes the i18n pass v0.33.0 started for the views. A repo's preset block follows the repo's OWN declared language (its audience); the view block and the anchor starter follow the ANCHOR entry's language, mirroring the views' rule. ja renders byte-identical to previous releases (locked by full-text goldens); en / en+ja / undeclared render English, the new default. All instruction-file strings move into the single core locale table, shrinking the language-lint E-5 allowlist from three files to one. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 30 ++ docs/spec/generated-markdown.md | 9 + packages/cli/src/commands/project.test.ts | 23 +- packages/core/src/index.ts | 5 +- packages/core/src/lib/view-strings.test.ts | 74 ++++- packages/core/src/lib/view-strings.ts | 314 +++++++++++++++++- .../core/src/project/anchor-starter.test.ts | 110 +++++- packages/core/src/project/anchor-starter.ts | 53 +-- packages/core/src/project/preset.test.ts | 166 +++++++-- packages/core/src/project/preset.ts | 153 +++------ scripts/check-language.mjs | 16 +- 11 files changed, 756 insertions(+), 197 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ff5c86..59f75de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,36 @@ All notable changes to **basou** are recorded here. The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) starting with v0.1.0. +## Unreleased + +### Added + +- The generated instruction files now localize their content from the + declaration, completing the i18n pass that v0.33.0 started for the views. A + repo's preset block (`basou project preset` / `derive` / `retrofit`) follows + the **repo's own** declared `language`: `ja` renders the Japanese content + byte-identical to previous releases; `en`, `en+ja`, or an undeclared language + render English. The workspace view's generated AGENTS.md block and the + anchor's create-only starter follow the **anchor entry's** language, + mirroring the views' "the workspace speaks the anchor's language" rule. All + instruction-file strings moved into the single `@basou/core` locale table + (`presetStrings` / `resolveRepoContentLanguage` / + `resolveAnchorContentLanguage`, all exported), shrinking the language-lint + E-5 allowlist from three files to that one module. + +### Changed + +- **Default instruction-file language is now English.** A repo that declares no + `language` (or declares `en` / `en+ja`) previously received Japanese preset + blocks, view blocks, and anchor starters; it now receives English. To keep + the Japanese content, declare `language: ja` on the repo entry (for its + preset block) or on the anchor entry (for the view block and anchor starter) + in `.basou/manifest.yaml`. Blocks committed by earlier releases will surface + as an ordinary preset `update` / wiring drift on the next + `basou project preset` / `derive` / `check` run when their language + resolution changed. Rendered prose is presentation, not contract (see + `docs/spec/compatibility.md`). + ## 0.33.0 — 2026-07-13 ### Added diff --git a/docs/spec/generated-markdown.md b/docs/spec/generated-markdown.md index 67eafed..e594fa0 100644 --- a/docs/spec/generated-markdown.md +++ b/docs/spec/generated-markdown.md @@ -43,6 +43,15 @@ language is a project declaration (the manifest), not a caller preference. The rendered prose itself (headings, wording) is presentation, not contract — see `compatibility.md`. The templates below show the English chrome. +The same declaration-driven rule extends to the GENERATED INSTRUCTION FILES +(`basou project preset` / `derive` / `retrofit` / anchor seeding), with one +refinement: a repo's preset block lives inside that repo's own instruction +file, so its content language follows the **repo's own** declared `language` +(`ja` renders Japanese; `en`, `en+ja`, undeclared, or a missing declaration +render English — silently, like the views). Workspace-level instruction +artifacts — the view's generated AGENTS.md block and the anchor's create-only +starter — follow the **anchor entry's** language, mirroring the views' rule. + ## §10.1 Living-artifact policy (handoff.md, decisions.md) - **Generated**: basou produces them by reading events.jsonl. diff --git a/packages/cli/src/commands/project.test.ts b/packages/cli/src/commands/project.test.ts index e4f7732..e9996fc 100644 --- a/packages/cli/src/commands/project.test.ts +++ b/packages/cli/src/commands/project.test.ts @@ -2303,8 +2303,8 @@ describe("basou project preset", () => { const body = await readFile(canonicalPath("pub"), "utf8"); expect(body).toContain(GENERATED_START); expect(body).toContain(GENERATED_END); - expect(body).toContain("ソース可視性: private"); - expect(body).toContain("web(デプロイ) — 公開 / en+ja"); + expect(body).toContain("Source visibility: private"); + expect(body).toContain("web (deployed) — public / en+ja"); }); it("--apply updates only the marker region, preserving hand-authored content", async () => { @@ -2417,7 +2417,7 @@ describe("basou project preset", () => { viewApplied: false, } satisfies ProjectPresetResult); expect(out).toContain("Preset blocks to generate"); - expect(out).toContain("ソース可視性: public"); + expect(out).toContain("Source visibility: public"); expect(out).toContain("agents/pub/AGENTS.md"); }); @@ -3620,9 +3620,10 @@ describe("basou project derive", () => { expect(gi).toContain("AGENTS.md"); // anchor seed: the planning master's own root AGENTS.md was created (a - // create-only starter, absent before the run). + // create-only starter, absent before the run; the anchor declares no + // language, so it renders the English starter). const anchorDoc = await readFile(join(anchor(), "AGENTS.md"), "utf8"); - expect(anchorDoc).toContain("planning master(anchor)"); + expect(anchorDoc).toContain("planning master (anchor)"); // anchor spokes: with its AGENTS.md now seeded, the anchor's OWN CLAUDE.md / // Copilot spokes are wired to it (self-style), like every other repo — never @@ -4311,14 +4312,18 @@ describe("basou project derive: anchor seed (doRunProjectSeedAnchor)", () => { await doRunProjectSeedAnchor({ apply: true }, { cwd: host() }); const content = await readFile(anchorDoc(), "utf8"); expect(content).toContain("# AGENTS.md (host)"); - expect(content).toContain("**Acme の planning master(anchor)**"); + // The anchor declares en+ja, which resolves to the English starter (one + // content language; en is the shared floor). + expect(content).toContain("**planning master (anchor) of Acme**"); // No manifest-derived roster snapshot table (it would drift in a frozen file). - expect(content).not.toContain("| repo | 可視性 | 言語 | 指示書 |"); + expect(content).not.toContain("| repo | visibility | language | instructions |"); // Pointers exclude the anchor itself and include the view (the live-roster source). expect(content).toContain("- pub/AGENTS.md"); expect(content).not.toContain("- host/AGENTS.md"); - expect(content).toContain("- view/AGENTS.md(workspace view"); - expect(content).toContain("最新の repo 構成(roster)はここを正とする"); + expect(content).toContain("- view/AGENTS.md (the workspace view"); + expect(content).toContain( + "the authoritative, up-to-date repo roster (the live roster) lives there", + ); expect(logs.join("\n")).toContain("Seeded the anchor's own `AGENTS.md`"); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ed0b5c4..372b298 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -100,8 +100,11 @@ export { } from "./lib/path-sanitizer.js"; export type { SourceRootScope } from "./lib/source-root-scope.js"; export { AGENT_INFRA_DIRS, classifyFilesBySourceRoot } from "./lib/source-root-scope.js"; -export type { ViewLanguage, ViewStrings } from "./lib/view-strings.js"; +export type { PresetStrings, ViewLanguage, ViewStrings } from "./lib/view-strings.js"; export { + presetStrings, + resolveAnchorContentLanguage, + resolveRepoContentLanguage, resolveViewLanguage, resolveViewLanguageFromPaths, viewStrings, diff --git a/packages/core/src/lib/view-strings.test.ts b/packages/core/src/lib/view-strings.test.ts index c0cdd2e..72a2ea7 100644 --- a/packages/core/src/lib/view-strings.test.ts +++ b/packages/core/src/lib/view-strings.test.ts @@ -5,7 +5,14 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { Manifest } from "../schemas/manifest.schema.js"; import { type BasouPaths, ensureBasouDirectory } from "../storage/basou-dir.js"; import { createManifest, writeManifest } from "../storage/manifest.js"; -import { resolveViewLanguage, resolveViewLanguageFromPaths, viewStrings } from "./view-strings.js"; +import { + presetStrings, + resolveAnchorContentLanguage, + resolveRepoContentLanguage, + resolveViewLanguage, + resolveViewLanguageFromPaths, + viewStrings, +} from "./view-strings.js"; type RepoEntry = NonNullable[number]; @@ -162,3 +169,68 @@ describe("viewStrings", () => { } }); }); + +describe("resolveRepoContentLanguage", () => { + it("resolves ja only from an explicit ja declaration", () => { + expect(resolveRepoContentLanguage("ja")).toBe("ja"); + expect(resolveRepoContentLanguage("en")).toBe("en"); + expect(resolveRepoContentLanguage("en+ja")).toBe("en"); + expect(resolveRepoContentLanguage(undefined)).toBe("en"); + }); +}); + +describe("resolveAnchorContentLanguage", () => { + it("follows the anchor-flagged entry's language", () => { + expect( + resolveAnchorContentLanguage([{ anchor: true, language: "ja" }, { language: "en" }]), + ).toBe("ja"); + expect( + resolveAnchorContentLanguage([{ anchor: true, language: "en+ja" }, { language: "ja" }]), + ).toBe("en"); + }); + + it("defaults to en with no anchor entry or no anchor language", () => { + expect(resolveAnchorContentLanguage([])).toBe("en"); + expect(resolveAnchorContentLanguage([{ language: "ja" }])).toBe("en"); + expect(resolveAnchorContentLanguage([{ anchor: true }])).toBe("en"); + }); +}); + +describe("presetStrings", () => { + // Same rationale as the viewStrings sweep: exercise every parameterized entry + // in BOTH languages so a table entry that throws or renders empty cannot ship, + // and the label maps cover every enum branch. + it("every parameterized entry renders a non-empty string in both languages", () => { + for (const lang of ["en", "ja"] as const) { + const t = presetStrings(lang); + const rendered: string[] = [ + t.repoBlock.visibilityLabel("public"), + t.repoBlock.visibilityLabel("private"), + t.repoBlock.visibilityLabel("future-public"), + t.repoBlock.visibilityLabel(undefined), + t.repoBlock.sourceLanguageLabel("en"), + t.repoBlock.sourceLanguageLabel("ja"), + t.repoBlock.sourceLanguageLabel("en+ja"), + t.repoBlock.sourceLanguageLabel(undefined), + t.repoBlock.publishKindLabel("web"), + t.repoBlock.publishKindLabel("npm"), + t.repoBlock.publishVisibilityLabel("public"), + t.repoBlock.publishVisibilityLabel("private"), + t.repoBlock.publishVisibilityLabel("future-public"), + t.repoBlock.publishVisibilityLabel(undefined), + t.repoBlock.contentLanguageLabel("en+ja"), + t.repoBlock.contentLanguageLabel(undefined), + t.viewBlock.selfNote("ws"), + t.viewBlock.aggregates(0), + t.viewBlock.aggregates(3), + t.anchorStarter.identityLine("Acme"), + t.anchorStarter.viewPointerLine("acme-workspace"), + ...t.anchorStarter.policyTodo, + ]; + for (const line of rendered) { + expect(typeof line).toBe("string"); + expect(line.length).toBeGreaterThan(0); + } + } + }); +}); diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index 55952fb..ea8675b 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -1,4 +1,5 @@ import { normalizeRelativePath } from "../project/relative-path.js"; +import type { PublishTarget, RepoLanguage, RepoVisibility } from "../project/roster.js"; import type { Manifest } from "../schemas/manifest.schema.js"; import type { BasouPaths } from "../storage/basou-dir.js"; import { readManifest } from "../storage/manifest.js"; @@ -57,9 +58,11 @@ export async function resolveViewLanguageFromPaths(paths: BasouPaths): Promise, +): ViewLanguage { + return resolveRepoContentLanguage(repos.find((r) => r.anchor === true)?.language); +} + +/** + * Every localized string the instruction-file generators emit: the per-repo + * preset block, the workspace view's block, and the anchor's starter. Lives in + * this module for the same reason as {@link ViewStrings}: it is the SINGLE + * home for generated Japanese, so the language-lint E-5 allowlist stays one + * file and "generated content language" is always a declaration-driven table + * lookup, never a hardcode. + */ +export type PresetStrings = { + repoBlock: { + heading: string; + intro: string; + /** Source git-visibility, rendered with the consequence the agent must respect. */ + visibilityLabel: (v: RepoVisibility | undefined) => string; + /** Source language (commits/comments/code), rendered with the audience it serves. */ + sourceLanguageLabel: (l: RepoLanguage | undefined) => string; + /** Published-surface kind. */ + publishKindLabel: (k: PublishTarget["kind"]) => string; + /** A published surface's visibility (independent of the source repo's). */ + publishVisibilityLabel: (v: RepoVisibility | undefined) => string; + /** A published surface's content language (read by end users; may differ from source). */ + contentLanguageLabel: (l: RepoLanguage | undefined) => string; + /** "ソース可視性" — the source-visibility line label. */ + sourceVisibilityLabel: string; + /** "ソース言語" — the source-language line label. */ + sourceLanguageLineLabel: string; + /** "- 配信物: なし" — no published surfaces. */ + publishesNone: string; + /** "- 配信物:" — the published-surfaces list header. */ + publishesHeader: string; + }; + viewBlock: { + heading: string; + intro: string; + selfNote: (viewName: string) => string; + aggregates: (repoCount: number) => string; + reposHeading: string; + tableHeader: string; + /** Instruction-file ownership labels: who writes the repo's AGENTS.md. */ + instructionsAnchor: string; + instructionsSelf: string; + instructionsHub: string; + /** "未設定" — the short table cell for an undeclared visibility / language. */ + unsetShort: string; + commitHeading: string; + commitBody: string; + conventionsHeading: string; + conventionsBody: string; + principlesHeading: string; + principleStateless: string; + principleNoFiles: string; + }; + anchorStarter: { + identityLine: (title: string) => string; + starterNote: string; + basicsHeading: string; + basicsTodo: string; + commitHeading: string; + commitPlanning: string; + commitImplementation: string; + commitView: string; + conventionsHeading: string; + conventionsBody: string; + viewPointerLine: (viewName: string) => string; + policyHeading: string; + policyTodo: string[]; + }; +}; + +/** Look up the instruction-file string table for a resolved content language. */ +export function presetStrings(language: ViewLanguage): PresetStrings { + return language === "ja" ? PRESET_JA : PRESET_EN; +} + +const PRESET_EN: PresetStrings = { + repoBlock: { + heading: "## Project configuration (generated by basou — the manifest is the source of truth)", + intro: + "This section is generated by `basou project preset` from the declarations in `.basou/manifest.yaml`. Edit the manifest, not this block (content outside the markers is preserved).", + visibilityLabel: (v) => { + switch (v) { + case "public": + return "public (the git history is public)"; + case "private": + return "private (the git history is not public)"; + case "future-public": + return "future-public (private today, planned to go public)"; + default: + return "unset"; + } + }, + sourceLanguageLabel: (l) => { + switch (l) { + case "en": + return "en (commits, comments, and code in English)"; + case "ja": + return "ja (commits, comments, and code in Japanese)"; + case "en+ja": + return "en+ja (commits, comments, and code in English and Japanese)"; + default: + return "unset"; + } + }, + publishKindLabel: (k) => (k === "web" ? "web (deployed)" : "npm (package)"), + publishVisibilityLabel: (v) => { + switch (v) { + case "public": + return "public"; + case "private": + return "private"; + case "future-public": + return "future-public"; + default: + return "visibility unset"; + } + }, + contentLanguageLabel: (l) => l ?? "language unset", + sourceVisibilityLabel: "Source visibility", + sourceLanguageLineLabel: "Source language", + publishesNone: "- Published surfaces: none", + publishesHeader: "- Published surfaces:", + }, + viewBlock: { + heading: "## Workspace view layout (generated by basou — the manifest is the source of truth)", + intro: + "This section is generated by `basou project preset` from the declarations in `.basou/manifest.yaml`. Edit the manifest, not this block (content outside the markers is preserved).", + selfNote: (viewName) => + `This AGENTS.md is itself generated by basou (canonical: \`agents/${viewName}/AGENTS.md\`; content outside the markers is preserved).`, + aggregates: (n) => + `This directory is a **view** aggregating the ${n} declared repo(s) via symlinks. It holds no content of its own and is not under git.`, + reposHeading: "### Aggregated repos", + tableHeader: "| repo | visibility | language | instructions |", + instructionsAnchor: "anchor (hand-maintained)", + instructionsSelf: "self (the repo owns it)", + instructionsHub: "hub (generated by basou)", + unsetShort: "unset", + commitHeading: "### Where to commit", + commitBody: + "You cannot commit in the view (it is not under git). Always `cd` into the actual repo before committing.", + conventionsHeading: "### Required reading", + conventionsBody: + "The working conventions live in each repo's AGENTS.md. Read these before working.", + principlesHeading: "### Key principles", + principleStateless: "- This directory holds no state (not under git)", + principleNoFiles: "- Do not place important files here directly (they belong in the repos)", + }, + anchorStarter: { + identityLine: (title) => + `> This repository is the **planning master (anchor) of ${title}**. AI agents working here should read this file first.`, + starterNote: + "> This file is a starter that `basou project derive` generated **once** at greenfield bring-up. Hand-maintain it from here — basou never regenerates or overwrites it (there are no BASOU:GENERATED markers; edit freely).", + basicsHeading: "## Project basics", + basicsTodo: "", + commitHeading: "## Where to commit", + commitPlanning: "- **This repository (the planning master)**: plans, designs, strategy docs.", + commitImplementation: + "- **Each implementation repo**: implementation code. Always `cd` into the target repo before committing.", + commitView: "- **The workspace view**: not under git. You cannot commit in the view.", + conventionsHeading: "## Required reading", + conventionsBody: + "The working conventions live in each repo's AGENTS.md. Read these before working.", + viewPointerLine: (viewName) => + `- ${viewName}/AGENTS.md (the workspace view, generated by basou) — **the authoritative, up-to-date repo roster (the live roster) lives there**`, + policyHeading: "## Working policy (project specifics)", + policyTodo: [ + "", + ], + }, +}; + +// E-5: the Japanese instruction-file content. These values must stay +// byte-identical to the pre-i18n generator output so a repo that declares +// `language: ja` (or a ja anchor, for the view/starter) renders exactly what +// it rendered before. +const PRESET_JA: PresetStrings = { + repoBlock: { + heading: "## プロジェクト構成(basou が生成 — manifest が正本)", + intro: + "このセクションは `.basou/manifest.yaml` の宣言から `basou project preset` が生成します。編集は manifest 側で行ってください(マーカー外の記述は保持されます)。", + visibilityLabel: (v) => { + switch (v) { + case "public": + return "public(git 履歴は公開)"; + case "private": + return "private(git 履歴は非公開)"; + case "future-public": + return "future-public(現在は非公開・将来公開予定)"; + default: + return "未設定"; + } + }, + sourceLanguageLabel: (l) => { + switch (l) { + case "en": + return "en(commit・コメント・コードは英語)"; + case "ja": + return "ja(commit・コメント・コードは日本語)"; + case "en+ja": + return "en+ja(commit・コメント・コードは日英)"; + default: + return "未設定"; + } + }, + publishKindLabel: (k) => (k === "web" ? "web(デプロイ)" : "npm(パッケージ)"), + publishVisibilityLabel: (v) => { + switch (v) { + case "public": + return "公開"; + case "private": + return "非公開"; + case "future-public": + return "将来公開"; + default: + return "可視性未設定"; + } + }, + contentLanguageLabel: (l) => l ?? "言語未設定", + sourceVisibilityLabel: "ソース可視性", + sourceLanguageLineLabel: "ソース言語", + publishesNone: "- 配信物: なし", + publishesHeader: "- 配信物:", + }, + viewBlock: { + heading: "## workspace view 構成(basou が生成 — manifest が正本)", + intro: + "このセクションは `.basou/manifest.yaml` の宣言から `basou project preset` が生成します。編集は manifest 側で行ってください(マーカー外の記述は保持されます)。", + selfNote: (viewName) => + `この AGENTS.md 自身も basou の生成物です(実体: \`agents/${viewName}/AGENTS.md\`、マーカー外の記述は保持されます)。`, + aggregates: (n) => + `このディレクトリは、宣言された ${n} 個の repo を symlink で集約する **view** です。実体を持たず、git 管理外です。`, + reposHeading: "### 集約している repo", + tableHeader: "| repo | 可視性 | 言語 | 指示書 |", + instructionsAnchor: "anchor(手管理)", + instructionsSelf: "self(repo が自己管理)", + instructionsHub: "hub(basou が生成)", + unsetShort: "未設定", + commitHeading: "### どこで commit するか", + commitBody: + "view では commit できません(git 管理外)。変更は必ず実体の repo に `cd` してから commit してください。", + conventionsHeading: "### 必ず読むべき規約", + conventionsBody: + "作業規約は各 repo の AGENTS.md にあります。以下を読んでから作業してください。", + principlesHeading: "### 重要原則", + principleStateless: "- このディレクトリは状態を持たない(git 管理外)", + principleNoFiles: "- 重要なファイルをここに直接置かない(実体は各 repo に置く)", + }, + anchorStarter: { + identityLine: (title) => + `> このリポジトリは **${title} の planning master(anchor)** です。ここで作業する AI エージェントは、まずこのファイルを読んでください。`, + starterNote: + "> このファイルは `basou project derive` が greenfield 立ち上げ時に **一度だけ生成した starter** です。以後は手管理してください — basou は再生成も上書きもしません(BASOU:GENERATED マーカーは無く、自由に編集できます)。", + basicsHeading: "## プロジェクトの基本情報", + basicsTodo: "", + commitHeading: "## どこで commit するか", + commitPlanning: "- **このリポジトリ(planning master)**: 構想・計画・設計ドキュメント。", + commitImplementation: + "- **各実装 repo**: 実装コード。必ず対象 repo に `cd` してから commit してください。", + commitView: "- **workspace view**: git 管理外。view では commit できません。", + conventionsHeading: "## 必ず読むべき規約", + conventionsBody: + "作業規約は各 repo の AGENTS.md にあります。以下を読んでから作業してください。", + viewPointerLine: (viewName) => + `- ${viewName}/AGENTS.md(workspace view・basou が生成)— **最新の repo 構成(roster)はここを正とする**`, + policyHeading: "## 作業方針(プロジェクト固有事項)", + policyTodo: [ + "", + ], + }, +}; diff --git a/packages/core/src/project/anchor-starter.test.ts b/packages/core/src/project/anchor-starter.test.ts index 70ac883..0f16759 100644 --- a/packages/core/src/project/anchor-starter.test.ts +++ b/packages/core/src/project/anchor-starter.test.ts @@ -10,10 +10,10 @@ describe("renderAnchorStarter", () => { }); expect(doc).toContain("# AGENTS.md (acme-planning)"); // Identity falls back to the anchor name when no project name is declared. - expect(doc).toContain("**acme-planning の planning master(anchor)**"); + expect(doc).toContain("**planning master (anchor) of acme-planning**"); // Commit-routing is prose, not a per-repo table. - expect(doc).toContain("## どこで commit するか"); - expect(doc).toContain("**このリポジトリ(planning master)**"); + expect(doc).toContain("## Where to commit"); + expect(doc).toContain("**This repository (the planning master)**"); // Per-repo pointers exclude the anchor (that IS this file). expect(doc).toContain("- acme/AGENTS.md"); expect(doc).toContain("- acme-beta/AGENTS.md"); @@ -28,18 +28,18 @@ describe("renderAnchorStarter", () => { }); // No manifest-derived roster snapshot table (it would drift silently in a // markerless, never-resynced file). - expect(doc).not.toContain("| repo | 可視性 | 言語 | 指示書 |"); - expect(doc).not.toContain("スナップショット"); + expect(doc).not.toContain("| repo | visibility | language | instructions |"); + expect(doc).not.toContain("snapshot"); // Instead, the view's generated AGENTS.md is the live-roster source of truth. expect(doc).toContain( - "- acme-workspace/AGENTS.md(workspace view・basou が生成)— **最新の repo 構成(roster)はここを正とする**", + "- acme-workspace/AGENTS.md (the workspace view, generated by basou) — **the authoritative, up-to-date repo roster (the live roster) lives there**", ); }); it("declares itself a create-only, marker-less starter that basou never rewrites", () => { const doc = renderAnchorStarter({ anchorName: "p", repos: [{ name: "p", anchor: true }] }); - expect(doc).toContain("一度だけ生成した starter"); - expect(doc).toContain("basou は再生成も上書きもしません"); + expect(doc).toContain("generated **once** at greenfield bring-up"); + expect(doc).toContain("basou never regenerates or overwrites it"); // It must NOT carry BASOU:GENERATED markers — it is hand-maintained, not synced. expect(doc).not.toContain("BASOU:GENERATED:START"); expect(doc).not.toContain("BASOU:GENERATED:END"); @@ -51,7 +51,7 @@ describe("renderAnchorStarter", () => { projectName: "Acme", repos: [{ name: "acme-planning", anchor: true }], }); - expect(doc).toContain("**Acme の planning master(anchor)**"); + expect(doc).toContain("**planning master (anchor) of Acme**"); expect(doc).toContain("Product name: Acme"); }); @@ -63,8 +63,8 @@ describe("renderAnchorStarter", () => { expect(doc).toContain("GitHub Organization: "); expect(doc).toContain("License: "); // Project-specific policy is left for the operator to fill. - expect(doc).toContain("## 作業方針(プロジェクト固有事項)"); - expect(doc).toContain("言語ポリシー"); + expect(doc).toContain("## Working policy (project specifics)"); + expect(doc).toContain("Language policy"); }); it("omits the view pointer when the project has no workspace view", () => { @@ -73,7 +73,7 @@ describe("renderAnchorStarter", () => { repos: [{ name: "solo-planning", anchor: true }, { name: "solo" }], }); expect(doc).toContain("- solo/AGENTS.md"); - expect(doc).not.toContain("workspace view・basou が生成"); + expect(doc).not.toContain("the workspace view, generated by basou"); }); it("is deterministic (byte-identical) for the same input and ends with a newline", () => { @@ -85,4 +85,90 @@ describe("renderAnchorStarter", () => { expect(renderAnchorStarter(input)).toBe(renderAnchorStarter(input)); expect(renderAnchorStarter(input).endsWith("\n")).toBe(true); }); + + it("renders the Japanese starter when the anchor declares language: ja", () => { + const doc = renderAnchorStarter({ + anchorName: "acme-planning", + viewName: "acme-workspace", + repos: [{ name: "acme-planning", anchor: true, language: "ja" }, { name: "acme" }], + }); + expect(doc).toContain("**acme-planning の planning master(anchor)**"); + expect(doc).toContain("## どこで commit するか"); + expect(doc).toContain( + "- acme-workspace/AGENTS.md(workspace view・basou が生成)— **最新の repo 構成(roster)はここを正とする**", + ); + }); + + it("only the ANCHOR entry's language participates (a ja member repo does not flip the starter)", () => { + const doc = renderAnchorStarter({ + anchorName: "acme-planning", + repos: [ + { name: "acme-planning", anchor: true, language: "en" }, + { name: "acme", language: "ja" }, + ], + }); + expect(doc).toContain("## Where to commit"); + expect(doc).not.toContain("どこで commit するか"); + }); + + // Golden full-file lock on the ja bytes: a ja anchor promises the exact + // pre-i18n starter, so any edit to the ja table (or to how the generator + // assembles it) must surface here as a full-file diff. + it("golden: a ja anchor renders this exact starter", () => { + const doc = renderAnchorStarter({ + anchorName: "acme-planning", + projectName: "Acme", + viewName: "acme-workspace", + repos: [ + { name: "acme-planning", anchor: true, language: "ja" }, + { name: "acme" }, + { name: "acme-site" }, + ], + }); + expect(doc).toBe(JA_GOLDEN_STARTER); + }); }); + +const JA_GOLDEN_STARTER = `${[ + "# AGENTS.md (acme-planning)", + "", + "> このリポジトリは **Acme の planning master(anchor)** です。ここで作業する AI エージェントは、まずこのファイルを読んでください。", + ">", + "> このファイルは `basou project derive` が greenfield 立ち上げ時に **一度だけ生成した starter** です。以後は手管理してください — basou は再生成も上書きもしません(BASOU:GENERATED マーカーは無く、自由に編集できます)。", + "", + "## プロジェクトの基本情報", + "", + "", + "", + "```text", + "Product name: Acme", + "Domain: ", + "GitHub Organization: ", + "Public repository: ", + "Planning repository: ", + "License: ", + "```", + "", + "## どこで commit するか", + "", + "- **このリポジトリ(planning master)**: 構想・計画・設計ドキュメント。", + "- **各実装 repo**: 実装コード。必ず対象 repo に `cd` してから commit してください。", + "- **workspace view**: git 管理外。view では commit できません。", + "", + "## 必ず読むべき規約", + "", + "作業規約は各 repo の AGENTS.md にあります。以下を読んでから作業してください。", + "", + "- acme/AGENTS.md", + "- acme-site/AGENTS.md", + "- acme-workspace/AGENTS.md(workspace view・basou が生成)— **最新の repo 構成(roster)はここを正とする**", + "", + "## 作業方針(プロジェクト固有事項)", + "", + "", +].join("\n")}\n`; diff --git a/packages/core/src/project/anchor-starter.ts b/packages/core/src/project/anchor-starter.ts index abdce0d..47f8443 100644 --- a/packages/core/src/project/anchor-starter.ts +++ b/packages/core/src/project/anchor-starter.ts @@ -27,12 +27,22 @@ * operator-specific string beyond the declared repo names / project name. */ +import { presetStrings, resolveAnchorContentLanguage } from "../lib/view-strings.js"; +import type { RepoLanguage } from "./roster.js"; + /** One roster repo referenced by the anchor starter's per-repo pointers. */ export type AnchorStarterRepo = { /** The repo's display name (its on-disk basename). */ name: string; /** True when this repo IS the anchor (the planning master itself; excluded from the pointers). */ anchor?: boolean | undefined; + /** + * The repo's declared `language`. Only the ANCHOR entry's value participates: + * the starter is the anchor's own file, so its content language follows the + * anchor's declared audience (`ja` renders the pre-i18n Japanese bytes; + * anything else renders English). + */ + language?: RepoLanguage | undefined; }; /** The declared fields the anchor starter is rendered from. */ @@ -53,26 +63,26 @@ export type AnchorStarterInput = { * declaration; everything basou cannot know (product facts, phase, secrets, * language policy) is left as an explicit `` for the operator. The * live roster is NOT snapshotted here — the file points at the workspace view's - * generated AGENTS.md for it. Returns the file content WITH a trailing newline. + * generated AGENTS.md for it. The content language follows the ANCHOR entry's + * declared `language` (a ja anchor renders byte-identical to the pre-i18n + * output; anything else renders English). Returns the file content WITH a + * trailing newline. */ export function renderAnchorStarter(input: AnchorStarterInput): string { + const t = presetStrings(resolveAnchorContentLanguage(input.repos)).anchorStarter; const lines: string[] = []; const title = input.projectName ?? input.anchorName; lines.push(`# AGENTS.md (${input.anchorName})`); lines.push(""); - lines.push( - `> このリポジトリは **${title} の planning master(anchor)** です。ここで作業する AI エージェントは、まずこのファイルを読んでください。`, - ); + lines.push(t.identityLine(title)); lines.push(">"); - lines.push( - "> このファイルは `basou project derive` が greenfield 立ち上げ時に **一度だけ生成した starter** です。以後は手管理してください — basou は再生成も上書きもしません(BASOU:GENERATED マーカーは無く、自由に編集できます)。", - ); + lines.push(t.starterNote); lines.push(""); - lines.push("## プロジェクトの基本情報"); + lines.push(t.basicsHeading); lines.push(""); - lines.push(""); + lines.push(t.basicsTodo); lines.push(""); lines.push("```text"); lines.push(`Product name: ${input.projectName ?? ""}`); @@ -84,36 +94,29 @@ export function renderAnchorStarter(input: AnchorStarterInput): string { lines.push("```"); lines.push(""); - lines.push("## どこで commit するか"); + lines.push(t.commitHeading); lines.push(""); - lines.push("- **このリポジトリ(planning master)**: 構想・計画・設計ドキュメント。"); - lines.push("- **各実装 repo**: 実装コード。必ず対象 repo に `cd` してから commit してください。"); - lines.push("- **workspace view**: git 管理外。view では commit できません。"); + lines.push(t.commitPlanning); + lines.push(t.commitImplementation); + lines.push(t.commitView); lines.push(""); - lines.push("## 必ず読むべき規約"); + lines.push(t.conventionsHeading); lines.push(""); - lines.push("作業規約は各 repo の AGENTS.md にあります。以下を読んでから作業してください。"); + lines.push(t.conventionsBody); lines.push(""); for (const r of input.repos) { if (r.anchor === true) continue; // this file lines.push(`- ${r.name}/AGENTS.md`); } if (input.viewName !== undefined) { - lines.push( - `- ${input.viewName}/AGENTS.md(workspace view・basou が生成)— **最新の repo 構成(roster)はここを正とする**`, - ); + lines.push(t.viewPointerLine(input.viewName)); } lines.push(""); - lines.push("## 作業方針(プロジェクト固有事項)"); + lines.push(t.policyHeading); lines.push(""); - lines.push(""); + lines.push(...t.policyTodo); return `${lines.join("\n")}\n`; } diff --git a/packages/core/src/project/preset.test.ts b/packages/core/src/project/preset.test.ts index 5fb8e11..5ae60a9 100644 --- a/packages/core/src/project/preset.test.ts +++ b/packages/core/src/project/preset.test.ts @@ -17,25 +17,25 @@ describe("renderPresetBlock", () => { { kind: "npm", visibility: "public", language: "en" }, ], }); - expect(block).toContain("ソース可視性: private(git 履歴は非公開)"); - expect(block).toContain("ソース言語: en(commit・コメント・コードは英語)"); - expect(block).toContain("- 配信物:"); - expect(block).toContain("web(デプロイ) — 公開 / en+ja"); - expect(block).toContain("npm(パッケージ) — 公開 / en"); + expect(block).toContain("Source visibility: private (the git history is not public)"); + expect(block).toContain("Source language: en (commits, comments, and code in English)"); + expect(block).toContain("- Published surfaces:"); + expect(block).toContain("web (deployed) — public / en+ja"); + expect(block).toContain("npm (package) — public / en"); // web is declared first, so it renders first. - expect(block.indexOf("web(デプロイ)")).toBeLessThan(block.indexOf("npm(パッケージ)")); + expect(block.indexOf("web (deployed)")).toBeLessThan(block.indexOf("npm (package)")); }); - it("renders '配信物: なし' when there are no published surfaces", () => { + it("renders 'Published surfaces: none' when there are no published surfaces", () => { const block = renderPresetBlock({ visibility: "public", language: "en" }); - expect(block).toContain("- 配信物: なし"); + expect(block).toContain("- Published surfaces: none"); }); - it("renders 未設定 for unset visibility/language and partial publish fields", () => { + it("renders 'unset' for unset visibility/language and partial publish fields", () => { const block = renderPresetBlock({ publishes: [{ kind: "web" }] }); - expect(block).toContain("ソース可視性: 未設定"); - expect(block).toContain("ソース言語: 未設定"); - expect(block).toContain("web(デプロイ) — 可視性未設定 / 言語未設定"); + expect(block).toContain("Source visibility: unset"); + expect(block).toContain("Source language: unset"); + expect(block).toContain("web (deployed) — visibility unset / language unset"); }); it("is deterministic (byte-identical for the same input) and has no trailing newline", () => { @@ -43,8 +43,53 @@ describe("renderPresetBlock", () => { expect(renderPresetBlock(repo)).toBe(renderPresetBlock(repo)); expect(renderPresetBlock(repo).endsWith("\n")).toBe(false); }); + + it("renders the Japanese content for a repo declaring language: ja", () => { + const block = renderPresetBlock({ + visibility: "private", + language: "ja", + publishes: [{ kind: "web", visibility: "public", language: "en+ja" }], + }); + expect(block).toContain("## プロジェクト構成(basou が生成 — manifest が正本)"); + expect(block).toContain("ソース可視性: private(git 履歴は非公開)"); + expect(block).toContain("ソース言語: ja(commit・コメント・コードは日本語)"); + expect(block).toContain("web(デプロイ) — 公開 / en+ja"); + }); + + it("renders English for en+ja (one content language; en is the shared floor)", () => { + const block = renderPresetBlock({ visibility: "private", language: "en+ja" }); + expect(block).toContain("## Project configuration"); + expect(block).toContain( + "Source language: en+ja (commits, comments, and code in English and Japanese)", + ); + expect(block).not.toContain("プロジェクト構成"); + }); + + // Golden full-block lock on the ja bytes: a repo declaring `language: ja` + // promises the exact pre-i18n output, so any edit to the ja table (or to how + // the generator assembles it) must surface here as a full-block diff. + it("golden: a ja repo renders this exact block", () => { + const block = renderPresetBlock({ + visibility: "private", + language: "ja", + publishes: [{ kind: "web", visibility: "public", language: "en+ja" }, { kind: "npm" }], + }); + expect(block).toBe(JA_GOLDEN_REPO_BLOCK); + }); }); +const JA_GOLDEN_REPO_BLOCK = [ + "## プロジェクト構成(basou が生成 — manifest が正本)", + "", + "このセクションは `.basou/manifest.yaml` の宣言から `basou project preset` が生成します。編集は manifest 側で行ってください(マーカー外の記述は保持されます)。", + "", + "- ソース可視性: private(git 履歴は非公開)", + "- ソース言語: ja(commit・コメント・コードは日本語)", + "- 配信物:", + " - web(デプロイ) — 公開 / en+ja", + " - npm(パッケージ) — 可視性未設定 / 言語未設定", +].join("\n"); + describe("isRenderable", () => { it("is false only when visibility, language, and publishes are all absent", () => { expect(isRenderable({})).toBe(false); @@ -74,7 +119,7 @@ describe("summarizePresetPlan", () => { expect(s.plans).toHaveLength(1); expect(s.plans[0]?.action).toBe("create"); expect(s.plans[0]?.path).toBe("../x"); - expect(s.plans[0]?.desiredBlock).toContain("ソース可視性: public"); + expect(s.plans[0]?.desiredBlock).toContain("Source visibility: public"); expect(s.ok).toBe(false); }); @@ -271,22 +316,25 @@ describe("renderViewPresetBlock", () => { { name: "acme-planning", visibility: "private", language: "ja" }, ], }); - expect(block).toContain("## workspace view 構成(basou が生成 — manifest が正本)"); - expect(block).toContain("宣言された 2 個の repo を symlink で集約する"); + // No entry is flagged `anchor`, so the block resolves to English (the default). + expect(block).toContain( + "## Workspace view layout (generated by basou — the manifest is the source of truth)", + ); + expect(block).toContain("aggregating the 2 declared repo(s) via symlinks"); // The aggregation table: header plus one row per repo, in declared order. - expect(block).toContain("| repo | 可視性 | 言語 | 指示書 |"); - expect(block).toContain("| acme | public | en | hub(basou が生成) |"); - expect(block).toContain("| acme-planning | private | ja | hub(basou が生成) |"); + expect(block).toContain("| repo | visibility | language | instructions |"); + expect(block).toContain("| acme | public | en | hub (generated by basou) |"); + expect(block).toContain("| acme-planning | private | ja | hub (generated by basou) |"); // Commit / read / principle sections. expect(block).toContain("- acme → `cd acme`"); expect(block).toContain("- acme-planning/AGENTS.md"); - expect(block).toContain("### 重要原則"); - expect(block).toContain("- このディレクトリは状態を持たない(git 管理外)"); + expect(block).toContain("### Key principles"); + expect(block).toContain("- This directory holds no state (not under git)"); }); it("describes itself as generated, naming its own canonical (agents//AGENTS.md)", () => { const block = renderViewPresetBlock({ viewName: "my-workspace", repos: [{ name: "a" }] }); - expect(block).toContain("この AGENTS.md 自身も basou の生成物です"); + expect(block).toContain("This AGENTS.md is itself generated by basou"); expect(block).toContain("`agents/my-workspace/AGENTS.md`"); }); @@ -298,8 +346,8 @@ describe("renderViewPresetBlock", () => { { name: "selfish", visibility: "public", language: "ja", self: true }, ], }); - expect(block).toContain("| hubbed | public | en | hub(basou が生成) |"); - expect(block).toContain("| selfish | public | ja | self(repo が自己管理) |"); + expect(block).toContain("| hubbed | public | en | hub (generated by basou) |"); + expect(block).toContain("| selfish | public | ja | self (the repo owns it) |"); }); it("marks the anchor as hand-maintained (never hub) in the instruction column", () => { @@ -312,14 +360,16 @@ describe("renderViewPresetBlock", () => { { name: "impl", visibility: "public", language: "en" }, ], }); + // The anchor declares ja, so the whole view block renders Japanese. expect(block).toContain("| planning | private | ja | anchor(手管理) |"); expect(block).toContain("| impl | public | en | hub(basou が生成) |"); - // `anchor` wins over an incidental `self` flag on the same row. + // `anchor` wins over an incidental `self` flag on the same row (this roster's + // anchor declares no language, so the block is English). const both = renderViewPresetBlock({ viewName: "ws", repos: [{ name: "planning", anchor: true, self: true }], }); - expect(both).toContain("| planning | 未設定 | 未設定 | anchor(手管理) |"); + expect(both).toContain("| planning | unset | unset | anchor (hand-maintained) |"); }); it("preserves the declared repo order in every list", () => { @@ -348,15 +398,15 @@ describe("renderViewPresetBlock", () => { expect(block).toContain("| future | future-public | en+ja |"); }); - it("renders 未設定 for unset visibility / language", () => { + it("renders 'unset' for unset visibility / language", () => { const block = renderViewPresetBlock({ viewName: "ws", repos: [{ name: "bare" }] }); - expect(block).toContain("| bare | 未設定 | 未設定 |"); + expect(block).toContain("| bare | unset | unset |"); }); it("renders cleanly for an empty roster (0 repos, header-only table, empty lists)", () => { const block = renderViewPresetBlock({ viewName: "ws", repos: [] }); - expect(block).toContain("宣言された 0 個の repo を symlink で集約する"); - expect(block).toContain("| repo | 可視性 | 言語 | 指示書 |"); + expect(block).toContain("aggregating the 0 declared repo(s) via symlinks"); + expect(block).toContain("| repo | visibility | language | instructions |"); expect(block).toContain("|---|---|---|---|"); // No repo rows / cd lines / AGENTS.md lines (the self-description names // `agents/ws/AGENTS.md`, so the check is scoped to list items). @@ -375,4 +425,62 @@ describe("renderViewPresetBlock", () => { expect(a).toBe(b); expect(a.endsWith("\n")).toBe(false); }); + + // Golden full-block lock on the ja bytes: a roster whose ANCHOR declares + // `language: ja` promises the exact pre-i18n view block, whatever the other + // repos declare. Any edit to the ja table (or to how the generator assembles + // it) must surface here as a full-block diff. + it("golden: a ja-anchor roster renders this exact block", () => { + const block = renderViewPresetBlock({ + viewName: "acme-workspace", + repos: [ + { name: "acme-planning", visibility: "private", language: "ja", anchor: true }, + { name: "acme", visibility: "public", language: "en", self: true }, + { name: "acme-site", visibility: "private", language: "ja" }, + { name: "acme-lab" }, + ], + }); + expect(block).toBe(JA_GOLDEN_VIEW_BLOCK); + }); }); + +const JA_GOLDEN_VIEW_BLOCK = [ + "## workspace view 構成(basou が生成 — manifest が正本)", + "", + "このセクションは `.basou/manifest.yaml` の宣言から `basou project preset` が生成します。編集は manifest 側で行ってください(マーカー外の記述は保持されます)。", + "この AGENTS.md 自身も basou の生成物です(実体: `agents/acme-workspace/AGENTS.md`、マーカー外の記述は保持されます)。", + "", + "このディレクトリは、宣言された 4 個の repo を symlink で集約する **view** です。実体を持たず、git 管理外です。", + "", + "### 集約している repo", + "", + "| repo | 可視性 | 言語 | 指示書 |", + "|---|---|---|---|", + "| acme-planning | private | ja | anchor(手管理) |", + "| acme | public | en | self(repo が自己管理) |", + "| acme-site | private | ja | hub(basou が生成) |", + "| acme-lab | 未設定 | 未設定 | hub(basou が生成) |", + "", + "### どこで commit するか", + "", + "view では commit できません(git 管理外)。変更は必ず実体の repo に `cd` してから commit してください。", + "", + "- acme-planning → `cd acme-planning`", + "- acme → `cd acme`", + "- acme-site → `cd acme-site`", + "- acme-lab → `cd acme-lab`", + "", + "### 必ず読むべき規約", + "", + "作業規約は各 repo の AGENTS.md にあります。以下を読んでから作業してください。", + "", + "- acme-planning/AGENTS.md", + "- acme/AGENTS.md", + "- acme-site/AGENTS.md", + "- acme-lab/AGENTS.md", + "", + "### 重要原則", + "", + "- このディレクトリは状態を持たない(git 管理外)", + "- 重要なファイルをここに直接置かない(実体は各 repo に置く)", +].join("\n"); diff --git a/packages/core/src/project/preset.ts b/packages/core/src/project/preset.ts index 7d03f5d..65532b7 100644 --- a/packages/core/src/project/preset.ts +++ b/packages/core/src/project/preset.ts @@ -14,6 +14,11 @@ * caller's job. */ +import { + presetStrings, + resolveAnchorContentLanguage, + resolveRepoContentLanguage, +} from "../lib/view-strings.js"; import { normalizeRelativePath as normalizePath } from "./relative-path.js"; import type { PublishTarget, RepoLanguage, RepoVisibility } from "./roster.js"; @@ -24,61 +29,9 @@ export type PresetRepo = { publishes?: PublishTarget[] | undefined; }; -/** Source git-visibility, rendered with the consequence the agent must respect. */ -function visibilityLabel(v: RepoVisibility | undefined): string { - switch (v) { - case "public": - return "public(git 履歴は公開)"; - case "private": - return "private(git 履歴は非公開)"; - case "future-public": - return "future-public(現在は非公開・将来公開予定)"; - default: - return "未設定"; - } -} - -/** Source language (commits/comments/code), rendered with the audience it serves. */ -function sourceLanguageLabel(l: RepoLanguage | undefined): string { - switch (l) { - case "en": - return "en(commit・コメント・コードは英語)"; - case "ja": - return "ja(commit・コメント・コードは日本語)"; - case "en+ja": - return "en+ja(commit・コメント・コードは日英)"; - default: - return "未設定"; - } -} - -/** Published-surface kind. */ -function publishKindLabel(k: PublishTarget["kind"]): string { - return k === "web" ? "web(デプロイ)" : "npm(パッケージ)"; -} - -/** A published surface's visibility (independent of the source repo's). */ -function publishVisibilityLabel(v: RepoVisibility | undefined): string { - switch (v) { - case "public": - return "公開"; - case "private": - return "非公開"; - case "future-public": - return "将来公開"; - default: - return "可視性未設定"; - } -} - -/** A published surface's content language (read by end users; may differ from source). */ -function contentLanguageLabel(l: RepoLanguage | undefined): string { - return l ?? "言語未設定"; -} - /** * Whether a repo has anything to render. A repo with no visibility, no language, - * and no published surface yields an all-"未設定" block that helps no one, so it + * and no published surface yields an all-"unset" block that helps no one, so it * is reported as `undeclared` rather than generated. */ export function isRenderable(repo: PresetRepo): boolean { @@ -94,44 +47,36 @@ export function isRenderable(repo: PresetRepo): boolean { * BASOU:GENERATED markers in a canonical). Deterministic and OSS-generic: it * derives entirely from the declared fields, embedding no operator-specific * names, so re-running on an unchanged manifest produces byte-identical output - * (the basis for drift detection). The published surfaces are listed in their - * declared order. Returns the block WITHOUT a trailing newline; the marker - * writer adds the surrounding structure. + * (the basis for drift detection). The block's content language follows the + * repo's own declared `language` (its audience): `ja` renders the Japanese + * strings byte-identical to the pre-i18n output; `en` / `en+ja` / undeclared + * render English. The published surfaces are listed in their declared order. + * Returns the block WITHOUT a trailing newline; the marker writer adds the + * surrounding structure. */ export function renderPresetBlock(repo: PresetRepo): string { + const t = presetStrings(resolveRepoContentLanguage(repo.language)).repoBlock; const lines: string[] = []; - lines.push("## プロジェクト構成(basou が生成 — manifest が正本)"); + lines.push(t.heading); lines.push(""); - lines.push( - "このセクションは `.basou/manifest.yaml` の宣言から `basou project preset` が生成します。編集は manifest 側で行ってください(マーカー外の記述は保持されます)。", - ); + lines.push(t.intro); lines.push(""); - lines.push(`- ソース可視性: ${visibilityLabel(repo.visibility)}`); - lines.push(`- ソース言語: ${sourceLanguageLabel(repo.language)}`); + lines.push(`- ${t.sourceVisibilityLabel}: ${t.visibilityLabel(repo.visibility)}`); + lines.push(`- ${t.sourceLanguageLineLabel}: ${t.sourceLanguageLabel(repo.language)}`); const publishes = repo.publishes ?? []; if (publishes.length === 0) { - lines.push("- 配信物: なし"); + lines.push(t.publishesNone); } else { - lines.push("- 配信物:"); + lines.push(t.publishesHeader); for (const p of publishes) { lines.push( - ` - ${publishKindLabel(p.kind)} — ${publishVisibilityLabel(p.visibility)} / ${contentLanguageLabel(p.language)}`, + ` - ${t.publishKindLabel(p.kind)} — ${t.publishVisibilityLabel(p.visibility)} / ${t.contentLanguageLabel(p.language)}`, ); } } return lines.join("\n"); } -/** Short visibility label (the plain declared string, or 未設定 when unset). */ -function visibilityShortLabel(v: RepoVisibility | undefined): string { - return v ?? "未設定"; -} - -/** Short language label (the plain declared string, or 未設定 when unset). */ -function languageShortLabel(l: RepoLanguage | undefined): string { - return l ?? "未設定"; -} - /** One repo aggregated by the view, rendered with its short visibility / language. */ export type ViewPresetRepo = { name: string; @@ -164,70 +109,64 @@ export type ViewPresetInput = { repos: ViewPresetRepo[]; }; -/** Instruction-file ownership label: who writes the repo's AGENTS.md. */ -function instructionsLabel(repo: ViewPresetRepo): string { - if (repo.anchor === true) return "anchor(手管理)"; - return repo.self === true ? "self(repo が自己管理)" : "hub(basou が生成)"; -} - /** * Render the workspace-view instruction-file preset block (the content between * the BASOU:GENERATED markers in the view's own canonical). Like * {@link renderPresetBlock} it is deterministic and OSS-generic: it derives * entirely from the declared roster (repo names come from the manifest, no * operator-specific string is embedded), so re-running on an unchanged manifest - * produces byte-identical output. The repos are listed in the order supplied. - * An empty roster still renders cleanly (a header-only table, empty lists). - * Returns the block WITHOUT a trailing newline; the marker writer adds the - * surrounding structure. + * produces byte-identical output. The view is a workspace-level artifact, so + * its content language follows the ANCHOR entry's declared language (mirroring + * the generated views' rule); a `ja` anchor renders byte-identical to the + * pre-i18n output. The repos are listed in the order supplied. An empty roster + * still renders cleanly (a header-only table, empty lists). Returns the block + * WITHOUT a trailing newline; the marker writer adds the surrounding structure. */ export function renderViewPresetBlock(input: ViewPresetInput): string { + const t = presetStrings(resolveAnchorContentLanguage(input.repos)).viewBlock; + const shortLabel = (v: string | undefined): string => v ?? t.unsetShort; + const instructionsLabel = (repo: ViewPresetRepo): string => { + if (repo.anchor === true) return t.instructionsAnchor; + return repo.self === true ? t.instructionsSelf : t.instructionsHub; + }; const lines: string[] = []; - lines.push("## workspace view 構成(basou が生成 — manifest が正本)"); + lines.push(t.heading); lines.push(""); - lines.push( - "このセクションは `.basou/manifest.yaml` の宣言から `basou project preset` が生成します。編集は manifest 側で行ってください(マーカー外の記述は保持されます)。", - ); - lines.push( - `この AGENTS.md 自身も basou の生成物です(実体: \`agents/${input.viewName}/AGENTS.md\`、マーカー外の記述は保持されます)。`, - ); + lines.push(t.intro); + lines.push(t.selfNote(input.viewName)); lines.push(""); - lines.push( - `このディレクトリは、宣言された ${input.repos.length} 個の repo を symlink で集約する **view** です。実体を持たず、git 管理外です。`, - ); + lines.push(t.aggregates(input.repos.length)); lines.push(""); - lines.push("### 集約している repo"); + lines.push(t.reposHeading); lines.push(""); - lines.push("| repo | 可視性 | 言語 | 指示書 |"); + lines.push(t.tableHeader); lines.push("|---|---|---|---|"); for (const r of input.repos) { lines.push( - `| ${r.name} | ${visibilityShortLabel(r.visibility)} | ${languageShortLabel(r.language)} | ${instructionsLabel(r)} |`, + `| ${r.name} | ${shortLabel(r.visibility)} | ${shortLabel(r.language)} | ${instructionsLabel(r)} |`, ); } lines.push(""); - lines.push("### どこで commit するか"); + lines.push(t.commitHeading); lines.push(""); - lines.push( - "view では commit できません(git 管理外)。変更は必ず実体の repo に `cd` してから commit してください。", - ); + lines.push(t.commitBody); lines.push(""); for (const r of input.repos) { lines.push(`- ${r.name} → \`cd ${r.name}\``); } lines.push(""); - lines.push("### 必ず読むべき規約"); + lines.push(t.conventionsHeading); lines.push(""); - lines.push("作業規約は各 repo の AGENTS.md にあります。以下を読んでから作業してください。"); + lines.push(t.conventionsBody); lines.push(""); for (const r of input.repos) { lines.push(`- ${r.name}/AGENTS.md`); } lines.push(""); - lines.push("### 重要原則"); + lines.push(t.principlesHeading); lines.push(""); - lines.push("- このディレクトリは状態を持たない(git 管理外)"); - lines.push("- 重要なファイルをここに直接置かない(実体は各 repo に置く)"); + lines.push(t.principleStateless); + lines.push(t.principleNoFiles); return lines.join("\n"); } diff --git a/scripts/check-language.mjs b/scripts/check-language.mjs index 08f611f..c3cda21 100644 --- a/scripts/check-language.mjs +++ b/scripts/check-language.mjs @@ -22,17 +22,13 @@ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); // allowed. Keep this list small — a new entry needs an explicit E-5 rationale, // not a convenience escape from translating output. const ALLOWLIST = [ - // E-5: the single generated-view locale table (handoff/orientation/decisions/ - // report chrome). The `ja` strings live ONLY here; the renderers themselves - // are English-only and select a language via the manifest's anchor repo. + // E-5: the single locale table for ALL generated content — the view chrome + // (handoff/orientation/decisions/report) and the instruction-file strings + // (repo preset block, view block, anchor starter). The `ja` strings live + // ONLY here; the renderers/generators themselves are English-only and select + // a language from the declaration (the manifest anchor for workspace-level + // artifacts, the target repo's own `language` for its preset block). "packages/core/src/lib/view-strings.ts", - // E-5: generated instruction-file preset — a separate surface from CLI output. - // Its content is currently Japanese unconditionally; it does not yet follow the - // target repo's declared `language`. - "packages/core/src/project/preset.ts", - // E-5: generated anchor AGENTS.md starter — the content language matches the - // sibling preset/view blocks it lives beside, a separate surface from CLI output. - "packages/core/src/project/anchor-starter.ts", ]; // CJK symbols & punctuation (U+3000-303F, e.g. 、。「」), Hiragana/Katakana From d565e4ac415907a11cbf3127cc46d4b63590067b Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Tue, 14 Jul 2026 00:48:23 +0900 Subject: [PATCH 2/2] docs+test: apply adversarial-review findings A1-A4 - A1: correct the CHANGELOG migration note: language-resolution drift surfaces via preset/derive update plans; project check inspects wiring structure, not generated-region content - A2: document the caller-set anchor flag vs the views' path-dot anchor predicate (and first-wins ordering) on resolveAnchorContentLanguage, with a first-wins test - A3: retitle spec 10.0 to cover generated content broadly, sub-head the instruction-file rule, drop the duplicated undeclared case - A4: note the table-selection invariant that makes some source-language label branches unreachable from renders Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 9 +++++---- docs/spec/generated-markdown.md | 12 +++++++----- packages/core/src/lib/view-strings.test.ts | 12 ++++++++++++ packages/core/src/lib/view-strings.ts | 19 +++++++++++++++++-- 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59f75de..76b2b27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,10 +27,11 @@ All notable changes to **basou** are recorded here. The project follows blocks, view blocks, and anchor starters; it now receives English. To keep the Japanese content, declare `language: ja` on the repo entry (for its preset block) or on the anchor entry (for the view block and anchor starter) - in `.basou/manifest.yaml`. Blocks committed by earlier releases will surface - as an ordinary preset `update` / wiring drift on the next - `basou project preset` / `derive` / `check` run when their language - resolution changed. Rendered prose is presentation, not contract (see + in `.basou/manifest.yaml`. Blocks committed by earlier releases surface as an + ordinary preset `update` plan on the next `basou project preset` / `derive` + run when their language resolution changed (`basou project check` inspects + wiring structure, not generated-region content, so it does not detect this). + Rendered prose is presentation, not contract (see `docs/spec/compatibility.md`). ## 0.33.0 — 2026-07-13 diff --git a/docs/spec/generated-markdown.md b/docs/spec/generated-markdown.md index e594fa0..6628214 100644 --- a/docs/spec/generated-markdown.md +++ b/docs/spec/generated-markdown.md @@ -18,7 +18,7 @@ All four share the same surface convention: an English document title, a `> Generated at ` line, and localized `##` section headings (see the view language rule below). -## §10.0 View language ("the workspace speaks the anchor's language") +## §10.0 Generated-content language ("the workspace speaks the anchor's language") The tool-generated chrome of all four views — section headings, labels, and verdict prose — is localized per workspace, resolved from the manifest roster: @@ -43,14 +43,16 @@ language is a project declaration (the manifest), not a caller preference. The rendered prose itself (headings, wording) is presentation, not contract — see `compatibility.md`. The templates below show the English chrome. +### Instruction files (preset block, view block, anchor starter) + The same declaration-driven rule extends to the GENERATED INSTRUCTION FILES (`basou project preset` / `derive` / `retrofit` / anchor seeding), with one refinement: a repo's preset block lives inside that repo's own instruction file, so its content language follows the **repo's own** declared `language` -(`ja` renders Japanese; `en`, `en+ja`, undeclared, or a missing declaration -render English — silently, like the views). Workspace-level instruction -artifacts — the view's generated AGENTS.md block and the anchor's create-only -starter — follow the **anchor entry's** language, mirroring the views' rule. +(`ja` renders Japanese; `en`, `en+ja`, or an undeclared language render +English — silently, like the views). Workspace-level instruction artifacts — +the view's generated AGENTS.md block and the anchor's create-only starter — +follow the **anchor entry's** language, mirroring the views' rule. ## §10.1 Living-artifact policy (handoff.md, decisions.md) diff --git a/packages/core/src/lib/view-strings.test.ts b/packages/core/src/lib/view-strings.test.ts index 72a2ea7..0641247 100644 --- a/packages/core/src/lib/view-strings.test.ts +++ b/packages/core/src/lib/view-strings.test.ts @@ -194,6 +194,18 @@ describe("resolveAnchorContentLanguage", () => { expect(resolveAnchorContentLanguage([{ language: "ja" }])).toBe("en"); expect(resolveAnchorContentLanguage([{ anchor: true }])).toBe("en"); }); + + it("first-wins when more than one entry carries the anchor flag (declared order)", () => { + expect( + resolveAnchorContentLanguage([ + { anchor: true, language: "ja" }, + { anchor: true, language: "en" }, + ]), + ).toBe("ja"); + expect(resolveAnchorContentLanguage([{ anchor: true }, { anchor: true, language: "ja" }])).toBe( + "en", + ); + }); }); describe("presetStrings", () => { diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index ea8675b..e877c4f 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -431,7 +431,15 @@ export function resolveRepoContentLanguage(language: RepoLanguage | undefined): * view's AGENTS.md block, the anchor's starter) from an already-gathered * roster: the entry flagged `anchor` speaks for the workspace, mirroring the * views' anchor-language rule. No anchor entry (or no declared language) - * resolves to English. + * resolves to English. When more than one entry carries the flag, the first + * wins (declared order). + * + * Note the anchor is identified by the CALLER-SET flag, not by this module: + * {@link resolveViewLanguage} keys on the manifest path being `.`, while the + * instruction-file callers flag the anchor by resolved-path identity. For a + * conventional manifest (anchor declared as `.`) the two agree; a roster that + * reaches the anchor only through an aliased path is where they can diverge, + * and the caller's flag is authoritative for the instruction files. */ export function resolveAnchorContentLanguage( repos: ReadonlyArray<{ anchor?: boolean | undefined; language?: RepoLanguage | undefined }>, @@ -453,7 +461,14 @@ export type PresetStrings = { intro: string; /** Source git-visibility, rendered with the consequence the agent must respect. */ visibilityLabel: (v: RepoVisibility | undefined) => string; - /** Source language (commits/comments/code), rendered with the audience it serves. */ + /** + * Source language (commits/comments/code), rendered with the audience it + * serves. Invariant note: the table itself is SELECTED by this same field + * (ja -> JA table, everything else -> EN), so the JA table's en / en+ja / + * unset branches and the EN table's ja branch are unreachable from + * renderPresetBlock — they exist for table completeness (and the + * both-language sweep test), not because a render can emit them. + */ sourceLanguageLabel: (l: RepoLanguage | undefined) => string; /** Published-surface kind. */ publishKindLabel: (k: PublishTarget["kind"]) => string;