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
32 changes: 32 additions & 0 deletions .changeset/docs-for-developers-src-tree.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 9 additions & 5 deletions content/docs/getting-started/for-developers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down Expand Up @@ -84,7 +88,7 @@ export const Warranty = ObjectSchema.create({

### Add an AI skill

Create `src/skills/<name>.skill.ts`, export it from `src/skills/index.ts`, and add the skill name to the relevant agent in `src/agents/`.
Create `src/skills/<name>.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

Expand Down
14 changes: 9 additions & 5 deletions content/docs/getting-started/for-developers.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

## 文件后缀协议

| 后缀 | 定义 |
Expand All @@ -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` | 仪表盘 |
Expand Down Expand Up @@ -84,7 +88,7 @@ export const Warranty = ObjectSchema.create({

### 新增 AI skill

创建 `src/skills/<name>.skill.ts`, `src/skills/index.ts` 导出,并把 skill 名称加入 `src/agents/` 中对应的 agent。
创建 `src/skills/<name>.skill.ts`,并从 `src/skills/index.ts` 导出。从这个 barrel 导出就是全部的接线工作——skill 会附着到平台助手(`ask`)上,本应用不定义任何自己的 agent。

### 新增 UI 元数据

Expand Down
14 changes: 9 additions & 5 deletions content/docs/getting-started/for-developers.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

## 檔案後綴協議

| 後綴 | 定義 |
Expand All @@ -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` | 儀表板 |
Expand Down Expand Up @@ -84,7 +88,7 @@ export const Warranty = ObjectSchema.create({

### 新增 AI skill

建立 `src/skills/<name>.skill.ts`, `src/skills/index.ts` 匯出,並把 skill 名稱加入 `src/agents/` 中對應的 agent。
建立 `src/skills/<name>.skill.ts`,並從 `src/skills/index.ts` 匯出。從這個 barrel 匯出就是全部的接線工作——skill 會附著到平台助手(`ask`)上,本應用不定義任何自己的 agent。

### 新增 UI 中繼資料

Expand Down
146 changes: 145 additions & 1 deletion test/docs-drift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,84 @@ describe('package docs do not drift from the flows they document', () => {
* maintainer docs, pulls every `src/<dir>/` 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 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
* `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/<dir>/` 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/<dir>/<sub>/`, 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',
Expand All @@ -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,
Expand All @@ -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/<dir>/ it names exists`, () => {
const text = readFileSync(join(REPO_ROOT, docFile), 'utf8');
const named = [...new Set(inlineSrcDirs(text))];
expect(
named.length,
`${docFile} names no src/<dir>/ 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.
Expand Down
Loading