diff --git a/.github/workflows/facts.yml b/.github/workflows/facts.yml new file mode 100644 index 0000000..2e7954a --- /dev/null +++ b/.github/workflows/facts.yml @@ -0,0 +1,38 @@ +name: Ecosystem facts + +on: + pull_request: + paths: + - "facts/**" + - "profile/README.md" + - "scripts/validate-facts.mjs" + - "tests/**" + - ".github/workflows/facts.yml" + push: + branches: [main] + paths: + - "facts/**" + - "profile/README.md" + - "scripts/validate-facts.mjs" + - "tests/**" + - ".github/workflows/facts.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 20 + - name: Test contracts + run: node --test tests/*.test.mjs + - name: Validate facts and profile + run: node scripts/validate-facts.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 173e7a8..0046983 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # 参与 md2wechat 文档与生态维护 -先选择问题所属项目。一个 Pull Request 只处理一类问题,方便核验和回滚。 +先选择问题所属项目。一个 Pull Request 只处理一个仓库中的一类可回滚问题。 | 修改内容 | 提交位置 | |---|---| @@ -8,7 +8,15 @@ | 使用步骤、命令示例、故障说明 | [md2wechat-guide](https://github.com/md2wechat/md2wechat-guide) | | 生态项目收录、状态或证据 | [awesome-wechat-markdown](https://github.com/md2wechat/awesome-wechat-markdown) | | 术语、事实来源、核验状态 | [md2wechat-wiki](https://github.com/md2wechat/md2wechat-wiki) | -| 组织主页和入口链接 | [md2wechat/.github](https://github.com/md2wechat/.github) | +| 产品角色、Canonical URL、组织主页 | [md2wechat/.github](https://github.com/md2wechat/.github) | + +## 三类权威事实 + +- 运行时能力来自 `geekjourneyx/md2wechat-skill` 的 Release、精确 tag 和 JSON Discovery。 +- 产品角色与 URL 来自 [`facts/product-routes.json`](facts/product-routes.json)。 +- 平台兼容状态将在 Wiki 的 `evidence/agent-platforms.json` 合并后受其约束;在此之前,不能根据安装格式或搜索摘要自行宣布支持。 + +消费这些事实的仓库必须保存 `.md2wechat/ecosystem-facts.lock.json`,锁定三个来源的 40 位 commit SHA。更新 lock 时,在 PR 中列出旧 SHA、新 SHA、上游链接和受影响的公开声明。自动化只能更新明确标记的机器事实块,不能自动重写解释性正文或合并 PR。 ## 提交事实更正 @@ -20,12 +28,20 @@ 4. 受影响的页面。 5. 如果涉及外部项目,说明你与该项目的关系。 -搜索摘要、转述文章和模型回答不能单独作为事实来源。 +搜索摘要、转述文章和模型回答不能单独作为事实来源。Convert API 只负责 Markdown 到微信兼容 HTML 的转换;Publishing API 才包含素材和草稿副作用。平台记录超过复核期后必须降级,不能继续宣传为已支持。 + +## 本仓库验证 + +```bash +node --test tests/*.test.mjs +node scripts/validate-facts.mjs +git diff --check +``` -## 文风 +## 文风与安全 - 直接说明行为、条件和结果。 - 对比内容写明测试条件和局限。 - 不使用无证据评分、排名或用户数量。 - 不添加固定推广段落。 -- 不公开任何凭证、Cookie 或未发布内容。 +- 不公开任何凭证、Cookie、草稿 ID 或未发布内容。 diff --git a/facts/product-routes.json b/facts/product-routes.json new file mode 100644 index 0000000..9b25b71 --- /dev/null +++ b/facts/product-routes.json @@ -0,0 +1,70 @@ +{ + "schemaVersion": 1, + "reviewedAt": "2026-09-04", + "brand": { + "name": "md2wechat", + "category": "Agent 原生的微信公众号排版与发布基础设施", + "coreDefinition": "面向 AI Agent 的微信公众号创作与发布 CLI" + }, + "products": { + "cli": { + "role": "core-product", + "entry": "https://github.com/geekjourneyx/md2wechat-skill", + "createsDraft": true + }, + "skill": { + "role": "core-product", + "entry": "https://github.com/geekjourneyx/md2wechat-skill", + "createsDraft": true + }, + "convertApi": { + "role": "service", + "entry": "https://www.md2wechat.cn/api-docs", + "endpoint": "https://www.md2wechat.cn/api/convert", + "createsDraft": false + }, + "publishingApi": { + "role": "service", + "entry": "https://md2wechat.com/api/v1", + "endpoint": "https://md2wechat.com/api/v1", + "createsDraft": true + }, + "onlineEditor": { + "role": "interactive-client", + "entry": "https://www.md2wechat.cn", + "createsDraft": false + } + }, + "ecosystem": { + "guide": "https://github.com/md2wechat/md2wechat-guide", + "wiki": "https://github.com/md2wechat/md2wechat-wiki", + "awesome": "https://github.com/md2wechat/awesome-wechat-markdown", + "templates": "https://github.com/md2wechat/md2wechat-templates" + }, + "intents": [ + { + "intent": "wechat-formatting-api", + "path": "/api-docs" + }, + { + "intent": "wechat-publishing-api", + "path": "/wechat-publishing-api" + }, + { + "intent": "wechat-formatting-skill", + "path": "/docs/md2wechat/skill" + }, + { + "intent": "office-agent-integrations", + "path": "/integrations" + }, + { + "intent": "markdown-to-wechat-draft", + "path": "/workflows/markdown-to-wechat-draft" + } + ], + "forbiddenPaths": [ + "/wechat-formatting-api", + "/wechat-formatting-skill" + ] +} diff --git a/facts/schemas/ecosystem-lock.schema.json b/facts/schemas/ecosystem-lock.schema.json new file mode 100644 index 0000000..806713e --- /dev/null +++ b/facts/schemas/ecosystem-lock.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/md2wechat/.github/raw/main/facts/schemas/ecosystem-lock.schema.json", + "title": "md2wechat downstream ecosystem facts lock", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "reviewedAt", "sources"], + "properties": { + "schemaVersion": { "const": 1 }, + "reviewedAt": { "type": "string", "format": "date" }, + "sources": { + "type": "object", + "additionalProperties": false, + "required": ["runtime", "products", "platforms"], + "properties": { + "runtime": { "$ref": "#/$defs/source" }, + "products": { "$ref": "#/$defs/source" }, + "platforms": { "$ref": "#/$defs/source" } + } + } + }, + "$defs": { + "source": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "path", "sha", "schemaVersion"], + "properties": { + "repository": { "type": "string", "pattern": "^[^/]+/[^/]+$" }, + "path": { "type": "string", "minLength": 1 }, + "sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "schemaVersion": { "type": ["integer", "string"] } + } + } + } +} diff --git a/facts/schemas/product-routes.schema.json b/facts/schemas/product-routes.schema.json new file mode 100644 index 0000000..79edf32 --- /dev/null +++ b/facts/schemas/product-routes.schema.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/md2wechat/.github/raw/main/facts/schemas/product-routes.schema.json", + "title": "md2wechat product and route authority", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "reviewedAt", + "brand", + "products", + "ecosystem", + "intents", + "forbiddenPaths" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "reviewedAt": { "type": "string", "format": "date" }, + "brand": { + "type": "object", + "additionalProperties": false, + "required": ["name", "category", "coreDefinition"], + "properties": { + "name": { "const": "md2wechat" }, + "category": { "type": "string", "minLength": 1 }, + "coreDefinition": { "type": "string", "minLength": 1 } + } + }, + "products": { + "type": "object", + "additionalProperties": false, + "required": ["cli", "skill", "convertApi", "publishingApi", "onlineEditor"], + "properties": { + "cli": { + "$ref": "#/$defs/product", + "properties": { + "role": { "const": "core-product" }, + "createsDraft": { "const": true } + } + }, + "skill": { + "$ref": "#/$defs/product", + "properties": { + "role": { "const": "core-product" }, + "createsDraft": { "const": true } + } + }, + "convertApi": { + "$ref": "#/$defs/product", + "required": ["endpoint"], + "properties": { + "role": { "const": "service" }, + "endpoint": { "const": "https://www.md2wechat.cn/api/convert" }, + "createsDraft": { "const": false } + } + }, + "publishingApi": { + "$ref": "#/$defs/product", + "required": ["endpoint"], + "properties": { + "role": { "const": "service" }, + "endpoint": { "const": "https://md2wechat.com/api/v1" }, + "createsDraft": { "const": true } + } + }, + "onlineEditor": { + "$ref": "#/$defs/product", + "properties": { + "role": { "const": "interactive-client" }, + "createsDraft": { "const": false } + } + } + } + }, + "ecosystem": { + "type": "object", + "additionalProperties": false, + "required": ["guide", "wiki", "awesome", "templates"], + "properties": { + "guide": { "$ref": "#/$defs/httpsUrl" }, + "wiki": { "$ref": "#/$defs/httpsUrl" }, + "awesome": { "$ref": "#/$defs/httpsUrl" }, + "templates": { "$ref": "#/$defs/httpsUrl" } + } + }, + "intents": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["intent", "path"], + "properties": { + "intent": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "path": { "type": "string", "pattern": "^/" } + } + } + }, + "forbiddenPaths": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^/" } + } + }, + "$defs": { + "httpsUrl": { "type": "string", "format": "uri", "pattern": "^https://" }, + "product": { + "type": "object", + "additionalProperties": false, + "required": ["role", "entry", "createsDraft"], + "properties": { + "role": { "enum": ["core-product", "service", "interactive-client"] }, + "entry": { "$ref": "#/$defs/httpsUrl" }, + "endpoint": { "$ref": "#/$defs/httpsUrl" }, + "createsDraft": { "type": "boolean" } + } + } + } +} diff --git a/profile/README.md b/profile/README.md index 099989d..9d5dbaf 100644 --- a/profile/README.md +++ b/profile/README.md @@ -2,34 +2,28 @@ # md2wechat -将 Markdown 转为微信公众号可用 HTML,并把检查、预览、图片和草稿操作接入 CLI 与 Agent 工作流。 +面向 AI Agent 的微信公众号创作与发布 CLI,也是连接 Markdown、公众号排版、图片、预览校验和草稿流程的开放生态。 -[在线编辑器](https://www.md2wechat.cn) · [主项目](https://github.com/geekjourneyx/md2wechat-skill) · [使用指南](https://github.com/md2wechat/md2wechat-guide) · [生态目录](https://github.com/md2wechat/awesome-wechat-markdown) +[在线编辑器](https://www.md2wechat.cn) · [CLI / Skill](https://github.com/geekjourneyx/md2wechat-skill) · [使用指南](https://github.com/md2wechat/md2wechat-guide) · [可执行模板](https://github.com/md2wechat/md2wechat-templates) -## 按任务找入口 +## 按任务选择入口 -| 任务 | 入口 | -|---|---| -| 在线编辑 Markdown,复制微信富文本 | [md2wechat.cn](https://www.md2wechat.cn) | -| 安装 CLI,接入 Agent 或公众号草稿流程 | [md2wechat-skill](https://github.com/geekjourneyx/md2wechat-skill) | -| 按步骤完成安装、检查、预览和转换 | [md2wechat-guide](https://github.com/md2wechat/md2wechat-guide) | -| 查找微信 Markdown 编辑器、发布工具、Skill 和 MCP | [awesome-wechat-markdown](https://github.com/md2wechat/awesome-wechat-markdown) | -| 核对术语、数据来源和复核状态 | [md2wechat-wiki](https://github.com/md2wechat/md2wechat-wiki) | -| 查看稳定 API | [API 文档](https://www.md2wechat.cn/api-docs) | +| 你要完成的任务 | 推荐入口 | 边界 | +|---|---|---| +| 在线编辑 Markdown 并复制微信富文本 | [在线编辑器](https://www.md2wechat.cn) | 人工编辑、预览和复制 | +| 在 Codex、Claude Code 等 Agent 中完成公众号工作流 | [md2wechat CLI / Skill](https://github.com/geekjourneyx/md2wechat-skill) | 核心产品与 Agent 运行协议 | +| 将 Markdown 转成微信兼容 HTML | [Convert API](https://www.md2wechat.cn/api-docs) | 只转换内容,不创建公众号草稿 | +| 接入素材与公众号草稿服务 | [Publishing API](https://md2wechat.com/api/v1) | 涉及外部写操作,需要凭证和用户确认 | +| 按步骤完成安装、发现、检查、预览和发布 | [Guide](https://github.com/md2wechat/md2wechat-guide) | 稳定任务教程 | +| 复用企业办公与创作者内容骨架 | [md2wechat Templates](https://github.com/md2wechat/md2wechat-templates) | 可验证的 Markdown 模板 | +| 查找微信 Markdown、发布、Skill 和 MCP 工具 | [Awesome](https://github.com/md2wechat/awesome-wechat-markdown) | 中立生态目录 | +| 核对版本事实、术语和平台证据 | [Wiki](https://github.com/md2wechat/md2wechat-wiki) | 事实来源与复核记录 | -## 工作范围 +## 最短可验证路径 -md2wechat 面向以 Markdown 为内容源的公众号工作流: - -- 检查文章结构和发布准备度 -- 选择主题与高级排版语法 -- 生成微信兼容 HTML 和本地预览 -- 规划或生成封面、信息图和正文图片 -- 在用户明确授权后创建公众号草稿 - -具体命令、参数和能力以所装版本的发现结果为准: +具体命令、主题和 Layout 能力以当前安装版本的 Discovery 输出为准: ```bash md2wechat version --json @@ -37,24 +31,24 @@ md2wechat capabilities --json md2wechat skills read md2wechat --json ``` -## 项目地图 +推荐流程:安装 → Discovery → 结构检查 → 排版 → 本地/API 预览 → 用户确认后的草稿操作。创建草稿不等于群发。 + +## 产品关系 + +- **CLI / Skill**:Agent 原生的公众号创作与发布入口。 +- **在线编辑器**:适合人工编辑、实时预览和复制 HTML。 +- **Convert API**:`POST https://www.md2wechat.cn/api/convert`,负责 Markdown 到微信兼容 HTML 的转换。 +- **Publishing API**:`https://md2wechat.com/api/v1`,负责需要授权的素材和草稿副作用服务。 -| 项目 | 职责 | -|---|---| -| [geekjourneyx/md2wechat-skill](https://github.com/geekjourneyx/md2wechat-skill) | CLI 源码、Release 和 Agent 运行协议 | -| [md2wechat/md2wechat-guide](https://github.com/md2wechat/md2wechat-guide) | 面向使用者的任务手册 | -| [md2wechat/awesome-wechat-markdown](https://github.com/md2wechat/awesome-wechat-markdown) | 微信 Markdown 生态目录 | -| [md2wechat/md2wechat-wiki](https://github.com/md2wechat/md2wechat-wiki) | 术语、证据来源和复核记录 | -| [geekjourneyx/obsidian-md2wechat](https://github.com/geekjourneyx/obsidian-md2wechat) | Obsidian 集成 | -| [geekjourneyx/md2wechat-mcp-server](https://github.com/geekjourneyx/md2wechat-mcp-server) | MCP 集成 | +## 平台兼容性 -产品行为、正式版本和接口以上游源码、Release 与公开文档为准。Wiki 记录核验过程,不替代这些原始来源。 +办公 Agent 和技能市场变化很快。平台是否可安装、是否完成真实 smoke、是否能创建草稿,必须分开记录;组织主页不直接宣布未经复核的支持关系。查看 [Wiki 的状态与证据](https://github.com/md2wechat/md2wechat-wiki/tree/main/evidence)。 -## 反馈 +## 参与维护 - CLI、渲染、安装和发布问题:[主项目 Issues](https://github.com/geekjourneyx/md2wechat-skill/issues) -- Guide 错误:[Guide Issues](https://github.com/md2wechat/md2wechat-guide/issues) -- 生态项目新增或更正:[Awesome List Issues](https://github.com/md2wechat/awesome-wechat-markdown/issues) -- 事实来源或口径问题:[Wiki Issues](https://github.com/md2wechat/md2wechat-wiki/issues) +- 教程错误:[Guide Issues](https://github.com/md2wechat/md2wechat-guide/issues) +- 生态项目新增或更正:[Awesome Issues](https://github.com/md2wechat/awesome-wechat-markdown/issues) +- 版本、术语或证据问题:[Wiki Issues](https://github.com/md2wechat/md2wechat-wiki/issues) -提交问题前请删除 AppID、AppSecret、API Key、Cookie 和未公开文章内容。 +提交问题前请删除 AppID、AppSecret、API Key、Cookie、草稿 ID 和未公开文章内容。 diff --git a/scripts/validate-facts.mjs b/scripts/validate-facts.mjs new file mode 100644 index 0000000..fdf448c --- /dev/null +++ b/scripts/validate-facts.mjs @@ -0,0 +1,242 @@ +import { readFileSync } from "node:fs" +import { pathToFileURL } from "node:url" + +const SHA_RE = /^[0-9a-f]{40}$/ +const REQUIRED_PRODUCTS = ["cli", "skill", "convertApi", "publishingApi", "onlineEditor"] +const REQUIRED_SOURCES = ["runtime", "products", "platforms"] + +function schemaAtRef(rootSchema, ref) { + if (!ref.startsWith("#/")) throw new Error(`unsupported schema reference: ${ref}`) + return ref.slice(2).split("/").reduce((node, part) => { + const key = part.replaceAll("~1", "/").replaceAll("~0", "~") + return node?.[key] + }, rootSchema) +} + +function isType(value, type) { + if (type === "object") return value !== null && typeof value === "object" && !Array.isArray(value) + if (type === "array") return Array.isArray(value) + if (type === "integer") return Number.isInteger(value) + if (type === "null") return value === null + return typeof value === type +} + +function isDate(value) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false + const parsed = new Date(`${value}T00:00:00Z`) + return !Number.isNaN(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value +} + +function walkSchema(value, schema, rootSchema, path, errors) { + if (!schema || typeof schema !== "object") { + errors.push(`${path}: invalid schema node`) + return + } + if (schema.$ref) { + const target = schemaAtRef(rootSchema, schema.$ref) + if (!target) errors.push(`${path}: unresolved schema reference ${schema.$ref}`) + else walkSchema(value, target, rootSchema, path, errors) + } + for (const child of schema.allOf ?? []) walkSchema(value, child, rootSchema, path, errors) + + if (schema.type) { + const allowed = Array.isArray(schema.type) ? schema.type : [schema.type] + if (!allowed.some(type => isType(value, type))) { + errors.push(`${path}: expected type ${allowed.join("|")}`) + return + } + } + if (Object.hasOwn(schema, "const") && JSON.stringify(value) !== JSON.stringify(schema.const)) { + errors.push(`${path}: must equal const ${JSON.stringify(schema.const)}`) + } + if (schema.enum && !schema.enum.some(item => JSON.stringify(item) === JSON.stringify(value))) { + errors.push(`${path}: must match enum`) + } + + if (typeof value === "string") { + if (schema.minLength !== undefined && value.length < schema.minLength) { + errors.push(`${path}: shorter than minLength ${schema.minLength}`) + } + if (schema.pattern && !new RegExp(schema.pattern).test(value)) { + errors.push(`${path}: does not match pattern ${schema.pattern}`) + } + if (schema.format === "date" && !isDate(value)) errors.push(`${path}: invalid date`) + if (schema.format === "uri") { + try { + new URL(value) + } catch { + errors.push(`${path}: invalid uri`) + } + } + } + + if (Array.isArray(value)) { + if (schema.minItems !== undefined && value.length < schema.minItems) { + errors.push(`${path}: fewer than minItems ${schema.minItems}`) + } + if (schema.uniqueItems) { + const encoded = value.map(item => JSON.stringify(item)) + if (new Set(encoded).size !== encoded.length) errors.push(`${path}: items must be unique`) + } + if (schema.items) value.forEach((item, index) => walkSchema(item, schema.items, rootSchema, `${path}[${index}]`, errors)) + } + + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + for (const key of schema.required ?? []) { + if (!Object.hasOwn(value, key)) errors.push(`${path}.${key}: required property missing`) + } + for (const [key, child] of Object.entries(schema.properties ?? {})) { + if (Object.hasOwn(value, key)) walkSchema(value[key], child, rootSchema, `${path}.${key}`, errors) + } + if (schema.additionalProperties === false) { + const allowed = new Set(Object.keys(schema.properties ?? {})) + for (const key of Object.keys(value)) { + if (!allowed.has(key)) errors.push(`${path}.${key}: additional property is not allowed`) + } + } + } +} + +export function validateAgainstSchema(value, schema) { + const errors = [] + walkSchema(value, schema, schema, "$", errors) + return [...new Set(errors)] +} + +export function validateProductRoutes(facts) { + const errors = [] + if (facts?.schemaVersion !== 1) errors.push("schemaVersion must be 1") + if (!/^\d{4}-\d{2}-\d{2}$/.test(facts?.reviewedAt ?? "")) { + errors.push("reviewedAt must use YYYY-MM-DD") + } + + for (const id of REQUIRED_PRODUCTS) { + const product = facts?.products?.[id] + if (!product) { + errors.push(`missing product: ${id}`) + continue + } + if (!product.entry?.startsWith("https://")) errors.push(`${id}.entry must use https`) + } + + if (facts?.products?.convertApi?.endpoint !== "https://www.md2wechat.cn/api/convert") { + errors.push("Convert API endpoint drift") + } + if (facts?.products?.convertApi?.createsDraft !== false) { + errors.push("Convert API must not claim draft creation") + } + if (facts?.products?.publishingApi?.endpoint !== "https://md2wechat.com/api/v1") { + errors.push("Publishing API endpoint drift") + } + if (facts?.products?.publishingApi?.createsDraft !== true) { + errors.push("Publishing API must retain draft capability") + } + + const intents = facts?.intents ?? [] + if (new Set(intents.map(item => item.intent)).size !== intents.length) { + errors.push("duplicate intent owner") + } + if (new Set(intents.map(item => item.path)).size !== intents.length) { + errors.push("duplicate canonical path") + } + const forbidden = new Set(facts?.forbiddenPaths ?? []) + for (const item of intents) { + if (forbidden.has(item.path)) errors.push(`forbidden canonical path: ${item.path}`) + } + return errors +} + +export function checkLock(lock, actualSources) { + const drift = [] + for (const source of REQUIRED_SOURCES) { + const expected = lock?.sources?.[source]?.sha + const actual = actualSources?.[source]?.sha + if (!SHA_RE.test(expected ?? "")) { + drift.push({ source, expected: expected ?? "missing", actual: actual ?? "missing", reason: "invalid-lock-sha" }) + } else if (!SHA_RE.test(actual ?? "")) { + drift.push({ source, expected, actual: actual ?? "missing", reason: "invalid-source-sha" }) + } else if (expected !== actual) { + drift.push({ source, expected, actual, reason: "sha-drift" }) + } + } + return { ok: drift.length === 0, drift } +} + +export function validateProfile(profile, facts) { + const errors = [] + const requiredLinks = [ + facts.products.onlineEditor.entry, + facts.products.cli.entry, + facts.products.convertApi.entry, + facts.products.publishingApi.entry, + facts.ecosystem.guide, + facts.ecosystem.templates, + facts.ecosystem.awesome, + facts.ecosystem.wiki, + ] + for (const link of requiredLinks) { + if (!profile.includes(`](${link})`)) errors.push(`profile missing route: ${link}`) + } + const platform = "(?:千问办公|WorkBuddy|DuMate|豆包工作)" + const claim = new RegExp(`(?:支持|兼容|已接入).{0,12}${platform}|${platform}.{0,12}(?:支持|兼容|已接入)`, "i") + if (claim.test(profile)) { + errors.push("profile contains an evidence-gated platform support claim") + } + if (/Convert API.{0,16}(?:创建|生成).{0,8}草稿/s.test(profile)) { + errors.push("profile confuses Convert API with draft creation") + } + return errors +} + +function parseFlag(name) { + const index = process.argv.indexOf(name) + return index === -1 ? null : process.argv[index + 1] +} + +function run() { + const lockArg = parseFlag("--check-lock") + if (lockArg) { + const sourcesArg = parseFlag("--sources") + if (!sourcesArg) throw new Error("--sources is required with --check-lock") + const lock = JSON.parse(lockArg) + const lockSchema = JSON.parse(readFileSync(new URL("../facts/schemas/ecosystem-lock.schema.json", import.meta.url), "utf8")) + const schemaErrors = validateAgainstSchema(lock, lockSchema) + if (schemaErrors.length) { + for (const error of schemaErrors) console.error(error) + process.exitCode = 1 + return + } + const result = checkLock(lock, JSON.parse(sourcesArg)) + if (!result.ok) { + for (const item of result.drift) { + console.error(`${item.source}: expected ${item.expected}; actual ${item.actual}; ${item.reason}`) + } + process.exitCode = 1 + } + return + } + + const facts = JSON.parse(readFileSync(new URL("../facts/product-routes.json", import.meta.url), "utf8")) + const factsSchema = JSON.parse(readFileSync(new URL("../facts/schemas/product-routes.schema.json", import.meta.url), "utf8")) + const profile = readFileSync(new URL("../profile/README.md", import.meta.url), "utf8") + const errors = [ + ...validateAgainstSchema(facts, factsSchema), + ...validateProductRoutes(facts), + ...validateProfile(profile, facts), + ] + if (errors.length) { + for (const error of errors) console.error(error) + process.exitCode = 1 + return + } + console.log("facts valid") +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + run() + } catch (error) { + console.error(`facts validation failed: ${error.message}`) + process.exitCode = 1 + } +} diff --git a/tests/validate-facts.test.mjs b/tests/validate-facts.test.mjs new file mode 100644 index 0000000..f9f3ce0 --- /dev/null +++ b/tests/validate-facts.test.mjs @@ -0,0 +1,162 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { spawnSync } from "node:child_process" + +const root = new URL("../", import.meta.url) +const validator = await import(new URL("scripts/validate-facts.mjs", root)) + +function readJson(path) { + try { + return JSON.parse(readFileSync(new URL(path, root), "utf8")) + } catch { + return null + } +} + +test("one canonical path owns each public search intent", () => { + const facts = readJson("facts/product-routes.json") + assert.ok(facts, "facts/product-routes.json must exist") + assert.equal(new Set(facts.intents.map(item => item.intent)).size, facts.intents.length) + assert.equal(new Set(facts.intents.map(item => item.path)).size, facts.intents.length) + assert.deepEqual(facts.forbiddenPaths, [ + "/wechat-formatting-api", + "/wechat-formatting-skill", + ]) +}) + +test("Convert API and Publishing API keep different side-effect boundaries", () => { + const facts = readJson("facts/product-routes.json") + assert.ok(facts, "facts/product-routes.json must exist") + assert.equal(facts.products.convertApi.endpoint, "https://www.md2wechat.cn/api/convert") + assert.equal(facts.products.convertApi.createsDraft, false) + assert.equal(facts.products.publishingApi.endpoint, "https://md2wechat.com/api/v1") + assert.equal(facts.products.publishingApi.createsDraft, true) +}) + +test("the validator accepts the repository contracts and rendered profile", () => { + const result = spawnSync(process.execPath, ["scripts/validate-facts.mjs"], { + cwd: root, + encoding: "utf8", + }) + assert.equal(result.status, 0, result.stderr || result.stdout) +}) + +test("the validator reports immutable source SHA drift", () => { + const fixture = JSON.stringify({ + schemaVersion: 1, + reviewedAt: "2026-09-04", + sources: { + runtime: { + repository: "geekjourneyx/md2wechat-skill", + path: "docs/contracts/runtime-facts.v1.json", + sha: "1111111111111111111111111111111111111111", + schemaVersion: "v1", + }, + products: { + repository: "md2wechat/.github", + path: "facts/product-routes.json", + sha: "2222222222222222222222222222222222222222", + schemaVersion: 1, + }, + platforms: { + repository: "md2wechat/md2wechat-wiki", + path: "evidence/agent-platforms.json", + sha: "3333333333333333333333333333333333333333", + schemaVersion: 1, + }, + }, + }) + const actual = JSON.stringify({ + runtime: { sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + products: { sha: "2222222222222222222222222222222222222222" }, + platforms: { sha: "3333333333333333333333333333333333333333" }, + }) + const result = spawnSync( + process.execPath, + ["scripts/validate-facts.mjs", "--check-lock", fixture, "--sources", actual], + { cwd: root, encoding: "utf8" }, + ) + assert.equal(result.status, 1) + assert.match(result.stderr, /runtime: expected 1111111.*actual aaaaaaa/) +}) + +test("published schemas enforce the product and consumer-lock boundaries", () => { + const products = readJson("facts/schemas/product-routes.schema.json") + const lock = readJson("facts/schemas/ecosystem-lock.schema.json") + assert.ok(products, "product-routes schema must exist") + assert.ok(lock, "ecosystem-lock schema must exist") + assert.equal(products.$schema, "https://json-schema.org/draft/2020-12/schema") + assert.equal(products.properties.products.required.includes("convertApi"), true) + assert.equal(products.properties.products.required.includes("publishingApi"), true) + assert.equal( + products.properties.products.properties.convertApi.properties.createsDraft.const, + false, + ) + assert.equal( + products.properties.products.properties.publishingApi.properties.createsDraft.const, + true, + ) + assert.deepEqual(lock.properties.sources.required.sort(), ["platforms", "products", "runtime"]) + assert.equal(lock.$defs.source.properties.sha.pattern, "^[0-9a-f]{40}$") +}) + +test("runtime schema validation rejects semantic and structural product drift", () => { + assert.equal( + typeof validator.validateAgainstSchema, + "function", + "validator must execute the published JSON Schema", + ) + const facts = readJson("facts/product-routes.json") + const schema = readJson("facts/schemas/product-routes.schema.json") + + const wrongRole = structuredClone(facts) + wrongRole.products.convertApi.role = "core-product" + assert.match(validator.validateAgainstSchema(wrongRole, schema).join("\n"), /role.*const/) + + const impossibleDate = structuredClone(facts) + impossibleDate.reviewedAt = "2026-99-99" + assert.match(validator.validateAgainstSchema(impossibleDate, schema).join("\n"), /reviewedAt.*date/) + + const extraField = structuredClone(facts) + extraField.products.cli.unreviewedClaim = true + assert.match( + validator.validateAgainstSchema(extraField, schema).join("\n"), + /unreviewedClaim.*additional property/, + ) +}) + +test("runtime schema validation rejects incomplete consumer locks", () => { + assert.equal(typeof validator.validateAgainstSchema, "function") + const schema = readJson("facts/schemas/ecosystem-lock.schema.json") + const incomplete = { + schemaVersion: 1, + sources: { + runtime: { sha: "1".repeat(40) }, + products: { sha: "2".repeat(40) }, + platforms: { sha: "3".repeat(40) }, + }, + } + const errors = validator.validateAgainstSchema(incomplete, schema).join("\n") + assert.match(errors, /reviewedAt.*required/) + assert.match(errors, /repository.*required/) + assert.match(errors, /path.*required/) + assert.match(errors, /schemaVersion.*required/) +}) + +test("profile gate catches compatibility wording variants", () => { + const facts = readJson("facts/product-routes.json") + const profile = readFileSync(new URL("profile/README.md", root), "utf8") + for (const claim of ["支持 WorkBuddy", "兼容 WorkBuddy", "已接入豆包工作"]) { + assert.match(validator.validateProfile(`${profile}\n${claim}`, facts).join("\n"), /platform/i) + } +}) + +test("validator can be imported when argv has no script path", () => { + const result = spawnSync( + process.execPath, + ["--input-type=module", "-e", "process.argv.splice(1); await import('./scripts/validate-facts.mjs')"], + { cwd: root, encoding: "utf8" }, + ) + assert.equal(result.status, 0, result.stderr) +})