diff --git a/src/kernel/profile.ts b/src/kernel/profile.ts new file mode 100644 index 0000000..7b2fa3e --- /dev/null +++ b/src/kernel/profile.ts @@ -0,0 +1,172 @@ +import { readFile } from "node:fs/promises" +import { join } from "node:path" + +/** AGENTS.md 中 Project Profile 区块的固定标题 */ +export const PROFILE_SECTION_TITLE = "## Project Profile" + +export type TddDefaultMode = "strict" | "relaxed" | "bypass" + +/** 解析后的结构化项目 Profile(只含可机检字段) */ +export interface ProjectProfile { + testCommand: string | null + regressionCommand: string | null + testFilePatterns: string[] + implementationFilePatterns: string[] + tddDefaultMode: TddDefaultMode | null + versionBumpRule: string | null + versionFile: string | null + tagFormat: string | null + releaseWorkflowPath: string | null + riskPatterns: string[] +} + +export interface ProfileParseResult { + profile: ProjectProfile + warnings: string[] +} + +/** 无 Profile 时的空值:所有可选字段为 null、列表字段为空数组 */ +export function emptyProfile(): ProjectProfile { + return { + testCommand: null, + regressionCommand: null, + testFilePatterns: [], + implementationFilePatterns: [], + tddDefaultMode: null, + versionBumpRule: null, + versionFile: null, + tagFormat: null, + releaseWorkflowPath: null, + riskPatterns: [], + } +} + +/** 条目行:`- key: value`,key 仅含小写字母/数字/空格/下划线/连字符 */ +const KEY_VALUE_PATTERN = /^- ([a-z0-9 _-]+):\s*(.+)$/ + +const VALID_TDD_MODES = new Set(["strict", "relaxed", "bypass"]) + +/** + * 解析 markdown 中的 `## Project Profile` 区块。 + * 定位标题到下一个 `##` 标题之间的条目行,按白名单提取; + * 未知 key 记 warning(不阻断),重复 key 取最后(文档语义)。 + */ +export function parseProjectProfile(markdown: string): ProfileParseResult { + const profile = emptyProfile() + const warnings: string[] = [] + const section = findProfileSection(markdown) + if (!section) { + return { profile, warnings } + } + + const lines = markdown.split("\n") + for (let i = section.start + 1; i < section.end; i++) { + const match = KEY_VALUE_PATTERN.exec(lines[i]) + if (!match) continue + applyKey(profile, warnings, match[1], cleanValue(match[2])) + } + return { profile, warnings } +} + +/** 从项目根目录读取 AGENTS.md 并解析;文件缺失或区块缺失返回空 Profile,不抛错 */ +export async function readProjectProfile(projectDir: string): Promise { + let markdown: string + try { + markdown = await readFile(join(projectDir, "AGENTS.md"), "utf8") + } catch { + return emptyProfile() + } + return parseProjectProfile(markdown).profile +} + +/** + * 确认回写辅助:将 profileBlock(完整 `## Project Profile` 区块)写入 markdown。 + * 已存在区块则整块替换,否则追加到末尾。 + */ +export function upsertProjectProfile(markdown: string, profileBlock: string): string { + const section = findProfileSection(markdown) + if (!section) { + const trimmed = markdown.trimEnd() + if (trimmed === "") return profileBlock.trimStart() + return `${trimmed}\n\n${profileBlock}` + } + + const lines = markdown.split("\n") + const before = lines.slice(0, section.start).join("\n").trimEnd() + const after = lines.slice(section.end).join("\n").trimStart() + return [before, profileBlock.trim(), after].filter(part => part !== "").join("\n\n") +} + +/** 定位 Profile 区块:标题行索引与结束行索引(下一个 `##` 标题,含;无则到文件末尾) */ +function findProfileSection(markdown: string): { start: number; end: number } | null { + const lines = markdown.split("\n") + const start = lines.findIndex(line => line.trim() === PROFILE_SECTION_TITLE) + if (start === -1) return null + + let end = lines.length + for (let i = start + 1; i < lines.length; i++) { + if (/^#{2,}\s/.test(lines[i])) { + end = i + break + } + } + return { start, end } +} + +/** 去掉值中的反引号与两端空白 */ +function cleanValue(raw: string): string { + return raw.replaceAll("`", "").trim() +} + +/** 逗号分隔的模式列表:trim 并过滤空项 */ +function splitPatterns(value: string): string[] { + return value + .split(",") + .map(part => part.trim()) + .filter(part => part !== "") +} + +function applyKey(profile: ProjectProfile, warnings: string[], key: string, value: string): void { + switch (key) { + case "test command": + profile.testCommand = value + return + case "regression command": + profile.regressionCommand = value + return + case "test file patterns": + profile.testFilePatterns = splitPatterns(value) + return + case "implementation file patterns": + profile.implementationFilePatterns = splitPatterns(value) + return + case "tdd default mode": + if (VALID_TDD_MODES.has(value)) { + profile.tddDefaultMode = value as TddDefaultMode + } else { + warnings.push(`Invalid tdd default mode: ${value}`) + } + return + case "version bump rule": + profile.versionBumpRule = value + return + case "version file": + profile.versionFile = value + return + case "tag format": + if (value.includes("{version}")) { + profile.tagFormat = value + } else { + warnings.push(`tag format must contain {version} placeholder: ${value}`) + } + return + case "release workflow": + profile.releaseWorkflowPath = value + return + case "risk patterns": + profile.riskPatterns = splitPatterns(value) + return + default: + warnings.push(`Unknown profile key: ${key}`) + } +} diff --git a/test/kernel/profile.test.ts b/test/kernel/profile.test.ts new file mode 100644 index 0000000..ec9bef7 --- /dev/null +++ b/test/kernel/profile.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from "vitest" +import { mkdtemp, writeFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + parseProjectProfile, + readProjectProfile, + upsertProjectProfile, + emptyProfile, + PROFILE_SECTION_TITLE, +} from "../../src/kernel/profile.js" + +const FULL_BLOCK = `## Project Profile + +- test command: \`npm test -- run\` +- regression command: \`npm run test:regression\` +- test file patterns: \`test/**/*.test.ts\` +- implementation file patterns: \`src/**/*.ts\` +- tdd default mode: \`strict\` +- version bump rule: \`breaking→major, feature→minor, fix→patch\` +- version file: \`package.json\` +- tag format: \`v{version}\` +- release workflow: \`.github/workflows/release.yml\` +- risk patterns: \`migrations/**, **/auth/**, **/permissions/**\` +` + +describe("parseProjectProfile", () => { + it("parses a well-formed profile section into structured fields", () => { + const { profile, warnings } = parseProjectProfile(FULL_BLOCK) + expect(warnings).toEqual([]) + expect(profile).toEqual({ + testCommand: "npm test -- run", + regressionCommand: "npm run test:regression", + testFilePatterns: ["test/**/*.test.ts"], + implementationFilePatterns: ["src/**/*.ts"], + tddDefaultMode: "strict", + versionBumpRule: "breaking→major, feature→minor, fix→patch", + versionFile: "package.json", + tagFormat: "v{version}", + releaseWorkflowPath: ".github/workflows/release.yml", + riskPatterns: ["migrations/**", "**/auth/**", "**/permissions/**"], + }) + }) + + it("stops at the next heading, ignoring later content", () => { + const markdown = `${FULL_BLOCK}\n## Other Section\n\n- test command: \`ignored\`\n` + const { profile } = parseProjectProfile(markdown) + expect(profile.testCommand).toBe("npm test -- run") + }) + + it("strips backticks and surrounding whitespace from values", () => { + const markdown = "## Project Profile\n\n- test command: ` npm test -- run `\n" + const { profile } = parseProjectProfile(markdown) + expect(profile.testCommand).toBe("npm test -- run") + }) + + it("splits comma-separated pattern lists", () => { + const markdown = "## Project Profile\n\n- implementation file patterns: `src/**/*.ts, src/kernel/**/*.ts, src/util/**/*.ts`\n" + const { profile } = parseProjectProfile(markdown) + expect(profile.implementationFilePatterns).toEqual([ + "src/**/*.ts", + "src/kernel/**/*.ts", + "src/util/**/*.ts", + ]) + }) + + it("reports unknown keys as warnings without blocking known keys", () => { + const markdown = "## Project Profile\n\n- mystery key: `value`\n- test command: `npm test`\n" + const { profile, warnings } = parseProjectProfile(markdown) + expect(profile.testCommand).toBe("npm test") + expect(warnings).toEqual(["Unknown profile key: mystery key"]) + }) + + it("takes the last value when a key is repeated", () => { + const markdown = "## Project Profile\n\n- test command: `first`\n- test command: `second`\n" + const { profile } = parseProjectProfile(markdown) + expect(profile.testCommand).toBe("second") + }) + + it("rejects a tag format without the {version} placeholder", () => { + const markdown = "## Project Profile\n\n- tag format: `v1.0`\n" + const { profile, warnings } = parseProjectProfile(markdown) + expect(profile.tagFormat).toBeNull() + expect(warnings.some(w => w.includes("tag format"))).toBe(true) + }) + + it("rejects an invalid tdd default mode", () => { + const markdown = "## Project Profile\n\n- tdd default mode: `sometimes`\n" + const { profile, warnings } = parseProjectProfile(markdown) + expect(profile.tddDefaultMode).toBeNull() + expect(warnings.some(w => w.includes("tdd default mode"))).toBe(true) + }) + + it("skips malformed lines without throwing", () => { + const markdown = [ + "## Project Profile", + "", + "this is not a key-value line", + "- ", + "- test command:", + "- broken key with ! char: `x`", + "- test command: `npm test`", + ].join("\n") + const { profile, warnings } = parseProjectProfile(markdown) + expect(profile.testCommand).toBe("npm test") + expect(warnings).toEqual([]) + }) + + it("returns an empty profile with no warnings when the section is missing", () => { + const { profile, warnings } = parseProjectProfile("# Just a title\n\nsome text\n") + expect(profile).toEqual(emptyProfile()) + expect(warnings).toEqual([]) + }) + + it("returns an empty profile when the section has no entries", () => { + const { profile } = parseProjectProfile(`${PROFILE_SECTION_TITLE}\n`) + expect(profile).toEqual(emptyProfile()) + }) +}) + +describe("readProjectProfile", () => { + async function withProjectDir(agentMd: string | null, fn: (dir: string) => Promise) { + const dir = await mkdtemp(join(tmpdir(), "cabbage-profile-")) + try { + if (agentMd !== null) { + await writeFile(join(dir, "AGENTS.md"), agentMd) + } + await fn(dir) + } finally { + await rm(dir, { recursive: true, force: true }) + } + } + + it("returns an empty profile when AGENTS.md does not exist", async () => { + await withProjectDir(null, async dir => { + await expect(readProjectProfile(dir)).resolves.toEqual(emptyProfile()) + }) + }) + + it("parses AGENTS.md when the profile section exists", async () => { + await withProjectDir(FULL_BLOCK, async dir => { + const profile = await readProjectProfile(dir) + expect(profile.testCommand).toBe("npm test -- run") + expect(profile.tagFormat).toBe("v{version}") + expect(profile.riskPatterns).toEqual(["migrations/**", "**/auth/**", "**/permissions/**"]) + }) + }) + + it("returns an empty profile when AGENTS.md lacks the section", async () => { + await withProjectDir("# Rules\n\n- keep it simple\n", async dir => { + await expect(readProjectProfile(dir)).resolves.toEqual(emptyProfile()) + }) + }) +}) + +describe("upsertProjectProfile", () => { + const block = FULL_BLOCK.trimEnd() + + it("appends the block to markdown without a profile section", () => { + const markdown = "# Project Rules\n\n- be concise\n" + const result = upsertProjectProfile(markdown, block) + expect(result.startsWith(markdown.trimEnd())).toBe(true) + expect(result).toContain(`\n\n${block}`) + }) + + it("returns the block alone for empty markdown", () => { + expect(upsertProjectProfile("", block)).toBe(block) + }) + + it("replaces the existing profile section and keeps surrounding content", () => { + const markdown = `# Project Rules\n\n- be concise\n\n## Project Profile\n\n- test command: \`old\`\n\n## Other Section\n\nkeep me` + const result = upsertProjectProfile(markdown, block) + expect(result).toContain("# Project Rules\n\n- be concise") + expect(result).toContain("## Other Section\n\nkeep me") + expect(result).not.toContain("old") + expect(result).toContain(block) + }) +})