Skip to content

Commit a279d6c

Browse files
authored
feat(lint): Prompt 静态 Lint + CI 集成 (#63)
- src/plugin/prompt-lint.ts: 引用完整性/禁止模式/Contract 完整性/Agent 能力一致性检查 - test/plugin/prompt-lint.test.ts: 5 个检查项 - package.json: 新增 prompt-lint 脚本 - ci.yml: 加入 prompt-lint 步骤 - flow-release: 动态分支 push Closes #60
1 parent 59338d4 commit a279d6c

5 files changed

Lines changed: 226 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ jobs:
1414
node-version: 20
1515
cache: npm
1616
- run: npm ci
17+
- run: npm run prompt-lint
1718
- run: npm run typecheck
1819
- run: npm test
1920
- run: npm run build

assets/skills/flow-release/SKILL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ git log $(git describe --tags --abbrev=0)..HEAD --oneline
2525
npm version <major|minor|patch> --no-git-tag-version
2626
# 更新 CHANGELOG.md
2727
git commit -m "chore(release): v<version>"
28+
BASE=$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name')
2829
git tag v<version>
29-
git push origin main --tags
30+
git push origin $BASE --tags
3031
```
3132

3233
### 3. Release 草稿(自动)

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"prepublishOnly": "npm test && npm run typecheck && npm run build",
4343
"typecheck": "tsc -p tsconfig.json --noEmit",
4444
"test": "vitest run",
45+
"prompt-lint": "vitest run test/plugin/prompt-lint.test.ts",
4546
"docs:dev": "vitepress dev docs",
4647
"docs:build": "vitepress build docs",
4748
"docs:preview": "vitepress preview docs"

src/plugin/prompt-lint.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { globSync } from "node:fs"
2+
import { readFileSync, existsSync } from "node:fs"
3+
import path from "node:path"
4+
5+
export interface LintFinding {
6+
severity: "error" | "warn"
7+
file: string
8+
rule: string
9+
message: string
10+
}
11+
12+
const CONTRACT_SECTIONS = [
13+
"Trigger", "Inputs", "Preconditions", "Procedure",
14+
"Outputs", "Failure", "Idempotency", "Prohibited Actions",
15+
]
16+
17+
const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; rule: string; message: string }> = [
18+
// Only flag direct pushes to hardcoded branches, not tag pushes
19+
{ pattern: /\bgit push origin main\b(?!\s+--tags)/, rule: "no-hardcoded-default-branch", message: "contains hardcoded 'git push origin main'" },
20+
{ pattern: /\bgit push origin master\b(?!\s+--tags)/, rule: "no-hardcoded-default-branch", message: "contains hardcoded 'git push origin master'" },
21+
{ pattern: /\bgit push origin dev\b(?!\s+--tags)/, rule: "no-hardcoded-default-branch", message: "contains hardcoded 'git push origin dev'" },
22+
{ pattern: /^\s*git add \.\s*$/m, rule: "no-blanket-git-add", message: "contains blanket 'git add .' without context" },
23+
{ pattern: /git worktree remove.*--force/, rule: "no-default-force-cleanup", message: "contains default --force worktree cleanup" },
24+
]
25+
26+
function findMdFiles(root: string, dirs: string[]): string[] {
27+
const results: string[] = []
28+
for (const dir of dirs) {
29+
const full = path.join(root, dir)
30+
if (!existsSync(full)) continue
31+
const pattern = path.join(full, "**", "*.md").replace(/\\/g, "/")
32+
results.push(...globSync(pattern))
33+
}
34+
return results
35+
}
36+
37+
function checkRelativeRefs(content: string, filePath: string): LintFinding[] {
38+
const findings: LintFinding[] = []
39+
const refPattern = /`([^`]*\.md)`/g
40+
let match
41+
while ((match = refPattern.exec(content)) !== null) {
42+
const ref = match[1]
43+
if (ref.startsWith("/") || ref.startsWith("http")) continue
44+
if (ref.includes("_prompts/") || ref.includes("_context/")) continue
45+
if (ref.includes("<") && ref.includes(">")) continue
46+
// only flag paths starting with ../ or ./
47+
if (!ref.startsWith(".")) continue
48+
const dir = path.dirname(filePath)
49+
const resolved = path.resolve(dir, ref)
50+
if (!existsSync(resolved)) {
51+
findings.push({
52+
severity: "error",
53+
file: filePath,
54+
rule: "broken-reference",
55+
message: `references non-existent file: \`${ref}\``,
56+
})
57+
}
58+
}
59+
return findings
60+
}
61+
62+
function checkContractCompleteness(content: string, filePath: string): LintFinding[] {
63+
const findings: LintFinding[] = []
64+
for (const section of CONTRACT_SECTIONS) {
65+
const headingRegex = new RegExp(`^### ${section}$`, "m")
66+
if (!headingRegex.test(content)) {
67+
findings.push({
68+
severity: "warn",
69+
file: filePath,
70+
rule: "missing-contract-section",
71+
message: `missing Contract section: ### ${section}`,
72+
})
73+
}
74+
}
75+
return findings
76+
}
77+
78+
function checkForbiddenPatterns(content: string, filePath: string): LintFinding[] {
79+
const findings: LintFinding[] = []
80+
for (const { pattern, rule, message } of FORBIDDEN_PATTERNS) {
81+
if (pattern.test(content)) {
82+
findings.push({
83+
severity: "error",
84+
file: filePath,
85+
rule,
86+
message,
87+
})
88+
}
89+
}
90+
return findings
91+
}
92+
93+
function checkAgentCapabilityConsistency(content: string, filePath: string): LintFinding[] {
94+
const findings: LintFinding[] = []
95+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/)
96+
if (!frontmatterMatch) return findings
97+
98+
const fm = frontmatterMatch[1]
99+
const body = content.slice(frontmatterMatch[0].length)
100+
101+
// Check reviewer: complete_goal must be false, and must not contain positive directive
102+
if (filePath.includes("reviewer")) {
103+
if (/complete_goal:\s*true/.test(fm)) {
104+
findings.push({
105+
severity: "error",
106+
file: filePath,
107+
rule: "reviewer-capability-conflict",
108+
message: "reviewer agent declares complete_goal: true (should be false)",
109+
})
110+
}
111+
// Only flag goal({op:"complete"}) if it's not in a negation/prohibition context
112+
if (/\bgoal\(\{op:"complete"\}\)/.test(body)) {
113+
// Check if the surrounding context is a prohibition
114+
const idx = body.indexOf("goal({op:\"complete\"})")
115+
const before = body.slice(Math.max(0, idx - 100), idx)
116+
const isNegation = /|.*complete|not.*complete|cannot complete|blocked/i.test(before)
117+
if (!isNegation) {
118+
findings.push({
119+
severity: "error",
120+
file: filePath,
121+
rule: "reviewer-capability-conflict",
122+
message: "reviewer prompt contains positive goal({op:'complete'}) directive",
123+
})
124+
}
125+
}
126+
}
127+
128+
// Check worker agents: create_pr must be false
129+
if (filePath.includes("backend") || filePath.includes("frontend")) {
130+
if (/create_pr:\s*true/.test(fm)) {
131+
findings.push({
132+
severity: "error",
133+
file: filePath,
134+
rule: "worker-capability-conflict",
135+
message: "worker agent declares create_pr: true (should be false)",
136+
})
137+
}
138+
if (body.includes("gh pr create")) {
139+
findings.push({
140+
severity: "error",
141+
file: filePath,
142+
rule: "worker-capability-conflict",
143+
message: "worker prompt contains 'gh pr create' directive",
144+
})
145+
}
146+
}
147+
148+
return findings
149+
}
150+
151+
export function lintAll(projectRoot: string): { findings: LintFinding[]; passed: boolean } {
152+
const assetDirs = ["assets/agents", "assets/skills", "assets/commands", "assets/prompts"]
153+
const files = findMdFiles(projectRoot, assetDirs)
154+
const allFindings: LintFinding[] = []
155+
156+
for (const file of files) {
157+
const content = readFileSync(file, "utf8")
158+
159+
if (file.includes("SKILL.md")) {
160+
allFindings.push(...checkContractCompleteness(content, file))
161+
}
162+
allFindings.push(...checkForbiddenPatterns(content, file))
163+
allFindings.push(...checkRelativeRefs(content, file))
164+
165+
if (file.includes("agents/")) {
166+
allFindings.push(...checkAgentCapabilityConsistency(content, file))
167+
}
168+
}
169+
170+
const errors = allFindings.filter(f => f.severity === "error")
171+
const warnings = allFindings.filter(f => f.severity === "warn")
172+
173+
return {
174+
findings: allFindings,
175+
passed: errors.length === 0,
176+
}
177+
}

test/plugin/prompt-lint.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, it, expect } from "vitest"
2+
import { lintAll, type LintFinding } from "../../src/plugin/prompt-lint.js"
3+
import path from "node:path"
4+
5+
const PROJECT_ROOT = path.resolve(import.meta.dirname || __dirname, "..", "..")
6+
7+
describe("prompt-lint", () => {
8+
it("finds no errors in current assets", () => {
9+
const { passed, findings } = lintAll(PROJECT_ROOT)
10+
const errors: LintFinding[] = findings.filter((f: LintFinding) => f.severity === "error")
11+
if (errors.length > 0) {
12+
console.error("Lint errors found:")
13+
for (const e of errors) {
14+
console.error(` [${e.rule}] ${e.file}: ${e.message}`)
15+
}
16+
}
17+
expect(passed).toBe(true)
18+
})
19+
20+
it("reports Contract completeness warnings", () => {
21+
const { findings } = lintAll(PROJECT_ROOT)
22+
const warnings: LintFinding[] = findings.filter((f: LintFinding) => f.rule === "missing-contract-section")
23+
expect(warnings.length).toBe(0)
24+
})
25+
26+
it("reports no forbidden patterns", () => {
27+
const { findings } = lintAll(PROJECT_ROOT)
28+
const forbidden: LintFinding[] = findings.filter(
29+
(f: LintFinding) => f.severity === "error" && f.rule.startsWith("no-")
30+
)
31+
expect(forbidden.length).toBe(0)
32+
})
33+
34+
it("reports no broken references", () => {
35+
const { findings } = lintAll(PROJECT_ROOT)
36+
const refs: LintFinding[] = findings.filter((f: LintFinding) => f.rule === "broken-reference")
37+
expect(refs.length).toBe(0)
38+
})
39+
40+
it("reports no agent capability conflicts", () => {
41+
const { findings } = lintAll(PROJECT_ROOT)
42+
const conflicts: LintFinding[] = findings.filter((f: LintFinding) => f.rule.includes("capability-conflict"))
43+
expect(conflicts.length).toBe(0)
44+
})
45+
})

0 commit comments

Comments
 (0)