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
172 changes: 172 additions & 0 deletions src/kernel/profile.ts
Original file line number Diff line number Diff line change
@@ -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<string>(["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<ProjectProfile> {
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}`)
}
}
178 changes: 178 additions & 0 deletions test/kernel/profile.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>) {
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)
})
})
Loading