From bc326f728c5a2a9fbc3e3d0eb7d264b3ef5d978e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 16:12:52 +0000 Subject: [PATCH 1/3] =?UTF-8?q?docs(spec):=20`ApiEndpointSchema.path`=20?= =?UTF-8?q?=E7=9A=84=20`.describe()`=20=E6=8D=A2=E6=88=90=20ADR-0121=20car?= =?UTF-8?q?ve-out=20=E5=BD=A2=E7=8A=B6=E7=9A=84=E7=A4=BA=E4=BE=8B=20(#5310?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这段文案原本是 `URL Path (e.g. /api/v1/customers)`。ADR-0121 D1 把声明路径收紧为 `<运行前缀>/apps/<命名空间>/<子路径>`,publish 门 `namespaceGate` 对 carve-out 之外的 路径直接拒绝 —— 词表自己举的例子,publish 会当场拒。 #5271 让 `api` 成为注册元数据类型之后,这段 `.describe()` 成为 metadata-admin 端点表单 里 `path` 字段的说明文字,并进入生成的 JSON Schema,是作者(常为 AI 作者,ADR-0033) 照抄的第一手提示。 新文案给出 carve-out 形状、一个具体示例 `/api/v1/apps/crm/leads`,并说明命名空间段派生 自 `manifest.namespace`(ADR-0121 D2)。措辞复用 `endpoint-publish-gate.ts` 中 `namespaceGate` 的拒绝文案,未新发明判据。 只改文案:schema 结构、`/^\//` 正则、门逻辑一律未动。 新增断言把 `.describe()` 的文本读回来再喂给门:文案里的每个具体示例路径都必须在它自己 命名的命名空间下通过 `validateApiEndpointDeclarations`。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- .changeset/endpoint-path-describe-carveout.md | 33 +++++++++++ .../spec/src/api/apis-publish-gates.test.ts | 59 +++++++++++++++++++ packages/spec/src/api/endpoint.zod.ts | 9 ++- 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 .changeset/endpoint-path-describe-carveout.md diff --git a/.changeset/endpoint-path-describe-carveout.md b/.changeset/endpoint-path-describe-carveout.md new file mode 100644 index 0000000000..5332faacd0 --- /dev/null +++ b/.changeset/endpoint-path-describe-carveout.md @@ -0,0 +1,33 @@ +--- +'@objectstack/spec': patch +--- + +**`ApiEndpointSchema.path` 的 `.describe()` 换成 carve-out 形状的示例(#5310)** + +这段文案过去是 `URL Path (e.g. /api/v1/customers)`。ADR-0121 D1 把声明路径收紧为 +`运行前缀 + /apps/ + 命名空间 + 子路径`(`appEndpointMountPrefix()` = +`/api/v1/apps//`),publish 门 `namespaceGate` 对 carve-out 之外的路径直接拒绝 +—— 也就是说,**词表自己举的例子,publish 会当场拒**。 + +**为什么现在要紧。** #5271 之前 `api` 没有注册 schema,`/meta/types` 不为它出 JSON Schema, +Studio 只能给一个 raw-JSON 文本框,这段 `.describe()` 没有渲染面。#5271 之后它成为 +metadata-admin 端点表单里 `path` 字段的说明文字,并进入生成的 JSON Schema —— 于是一段会被 +拒绝的示例,变成了作者(按 ADR-0033,常常是 AI 作者)照抄的第一手提示。 + +**新文案**给出 carve-out 形状 `/api/v1/apps//`、一个具体示例 +`/api/v1/apps/crm/leads`,并说明命名空间段派生自 `manifest.namespace`(ADR-0121 D2)而不是 +作者的自由字段。措辞直接复用 `endpoint-publish-gate.ts` 里 `namespaceGate` 的拒绝文案, +没有新发明判据 —— 作者在表单里读到的,和被拒时读到的,是同一条规则。 + +**只改了文案。** schema 结构、`/^\//` 正则、门逻辑一律未动:`path` 仍是一个以斜杠开头的 +字符串,carve-out 仍然只由 publish 门(和运行期匹配器)判定。因此这不是破坏性变更,今天能 +发布的声明明天照样能发布。 + +`ApiMappingSchema` 的三条 `.describe()` 逐条复核后**保持原样**:`source` / `target` +(*Source field/path* / *Target field/path*)不举例,也没有说错;`transform` +(*Transformation function name*)描述的键确实会被门整键拒绝,但那属于「词表冻结后,被拒键 +是否应在表单里自陈」的另一类问题,已另行归档,不在本次文案修正内。 + +配套:`packages/spec/src/api/apis-publish-gates.test.ts` 新增一条断言,把 `.describe()` 的 +文本**读回来**再喂给门 —— 文案里出现的每个具体示例路径都必须在它自己命名的命名空间下通过 +`validateApiEndpointDeclarations`。把一个会被拒的路径写回 `.describe()`,这条测试就红。 diff --git a/packages/spec/src/api/apis-publish-gates.test.ts b/packages/spec/src/api/apis-publish-gates.test.ts index 808bfa96f4..d578c9a1d4 100644 --- a/packages/spec/src/api/apis-publish-gates.test.ts +++ b/packages/spec/src/api/apis-publish-gates.test.ts @@ -199,6 +199,65 @@ describe('[#5111] gate (c) — namespace carve-out (ADR-0121 D1/D2)', () => { }); }); +describe('[#5310] the `path` vocabulary text is itself publishable', () => { + /** + * `ApiEndpointSchema.path`'s `.describe()` is not a code comment. Since `api` + * became a registered metadata kind (#5271) it is the field help the + * metadata-admin endpoint form renders, and it lands in the generated JSON + * Schema — so it is the first-hand prompt an author copies, and the author is + * very often an AI maintainer (ADR-0033) that copies it verbatim. It used to + * read `URL Path (e.g. /api/v1/customers)`, an example `namespaceGate` rejects + * on sight (ADR-0121 D1): the vocabulary's own example was the one shape + * publish refuses. + * + * The assertion reads the text back OUT of the schema instead of restating + * it, so it cannot drift from the string an author actually sees — put a + * rejected path back into the `.describe()` and this file goes red. + */ + const description = ApiEndpointSchema.shape.path.description ?? ''; + + /** Concrete paths in that text. `` is a placeholder, not an example. */ + const concreteExamples = (description.match(/\/[A-Za-z0-9_<>./-]+/g) ?? []).filter( + (candidate) => !candidate.includes('<'), + ); + + it('shows the author at least one CONCRETE example path', () => { + expect(description, '`path` must carry a description — it is the form\'s field help').not.toBe(''); + expect( + concreteExamples, + `no concrete example path found in: ${description}`, + ).not.toHaveLength(0); + }); + + it('every concrete example it shows PASSES the gate that judges authored paths', () => { + for (const path of concreteExamples) { + const carveOut = /^\/api\/v1\/apps\/([a-z][a-z0-9_]{1,19})\/(.+)$/.exec(path); + expect( + carveOut, + `example '${path}' is not \`/api/v1/apps/[namespace]/[subpath]\` — the shape ADR-0121 D1 requires`, + ).not.toBeNull(); + + // Judged under the namespace the example itself names: the carve-out is + // derived from `manifest.namespace` (D2), so an example is only honest + // paired with the manifest that could declare it. + const namespace = carveOut![1]!; + const issues = validateApiEndpointDeclarations( + [ApiEndpointSchema.parse({ ...validObjectEndpoint, path })], + { namespace }, + ); + expect( + issues.map((issue) => issue.message).join('\n'), + `example '${path}' must publish under \`manifest.namespace: '${namespace}'\``, + ).toBe(''); + } + }); + + it('the example it USED to show is still rejected — the defect itself, pinned', () => { + const message = reject({ manifest, apis: [{ ...validObjectEndpoint, path: '/api/v1/customers' }] }); + expect(message).toMatch(/not inside this stack's endpoint carve-out/); + }); +}); + describe('[#5111] gate (a) — the supported subset (mirrors `planEndpointTarget`)', () => { it("rejects `type: 'script'` with the flow prescription", () => { const message = reject({ diff --git a/packages/spec/src/api/endpoint.zod.ts b/packages/spec/src/api/endpoint.zod.ts index 92efd7e09e..6f531dc30e 100644 --- a/packages/spec/src/api/endpoint.zod.ts +++ b/packages/spec/src/api/endpoint.zod.ts @@ -56,7 +56,14 @@ export const ApiMappingSchema = lazySchema(() => z.object({ export const ApiEndpointSchema = z.object({ /** Identity */ name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Unique endpoint ID'), - path: z.string().regex(/^\//).describe('URL Path (e.g. /api/v1/customers)'), + path: z.string().regex(/^\//).describe( + 'URL Path — must be inside this stack\'s endpoint carve-out: ' + + '`/api/v1/apps//` with a non-empty subpath (ADR-0121 D1), ' + + 'e.g. `/api/v1/apps/crm/leads` for a stack whose `manifest.namespace` is `crm`. ' + + 'Only the subpath is yours to name; the namespace segment is derived from ' + + '`manifest.namespace` (ADR-0121 D2), never authored here. A path outside the carve-out ' + + 'is rejected at publish and would match NOTHING at runtime.', + ), method: HttpMethod.describe('HTTP Method'), /** Documentation */ From 9becfc9d87f9c468b8150805a1e0f28943c8e0f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 16:22:34 +0000 Subject: [PATCH 2/3] =?UTF-8?q?docs(spec):=20=E9=87=8D=E7=94=9F=E6=88=90?= =?UTF-8?q?=20`content/docs/references/api/endpoint.mdx`(#5310)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.describe()` 文案变化会落到生成的参考文档表格里。按 os-regen 纪律用 `check:generated` 判定失效面(仅 `content/docs/references/**` 一项),再用 `--fix` 只重生成被证实失效的那一项,随后补跑 `gen:openapi`(#5371,无门)。 未手改任何生成物。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- content/docs/references/api/endpoint.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/api/endpoint.mdx b/content/docs/references/api/endpoint.mdx index 8b9d78ccee..327498102b 100644 --- a/content/docs/references/api/endpoint.mdx +++ b/content/docs/references/api/endpoint.mdx @@ -32,7 +32,7 @@ const result = ApiEndpointSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Unique endpoint ID | -| **path** | `string` | ✅ | URL Path (e.g. /api/v1/customers) | +| **path** | `string` | ✅ | URL Path — must be inside this stack's endpoint carve-out: `/api/v1/apps//` with a non-empty subpath (ADR-0121 D1), e.g. `/api/v1/apps/crm/leads` for a stack whose `manifest.namespace` is `crm`. Only the subpath is yours to name; the namespace segment is derived from `manifest.namespace` (ADR-0121 D2), never authored here. A path outside the carve-out is rejected at publish and would match NOTHING at runtime. | | **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>` | ✅ | HTTP Method | | **summary** | `string` | optional | | | **description** | `string` | optional | | From cf9cc23ed7bdead1211faf73016aa97dbdaf791e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 23:50:24 +0000 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=E9=87=8D=E8=A7=A6=E5=8F=91=20CI?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E5=B9=B3=E5=8F=B0=E5=86=BB=E7=BB=93?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E7=9A=84=20rerun=20attempt=20=E5=8D=A1?= =?UTF-8?q?=E6=AD=BB=E9=A2=84=E6=8E=92=E9=98=9F,=E6=8D=A2=20head=20?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E8=B0=83=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY