Skip to content
Open
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,9 @@
},
"scripts": {
"prepublishOnly": "bun run check && bun run test",
"check": "bun run check:config-contract && bun run check:discipline && bun run format:check && bun run lint && bun run typecheck",
"check": "bun run check:config-contract && bun run check:docs-contract && bun run check:discipline && bun run format:check && bun run lint && bun run typecheck",
"check:config-contract": "node scripts/check-config-contract.mjs",
"check:docs-contract": "node scripts/check-docs-contract.mjs",
"check:discipline": "node scripts/check-discipline-ledger.mjs",
"prepare": "node scripts/prepare-effect-tsgo.mjs",
"format": "biome format --write .",
Expand Down
60 changes: 60 additions & 0 deletions scripts/check-docs-contract.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { readdirSync, readFileSync } from "node:fs";
import { join, relative, resolve } from "node:path";

const root = resolve("docs");
const requiredDecisionFields = [
"decision-status",
"created",
"last-reviewed",
"applies-to",
"owner",
"related-issues",
"related-prs",
"supersedes",
];

function markdownFiles(directory) {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return markdownFiles(path);
return entry.isFile() && entry.name.endsWith(".md") ? [path] : [];
});
}

function frontmatter(text) {
const match = text.match(/^---\n([\s\S]*?)\n---\n/);
if (!match) return null;
return new Map(
match[1]
.split("\n")
.map((line) => line.match(/^([\w-]+):\s*(.*)$/))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [P2] 按 YAML 值判断元数据,不要只判断原始行非空

当前 Map 保存的是未经解析的文本,所以 owner: ""owner: nullowner: # no owner 都被第 51 行当成有值;反过来,合法的 related-issues:\n - "#198" 因首行值为空而被判缺失。对实际脚本的内存 fixture 复现了这四种情况。这会让必填信息遗漏漏过 gate,同时拒绝正常 YAML 写法。

请使用可靠的 YAML 解析并校验所需字段的非空值/允许类型,或者明确规定并严格校验受限格式;加上空字符串、null、注释和列表回归测试。保留当前小检查器即可,不必扩为通用文档框架。

.filter(Boolean)
.map(([, key, value]) => [key, value.trim()]),
);
}

const files = markdownFiles(root);
const decisions = files.filter(
(file) =>
file.includes(`${join("docs", "decisions")}${"/"}`) &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [P2] 不要混合平台路径分隔符,否则 Windows 会漏扫所有记录

Windows 上 join("docs", "decisions") 返回反斜杠路径,这里又拼接 /,最终查找的是 docs\\decisions/;实际文件路径是 ...\\docs\\decisions\\0001-....md,不会匹配。使用实际脚本配合 path.win32 的只读探针,即使 Decision 完全没有 frontmatter,也成功输出 docs contract (0 decision records)。下一行 split("/") 同样是 POSIX 假设。

请从明确的 decisions 目录枚举,或统一使用原生路径组件和 basename;补一个 Windows 路径下必须发现记录并拒绝缺失元数据的测试,不能把零记录扫描当成验证成功。

!["README.md", "TEMPLATE.md"].includes(file.split("/").pop()),
);
const errors = [];

for (const file of decisions) {
const metadata = frontmatter(readFileSync(file, "utf8"));
if (!metadata) {
errors.push(`${relative(process.cwd(), file)}: missing YAML frontmatter`);
continue;
}
for (const field of requiredDecisionFields) {
if (!metadata.get(field)) errors.push(`${relative(process.cwd(), file)}: missing ${field}`);
}
}

if (errors.length) {
console.error(errors.join("\n"));
process.exit(1);
}

console.log(`docs contract (${decisions.length} decision records)`);
Loading