From 6fc885e7c499c236476eb183f0d4f6861f9f8187 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 12:23:44 +0000 Subject: [PATCH] =?UTF-8?q?test(cli,create-plugin):=20=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=99=A8=E9=94=9A=E8=A7=84=E5=88=99=E6=94=B9?= =?UTF-8?q?=E7=B4=AF=E7=A7=AF=E6=8A=A5=E5=85=A8=E9=83=A8=E6=BC=82=E7=A7=BB?= =?UTF-8?q?,=E5=89=8D=E7=BD=AE=E4=B8=8E=E6=BC=82=E7=A7=BB=E5=88=86?= =?UTF-8?q?=E5=BC=80=20(#4974)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/cli` 的 `sources every range from this repo instead of inventing one` 与 `packages/create-plugin` 的 `sources every devDependency range from this repo instead of inventing them` 都在 `for` 循环里逐名 `expect`。`expect` 失败即抛,所以一次只报第一个 不匹配的名字,而表是插入序遍历的 —— 被报的是「谁排在前面」,不是「谁更严重」。 代价兑现过两次:#4098 有五个同窗 bump 藏在第一个后面;#4968 里 `lucide-react` 排在 `postcss`/`react`/`tailwindcss`/`typescript`/`vite` 之前,遮住后面六个名字的判定, 只能另写脚本才敢说这批到底漂了几处。跨包那半更贵:create-plugin 那处当时在 `main` 上也是红的,但按包边界扫 cli 时没有任何东西报它,落地后仍要第二轮。 两处都改成两趟: - 前置断言(锚必须能解析:名字至少被一个生成器声明、`root` 锚在根清单里、仓内区间 必须一致)保持首败即抛,并且整趟跑在任何区间比对之前 —— 前置败时不产出任何漂移行, 不与漂移串扰。锚不可解析时也没有可累积的期望值。 - 漂移断言累积后一次报空集,逐条带生成器、名字、实得区间与应得区间。 顺带把两张锚表的文档块补上「范围规则只判本表内的名字、完备性规则要求生成清单的键集 恰好等于本表」这一对互补关系 —— 两条规则分居两个 `it`,读任一处都看不到另一处。 Co-authored-by: Claude --- .changeset/layered-read-declared-path-4016.md | 43 ++++ .../src/views/metadata-admin/LayeredDiff.tsx | 2 +- ...missionMatrixEditor.fieldEnvelope.test.tsx | 12 +- .../views/metadata-admin/ResourceEditPage.tsx | 4 +- .../src/metadata-client.layeredRoute.test.ts | 205 ++++++++++++++++++ .../data-objectstack/src/metadata-client.ts | 58 ++++- .../layered-read-declared-path-4016.test.ts | 117 ++++++++++ skills/objectui/guides/console-development.md | 7 +- 8 files changed, 436 insertions(+), 12 deletions(-) create mode 100644 .changeset/layered-read-declared-path-4016.md create mode 100644 packages/data-objectstack/src/metadata-client.layeredRoute.test.ts create mode 100644 scripts/__tests__/layered-read-declared-path-4016.test.ts diff --git a/.changeset/layered-read-declared-path-4016.md b/.changeset/layered-read-declared-path-4016.md new file mode 100644 index 0000000000..8e45bdfdbc --- /dev/null +++ b/.changeset/layered-read-declared-path-4016.md @@ -0,0 +1,43 @@ +--- +'@object-ui/data-objectstack': patch +--- + +`MetadataClient.layered()` now reads the three-layer view from its declared path, +`GET /meta/:type/:name/layers`, instead of flagging the ordinary item read. + +The consumer half of objectstack#5882 (ruled B by the maintainer; the server half +landed in objectstack#6596 and shipped in `@objectstack/spec@17.0.0`). The layered +projection — packaged baseline vs tenant overlay vs merged effective, which is +what the Studio metadata editor's comparison tabs render — used to be reached by +hanging a query flag on `GET /meta/:type/:name`. One route therefore answered two +unrelated representations chosen by a query parameter, while `packages/spec` +declared only the unflagged one: anything generating a client from the route +table produced a parser that was simply wrong for the flagged call. The +projection now has a path of its own and a response schema of its own +(`GetMetaItemLayeredResponseSchema`). + +Same body, same envelope, so nothing in the editor changes shape: `code`, +`overlay`, `overlayScope`, `effective`, the load-time `_diagnostics` and the full +ADR-0010 protection envelope all still arrive on one round trip, and `?package=` +(ADR-0048) is still threaded — the two entry points are served by ONE handler +upstream precisely so the deprecation window's promise holds. The retired +spelling still answers during that window, marked with RFC 9745 `Deprecation` and +an RFC 8288 `Link: rel="successor-version"` pointing here, so this migration is +safe against a lagging backend for as long as the window stays open, and it is +what lets the maintainer close it. + +One behaviour delta rides along, and it is the server's design rather than a +choice made here: the retired flag FELL THROUGH to the plain item read when the +backend's protocol implementation had no layered support, answering the +`{ type, name, item }` envelope. A dedicated path refuses to answer a different +resource under this one's declared shape and returns 501 `NOT_IMPLEMENTED`, which +surfaces as a failed read instead of a comparison view whose `code` and `overlay` +are silently blank. + +The request is built in this package rather than delegated to +`@objectstack/client` because the SDK expresses no layered read in either +spelling — the framework's REST route ledger records the route as `server-only`, +"consumed by objectui over plain HTTP", and whether the SDK should express it is +an open upstream product call. The new path expectation is derived from the +installed `@objectstack/spec` route table, and a ratchet keeps any shipped source +file or skills guide from reaching the projection by query flag again. diff --git a/packages/app-shell/src/views/metadata-admin/LayeredDiff.tsx b/packages/app-shell/src/views/metadata-admin/LayeredDiff.tsx index cb98af567a..22fcf90537 100644 --- a/packages/app-shell/src/views/metadata-admin/LayeredDiff.tsx +++ b/packages/app-shell/src/views/metadata-admin/LayeredDiff.tsx @@ -12,7 +12,7 @@ * • Overlay — pretty-printed JSON of just the deltas they've saved. * • Effective — the merged value the runtime serves. * - * Backed by `client.layered(type, name)` (Phase 3a `?layers=true`). + * Backed by `client.layered(type, name)`, i.e. `GET /meta/:type/:name/layers`. * * Diff scope: top-level keys only. Nested objects/arrays are compared by * JSON-stringify equality. Drilling into nested diffs is a future diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.fieldEnvelope.test.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.fieldEnvelope.test.tsx index 059af338d5..b9059b6258 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.fieldEnvelope.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.fieldEnvelope.test.tsx @@ -79,7 +79,17 @@ function realClient(): MetadataClient { baseUrl: 'http://localhost:3000', fetch: (async (input: RequestInfo | URL) => { const url = String(input); - if (url.includes('/meta/permission/sales_perms?layers=true')) { + // The layered read's declared path (objectstack#5882 ruling B, migrated + // in objectui#4016). Kept exact so this double stays truthful about what + // the real client puts on the wire — the next reader copies from here. + // + // Measured, not assumed: it carries no assertion pressure. Reverting the + // client to the retired query flag leaves all three cases below GREEN, + // because the layered body only feeds the artifact-backed verdict and the + // permission draft, and the field sub-table these cases assert is fed by + // the `get()` read further down. The URL itself is pinned where it belongs, + // in `packages/data-objectstack/src/metadata-client.layeredRoute.test.ts`. + if (url.endsWith('/meta/permission/sales_perms/layers')) { return json({ code: null, overlay: null, diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 9ed06f4708..87c80e007c 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -4,8 +4,8 @@ * MetadataResourceEditPage — generic AutoForm-driven editor (Phase 3c). * * What it does: - * 1. Fetches the layered view (`?layers=true`) so the user sees code - * vs overlay vs effective. + * 1. Fetches the layered view (`GET /meta/:type/:name/layers`) so the user + * sees code vs overlay vs effective. * 2. Renders a SchemaForm against the JSONSchema in the type's * `/meta/types` registry row. * 3. Save → PUT, with automatic destructive-change handling: a 409 diff --git a/packages/data-objectstack/src/metadata-client.layeredRoute.test.ts b/packages/data-objectstack/src/metadata-client.layeredRoute.test.ts new file mode 100644 index 0000000000..46cf6184c5 --- /dev/null +++ b/packages/data-objectstack/src/metadata-client.layeredRoute.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#4016 — `MetadataClient.layered()` reads the three-layer projection + * from the path the framework DECLARES for it, `GET /meta/:type/:name/layers`, + * instead of flagging the ordinary item read. + * + * Why the move (objectstack#5882, ruled B by the maintainer; server half landed + * in objectstack#6596): one route was answering two unrelated representations + * chosen by a query flag, while `packages/spec` declared only the unflagged one. + * Anything generating a client from the route table — SDK annotations, codegen, + * an AI-written integration — produced a parser that was simply wrong for the + * flagged call. The projection now has a path of its own AND a response schema + * of its own (`GetMetaItemLayeredResponseSchema`); the flag still answers the + * same body during a deprecation window, marked with RFC 9745 `Deprecation` and + * an RFC 8288 `Link: rel="successor-version"`, and is scheduled for removal. + * + * `@objectstack/client` is not in the picture on purpose: the SDK expresses no + * layered read in either spelling (the framework's REST route ledger records + * this route as `server-only`, "consumed by objectui over plain HTTP"), so this + * package builds the request and these cases are what hold it to the contract. + * + * The path expectation is DERIVED from the installed `@objectstack/spec` route + * table rather than retyped here — a hand-copied string would keep passing + * after the producer moved the route, which is the failure mode this whole card + * exists to remove. + * + * The repo-wide half — that no shipped source file or skills guide reaches the + * projection by query flag any more — is a ratchet over a scan surface wider + * than this package, so it lives in + * `scripts/__tests__/layered-read-declared-path-4016.test.ts`. + */ + +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_METADATA_ROUTES, + GetMetaItemLayeredResponseSchema, +} from '@objectstack/spec/api'; +import { MetadataClient } from './metadata-client'; + +const BASE_URL = 'http://localhost:3000'; + +/** The layered endpoint as the installed spec declares it. */ +function specLayeredEndpoint() { + const endpoints = (DEFAULT_METADATA_ROUTES as { endpoints: Array> }).endpoints; + const matches = endpoints.filter((e) => e.handler === 'getMetaItemLayered'); + // If the producer ever declares this handler twice (or drops it), the + // expectation below is meaningless — say so instead of silently picking one. + expect(matches).toHaveLength(1); + return matches[0] as { method: string; path: string; responseSchema?: string }; +} + +/** `http://host/api/v1/meta/object/showcase_project/layers`, spec-derived. */ +function specLayeredUrl(type: string, name: string): string { + const prefix = (DEFAULT_METADATA_ROUTES as { prefix: string }).prefix; + const suffix = specLayeredEndpoint().path + .replace(':type', type) + .replace(':name', name); + return `${BASE_URL}${prefix}${suffix}`; +} + +/** A client over a fetch that records the URL and answers `body`. */ +function clientAnswering(body: unknown, status = 200) { + const urls: string[] = []; + const client = new MetadataClient({ + baseUrl: BASE_URL, + fetch: (async (input: RequestInfo | URL) => { + urls.push(String(input)); + return new Response(status === 204 ? null : JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); + }) as unknown as typeof fetch, + }); + return { client, urls }; +} + +/** + * Exactly what the declared body carries — every layer, the load-time + * diagnostics and the full ADR-0010 protection envelope. Validated against the + * producer's own schema below, so the equivalence case cannot be green against + * a shape no server sends. + */ +const LAYERED_BODY = { + type: 'object', + name: 'showcase_project', + code: { name: 'showcase_project', label: 'Project', fields: { code: { type: 'text' } } }, + overlay: { label: 'Projects (ours)' }, + overlayScope: 'org', + effective: { name: 'showcase_project', label: 'Projects (ours)', fields: { code: { type: 'text' } } }, + _diagnostics: { valid: false, errors: [{ path: 'label', message: 'too long', code: 'too_big' }] }, + lock: 'no-delete', + lockReason: 'shipped by the crm package', + lockSource: 'package', + lockDocsUrl: 'https://docs.objectstack.ai/metadata/locks', + provenance: 'package', + packageId: 'crm', + packageVersion: '1.2.3', + editable: true, + deletable: false, + resettable: true, +} as const; + +describe('objectui#4016 · the layered read goes to its declared path', () => { + it('is declared by the installed spec as a GET on its own path with its own schema', () => { + const endpoint = specLayeredEndpoint(); + expect(endpoint.method).toBe('GET'); + expect(endpoint.path).toBe('/:type/:name/layers'); + // The point of ruling B: a representation of its own, not a second shape + // hiding behind the item read's schema. + expect(endpoint.responseSchema).toBe('GetMetaItemLayeredResponseSchema'); + }); + + it('requests the spec-declared path and carries no `layers` query flag', async () => { + const { client, urls } = clientAnswering(LAYERED_BODY); + + await client.layered('object', 'showcase_project'); + + expect(urls).toEqual([specLayeredUrl('object', 'showcase_project')]); + // Belt and braces on the retired spelling: the flag is gone as a QUERY + // parameter, not merely relocated inside the string. + expect(urls[0]).not.toMatch(/[?&]layers=/); + expect(urls[0]).not.toContain('?'); + }); + + it('leads the query string with `?package=` instead of trailing it (ADR-0048)', async () => { + const { client, urls } = clientAnswering(LAYERED_BODY); + + await client.layered('object', 'showcase_project', { packageId: 'crm/base' }); + + // The migration's live trap: the package id used to be appended with `&` + // behind the flag. Left as `&` it would have fused onto the last PATH + // segment (`…/layers&package=crm%2Fbase`), which matches no route — a 404 + // that `layered()` reports as an empty-but-successful layered view. + expect(urls).toEqual([`${specLayeredUrl('object', 'showcase_project')}?package=crm%2Fbase`]); + expect(urls[0]).not.toContain('layers&'); + }); + + it('percent-encodes the type and name into the path', async () => { + const { client, urls } = clientAnswering(LAYERED_BODY); + + await client.layered('object', 'a/b c'); + + expect(urls).toEqual([`${BASE_URL}/api/v1/meta/object/a%2Fb%20c/layers`]); + }); + + it('hands back every layer and protection carrier the declared body sends', async () => { + // Guard the fixture first: a value verdict is being asserted, so the body + // must parse fully green against the producer's schema, not merely avoid + // unknown keys. + const parsed = GetMetaItemLayeredResponseSchema.safeParse(LAYERED_BODY); + expect(parsed.success).toBe(true); + + const { client } = clientAnswering(LAYERED_BODY); + + const layered = await client.layered>('object', 'showcase_project'); + + // Behaviour equivalence with the retired spelling: same envelope, same + // keys, `type` / `name` still dropped (the caller passed them in). + expect(layered).toEqual({ + code: LAYERED_BODY.code, + overlay: LAYERED_BODY.overlay, + overlayScope: 'org', + effective: LAYERED_BODY.effective, + _diagnostics: LAYERED_BODY._diagnostics, + lock: 'no-delete', + lockReason: 'shipped by the crm package', + lockSource: 'package', + lockDocsUrl: 'https://docs.objectstack.ai/metadata/locks', + provenance: 'package', + packageId: 'crm', + packageVersion: '1.2.3', + editable: true, + deletable: false, + resettable: true, + }); + }); + + it('reports an unknown item as an empty layered view (404)', async () => { + const { client } = clientAnswering({ error: 'not found' }, 404); + + await expect(client.layered('object', 'nope')).resolves.toEqual({ + code: null, + overlay: null, + overlayScope: null, + effective: null, + }); + }); + + it('throws when the backend cannot serve the projection (501)', async () => { + // The one intentional behaviour delta of the move, and it is the server's + // call, not this client's: the retired flag FELL THROUGH to the plain item + // read on a backend whose protocol implementation had no layered support, + // answering `{ type, name, item }`. A dedicated path refuses to answer a + // different resource under this one's declared shape and returns 501 + // `NOT_IMPLEMENTED` — which must surface, not degrade into a view whose + // `code` and `overlay` are blank for a reason the operator cannot see. + const { client } = clientAnswering( + { error: 'Layered metadata view not supported by protocol implementation', code: 'NOT_IMPLEMENTED' }, + 501, + ); + + await expect(client.layered('object', 'showcase_project')).rejects.toThrow(); + }); +}); diff --git a/packages/data-objectstack/src/metadata-client.ts b/packages/data-objectstack/src/metadata-client.ts index 5566572be1..d9785567aa 100644 --- a/packages/data-objectstack/src/metadata-client.ts +++ b/packages/data-objectstack/src/metadata-client.ts @@ -239,7 +239,14 @@ export interface MetadataDeleteOptions extends MetadataClientSaveOptions { state?: 'active' | 'draft'; } -/** Layered view of a metadata item — Phase 3a `?layers=true`. */ +/** + * Layered view of a metadata item — the body of + * `GET /meta/:type/:name/layers` (`GetMetaItemLayeredResponseSchema`). + * + * A subset by design: the response also carries `type` / `name`, which the + * caller already knows because it passed them in, so + * {@link MetadataClient.layered} does not hand them back. + */ export interface MetadataLayered { /** Code-level (artifact) item; null if the item only exists as an overlay. */ code: T | null; @@ -803,17 +810,49 @@ export class MetadataClient { } /** - * Get the 3-state layered view of a metadata item (Phase 3a). Returns - * `code` (the artifact / fallback default), `overlay` (the saved - * customisation, if any), and `effective` (what the runtime sees). + * Get the 3-state layered view of a metadata item: `code` (the packaged + * artifact baseline), `overlay` (the tenant customisation row alone) and + * `effective` (the merged value the runtime sees). + * + * Reads **`GET /meta/:type/:name/layers`** — the path the framework declares + * for this projection, with a response schema of its own + * (`GetMetaItemLayeredResponseSchema`, objectstack#5882 ruling B). It used to + * be reached by hanging a `layers` flag on the ordinary item read, which made + * one route answer two unrelated representations while `packages/spec` + * declared only one of them. That spelling still answers this same body + * inside its deprecation window (the response carries RFC 9745 + * `Deprecation: true` and an RFC 8288 `Link: rel="successor-version"` back to + * this path) and is scheduled for removal upstream, so nothing here may + * depend on it — the repo-wide ratchet is + * `scripts/__tests__/layered-read-declared-path-4016.test.ts`. + * + * The request is built here rather than delegated to `@objectstack/client` + * because the SDK expresses no layered read in EITHER spelling: the + * framework's REST route ledger records this route as `server-only`, + * "consumed by objectui over plain HTTP", and whether the SDK should express + * it is an open upstream product call. + * + * One behaviour delta rides along with the path, and it is the server's + * choice rather than ours: the retired flag FELL THROUGH to the plain item + * read on a backend whose protocol implementation had no layered support, + * answering the `{ type, name, item }` envelope. A dedicated path refuses to + * answer a different resource under this one's declared shape, so it returns + * 501 `NOT_IMPLEMENTED` instead — which surfaces here as a thrown error + * rather than a view with `code` and `overlay` silently blank. */ async layered( type: string, name: string, options: { packageId?: string } = {}, ): Promise> { - const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : ''; - const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`; + // ADR-0048 — `?package=` scopes resolution to one installed package (the + // editor passes the edited item's owning package, not the Studio app's). + // It leads the query string now that the flag it used to trail is gone: + // keeping the `&` would have appended it to the last PATH segment + // (`…/layers&package=crm`), which matches no route — a 404 that this + // method turns into an empty-but-successful layered view. + const qs = options.packageId ? `?package=${encodeURIComponent(options.packageId)}` : ''; + const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/layers${qs}`; const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' }); if (res.status === 404) { return { code: null, overlay: null, overlayScope: null, effective: null }; @@ -822,6 +861,13 @@ export class MetadataClient { const body = (await res.json()) as MetadataLayered & Record; const hasEnvelope = body && (('code' in body) || ('overlay' in body) || ('effective' in body)); + // Left standing, but no longer reachable from a conforming server: the only + // producer of a 200 body WITHOUT these keys was the retired flag's + // fall-through to the plain item read, and the declared path answers 501 + // there. Kept out of this migration's scope on purpose — deleting it is a + // behaviour change for a non-conforming backend, not part of moving the + // request — and filed as objectui#4983 for removal once the upstream + // deprecation window closes. if (!hasEnvelope) { return { code: null, diff --git a/scripts/__tests__/layered-read-declared-path-4016.test.ts b/scripts/__tests__/layered-read-declared-path-4016.test.ts new file mode 100644 index 0000000000..98a811b85e --- /dev/null +++ b/scripts/__tests__/layered-read-declared-path-4016.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * objectui#4016 ratchet — nothing in this repo reaches the metadata LAYERED + * projection by hanging a query flag on the ordinary item read. + * + * The projection (packaged baseline / tenant overlay / merged effective, which + * is what Studio's metadata-editor comparison tabs render) has a path and a + * response schema of its own: `GET /meta/:type/:name/layers`, declared by + * `@objectstack/spec` with `GetMetaItemLayeredResponseSchema` + * (objectstack#5882, ruled B by the maintainer; server half objectstack#6596). + * Before that ruling, one route answered two unrelated representations chosen + * by a query parameter while the spec declared only the unflagged one, so any + * client generated from the route table was simply wrong for the flagged call. + * + * The retired spelling still answers the same body during a deprecation window, + * advertised with RFC 9745 `Deprecation: true` and an RFC 8288 + * `Link: rel="successor-version"` pointing at the new path — which means a + * regression here CANNOT be caught by anything failing. It would keep working, + * quietly, until the maintainer closes the window; and closing the window is + * exactly what this repo moving off the flag is supposed to unblock. Hence a + * ratchet rather than trust. + * + * Why it lives in `scripts/__tests__` rather than beside the client: its scan + * surface is the whole repo (the packages that build metadata requests, the apps + * that could hand-roll one, and the skills guides that teach AI agents how). The + * transport-level cases — that `MetadataClient.layered()` requests the + * spec-declared path, leads the query string with `?package=`, and reports the + * declared body unchanged — live in + * `packages/data-objectstack/src/metadata-client.layeredRoute.test.ts`. + * + * Reverse verification (direction predicted before running, then measured): + * restoring the retired spelling in `MetadataClient.layered()` turns this RED by + * naming that file in `offenders` — the assertion fails because it PRODUCED a + * finding, not because it produced nothing. The two floors below exist for the + * other direction: a walk that silently scanned zero files would otherwise pass + * with an empty `offenders` and report the repo clean. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +/** Source files that could construct a metadata request, tests excluded. */ +function walkSources(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry === 'dist' || entry === '__tests__') continue; + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) walkSources(full, out); + else if (/\.(ts|tsx)$/.test(entry) && !/\.test\.tsx?$/.test(entry)) out.push(full); + } + return out; +} + +/** Skills guides — what an AI agent reads before writing console code. */ +function walkMarkdown(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules') continue; + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) walkMarkdown(full, out); + else if (entry.endsWith('.md')) out.push(full); + } + return out; +} + +/** + * The retired spelling as a QUERY parameter. Deliberately broader than the one + * value this repo used: the first version of this projection was reached with a + * layer LIST rather than a boolean, and either form is the same mistake now. + * + * Matching on `[?&]` is what keeps it off the many legitimate uses of the word — + * the editor's `'layers'` sheet id, `engine.edit.layers` i18n keys, and prose + * about "three layers" are all untouched. CHANGELOG files are out of the scan + * surface by construction: they are history, and history did use the flag. + */ +const RETIRED_FLAG = /[?&]layers=/; + +describe('objectui#4016 ratchet · nothing reaches the layered projection by query flag', () => { + it('no shipped source file and no skills guide names the retired flag', () => { + // Both halves of the consumption radius: the packages that build the + // request and the apps that could hand-roll one beside them. + const sources = ['packages', 'apps'] + .flatMap((group) => { + const groupDir = path.join(repoRoot, group); + return readdirSync(groupDir).map((pkg: string) => path.join(groupDir, pkg, 'src')); + }) + .filter((src) => { + try { + return statSync(src).isDirectory(); + } catch { + return false; + } + }) + .flatMap((src) => walkSources(src)); + const guides = walkMarkdown(path.join(repoRoot, 'skills')); + + // A scan that scanned nothing passes for the wrong reason. Both floors sit + // far below the real counts (1327 sources / 18 guides when this was + // written) — they fail a broken walk, not a growing repo. + expect(sources.length).toBeGreaterThan(800); + expect(guides.length).toBeGreaterThan(5); + + const offenders = [...sources, ...guides] + .filter((file) => RETIRED_FLAG.test(readFileSync(file, 'utf8'))) + .map((file) => path.relative(repoRoot, file)); + + // If this fails: call `client.layered(type, name)` from + // `@object-ui/data-objectstack`, which reads the declared + // `GET /meta/:type/:name/layers`. The flag it replaced answers the same body + // only until the maintainer closes the deprecation window, and the response + // already says so in its `Deprecation` / `Link` headers. + expect(offenders).toEqual([]); + }); +}); diff --git a/skills/objectui/guides/console-development.md b/skills/objectui/guides/console-development.md index 69db80b863..90b110503c 100644 --- a/skills/objectui/guides/console-development.md +++ b/skills/objectui/guides/console-development.md @@ -211,8 +211,11 @@ generic-engine defaults can be registered independently. `type: 'permission'`. 2. Otherwise it picks a schema: `createSchema` in create mode, else the `/meta/types` row's `schema`, else the registry's `defaultSchema`. -3. Fetches the layered view (`?layers=true`) so code / overlay / effective are all - visible, and renders `SchemaForm` against that schema. +3. Fetches the layered view (`GET /meta/:type/:name/layers`, via `client.layered()`) so + code / overlay / effective are all visible, and renders `SchemaForm` against that + schema. That projection has a path and a response schema of its own + (`GetMetaItemLayeredResponseSchema`); the older spelling that flagged the ordinary + item read is deprecated upstream — don't reintroduce it. 4. Save → PUT. A `409 destructive_change` opens a confirmation dialog and retries with `?force=true`. Reset overlay → DELETE. The References tab calls `client.references()` so an admin sees the back-pointers before deleting.