From d350b2aeeef2ab70c9bd9ca65103653c2e2aa28a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 15:03:09 +0000 Subject: [PATCH 1/2] docs(getting-started): redraw the for-developers src/ tree from reality and teach docs-drift to read a tree diagram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The developer page still drew an `agents/` branch, still listed `*.agent.ts` in the suffix table, and still closed the "Add an AI skill" recipe with "add the skill name to the relevant agent in `src/agents/`". The directory and the suffix went away with the two app-owned copilots — this app authors skills and the agent comes from the platform — so every one of those three sent a reader to a path that does not exist. The tree was then audited branch by branch: `hooks/`, `mappings/`, `docs/` and `interfaces/` are real and were missing, and each branch now states what it holds. All three locales move together. `test/docs-drift.test.ts` exists for this defect class and could not see it: its scan set was maintainer docs only, and its pattern required a literal `src/` prefix, which a tree diagram strips by drawing (`├── agents/`). Both axes are covered now — the product page is read for inline paths, and every doc that draws a `src/` tree has its branches resolved. The pre-existing maintainer-doc assertion is untouched; each new check fails loudly rather than vacuously when its input stops parsing. Refs #984 #512 #988 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VHrPAGEgFDoHjphqYG4BMa --- .changeset/docs-for-developers-src-tree.md | 32 ++++ .../docs/getting-started/for-developers.mdx | 14 +- .../for-developers.zh-Hans.mdx | 14 +- .../for-developers.zh-Hant.mdx | 14 +- test/docs-drift.test.ts | 146 +++++++++++++++++- 5 files changed, 204 insertions(+), 16 deletions(-) create mode 100644 .changeset/docs-for-developers-src-tree.md diff --git a/.changeset/docs-for-developers-src-tree.md b/.changeset/docs-for-developers-src-tree.md new file mode 100644 index 00000000..782c16ac --- /dev/null +++ b/.changeset/docs-for-developers-src-tree.md @@ -0,0 +1,32 @@ +--- +'hotcrm': patch +--- + +Redraw the developer page's repository tree from the real `src/`, and teach the +docs-drift guard to read a tree diagram. + +`content/docs/getting-started/for-developers.mdx` (and both Chinese locales) +still drew an `agents/` branch in the `src/` tree, still listed `*.agent.ts` in +the file-suffix table, and still ended the "Add an AI skill" recipe with "add +the skill name to the relevant agent in `src/agents/`". That directory and that +suffix were deleted with the two app-owned copilots: HotCRM authors skills, and +the agent comes from the platform. A developer following the page was being sent +to create a file under a path that does not exist, and to perform a wiring step +that no longer exists — exporting the skill from `src/skills/index.ts` is the +whole of it. + +The tree was checked branch by branch while it was open, so it now matches the +repository it claims to describe: `hooks/`, `mappings/`, `docs/` and +`interfaces/` are real directories that were missing from it, and every branch +states what it actually holds. + +`test/docs-drift.test.ts` owns exactly this defect class and could not see any +of it, because two blind spots overlapped: it scanned only the maintainer docs +(`README.md`, `AGENTS.md`, `docs/*`), never `content/docs`, and it matched only +paths written with a literal `src/` prefix in a sentence — while a tree diagram +draws the branch as `agents/`, with the prefix stripped by the drawing itself. +Both axes are now covered: the product page is scanned for inline paths, and +every doc that draws a `src/` tree, maintainer or product, has its branches +resolved against the real tree. Each new check refuses to pass vacuously — a +page whose diagram stops parsing fails loudly instead of silently checking +nothing. diff --git a/content/docs/getting-started/for-developers.mdx b/content/docs/getting-started/for-developers.mdx index c2fcea68..3702a135 100644 --- a/content/docs/getting-started/for-developers.mdx +++ b/content/docs/getting-started/for-developers.mdx @@ -32,20 +32,25 @@ HotCRM is a single ObjectStack app. `objectstack.config.ts` is the source of tru hotcrm/ ├── objectstack.config.ts ├── src/ -│ ├── objects/ # *.object.ts and object lifecycle hooks +│ ├── objects/ # *.object.ts schemas and *.hook.ts lifecycle logic +│ ├── hooks/ # barrel that hands the object hooks to the stack │ ├── actions/ # UI actions and executable action bodies │ ├── flows/ # automation flows -│ ├── agents/ # AI agents │ ├── skills/ # AI skills │ ├── apps/, views/, pages/ │ ├── dashboards/, reports/, datasets/ +│ ├── mappings/ # import column-to-field projections │ ├── profiles/, sharing/ │ ├── translations/ -│ └── data/ +│ ├── data/ # seed data +│ ├── docs/ # package docs, shipped inside the built artifact +│ └── interfaces/ # shared types (an empty barrel today) ├── apps/docs/ # Fumadocs app └── content/docs/ # documentation content ``` +HotCRM authors skills, not agents: the two app-owned copilots and the directory that held them were retired, and AI capability now comes from the platform assistant every ObjectStack environment provides. See [AI Skills](/docs/customization/ai-skills). + ## File suffix protocol | Suffix | Defines | @@ -55,7 +60,6 @@ hotcrm/ | `*.actions.ts` | UI actions and AI-callable action bodies | | `*.flow.ts` | Automation | | `*.skill.ts` | AI skill | -| `*.agent.ts` | AI agent | | `*.page.ts` | UI page | | `*.view.ts` | List or kanban view | | `*.dashboard.ts` | Dashboard | @@ -84,7 +88,7 @@ export const Warranty = ObjectSchema.create({ ### Add an AI skill -Create `src/skills/.skill.ts`, export it from `src/skills/index.ts`, and add the skill name to the relevant agent in `src/agents/`. +Create `src/skills/.skill.ts` and export it from `src/skills/index.ts`. Exporting it from that barrel is the whole wiring step — the skill attaches to the platform assistant (`ask`), and this app defines no agent of its own. ### Add UI metadata diff --git a/content/docs/getting-started/for-developers.zh-Hans.mdx b/content/docs/getting-started/for-developers.zh-Hans.mdx index b88b2b77..8095efe7 100644 --- a/content/docs/getting-started/for-developers.zh-Hans.mdx +++ b/content/docs/getting-started/for-developers.zh-Hans.mdx @@ -32,20 +32,25 @@ HotCRM 是一个单体 ObjectStack 应用。`objectstack.config.ts` 是注册 `s hotcrm/ ├── objectstack.config.ts ├── src/ -│ ├── objects/ # *.object.ts 和对象生命周期 hooks +│ ├── objects/ # *.object.ts 模型和 *.hook.ts 生命周期逻辑 +│ ├── hooks/ # 把对象 hooks 汇总交给 stack 的 barrel │ ├── actions/ # UI 动作和可执行动作体 │ ├── flows/ # 自动化流程 -│ ├── agents/ # AI agents │ ├── skills/ # AI skills │ ├── apps/, views/, pages/ │ ├── dashboards/, reports/, datasets/ +│ ├── mappings/ # 数据导入的列到字段映射 │ ├── profiles/, sharing/ │ ├── translations/ -│ └── data/ +│ ├── data/ # 种子数据 +│ ├── docs/ # 随构建产物一起分发的应用文档 +│ └── interfaces/ # 共享类型(目前是空 barrel) ├── apps/docs/ # Fumadocs 应用 └── content/docs/ # 文档内容 ``` +HotCRM 只定义 skills,不定义 agent:应用自带的两个 copilot 及其所在目录已被移除,AI 能力来自每个 ObjectStack 环境都提供的平台助手。参见 [AI Skills](/zh-Hans/docs/customization/ai-skills)。 + ## 文件后缀协议 | 后缀 | 定义 | @@ -55,7 +60,6 @@ hotcrm/ | `*.actions.ts` | UI 动作和 AI 可调用动作体 | | `*.flow.ts` | 自动化 | | `*.skill.ts` | AI skill | -| `*.agent.ts` | AI agent | | `*.page.ts` | UI 页面 | | `*.view.ts` | 列表或看板视图 | | `*.dashboard.ts` | 仪表盘 | @@ -84,7 +88,7 @@ export const Warranty = ObjectSchema.create({ ### 新增 AI skill -创建 `src/skills/.skill.ts`,从 `src/skills/index.ts` 导出,并把 skill 名称加入 `src/agents/` 中对应的 agent。 +创建 `src/skills/.skill.ts`,并从 `src/skills/index.ts` 导出。从这个 barrel 导出就是全部的接线工作——skill 会附着到平台助手(`ask`)上,本应用不定义任何自己的 agent。 ### 新增 UI 元数据 diff --git a/content/docs/getting-started/for-developers.zh-Hant.mdx b/content/docs/getting-started/for-developers.zh-Hant.mdx index c09c8465..221cf15a 100644 --- a/content/docs/getting-started/for-developers.zh-Hant.mdx +++ b/content/docs/getting-started/for-developers.zh-Hant.mdx @@ -32,20 +32,25 @@ HotCRM 是一個單體 ObjectStack 應用。`objectstack.config.ts` 是註冊 `s hotcrm/ ├── objectstack.config.ts ├── src/ -│ ├── objects/ # *.object.ts 和物件生命週期 hooks +│ ├── objects/ # *.object.ts 模型和 *.hook.ts 生命週期邏輯 +│ ├── hooks/ # 把物件 hooks 彙總交給 stack 的 barrel │ ├── actions/ # UI 動作和可執行動作體 │ ├── flows/ # 自動化流程 -│ ├── agents/ # AI agents │ ├── skills/ # AI skills │ ├── apps/, views/, pages/ │ ├── dashboards/, reports/, datasets/ +│ ├── mappings/ # 資料匯入的欄位對應 │ ├── profiles/, sharing/ │ ├── translations/ -│ └── data/ +│ ├── data/ # 種子資料 +│ ├── docs/ # 隨建置產物一起發布的應用文件 +│ └── interfaces/ # 共享型別(目前是空 barrel) ├── apps/docs/ # Fumadocs 應用 └── content/docs/ # 文件內容 ``` +HotCRM 只定義 skills,不定義 agent:應用自帶的兩個 copilot 及其所在目錄已被移除,AI 能力來自每個 ObjectStack 環境都提供的平台助手。參見 [AI Skills](/zh-Hant/docs/customization/ai-skills)。 + ## 檔案後綴協議 | 後綴 | 定義 | @@ -55,7 +60,6 @@ hotcrm/ | `*.actions.ts` | UI 動作和 AI 可呼叫動作體 | | `*.flow.ts` | 自動化 | | `*.skill.ts` | AI skill | -| `*.agent.ts` | AI agent | | `*.page.ts` | UI 頁面 | | `*.view.ts` | 清單或看板視圖 | | `*.dashboard.ts` | 儀表板 | @@ -84,7 +88,7 @@ export const Warranty = ObjectSchema.create({ ### 新增 AI skill -建立 `src/skills/.skill.ts`,從 `src/skills/index.ts` 匯出,並把 skill 名稱加入 `src/agents/` 中對應的 agent。 +建立 `src/skills/.skill.ts`,並從 `src/skills/index.ts` 匯出。從這個 barrel 匯出就是全部的接線工作——skill 會附著到平台助手(`ask`)上,本應用不定義任何自己的 agent。 ### 新增 UI 中繼資料 diff --git a/test/docs-drift.test.ts b/test/docs-drift.test.ts index e0bf381e..794db9b5 100644 --- a/test/docs-drift.test.ts +++ b/test/docs-drift.test.ts @@ -174,7 +174,84 @@ describe('package docs do not drift from the flows they document', () => { * maintainer docs, pulls every `src//` they mention, and resolves it * against the real tree. `docs/archive/` is deliberately excluded — it is a * historical record and is allowed to describe a repo that no longer exists. + * + * A doc states the tree in TWO forms, and the guard above reads only the first + * (#984 — `getting-started/for-developers.mdx` kept drawing `agents/` right + * through the deletion, under a guard whose own comment names `src/agents/` as + * the defect it exists to catch): + * + * inline — `src/skills/index.ts` written into a sentence. `inlineSrcDirs()`. + * drawn — an ASCII tree, whose entries under the `src/` node carry no + * `src/` prefix at all (`├── agents/`). `treeSrcDirs()`. + * + * Both forms are checked below, on both doc sets: the maintainer docs keep + * their inline check unchanged, the product pages get the same inline check, + * and every doc that DRAWS a tree — maintainer or product — gets the drawn one. + */ + +/** `src//` written inline in a sentence. */ +const inlineSrcDirs = (text: string): string[] => + [...text.matchAll(/\bsrc\/([a-z][a-z0-9_]*)\//g)].map((m) => m[1]); + +/** + * The entries an ASCII tree draws directly under its `src/` node. + * + * A tree makes the same claim as the inline form, with the prefix stripped by + * the drawing itself — both roots occur here, the maintainer docs at `hotcrm/` + * and the product docs at `src/`: + * + * hotcrm/ src/ + * ├── src/ ├── objects/ + * │ ├── objects/ └── data/ + * │ └── data/ + * + * so `src/objects/` never appears as a literal anywhere on the page and the + * inline regex sees nothing at all. + * + * Only the DIRECT children of `src/` are returned: a deeper level claims + * `src///`, which no tree in this repo draws, and resolving it would + * need the parent's name threaded through. One line may draw several entries + * (`apps/, views/, pages/` — comma- or space-separated), and a trailing `#` + * comment is not part of any of them. */ +const treeSrcDirs = (text: string): string[] => { + const BRANCH = /^([\s│]*)(?:├──|└──)\s?(.*)$/; + const dirs: string[] = []; + /** Indent width of the `src/` node itself; -1 when the tree is rooted at it. */ + let srcIndent: number | null = null; + /** Indent width of its direct children — the first child line sets it. */ + let childIndent: number | null = null; + + for (const line of text.split('\n')) { + const branch = BRANCH.exec(line); + if (!branch) { + // A tree ROOTED at `src/` writes that one line without a branch glyph. + // Every other non-branch line (prose, the closing fence) ends the tree. + srcIndent = /^src\/\s*$/.test(line) ? -1 : null; + childIndent = null; + continue; + } + const indent = branch[1].length; + const entry = branch[2].replace(/#.*$/, '').trim(); + if (srcIndent !== null && indent <= srcIndent) { + // Back out to a sibling of `src/` (`├── apps/docs/`, `└── content/docs/`). + srcIndent = null; + childIndent = null; + } + if (srcIndent === null) { + if (entry === 'src/') srcIndent = indent; + continue; + } + if (childIndent === null) childIndent = indent; + if (indent !== childIndent) continue; + for (const token of entry.split(/[\s,]+/)) { + const dir = /^([a-z][a-z0-9_]*)\/$/.exec(token); + if (dir) dirs.push(dir[1]); + } + } + return dirs; +}; + const TREE_DOCS = [ 'README.md', 'AGENTS.md', @@ -194,7 +271,7 @@ describe('maintainer docs do not point at directories that no longer exist', () // cwd-relative read turns this guard into an ENOENT the moment vitest is // launched from anywhere but the repo root. const text = readFileSync(join(REPO_ROOT, docFile), 'utf8'); - const named = new Set([...text.matchAll(/\bsrc\/([a-z][a-z0-9_]*)\//g)].map((m) => m[1])); + const named = new Set(inlineSrcDirs(text)); const missing = [...named].filter((dir) => !existsSync(join(REPO_ROOT, 'src', dir))); expect( missing, @@ -205,6 +282,73 @@ describe('maintainer docs do not point at directories that no longer exist', () } }); +/** + * The product pages that point readers at `src/` — the file axis of #984. + * + * Deliberately a LIST, not a walk of `content/docs`: a product page may name a + * directory in the negative and be right to. `customization/ai-skills.mdx` + * says "there is no `src/agents/` directory", which is the current truth and + * would be a false positive under "named ⇒ exists". Whether every `src/` path + * on a page is a pointer is an editorial fact about that page, so pages opt in + * here one at a time. + * + * `customization/index.{mdx,zh-Hans,zh-Hant}` draws the same tree and still + * carries the retired `agents/` entry plus a `*.agent.ts` row (#988); its three + * locales join both lists below with that fix. + */ +const PRODUCT_TREE_DOCS = [ + 'content/docs/getting-started/for-developers.mdx', + 'content/docs/getting-started/for-developers.zh-Hans.mdx', + 'content/docs/getting-started/for-developers.zh-Hant.mdx', +]; + +/** Every doc that DRAWS a `src/` tree — the form axis, maintainer and product alike. */ +const TREE_DIAGRAM_DOCS = ['README.md', 'AGENTS.md', 'docs/README.md', ...PRODUCT_TREE_DOCS]; + +describe('product docs do not point at directories that no longer exist', () => { + for (const docFile of PRODUCT_TREE_DOCS) { + it(`${docFile}: every src// it names exists`, () => { + const text = readFileSync(join(REPO_ROOT, docFile), 'utf8'); + const named = [...new Set(inlineSrcDirs(text))]; + expect( + named.length, + `${docFile} names no src// at all — this guard has gone vacuous over it. ` + + 'A page listed here is one that points readers into the tree; if this one stopped ' + + 'doing that, drop it from PRODUCT_TREE_DOCS instead of leaving it green over nothing.', + ).toBeGreaterThan(0); + const missing = named.filter((dir) => !existsSync(join(REPO_ROOT, 'src', dir))); + expect( + missing, + `${docFile} advertises src/ directories that do not exist: ${missing.join(', ')}. ` + + 'Delete the reference (or restore the directory) — a path in prose is still a promise.', + ).toEqual([]); + }); + } +}); + +describe('docs that draw the src/ tree only draw directories that exist', () => { + for (const docFile of TREE_DIAGRAM_DOCS) { + it(`${docFile}: every directory under its src/ node exists`, () => { + const text = readFileSync(join(REPO_ROOT, docFile), 'utf8'); + const drawn = [...new Set(treeSrcDirs(text))]; + expect( + drawn.length, + `${docFile} is listed as drawing a src/ tree, but no entry was parsed under a src/ node. ` + + 'Either the diagram was reformatted (teach treeSrcDirs() the new shape) or the page no ' + + 'longer draws one (drop it from TREE_DIAGRAM_DOCS) — a parser that matches nothing ' + + 'passes by asserting nothing, which is the state that let #984 through.', + ).toBeGreaterThan(0); + const missing = drawn.filter((dir) => !existsSync(join(REPO_ROOT, 'src', dir))); + expect( + missing, + `${docFile} draws src/ directories that do not exist: ${missing.join(', ')}. ` + + 'Delete the branch (or restore the directory) — a directory in a tree diagram is ' + + 'the same promise as one in a sentence.', + ).toEqual([]); + }); + } +}); + /** * Agent-name drift — a copy-pasteable sample must not name an agent that the * runtime refuses to load. From d956a4fa1923219eb7679863ec68ebadc4455f50 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 15:05:41 +0000 Subject: [PATCH 2/2] docs(test): fix a stale directional reference in the docs-drift comment The two-forms note said "the guard above" for a check that sits below it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VHrPAGEgFDoHjphqYG4BMa --- test/docs-drift.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/docs-drift.test.ts b/test/docs-drift.test.ts index 794db9b5..a637a4e8 100644 --- a/test/docs-drift.test.ts +++ b/test/docs-drift.test.ts @@ -175,10 +175,10 @@ describe('package docs do not drift from the flows they document', () => { * against the real tree. `docs/archive/` is deliberately excluded — it is a * historical record and is allowed to describe a repo that no longer exists. * - * A doc states the tree in TWO forms, and the guard above reads only the first - * (#984 — `getting-started/for-developers.mdx` kept drawing `agents/` right - * through the deletion, under a guard whose own comment names `src/agents/` as - * the defect it exists to catch): + * A doc states the tree in TWO forms, and that check — the first one below — + * reads only one of them (#984: `getting-started/for-developers.mdx` kept + * drawing `agents/` right through the deletion, under a guard whose own comment + * names `src/agents/` as the defect it exists to catch): * * inline — `src/skills/index.ts` written into a sentence. `inlineSrcDirs()`. * drawn — an ASCII tree, whose entries under the `src/` node carry no