From 110a21febb2dfd1b694e079ffb256d3cd4c03f9b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:42:31 +0000 Subject: [PATCH 01/11] chore(ui-merge): retire the objectui leftovers nothing runs, re-grade field.externalId, re-root spec renderer citations - ui/scripts/check-cross-repo-closer-outcome.mjs: deleted. Its subject, objectui's cross-repo-issue-closer workflow, left with ui/.github/workflows and nothing runs it; check-closing-keyword-parity drops its NON_PARSERS row. - ui/scripts/__tests__/workflow-checks.ts: deleted. Its workflow parser had no caller; the two tests that imported repoRoot compute it themselves. - ui/scripts/markdown-test-inputs.mjs: --changed retired with objectui's decision step, its only caller. The ledger stays as an audit. - packages/spec/liveness/field.json: field.externalId re-graded live -> dead; no reader in packages/** or ui/**. - packages/spec/src/ui/{page,component}.zod.ts: renderer citations spelled ui/packages/... instead of the objectui: realm marker. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibvE77Ed7MKdzPxJTQQ --- .changeset/25753-field-external-id-dead.md | 13 + packages/spec/liveness/field.json | 9 +- packages/spec/src/ui/component.zod.ts | 4 +- .../ui/expression-scope-data-root.pin.test.ts | 2 +- packages/spec/src/ui/page.zod.ts | 8 +- scripts/check-closing-keyword-parity.mjs | 14 +- .../__tests__/check-lockfile-dedupe.test.ts | 3 +- .../check-lockfile-integrity.test.ts | 3 +- .../__tests__/dependabot-merge-gate.test.ts | 2 +- .../__tests__/merge-queue-reporting.test.ts | 2 +- ui/scripts/__tests__/workflow-checks.ts | 130 -- .../check-cross-repo-closer-outcome.mjs | 1178 ----------------- ui/scripts/check-test-shard-results.mjs | 4 +- ui/scripts/markdown-test-inputs.mjs | 46 +- 14 files changed, 55 insertions(+), 1363 deletions(-) create mode 100644 .changeset/25753-field-external-id-dead.md delete mode 100644 ui/scripts/__tests__/workflow-checks.ts delete mode 100644 ui/scripts/check-cross-repo-closer-outcome.mjs diff --git a/.changeset/25753-field-external-id-dead.md b/.changeset/25753-field-external-id-dead.md new file mode 100644 index 00000000000..63645526851 --- /dev/null +++ b/.changeset/25753-field-external-id-dead.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): `os lint` tells an author that a field's `externalId: true` does nothing + +Clause-②: no (a liveness-ledger re-grade: no schema, accept set, served body or error code moves; only the advisory `liveness-dead-property` warning on an authored `externalId: true` starts) +Metadata-protocol: none + +The shipped liveness ledger (`liveness/field.json`) graded a field's `externalId` flag `live` on the strength of one renderer module that was deleted for having no importers. Re-measured across `packages/**` and `ui/**`, nothing reads the flag: the Studio field inspector edits it and a metadata summary copies it, and no upsert matches on it. The row is now `dead`, so `os lint` warns on a field that sets `externalId: true` (the default `false` is not warned). + +- **What to do.** Remove the flag. To match seed records on a field, name the field in the seed dataset's `externalId` (`name` when it is left out). For an import, name it in the mapping's `upsertKey`. +- **Unchanged.** Parsing (the key is still accepted, default `false`), seed loading, imports and every upsert. diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index aa761d7f5a3..0ab1b8d9f19 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -383,9 +383,12 @@ "note": "LIVE via objectui renderer — the 2026-06 audit mis-classified as dead (renderer side not re-verified). Corrected after checking ../objectui." }, "externalId": { - "status": "live", - "evidence": "objectui console `metadataConverters.ts` (:125, commit not pinned) read field.externalId; that module was deleted in objectui dc14bcb for having zero importers, so no reader is cited here — re-verification owed (#25712 follow-up F23)", - "note": "LIVE via objectui renderer — the 2026-06 audit mis-classified as dead (renderer side not re-verified). Corrected after checking ../objectui. 2026-10-26 (#25712, objectui merged into this repo as ui/): the only cited reader, objectui console `metadataConverters.ts`, was deleted in objectui dc14bcb (\"zero importers\"), so the pointer is now prose. Follow-up F23 on #25712 owns finding the current reader or re-classifying." + "status": "dead", + "verifiedAt": "2026-10-27", + "evidenceScope": "in-repo", + "evidence": "no reader: at 1c632311, outside packages/spec, nothing under packages/** or ui/** reads a field's `externalId`. The two `ui/` mentions only carry it: the Studio field inspector edits it (ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx#ObjectFieldInspector) and ui/packages/app-shell/src/services/MetadataService.ts copies it into a field summary; neither is a preview that shows it to a person. Every upsert matches on something else: a seed dataset's `externalId` (packages/metadata-protocol/src/seed-loader.ts, `name` by default) or an import mapping's `upsertKey` (packages/rest/src/import-prepare.ts)", + "note": "RE-GRADED live -> dead 2026-10-27 (#25753, follow-up F23 of #25712). The `live` grade rested on objectui console `metadataConverters.ts` (:125, commit not pinned), which objectui dc14bcb deleted for having zero importers; the re-measure above found no other reader. A boolean `default(false)`, so the author lint warns only on an authored `true`. Owed enforce-or-remove (ADR-0049).", + "authorHint": "Remove it: no upsert reads a field's `externalId`. To match seed records on this field, name it in the seed dataset's `externalId`; for an import, in the mapping's `upsertKey`." }, "currencyConfig": { "status": "live", diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 9862b95bb4a..28100a33e38 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -1756,10 +1756,10 @@ export const RecordRelatedListProps = strictObject({ * binding: the ruling's own exclusion (a new gate defaults to no). * * RENDERED by the console. The block hands this key to its parent read - * (objectui: packages/plugin-detail/src/renderers/record-related-list.tsx, + * (ui/packages/plugin-detail/src/renderers/record-related-list.tsx, * whose `RecordRelatedListRenderer` passes `schema.parentDataSource` to * `ParentRecordBinding`; the read itself is - * objectui: packages/plugin-detail/src/renderers/recordBinding.tsx). Its + * ui/packages/plugin-detail/src/renderers/recordBinding.tsx). Its * `BoundRecordProvider` asks the page's own adapter, so under the caller's * identity, for one row (`$top: 1`) by the binding's `sort`, and provides * that row as the record context the list body reads. No row, or a read the diff --git a/packages/spec/src/ui/expression-scope-data-root.pin.test.ts b/packages/spec/src/ui/expression-scope-data-root.pin.test.ts index c1f453612c7..7a0c69bae50 100644 --- a/packages/spec/src/ui/expression-scope-data-root.pin.test.ts +++ b/packages/spec/src/ui/expression-scope-data-root.pin.test.ts @@ -191,7 +191,7 @@ describe('a page component `visibleWhen` names no `data` root but the `api` data const s = sentenceAt(doc, 'The one `data` this surface has'); expect(s).toContain("provider: 'api'"); expect(s).toContain('`useElementApiDataSource`'); - expect(s).toContain('objectui: packages/react/src/SchemaRenderer.tsx'); + expect(s).toContain('ui/packages/react/src/SchemaRenderer.tsx'); }); }); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 496d3d07db1..8bca7c43073 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -483,9 +483,9 @@ const ELEMENT_API_NO_CREDENTIALS = * ## Rendered by the console, on a page element * * The console reads this binding on every page element, whatever its type - * (objectui: packages/react/src/SchemaRenderer.tsx, which calls + * (ui/packages/react/src/SchemaRenderer.tsx, which calls * `useElementApiDataSource` for each node it renders; the read itself is - * objectui: packages/react/src/element-data-source/useElementApiDataSource.ts). + * ui/packages/react/src/element-data-source/useElementApiDataSource.ts). * It parses the binding with this schema before anything else, so a binding * refused here is refused there, with this schema's own message, and nothing * is read. @@ -498,7 +498,7 @@ const ELEMENT_API_NO_CREDENTIALS = * answer, a body that is not JSON, a `path` that selects nothing) draws an * error panel naming which. The element's `visibleWhen` is decided only once * the value is in. The console's generic `stat`, `usage-bar` and `checklist` - * blocks (objectui: packages/app-shell/src/console/blocks/productBlocks.tsx) + * blocks (ui/packages/app-shell/src/console/blocks/productBlocks.tsx) * are built to take it as their `dataSource`. Measured at the console pin * (`.objectui-sha` `0707b4ae37`), which carries the renderer * (objectui#12197). The liveness record is the `liveness/page.json` `_note`: @@ -751,7 +751,7 @@ export const PageComponentSchema = lazySchema(() => strictObject({ * {@link ElementApiDataSourceSchema}): on an element whose `dataSource` is * `{ provider: 'api', … }`, `data` is the value the binding selects, in * that element's own `visibleWhen` and in every element nested inside it - * (objectui: packages/react/src/SchemaRenderer.tsx, which calls + * (ui/packages/react/src/SchemaRenderer.tsx, which calls * `useElementApiDataSource` for the node and publishes the value to its * subtree through `PredicateScopeProvider`). So `data.used < data.limit` * gates there. That `visibleWhen` is decided once the read has answered: diff --git a/scripts/check-closing-keyword-parity.mjs b/scripts/check-closing-keyword-parity.mjs index 9540403cdbd..60432cb8559 100644 --- a/scripts/check-closing-keyword-parity.mjs +++ b/scripts/check-closing-keyword-parity.mjs @@ -210,15 +210,11 @@ const NON_PARSERS = [ file: SELF, why: 'this gate -- it carries the keyword set as its own MUTATION ANCHORS (X4) and as the specimen the sweep self-test plants', }, - // The one below arrived with the objectui import under ui/. Two others left: - // objectui's copy of the closer workflow with ui/.github/workflows, and - // ui/AGENTS.md's quotation of the alternation when that file stopped restating - // the root's body-bytes rule (objectstack#25712); the harness row goes with the - // closer's roster row in sub-issue 4. - { - file: 'ui/scripts/check-cross-repo-closer-outcome.mjs', - why: 'the imported objectui closer\'s outcome harness -- it carries the keyword set as MUTATION ANCHORS (M5), the same role as the root harness above', - }, + // Three rows arrived with the objectui import under ui/ and all have left: + // objectui's copy of the closer workflow with ui/.github/workflows, ui/AGENTS.md's + // quotation of the alternation when that file stopped restating the root's + // body-bytes rule, and that closer's outcome harness once nothing ran it + // (objectstack#25712, #25753). ]; // ── Extraction ─────────────────────────────────────────────────────────────── diff --git a/ui/scripts/__tests__/check-lockfile-dedupe.test.ts b/ui/scripts/__tests__/check-lockfile-dedupe.test.ts index 76149895df4..f46988fe977 100644 --- a/ui/scripts/__tests__/check-lockfile-dedupe.test.ts +++ b/ui/scripts/__tests__/check-lockfile-dedupe.test.ts @@ -119,7 +119,8 @@ import { FLOORS, TEST_FILE } from '../check-test-path-roots.mjs'; import { NOT_A_GATE, OPTIONAL_CONTEXTS, REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs'; import { maskComments } from '../js-comment-mask.mjs'; import { rootWorkflow, rootWorkflowFiles, rootWorkflowsDir, withoutComments } from './helpers/root-ci'; -import { repoRoot } from './workflow-checks.js'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const SCRIPT = 'scripts/check-lockfile-dedupe.mjs'; const CONTEXT = 'Lockfile Dedupe Check'; diff --git a/ui/scripts/__tests__/check-lockfile-integrity.test.ts b/ui/scripts/__tests__/check-lockfile-integrity.test.ts index fc111c05f43..109a7ebcc76 100644 --- a/ui/scripts/__tests__/check-lockfile-integrity.test.ts +++ b/ui/scripts/__tests__/check-lockfile-integrity.test.ts @@ -21,10 +21,10 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { lockfilePath } from '../workspace-root.mjs'; -import { repoRoot } from './workflow-checks'; import { NOT_A_GATE, OPTIONAL_CONTEXTS, REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs'; import { compareLockfiles, @@ -33,6 +33,7 @@ import { readSnapshotKeys, } from '../check-lockfile-integrity.mjs'; +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const GATE = path.join(repoRoot, 'scripts/check-lockfile-integrity.mjs'); const LOCKFILE = lockfilePath(); const realLock = fs.readFileSync(LOCKFILE, 'utf8'); diff --git a/ui/scripts/__tests__/dependabot-merge-gate.test.ts b/ui/scripts/__tests__/dependabot-merge-gate.test.ts index 378f8f46819..1baafccbc3d 100644 --- a/ui/scripts/__tests__/dependabot-merge-gate.test.ts +++ b/ui/scripts/__tests__/dependabot-merge-gate.test.ts @@ -520,7 +520,7 @@ describe('the refusal is legible', () => { // contexts whose workflow IS path-filtered` retired with objectstack#25712, // together with the exemption control in the counterfactual describe. They // partitioned the three buckets against the check names objectui's -// `pull_request` workflows produce, read through `./workflow-checks.ts` from +// `pull_request` workflows produce, read through the since-deleted `workflow-checks.ts` from // `ui/.github/workflows/`. That directory runs nowhere under the monorepo: // GitHub runs the root `.github/workflows/` alone, and those produce other // names (`Lint UI`, `Test UI (n/16)`). The buckets still hold objectui's names diff --git a/ui/scripts/__tests__/merge-queue-reporting.test.ts b/ui/scripts/__tests__/merge-queue-reporting.test.ts index b544e53beff..2a38ab3b27c 100644 --- a/ui/scripts/__tests__/merge-queue-reporting.test.ts +++ b/ui/scripts/__tests__/merge-queue-reporting.test.ts @@ -112,7 +112,7 @@ const MUST_SUBSCRIBE_MERGE_GROUP = new Map([ // workflow that exists` and `runs the test suite on a queue build, not only on // pull requests`); the floor derived from `REQUIRED_CONTEXTS` (objectui#6160: // `deriveMergeGroupFloor()` and its three cases, which resolved each context -// through `./workflow-checks.ts`); and step 2 (`filters no pull request at the +// through the since-deleted `workflow-checks.ts`); and step 2 (`filters no pull request at the // trigger`, `keeps ONE ignore list, on the push trigger`, `gates every step // after the gate, in every gated job` and both fail-OPEN cases, on objectui's // `ci.yml` and `lint.yml`). Each read objectui's workflows from diff --git a/ui/scripts/__tests__/workflow-checks.ts b/ui/scripts/__tests__/workflow-checks.ts deleted file mode 100644 index 900856202d2..00000000000 --- a/ui/scripts/__tests__/workflow-checks.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * The one parser that answers "which workflow produces which check run?". - * - * It lived inside `dependabot-merge-gate.test.ts`, which is the only place that - * needed it while only one test asked the question. `merge-queue-reporting.test.ts` - * now asks it too — it derives the `merge_group` subscription floor from - * `REQUIRED_CONTEXTS` (objectui#6160) — so the parser moved here rather than - * being written a second time. A second copy of a YAML parser is a second - * answer to the same question, and the whole point of the assertion that needed - * it is that two declarations of one fact drift. - * - * It is a module and not an export of the test file on purpose: importing a - * `*.test.ts` from another `*.test.ts` re-registers its `describe` blocks inside - * the importing file, so the same suite would run twice and report failures - * under the wrong file. - * - * Deliberately not a YAML library: these functions read the workflows as the - * repository actually authors them (two-space job keys, `name:` at four spaces, - * a single `shard:` matrix), and every one of them strips comment lines first — - * every workflow in this repo discusses `pull_request:`, `merge_group:` and - * `paths-ignore:` in prose, and a scan that counted the prose would report - * triggers no file has. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -export const workflowDir = path.join(repoRoot, '.github', 'workflows'); - -export type Workflow = { - file: string; - text: string; - /** Lines with comments stripped, so prose mentioning `pull_request:` cannot count. */ - lines: string[]; -}; - -export function readWorkflows(): Workflow[] { - return fs - .readdirSync(workflowDir) - .filter((file) => file.endsWith('.yml') || file.endsWith('.yaml')) - .map((file) => { - const text = fs.readFileSync(path.join(workflowDir, file), 'utf8'); - return { file, text, lines: text.split('\n').filter((line) => !/^\s*#/.test(line)) }; - }); -} - -/** The `on:` block of a workflow, comment lines already stripped. */ -export function triggerBlock(workflow: Workflow): string[] { - const start = workflow.lines.findIndex((line) => /^on:/.test(line)); - if (start === -1) return []; - const rest = workflow.lines.slice(start + 1); - const end = rest.findIndex((line) => /^[A-Za-z]/.test(line)); - return end === -1 ? rest : rest.slice(0, end); -} - -/** - * Does this workflow subscribe `pull_request`, and does that subscription carry - * a path filter? `pull_request_target` deliberately does not count: - * `cross-repo-issue-closer.yml` uses it with `types: [closed]`, so it acts after - * a merge and has no verdict to contribute to one. - */ -export function pullRequestTrigger(workflow: Workflow): { subscribes: boolean; filtered: boolean } { - const block = triggerBlock(workflow); - const start = block.findIndex((line) => /^ {2}pull_request:\s*$/.test(line)); - if (start === -1) return { subscribes: false, filtered: false }; - - const rest = block.slice(start + 1); - const end = rest.findIndex((line) => /^ {2}\S/.test(line)); - const sub = end === -1 ? rest : rest.slice(0, end); - return { subscribes: true, filtered: sub.some((line) => /^ {4}paths(-ignore)?:/.test(line)) }; -} - -/** Does this workflow subscribe the `merge_group` event? */ -export function subscribesMergeGroup(workflow: Workflow): boolean { - return triggerBlock(workflow).some((line) => /^ {2}merge_group:/.test(line)); -} - -/** - * The check names a workflow's jobs appear under. A job's `name:` if it has one, - * else its id (`labeler.yml`'s job is simply `label`), with `matrix.shard` - * expanded. Every job of a subscribing workflow produces a check run on a pull - * request — including one skipped by a job-level `if:`, which reports - * `conclusion=skipped` rather than not existing. - */ -export function checkNames(workflow: Workflow): string[] { - const jobsAt = workflow.lines.findIndex((line) => /^jobs:\s*$/.test(line)); - if (jobsAt === -1) return []; - const body = workflow.lines.slice(jobsAt + 1); - - const starts: number[] = []; - body.forEach((line, index) => { - if (/^ {2}[A-Za-z0-9_-]+:\s*$/.test(line)) starts.push(index); - }); - - return starts.flatMap((start, i) => { - const block = body.slice(start, starts[i + 1] ?? body.length); - const id = block[0].trim().replace(/:$/, ''); - const named = block.find((line) => /^ {4}name:/.test(line)); - const name = named ? named.replace(/^ {4}name:\s*/, '').trim() : id; - - const shards = block.find((line) => /^ {8}shard: \[/.test(line)); - if (!shards || !name.includes('matrix.shard')) return [name]; - - return (shards.match(/\[(.*)\]/)?.[1] ?? '') - .split(',') - .map((shard) => shard.trim()) - .filter(Boolean) - .map((shard) => name.replace(/\$\{\{\s*matrix\.shard\s*\}\}/g, shard)); - }); -} - -/** - * `check name -> the workflow file that produces it`, over every workflow that - * subscribes `pull_request`. - * - * This is the mapping both callers mean by "which workflow produces this - * check": `dependabot-merge-gate.test.ts` uses it to assert the gate's three - * buckets partition the produced set exactly, and `merge-queue-reporting.test.ts` - * uses it to turn `REQUIRED_CONTEXTS` — a list of check NAMES — into the set of - * workflow FILES that must subscribe `merge_group`. - */ -export function producedCheckNames(): Map { - const produced = new Map(); - for (const workflow of readWorkflows()) { - if (!pullRequestTrigger(workflow).subscribes) continue; - for (const name of checkNames(workflow)) produced.set(name, workflow.file); - } - return produced; -} diff --git a/ui/scripts/check-cross-repo-closer-outcome.mjs b/ui/scripts/check-cross-repo-closer-outcome.mjs deleted file mode 100644 index b2f52f3940c..00000000000 --- a/ui/scripts/check-cross-repo-closer-outcome.mjs +++ /dev/null @@ -1,1178 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// check-cross-repo-closer-outcome -- run the SHIPPED cross-repo-issue-closer -// script under doubles and hold its OUTCOMES to contract. -// -// node scripts/check-cross-repo-closer-outcome.mjs # judge the shipped script -// node scripts/check-cross-repo-closer-outcome.mjs --self-test # prove the battery can go red -// node scripts/check-cross-repo-closer-outcome.mjs --list # the scenario table -// -// ## Why this file exists (#5261) -// -// `.github/workflows/cross-repo-issue-closer.yml` carries ~250 lines of inline -// `actions/github-script`, and until this file landed that script was CODE -// NOBODY HAD EVER SEEN RUN. It is a port of the framework's harness, brought -// across with the four hardening rounds it pins; the scenarios and mutations -// below are that file's, adapted to this repository's side of the pair. -// -// The framework's own reason for writing it was that its branch had never had -// a target: zero qualified cross-repo closing keywords over its 1176 most -// recently merged pull requests, measured twice over two independent windows. -// THIS REPOSITORY IS THE OPPOSITE CASE, and the difference is the reason the -// port matters more here than the original did there. Measured 2026-08-19 over -// ALL 3425 merged pull requests in this repository (#1..#5288, the closed-PR -// listing exhausted at 37 pages, the workflow's own regex applied to each -// body): -// -// - 60 merged pull requests carry a qualified FOREIGN closing keyword -- 61 -// occurrences over 59 distinct targets. 246 more carry the qualified -// SAME-repo spelling, which the loop skips, and 972 carry the bare `#N` -// form GitHub handles natively. (Those last two are the counter-probe: -// they are what makes the 60 a reading rather than a broken regex.) -// - 15 of the 60 merged AFTER this workflow landed here on 2026-08-09 -// (#3969). In FOURTEEN of those fifteen the framework issue was closed by a -// seat one to two seconds after the merge -- before the workflow's run had -// even been created -- and in the fifteenth the close lands in the same -// second the run is created, with the runner starting later still. So the -// loop hit `state === 'closed'` fifteen times out of fifteen, skipped each -// target whole, and left no backlink. None of those fifteen framework -// issues carries this workflow's backlink comment today, and none of the -// fifteen pull requests carries its token-absent notice. -// - all 941 runs of this workflow have reported success. -// -// So the branch this repository maintains for the occasion it exists for has -// had a target roughly 1.5 times a day since it landed, has taken exactly one -// exit every time, and has delivered nothing on every one of them -- greenly. -// Reading is still the only thing that has ever found a defect here; this file -// is what makes reading repeatable. -// -// Two consequences, and this file answers both: -// -// 1. The defects are found by reading, one card at a time -- objectstack#9575 -// for the notice, objectstack#9595 for the loop's verdict, -// objectstack#9643 for the already-closed target, objectstack#9711 for the -// pull-request guard -- and each fix lands as more unexercised code. -// 2. Nothing catches a script that does not even COMPILE. github-script -// compiles the whole block as one `AsyncFunction` body, and the framework -// copy of this workflow was taken down twice in one day by a `SyntaxError` -// that no outcome assertion could ever have seen. Assertion 0 below is -// that compile, on the real text. -// -// ## The method: the shipped bytes, never a copy -// -// The script is read out of the YAML with a real parser (`jobs -> -// close-foreign-issues -> steps[github-script] -> with.script`) and executed -// the way the action executes it: -// -// new AsyncFunction('github', 'context', 'core', ..., source) -// -// A copy of the script pasted into this file would be a test of the copy. The -// extraction is therefore load-bearing, and every failure to extract is a -// FAILURE rather than a skip: a workflow that has been renamed, a step that no -// longer uses github-script, an empty `script:` -- all of them exit non-zero -// here, because a harness that could not find its subject has verified nothing -// at all. -// -// ## What is asserted, and what is deliberately not -// -// Asserted: the target parse (which keyword spellings qualify, which forms do -// not, that a same-repo reference is skipped), the target KIND (a pull request -// is also an issue to `issues.get`, so `owner/repo#N` naming a PR reaches the -// loop like anything else -- the scenarios pin that it is REFUSED rather than -// closed, and that the refusal happens before the already-closed branch can -// leave a comment on somebody else's pull request), and the OUTCOME of every -// exit -- which of `core.setFailed` / `core.warning` / a job summary fires, and -// which API calls were made. -// -// Also asserted: that every API the script calls is one this harness MODELS. -// That is not pedantry -- it is a failure the framework copy walked into. -// Adding `listComments` to the shipped script moved real behaviour and all 52 -// assertions stayed green, because the unstubbed method threw a `TypeError` -// INSIDE the script's own `try` and was absorbed by the very degradation branch -// the new code had just added. A harness whose stubs lag the script does not -// under-report; it reports a pass about a path the script no longer takes. So -// the guard records the access out-of-band and `judge` fails the scenario on -// the record, whatever that scenario's own assertions say. -// -// NOT asserted: the step's `retries:` / `retry-exempt-status-codes:` inputs. -// They are consumed by the ACTION, not by the script, so no stub of `github` -// can exercise them; their acceptance on the pinned action version is evidenced -// by the action echoing its own defaults into every run of this job. -// -// NOT asserted either: that GitHub's own keyword parser agrees with the regex. -// That is a property of GitHub, and the only honest evidence for it is the 246 -// same-repo qualified references measured above, which GitHub did close. -// -// ## The self-test, and why it is not optional -// -// A battery of assertions over a script that is already correct is green on day -// one and green forever, including the day someone deletes the thing it -// guards. `--self-test` mutates the extracted source -- drop the `setFailed`, -// stop collecting failed keys, `break` out of the loop instead of isolating, -// disable the same-repo skip, narrow the keyword set, collapse the -// already-closed branch, strip the backlink marker, drop the triage guard, -// post blind when the comment listing is refused, stop recording which half -// was lost, drop the pull-request guard -- and requires the battery to go RED -// for each, naming the scenario it expects. Each mutation also asserts its own -// anchor was PRESENT before substituting: a mutation that silently matched -// nothing would leave the battery green and read exactly like a passing -// self-test. -// -// One scenario (L11) is driven TWICE, the second run's world built out of the -// first run's calls. Re-run idempotency is a property of the PAIR, and a -// fixture that hand-writes the marker proves each half while leaving the two -// runs free to disagree about its spelling -- which is the only way the -// property can actually break. - -import { execFileSync } from 'node:child_process'; -import { createRequire } from 'node:module'; -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { isMap, isSeq, parseDocument } from 'yaml'; -import { isEntrypoint } from './invoked-as.mjs'; -import { uiRoot } from './ui-root.mjs'; - -const WORKFLOW = '.github/workflows/cross-repo-issue-closer.yml'; -const JOB = 'close-foreign-issues'; -const SELF = 'scripts/check-cross-repo-closer-outcome.mjs'; -const THIS_REPO = { owner: 'objectstack-ai', repo: 'objectui' }; - -const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; - -function repoRoot() { - return uiRoot(); -} - -// ── Extraction ────────────────────────────────────────────────────────────── - -/** - * The inline github-script source, read out of the shipped workflow. - * - * @param {string} root repository root - * @returns {{ source: string|null, problems: string[] }} - */ -export function extractScript(root) { - const problems = []; - const path = join(root, WORKFLOW); - if (!existsSync(path)) { - problems.push(`${WORKFLOW} does not exist -- nothing was extracted, so nothing was verified.`); - return { source: null, problems }; - } - const doc = parseDocument(readFileSync(path, 'utf8')); - if (doc.errors.length > 0) { - problems.push(`${WORKFLOW}: YAML parse error -- ${doc.errors[0].message}`); - return { source: null, problems }; - } - const steps = doc.getIn(['jobs', JOB, 'steps']); - if (!isSeq(steps)) { - problems.push(`${WORKFLOW}: job \`${JOB}\` has no \`steps:\` sequence -- the subject of this check is gone.`); - return { source: null, problems }; - } - const scripted = steps.items.filter((step) => { - if (!isMap(step)) return false; - const uses = String(step.get('uses') ?? ''); - return uses.startsWith('actions/github-script@'); - }); - if (scripted.length !== 1) { - problems.push( - `${WORKFLOW}: expected exactly one \`actions/github-script@*\` step in \`${JOB}\`, found ${scripted.length}. ` + - 'This harness drives one script; a second one would be unverified while this still reported OK.', - ); - return { source: null, problems }; - } - const source = scripted[0].getIn(['with', 'script']); - if (typeof source !== 'string' || source.trim() === '') { - problems.push(`${WORKFLOW}: the github-script step has no non-empty \`with.script\` -- nothing to run.`); - return { source: null, problems }; - } - return { source, problems }; -} - -// ── Doubles ───────────────────────────────────────────────────────────────── - -/** An octokit-shaped rejection: `status` is what the script's `describe` reads. */ -function httpError(status, message) { - return Object.assign(new Error(message), { status }); -} - -/** The `github.rest.issues.*` methods this harness models. See the `guard` in `makeDoubles`. */ -const MODELLED_ISSUE_METHODS = new Set(['get', 'createComment', 'update', 'listComments']); - -/** - * `github` / `context` / `core` doubles plus a call log. - * - * `issues` maps `owner/repo#number` to a fixture: `{ state, stateReason, - * comments }` describes the issue, and `{ getError, listError, commentError, - * updateError }` refuses one specific call on it -- so a scenario can break one - * target and leave the rest reachable, which is the whole point of the - * isolation the loop must keep. - */ -function makeDoubles({ body, token, issues = {}, prCommentError = null, summaryError = null }) { - const calls = { get: [], list: [], comment: [], update: [], prComment: [] }; - const log = { info: [], warning: [], failed: [], summary: [] }; - const unstubbedCalls = []; - let summaryBuffer = []; - - const target = (o, r, n) => issues[`${o}/${r}#${n}`] ?? {}; - - const issuesApi = { - async get({ owner, repo, issue_number: n }) { - calls.get.push(`${owner}/${repo}#${n}`); - const t = target(owner, repo, n); - if (t.getError) throw t.getError; - // `state_reason` is nullable on this endpoint, so the default models - // that rather than inventing a value the API does not promise. - // - // `pull_request` is how the same endpoint says the number is a PULL - // REQUEST, and it is modelled as ABSENCE rather than as `undefined` - // because that is what was measured: objectstack-ai/objectstack#9716 (a - // PR) answers `{ url, html_url, diff_url, patch_url, merged_at }`, and - // objectstack-ai/objectstack#9711 (an issue) carries no such key at all. - // A fixture that always spelled the key would let a truthiness test pass - // here that the real API would fail. - return { - data: { - state: t.state ?? 'open', - state_reason: t.stateReason ?? null, - ...(t.pullRequest ? { pull_request: t.pullRequest } : {}), - }, - }; - }, - async listComments({ owner, repo, issue_number: n }) { - const key = `${owner}/${repo}#${n}`; - calls.list.push(key); - const t = target(owner, repo, n); - if (t.listError) throw t.listError; - return { data: (t.comments ?? []).map((text) => ({ body: text })) }; - }, - async createComment({ owner, repo, issue_number: n, body: text }) { - const key = `${owner}/${repo}#${n}`; - const isThisPr = owner === THIS_REPO.owner && repo === THIS_REPO.repo; - if (isThisPr && prCommentError) { - calls.prComment.push({ key, attempted: true, delivered: false }); - throw prCommentError; - } - if (isThisPr) { - calls.prComment.push({ key, attempted: true, delivered: true, body: text }); - return { data: {} }; - } - const t = target(owner, repo, n); - if (t.commentError) throw t.commentError; - calls.comment.push({ key, body: text }); - return { data: {} }; - }, - async update({ owner, repo, issue_number: n, state, state_reason: reason }) { - const key = `${owner}/${repo}#${n}`; - const t = target(owner, repo, n); - if (t.updateError) throw t.updateError; - calls.update.push({ key, state, reason }); - return { data: {} }; - }, - }; - - // An unmodelled API call must be LOUD, and a throw alone is not loud enough - // here: every call in the loop sits inside the script's own `try`, so a - // `TypeError` from an unstubbed method is caught by the script and absorbed - // into whichever degradation that `catch` implements -- leaving the battery - // green over a behaviour it never exercised. Measured, on the objectstack#9643 change - // itself: adding `listComments` to the shipped script moved real behaviour - // and all 52 assertions stayed green, because the missing stub landed in the - // script's own "could not read the comments" branch. So the access is - // RECORDED as well as thrown, and `judge` fails the scenario on the record. - // - // The guard is applied at every level of `github`, not just to the issues - // methods: `github.paginate`, `github.request` and `github.graphql` are all - // reachable from a github-script body and all three would otherwise be - // `undefined`, i.e. the identical silent-absorption bug one level up. - const guard = (impl, path, allowed) => - new Proxy(impl, { - get(t, prop, receiver) { - if (typeof prop === 'string' && !allowed.has(prop)) { - unstubbedCalls.push(`${path}.${prop}`); - throw new Error( - `${SELF}: the shipped script now uses \`${path}.${prop}\`, which this harness does not ` + - 'stub. Model it here rather than letting a scenario absorb it.', - ); - } - return Reflect.get(t, prop, receiver); - }, - }); - - const github = guard( - { rest: guard({ issues: guard(issuesApi, 'github.rest.issues', MODELLED_ISSUE_METHODS) }, 'github.rest', new Set(['issues'])) }, - 'github', - new Set(['rest']), - ); - - const summary = { - addRaw(text) { - summaryBuffer.push(text); - return summary; - }, - async write() { - if (summaryError) throw summaryError; - log.summary.push(summaryBuffer.join('')); - summaryBuffer = []; - return summary; - }, - }; - - const core = { - info: (m) => log.info.push(String(m)), - warning: (m, props) => log.warning.push({ message: String(m), props: props ?? null }), - setFailed: (m) => log.failed.push(String(m)), - summary, - }; - - const context = { - repo: { ...THIS_REPO }, - payload: { - pull_request: { - body, - html_url: 'https://github.com/objectstack-ai/objectui/pull/1234', - number: 1234, - }, - }, - }; - - return { github, context, core, calls, log, token, unstubbedCalls }; -} - -/** - * Anything github-script hands the script that this harness does not model. - * - * Records into the same sink as the `github` guard before throwing, for the - * same reason: a throw raised inside the script's own `try` is caught by the - * script, and a scenario then reports a pass about a degradation path. - */ -function unstubbed(name, sink) { - return new Proxy( - {}, - { - get(_t, prop) { - sink.push(typeof prop === 'string' ? `${name}.${prop}` : name); - throw new Error( - `${SELF}: the shipped script now uses \`${name}\`, which this harness does not stub. ` + - 'Model it here rather than deleting the assertion that found it.', - ); - }, - }, - ); -} - -/** - * Run the script exactly as `actions/github-script` does: as the body of an - * AsyncFunction whose scope carries the action's argument set. - */ -async function runScript(source, scenario) { - const doubles = makeDoubles(scenario); - const args = { - github: doubles.github, - context: doubles.context, - core: doubles.core, - exec: unstubbed('exec', doubles.unstubbedCalls), - glob: unstubbed('glob', doubles.unstubbedCalls), - io: unstubbed('io', doubles.unstubbedCalls), - fetch: unstubbed('fetch', doubles.unstubbedCalls), - require: createRequire(import.meta.url), - __original_require__: createRequire(import.meta.url), - }; - const fn = new AsyncFunction(...Object.keys(args), source); - - const before = Object.hasOwn(process.env, 'CROSS_REPO_TOKEN') - ? { present: true, value: process.env.CROSS_REPO_TOKEN } - : { present: false }; - if (scenario.token === undefined) delete process.env.CROSS_REPO_TOKEN; - else process.env.CROSS_REPO_TOKEN = scenario.token; - - let threw = null; - try { - await fn(...Object.values(args)); - } catch (err) { - // github-script routes a throw to `main().catch(handleError)` -> setFailed, - // so an escape is not automatically wrong -- but it IS a different outcome - // from a deliberate verdict, and scenarios say which one they expect. - threw = err; - } finally { - if (before.present) process.env.CROSS_REPO_TOKEN = before.value; - else delete process.env.CROSS_REPO_TOKEN; - } - - return { ...doubles, threw }; -} - -// ── Scenarios ─────────────────────────────────────────────────────────────── - -const FIXED = httpError(503, 'No server is currently available to service your request.'); -const DENIED = httpError(404, 'Not Found'); -const UNAUTHORIZED = httpError(401, 'Bad credentials'); - -/** A body carrying the three keyword shapes plus three that must NOT qualify. */ -const MIXED_BODY = [ - 'Fixes objectstack-ai/objectstack#456', - 'CLOSES my-org/some.repo#22', - 'resolved third/party#7', - '', - 'Part of objectstack-ai/objectstack#999 -- a reference, not a close.', - 'Fixes #321 -- bare form, GitHub already handles it.', - 'Fixes objectstack-ai/objectui#5 -- same repo, GitHub already closed it.', -].join('\n'); - -const FOREIGN = ['objectstack-ai/objectstack#456', 'my-org/some.repo#22', 'third/party#7']; - -/** The target the already-closed scenarios (L2, L6-L9) work on. */ -const CLOSED_TARGET = 'objectstack-ai/objectstack#456'; - -/** The target the PULL REQUEST scenarios (L12, L13) work on. */ -const PR_TARGET = 'my-org/some.repo#22'; - -/** - * The `pull_request` key GitHub returns when an issue number is a pull request. - * - * Copied from a real response rather than invented, and re-measured for this - * copy on 2026-08-19: `GET /repos/objectstack-ai/objectstack/issues/9716` -- - * a merged pull request -- answers exactly these five fields, while - * `.../issues/9711` -- an issue -- answers with the key ABSENT. The loop - * reads only its truthiness, but the shape is pinned so that a change in what - * the script reads out of it (`html_url` reaches the job summary) fails here - * instead of printing `undefined` into somebody's run. - */ -const PR_KEY = { - url: 'https://api.github.com/repos/my-org/some.repo/pulls/22', - html_url: 'https://github.com/my-org/some.repo/pull/22', - diff_url: 'https://github.com/my-org/some.repo/pull/22.diff', - patch_url: 'https://github.com/my-org/some.repo/pull/22.patch', - merged_at: null, -}; -const PR_URL = 'https://github.com/objectstack-ai/objectui/pull/1234'; - -/** - * The backlink marker the shipped script is expected to write, spelled out. - * - * It is pinned as a LITERAL rather than recomputed from the script, because - * that is what makes the pair of assertions mean something: L1 asserts the - * script writes this exact string on the close path, and L6 seeds this exact - * string as an existing comment to prove the re-run path recognises it. Change - * the marker's shape in the workflow and L1 goes red -- which is the honest - * failure, because the two runs of a re-run must agree on one spelling and a - * harness that derived it from the script could never catch them disagreeing. - */ -const MARKER = ''; - -/** - * Each scenario names the exit path it walks and asserts the OUTCOME of it. - * `check` returns an array of failure strings. - */ -export const SCENARIOS = [ - { - id: 'P0', - name: 'no cross-repo keywords -- nothing to do, and nothing said about it', - scenario: () => ({ body: 'Fixes #12\n\nRefs objectstack-ai/objectstack#9 (not a keyword).', token: 'pat' }), - check: (r, t) => [ - t(r.log.failed.length === 0, `P0 stays green, got setFailed: ${r.log.failed[0]}`), - t(r.calls.get.length === 0, `P0 calls no API, got ${r.calls.get.length} get(s)`), - t(r.log.info.some((m) => /No cross-repository closing keywords/.test(m)), 'P0 says why it did nothing'), - ], - }, - { - id: 'P1', - name: 'the target parse: which spellings qualify, and which must not', - scenario: () => ({ body: MIXED_BODY, token: 'pat' }), - check: (r, t) => { - const line = r.log.info.find((m) => m.startsWith('Cross-repo targets:')) ?? ''; - const listed = line.replace('Cross-repo targets: ', '').split(', ').filter(Boolean); - return [ - t(listed.length === 3, `P1 finds exactly the three qualified foreign targets, got ${listed.length}: ${line}`), - ...FOREIGN.map((k) => t(listed.includes(k), `P1 recognises ${k}`)), - t(!line.includes('#999'), 'P1 does not treat `Part of` as a closing keyword'), - t(!line.includes('#321'), 'P1 does not act on the bare `#N` form GitHub already handles'), - t(!line.includes('objectstack-ai/objectui#5'), 'P1 skips the qualified SAME-repo reference'), - t(!r.calls.get.includes('objectstack-ai/objectui#5'), 'P1 never calls the API for the same-repo reference'), - ]; - }, - }, - { - id: 'N1', - name: 'token absent, notice delivered -- green, and the PR carries the work order', - scenario: () => ({ body: MIXED_BODY, token: undefined }), - check: (r, t) => [ - t(r.log.failed.length === 0, `N1 stays green, got setFailed: ${r.log.failed[0]}`), - t(r.calls.prComment.length === 1 && r.calls.prComment[0].delivered, 'N1 posts the notice on this PR'), - t(FOREIGN.every((k) => (r.calls.prComment[0]?.body ?? '').includes(k)), 'N1 notice lists every target'), - t( - r.log.warning.some((w) => FOREIGN.every((k) => w.message.includes(k))), - 'N1 announces the work order as an annotation too (objectstack#9575)', - ), - t(r.calls.update.length === 0, 'N1 closes nothing -- it has no credential to close with'), - ], - }, - { - id: 'N2', - name: 'token absent, notice refused -- summary keeps it, job goes red (objectstack#9594)', - scenario: () => ({ body: MIXED_BODY, token: undefined, prCommentError: FIXED }), - check: (r, t) => [ - t(r.log.failed.length === 1, `N2 fails the job exactly once, got ${r.log.failed.length}`), - t(FOREIGN.every((k) => (r.log.failed[0] ?? '').includes(k)), 'N2 setFailed names every unclosed target'), - t(/HTTP 503/.test(r.log.failed[0] ?? ''), 'N2 setFailed names the refusal'), - t(r.log.summary.length === 1, 'N2 reproduces the whole notice in the job summary'), - t(r.threw === null, 'N2 does not let the throw escape the script'), - ], - }, - { - id: 'N3', - name: 'token absent, notice refused AND the summary unwritable -- still red', - scenario: () => ({ body: MIXED_BODY, token: undefined, prCommentError: FIXED, summaryError: new Error('EACCES') }), - check: (r, t) => [ - t(r.log.failed.length === 1, 'N3 still fails the job when the richer channel is gone'), - t(FOREIGN.every((k) => (r.log.failed[0] ?? '').includes(k)), 'N3 setFailed still names every target'), - t(r.threw === null, 'N3 does not let the summary failure escape'), - ], - }, - { - id: 'L1', - name: 'token present, every target closed -- comment then close, and green', - scenario: () => ({ body: MIXED_BODY, token: 'pat' }), - check: (r, t) => [ - t(r.log.failed.length === 0, `L1 stays green, got setFailed: ${r.log.failed[0]}`), - t(r.calls.get.length === 3, `L1 reads all three targets, got ${r.calls.get.length}`), - t(r.calls.comment.length === 3, `L1 leaves the PR link on all three, got ${r.calls.comment.length}`), - t(r.calls.update.length === 3, `L1 closes all three, got ${r.calls.update.length}`), - t( - r.calls.update.every((u) => u.state === 'closed' && u.reason === 'completed'), - 'L1 closes as `completed`, not as `not_planned`', - ), - t( - r.calls.comment.every((c) => c.body.includes(PR_URL)), - 'L1 comment carries the PR link -- the backlink is half the point of the workflow', - ), - t( - r.calls.comment.every((c) => c.body.includes(MARKER)), - 'L1 comment carries the per-PR marker, so a LATER run can tell this backlink is already there (objectstack#9643)', - ), - t(r.log.warning.length === 0, 'L1 warns about nothing'), - ], - }, - { - id: 'L2', - name: 'already closed by a human -- the backlink still lands, the close does not (objectstack#9643)', - scenario: () => ({ - body: MIXED_BODY, - token: 'pat', - // Closed, `completed`, and carrying no backlink from this PR: the - // ordinary sequence the file's header is about -- the fix shipped, - // somebody tidied the tracker, then the merge happened. - issues: { [CLOSED_TARGET]: { state: 'closed', stateReason: 'completed', comments: [] } }, - }), - check: (r, t) => { - const posted = r.calls.comment.find((c) => c.key === CLOSED_TARGET); - return [ - t(r.log.failed.length === 0, `L2 stays green, got setFailed: ${r.log.failed[0]}`), - t(r.calls.list.includes(CLOSED_TARGET), 'L2 reads the comments before posting -- the re-run guard'), - t(Boolean(posted), 'L2 LEAVES THE BACKLINK on the already-closed issue -- the defect this card is about'), - t((posted?.body ?? '').includes(PR_URL), 'L2 backlink names the PR that fixed it'), - t((posted?.body ?? '').includes(MARKER), 'L2 backlink carries the marker, so a re-run recognises it'), - t( - !r.calls.update.some((u) => u.key === CLOSED_TARGET), - 'L2 does NOT re-close it -- the close is the half that really was redundant', - ), - t(r.calls.update.length === 2, `L2 still closes the other two, got ${r.calls.update.length}`), - t(r.log.warning.length === 0, 'L2 warns about nothing -- leaving a backlink is the normal outcome, not a degradation'), - ]; - }, - }, - { - id: 'L6', - name: 'already closed AND already linked (a re-run) -- nothing is posted twice (objectstack#9643)', - scenario: () => ({ - body: MIXED_BODY, - token: 'pat', - issues: { - [CLOSED_TARGET]: { - state: 'closed', - stateReason: 'completed', - // What an earlier run of THIS job left behind. The marker is the only - // thing that separates this case from L2: `closed_by` is a login and - // every seat in this org shares one identity. - comments: ['已由 objectstack-ai/objectstack 的 ' + PR_URL + ' 修复并合并。\n\n' + MARKER], - }, - }, - }), - check: (r, t) => [ - t(r.log.failed.length === 0, 'L6 stays green'), - t(r.calls.list.includes(CLOSED_TARGET), 'L6 reads the comments'), - t( - !r.calls.comment.some((c) => c.key === CLOSED_TARGET), - 'L6 posts NO second backlink -- re-running this job must be idempotent (objectstack#9643 H2)', - ), - t(!r.calls.update.some((u) => u.key === CLOSED_TARGET), 'L6 does not re-close it either'), - t(r.calls.update.length === 2, `L6 still closes the other two, got ${r.calls.update.length}`), - ], - }, - { - id: 'L7', - name: 'already closed as not_planned -- no backlink, because it would contradict the triage (objectstack#9643)', - scenario: () => ({ - body: MIXED_BODY, - token: 'pat', - issues: { [CLOSED_TARGET]: { state: 'closed', stateReason: 'not_planned', comments: [] } }, - }), - check: (r, t) => [ - t(r.log.failed.length === 0, 'L7 stays green -- a disagreement between two humans is not a job failure'), - t( - !r.calls.comment.some((c) => c.key === CLOSED_TARGET), - 'L7 posts NO backlink -- a comment saying this PR fixed a not_planned issue would be actively wrong', - ), - t(!r.calls.update.some((u) => u.key === CLOSED_TARGET), 'L7 does not reopen or re-close it'), - t( - !r.calls.list.includes(CLOSED_TARGET), - 'L7 does not even list the comments -- the triage reason settles it before idempotency matters', - ), - t( - r.log.warning.some((w) => w.message.includes(CLOSED_TARGET) && /not_planned/.test(w.message)), - 'L7 ANNOUNCES the contradiction rather than skipping silently -- only a human can settle it', - ), - t(r.calls.update.length === 2, `L7 still closes the other two, got ${r.calls.update.length}`), - ], - }, - { - id: 'L8', - name: 'already closed but the comment listing is refused -- skip AT MOST ONCE, and say so (objectstack#9643)', - scenario: () => ({ - body: MIXED_BODY, - token: 'pat', - issues: { [CLOSED_TARGET]: { state: 'closed', stateReason: 'completed', listError: FIXED } }, - }), - check: (r, t) => [ - t(r.log.failed.length === 0, `L8 stays green, got setFailed: ${r.log.failed[0]}`), - t( - !r.calls.comment.some((c) => c.key === CLOSED_TARGET), - 'L8 does NOT post blind -- the marker is stable, so a blind post strands a permanent duplicate ' - + "on another repo's closed issue (merge-queue-triage.yml chooses the opposite, from a per-RUN marker)", - ), - t( - r.log.warning.some((w) => w.message.includes(CLOSED_TARGET) && /HTTP 503/.test(w.message)), - 'L8 names the target AND the refusal -- a skip with a stated reason, not a silent one', - ), - t(r.calls.update.length === 2, `L8 still closes the other two -- one bad listing isolates, got ${r.calls.update.length}`), - ], - }, - { - id: 'L9', - name: 'already closed with a NULL state_reason -- read as completed, so the backlink lands (objectstack#9643)', - scenario: () => ({ - body: MIXED_BODY, - token: 'pat', - // `state_reason` is nullable, so a fix that keys on the reason has to - // say what null MEANS, and this pins the answer. Modelled rather than - // sampled on purpose: measured 2026-08-19, every closed ISSUE in this - // repository's first three pages answers `completed`, and the things - // that answer `null` are merged PULL REQUESTS - // (objectstack-ai/objectstack#9716 and objectstack-ai/objectstack#9143). - // That asymmetry is exactly why the kind guard has to run first -- L13. - issues: { [CLOSED_TARGET]: { state: 'closed', stateReason: null, comments: [] } }, - }), - check: (r, t) => [ - t(r.log.failed.length === 0, 'L9 stays green'), - t( - r.calls.comment.some((c) => c.key === CLOSED_TARGET), - 'L9 treats a null reason as "no objection recorded" and leaves the backlink', - ), - t(!r.calls.update.some((u) => u.key === CLOSED_TARGET), 'L9 still does not re-close it'), - ], - }, - { - id: 'L3', - name: 'ONE target refused -- the rest still close (isolation) AND the job goes red (objectstack#9595)', - scenario: () => ({ - body: MIXED_BODY, - token: 'pat', - issues: { 'objectstack-ai/objectstack#456': { getError: DENIED } }, - }), - check: (r, t) => [ - t(r.calls.get.length === 3, `L3 still visits every target after the refusal, got ${r.calls.get.length}`), - t(r.calls.update.length === 2, `L3 still closes the two reachable targets, got ${r.calls.update.length}`), - t( - r.log.warning.some((w) => w.message.includes('objectstack-ai/objectstack#456') && /HTTP 404/.test(w.message)), - 'L3 warns naming the target and the refusal', - ), - t(r.log.failed.length === 1, `L3 FAILS the job -- the defect this card is about; got ${r.log.failed.length}`), - t((r.log.failed[0] ?? '').includes('objectstack-ai/objectstack#456'), 'L3 setFailed names the target left open'), - t( - !(r.log.failed[0] ?? '').includes('my-org/some.repo#22'), - 'L3 setFailed names ONLY what failed -- a verdict that lists the successes teaches nobody', - ), - t(r.log.summary.length === 1, 'L3 writes the list into the job summary'), - t(r.threw === null, 'L3 does not let the refusal escape the script'), - ], - }, - { - id: 'L4', - name: 'every target refused (an expired credential) -- all named, none lost', - scenario: () => ({ - body: MIXED_BODY, - token: 'expired-pat', - issues: Object.fromEntries(FOREIGN.map((k) => [k, { getError: UNAUTHORIZED }])), - }), - check: (r, t) => [ - t(r.calls.get.length === 3, `L4 attempts every target, got ${r.calls.get.length}`), - t(r.log.failed.length === 1, 'L4 fails the job once, after the loop -- not once per target'), - t(FOREIGN.every((k) => (r.log.failed[0] ?? '').includes(k)), 'L4 setFailed names every target left open'), - t(/HTTP 401/.test(r.log.summary[0] ?? ''), 'L4 summary carries the reason, so the credential is the obvious suspect'), - ], - }, - { - id: 'L5', - name: 'a target refused AND the summary unwritable -- still red', - scenario: () => ({ - body: MIXED_BODY, - token: 'pat', - issues: { 'third/party#7': { updateError: DENIED } }, - summaryError: new Error('EACCES'), - }), - check: (r, t) => [ - t(r.log.failed.length === 1, 'L5 still fails the job when the richer channel is gone'), - t((r.log.failed[0] ?? '').includes('third/party#7'), 'L5 setFailed still names the target'), - t(r.threw === null, 'L5 does not let the summary failure escape'), - t( - r.calls.comment.some((c) => c.key === 'third/party#7'), - 'L5 fixture is honest: the comment landed and only the close was refused', - ), - ], - }, - { - id: 'L10', - name: 'the BACKLINK is refused on an already-closed target -- red, and the verdict says which half was lost', - scenario: () => ({ - body: MIXED_BODY, - token: 'pat', - issues: { - [CLOSED_TARGET]: { state: 'closed', stateReason: 'completed', comments: [], commentError: DENIED }, - }, - }), - check: (r, t) => [ - t(r.log.failed.length === 1, `L10 fails the job -- half the deliverable was lost; got ${r.log.failed.length}`), - t((r.log.failed[0] ?? '').includes(CLOSED_TARGET), 'L10 setFailed names the target'), - t(r.calls.update.length === 2, `L10 still closes the other two, got ${r.calls.update.length}`), - t( - /backlink/.test(r.log.summary[0] ?? ''), - 'L10 summary says the BACKLINK was lost, not the close -- reporting "still open" about a closed ' - + 'issue sends the reader to do the one thing that is already done', - ), - t( - !/close and backlink/.test(r.log.summary[0] ?? ''), - 'L10 does not claim the close was lost too -- the issue was already closed before this run', - ), - t(r.threw === null, 'L10 does not let the refusal escape the script'), - ], - }, - { - id: 'L11', - name: 'the ROUND TRIP: re-running the job after a clean close posts no second backlink (objectstack#9643 H2)', - scenario: () => ({ body: MIXED_BODY, token: 'pat' }), - // Run 2 sees exactly the world run 1 left behind: every target closed, each - // carrying the very comment run 1 posted. Nothing is hand-written -- the - // marker travels from run 1's OUTPUT into run 2's INPUT, so this is the one - // assertion that fails if the two runs ever disagree on its spelling. - rerun: (first) => ({ - body: MIXED_BODY, - token: 'pat', - issues: Object.fromEntries( - first.calls.comment.map((c) => [c.key, { state: 'closed', stateReason: 'completed', comments: [c.body] }]), - ), - }), - check: (r, t, first) => [ - t(first.calls.comment.length === 3, `L11 run 1 comments on all three, got ${first.calls.comment.length}`), - t(first.calls.update.length === 3, `L11 run 1 closes all three, got ${first.calls.update.length}`), - t(r.calls.list.length === 3, `L11 run 2 checks all three for an existing backlink, got ${r.calls.list.length}`), - t( - r.calls.comment.length === 0, - `L11 run 2 posts NOTHING -- \`pull_request_target: [closed]\` can be replayed by a re-run, and this ` - + `workflow's own job summary tells people to do exactly that; got ${r.calls.comment.length} duplicate(s)`, - ), - t(r.calls.update.length === 0, `L11 run 2 re-closes nothing, got ${r.calls.update.length}`), - t(r.log.failed.length === 0, `L11 run 2 stays green, got setFailed: ${r.log.failed[0]}`), - t(r.log.warning.length === 0, 'L11 run 2 warns about nothing -- an idempotent re-run is a normal outcome'), - ], - }, - { - id: 'L12', - name: 'the target is an OPEN pull request -- refused out loud, never closed (objectstack#9711)', - scenario: () => ({ - body: MIXED_BODY, - token: 'pat', - issues: { [PR_TARGET]: { state: 'open', pullRequest: PR_KEY } }, - }), - check: (r, t) => [ - t(r.calls.get.includes(PR_TARGET), 'L12 reads the target -- the kind is only knowable from the response'), - t( - !r.calls.update.some((u) => u.key === PR_TARGET), - "L12 does NOT close the pull request -- GitHub's own keyword parser never closes one, and this " - + 'workflow exists to carry that behaviour across repos, not to exceed it. A closed PR also loses ' - + 'its merge-queue membership and any armed auto-merge in the same step, and neither returns by itself', - ), - t(!r.calls.comment.some((c) => c.key === PR_TARGET), 'L12 posts nothing on the foreign pull request either'), - t( - !r.calls.list.includes(PR_TARGET), - 'L12 does not even list its comments -- the target KIND settles it before idempotency can matter', - ), - t( - r.log.warning.some((w) => w.message.includes(PR_TARGET) && /PULL REQUEST/.test(w.message)), - 'L12 ANNOUNCES the refusal and names the target -- a quiet `continue` here would have been one ' - + 'more silent exit in a file whose known ones were every one of them found by somebody reading it', - ), - t( - r.log.failed.length === 1, - `L12 fails the job: the merged body declares a close that can never fire, and this run is the only ` - + `thing that will ever know; got ${r.log.failed.length}`, - ), - t((r.log.failed[0] ?? '').includes(PR_TARGET), 'L12 setFailed names the malformed target'), - t( - !/re-run this job once the cause is cleared/.test(r.log.failed[0] ?? ''), - 'L12 verdict does not send the reader to re-run -- unlike every other red path here the refusal is ' - + 'deterministic, and only the PR body can change it', - ), - t((r.log.summary[0] ?? '').includes(PR_TARGET), 'L12 summary carries it too, with the PR url'), - t(r.calls.update.length === 2, `L12 still closes the other two targets -- isolation holds; got ${r.calls.update.length}`), - t(r.threw === null, 'L12 does not let anything escape the script'), - ], - }, - { - id: 'L13', - name: 'the target is a MERGED pull request -- refused BEFORE the already-closed branch comments on it (objectstack#9711)', - scenario: () => ({ - body: MIXED_BODY, - token: 'pat', - // A merged pull request answers `state: 'closed'` with - // `state_reason: null` from the issues endpoint -- measured on - // objectstack-ai/objectstack#9143 and #9716. That is the fixture L9 uses to - // prove a closed ISSUE gets its backlink, which makes this scenario the - // ORDERING test: a `pull_request` guard placed after the state branch - // would sail past it and comment on somebody else's pull request. - issues: { - [PR_TARGET]: { - state: 'closed', - stateReason: null, - comments: [], - pullRequest: { ...PR_KEY, merged_at: '2026-08-16T14:56:58Z' }, - }, - }, - }), - check: (r, t) => [ - t( - !r.calls.comment.some((c) => c.key === PR_TARGET), - 'L13 leaves NO backlink on a merged pull request -- the path L9 pins for a closed issue must not be reached here', - ), - t( - !r.calls.list.includes(PR_TARGET), - 'L13 never reaches the idempotency check, which is what proves the kind guard runs FIRST', - ), - t(!r.calls.update.some((u) => u.key === PR_TARGET), 'L13 does not touch its state'), - t(r.log.failed.length === 1, `L13 fails the job for the same reason L12 does, got ${r.log.failed.length}`), - t((r.log.failed[0] ?? '').includes(PR_TARGET), 'L13 setFailed names it'), - t(r.calls.update.length === 2, `L13 still closes the other two, got ${r.calls.update.length}`), - ], - }, -]; - -// ── The battery ───────────────────────────────────────────────────────────── - -/** - * Run every scenario against `source`. - * - * @returns {Promise<{ failures: {id: string, message: string}[], checked: number }>} - */ -export async function judge(source) { - const failures = []; - let checked = 0; - - try { - new AsyncFunction('github', 'context', 'core', source); - } catch (err) { - // Assertion 0. github-script compiles the whole block as one function body; - // this is the 2026-08-02 failure class, and no scenario can run past it. - checked++; - failures.push({ id: 'C0', message: `the shipped script does not compile as an AsyncFunction body -- ${err.message}` }); - return { failures, checked }; - } - checked++; - - for (const s of SCENARIOS) { - const t = (cond, message) => { - checked++; - return cond ? null : { id: s.id, message }; - }; - let result; - let first = null; - try { - result = await runScript(source, s.scenario()); - // A scenario with `rerun` is driven TWICE, the second run's world built - // from the first run's output. That is the only way to prove the two runs - // agree on one marker spelling -- a fixture that hand-writes the marker - // proves each half separately and the round trip not at all. - if (typeof s.rerun === 'function') { - first = result; - result = await runScript(source, s.rerun(first)); - } - } catch (err) { - failures.push({ id: s.id, message: `the harness itself threw -- ${err.message}` }); - continue; - } - // Checked for EVERY scenario, ahead of its own assertions: an API the - // harness does not model is swallowed by the script's own `catch` and - // reported as a degradation, so no scenario assertion can be trusted to - // notice it. See the guard in `makeDoubles`. - for (const name of new Set([...(first?.unstubbedCalls ?? []), ...result.unstubbedCalls])) { - checked++; - failures.push({ - id: s.id, - message: `the shipped script called \`${name}()\`, which this harness does not model -- ` - + "the script's own catch absorbed it, so this scenario verified a degradation path rather " - + 'than the behaviour it names. Model the call in `makeDoubles`.', - }); - } - for (const f of s.check(result, t, first)) if (f) failures.push(f); - } - - return { failures, checked }; -} - -// ── Reporting ─────────────────────────────────────────────────────────────── - -function reportProblems(problems) { - console.error(`check-cross-repo-closer-outcome: ${problems.length} input problem(s) -- the run is NOT a pass\n`); - for (const p of problems) console.error(` • ${p}`); - console.error(` -A harness that could not find its subject has verified nothing, so this exits -non-zero rather than reporting OK (objectstack#4690). Either ${WORKFLOW} -moved and this file must follow it, or the step it guards is gone -- and if it -is gone, deleting this check is the honest edit, not letting it pass.`); -} - -async function main() { - const root = repoRoot(); - const { source, problems } = extractScript(root); - if (problems.length > 0) { - reportProblems(problems); - process.exit(1); - } - - const { failures, checked } = await judge(source); - if (failures.length === 0) { - console.log( - `check-cross-repo-closer-outcome: OK (${checked} assertions over ${SCENARIOS.length} scenarios, ` + - `driving the ${source.length}-char script extracted from ${WORKFLOW}).`, - ); - process.exit(0); - } - - console.error(`check-cross-repo-closer-outcome: ${failures.length} failed assertion(s) over the SHIPPED script\n`); - for (const f of failures) console.error(` • [${f.id}] ${f.message}`); - console.error(` -The subject is the inline script in ${WORKFLOW}, run -under doubles exactly as actions/github-script runs it. Fix the workflow, or -- -if the contract genuinely changed -- change the scenario here and say so in the -PR body. Scenario table: node ${SELF} --list`); - process.exit(1); -} - -function list() { - for (const s of SCENARIOS) console.log(`${s.id.padEnd(4)} ${s.name}`); - console.log(`\n${SCENARIOS.length} scenarios over ${WORKFLOW}`); -} - -// ── Self-test ──────────────────────────────────────────────────────────────── -// -// Each mutation states the anchor it needs. A mutation whose anchor is absent -// is a FAILURE here, not a skip: the substitution would then be a no-op, the -// battery would stay green, and the self-test would report a detector it never -// exercised. That is the same objectstack#4690 shape the extraction guards against, one -// level in. - -const MUTATIONS = [ - { - id: 'M1', - what: 'the post-loop verdict is downgraded back to a warning (the objectstack#9595 defect, restored)', - from: "core.setFailed(verdict.join(' '));", - to: "core.warning(verdict.join(' '));", - // Since objectstack#9711 the verdict carries both red classes, so this one mutation - // now has to be caught by an API refusal AND by a malformed target. - expect: ['L3', 'L4', 'L5', 'L12', 'L13'], - }, - { - id: 'M2', - what: 'the loop stops collecting the keys it could not close', - from: 'failures.push({ key, reason, lost });', - to: '', - expect: ['L3', 'L4', 'L5'], - }, - { - id: 'M3', - what: 'the loop stops isolating and breaks out on the first refusal', - from: 'failures.push({ key, reason, lost });', - to: 'failures.push({ key, reason, lost }); break;', - expect: ['L3'], - }, - { - id: 'M4', - what: 'the same-repo reference is no longer skipped', - from: "if (`${owner}/${repo}`.toLowerCase() === thisRepo.toLowerCase()) continue;", - to: '', - expect: ['P1'], - }, - { - id: 'M5', - what: 'the keyword set is narrowed, so `Fixes` and `resolved` stop qualifying', - from: "const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved';", - to: "const KEYWORDS = 'close';", - expect: ['P1'], - }, - { - id: 'M6', - what: 'the already-closed branch is removed, so a closed issue is commented on AND re-closed', - from: "if (issue.state === 'closed') {", - to: 'if (false) {', - // L2/L9 lose the "does not re-close it" half; L6 re-comments on a target - // that already carries the backlink; L7 posts a "fixed by" comment on a - // not_planned issue; L8 posts blind. Every already-closed scenario. - expect: ['L2', 'L6', 'L7', 'L8', 'L9'], - }, - { - id: 'M8', - what: 'the backlink loses its per-PR marker, so a re-run cannot tell its own comment is already there', - from: '---\\n_Generated by [Claude Code](https://claude.ai/code)_\\n\\n${backlinkMarker}', - to: '---\\n_Generated by [Claude Code](https://claude.ai/code)_', - // L1 and L2 catch it on the comments the script POSTS; L11 catches the - // consequence, by feeding run 1's output into run 2 and finding a duplicate. - expect: ['L1', 'L2', 'L11'], - }, - { - id: 'M9', - what: 'the triage guard is dropped, so a not_planned issue gets a comment claiming this PR fixed it', - from: "if (issue.state_reason === 'not_planned' || issue.state_reason === 'duplicate') {", - to: 'if (false) {', - expect: ['L7'], - }, - { - id: 'M10', - what: 'an unreadable comment listing degrades to posting BLIND instead of skipping (a stranded duplicate)', - from: '{ title: \'Cross-repo backlink skipped, not confirmed\' },\n );\n continue;', - to: "{ title: 'Cross-repo backlink skipped, not confirmed' },\n );", - expect: ['L8'], - }, - { - id: 'M11', - what: 'the loop stops recording WHICH half it lost, so the verdict cannot tell a lost close from a lost backlink', - from: 'failures.push({ key, reason, lost });', - to: 'failures.push({ key, reason });', - expect: ['L10'], - }, - { - id: 'M12', - what: 'the pull-request guard is dropped, so a keyword aimed at a foreign PR comments on it and CLOSES it (objectstack#9711)', - from: 'if (issue.pull_request) {', - to: 'if (false) {', - expect: ['L12', 'L13'], - }, - { - id: 'M13', - what: 'a refused pull-request target stops being recorded, so the run refuses it and still reports green', - from: 'malformed.push({ key, url: issue.pull_request.html_url });', - to: '', - expect: ['L12', 'L13'], - }, - { - id: 'M14', - what: 'the refusal is downgraded to an info line, so nothing annotates the run it happened in', - from: 'core.warning(\n `${key} is a PULL REQUEST', - to: 'core.info(\n `${key} is a PULL REQUEST', - expect: ['L12'], - }, - { - id: 'M7', - what: 'the notice path loses its verdict (the objectstack#9594 half, restored to its defect)', - from: 'core.setFailed(\n `${targets.size} cross-repo issue(s) were left open', - to: 'core.warning(\n `${targets.size} cross-repo issue(s) were left open', - expect: ['N2', 'N3'], - }, -]; - -async function selfTest() { - const root = repoRoot(); - const { source, problems } = extractScript(root); - if (problems.length > 0) { - reportProblems(problems); - process.exit(1); - } - - const failures = []; - let checked = 0; - const assert = (cond, msg) => { - checked++; - if (!cond) failures.push(msg); - }; - - // 1. The unmutated shipped script must be green -- otherwise every red below - // proves nothing about the mutation. - const clean = await judge(source); - assert( - clean.failures.length === 0, - `the shipped script is green before any mutation, got: ${clean.failures.map((f) => `[${f.id}] ${f.message}`).join(' | ')}`, - ); - - // 2. Every mutation must be REACHED and must turn the battery red, in the - // scenarios it names. - for (const m of MUTATIONS) { - assert(source.includes(m.from), `${m.id}: its anchor is present in the shipped script (a no-op mutation proves nothing)`); - if (!source.includes(m.from)) continue; - const mutated = source.replace(m.from, m.to); - assert(mutated !== source, `${m.id}: the substitution changed the source`); - const red = await judge(mutated); - assert(red.failures.length > 0, `${m.id}: ${m.what} -- the battery goes RED`); - for (const id of m.expect) { - assert( - red.failures.some((f) => f.id === id), - `${m.id}: scenario ${id} is the one that catches it, got [${red.failures.map((f) => f.id).join(', ')}]`, - ); - } - } - - // 3. A script that does not compile is caught before any scenario runs -- the - // 2026-08-02 failure class, which no outcome assertion could ever see. - const broken = await judge(`${source}\nconst github = 1;`); - assert(broken.failures.length === 1 && broken.failures[0].id === 'C0', 'a non-compiling script is reported as C0, once'); - - // 4. Missing input is a failure, never a pass (objectstack#4690). - const gone = extractScript(join(root, 'scripts')); - assert(gone.source === null && gone.problems.length === 1, 'a missing workflow file is an input problem, not a pass'); - - // 5. Wiring. A check nobody runs is the shape both repositories keep paying - // for, so the step that invokes it is pinned here. - const lint = join(root, '.github', 'workflows', 'lint.yml'); - assert(existsSync(lint), 'wiring: .github/workflows/lint.yml exists -- it is where this check runs'); - if (existsSync(lint)) { - const body = readFileSync(lint, 'utf8'); - assert(body.includes(SELF), `wiring: lint.yml still invokes ${SELF}`); - assert(body.includes(`${SELF} --self-test`), 'wiring: lint.yml runs the --self-test half too'); - } - - if (failures.length) { - console.error(`✗ check-cross-repo-closer-outcome --self-test -- ${failures.length} failure(s)\n`); - for (const f of failures) console.error(` • ${f}`); - process.exit(1); - } - console.log( - `✓ check-cross-repo-closer-outcome --self-test: ${checked} assertions, ` + - `${MUTATIONS.length} mutations of the shipped script each driven to red.`, - ); -} - -// The CLI dispatch is guarded so that IMPORTING this module is inert. The -// reverse-verification route needs `extractScript` / `judge` pointed at another -// tree (a pre-fix checkout), and a module that runs its gate on import would -// silently judge THIS repo instead and print a pass about the wrong subject. -if (isEntrypoint(import.meta.url)) { - if (process.argv.includes('--self-test')) await selfTest(); - else if (process.argv.includes('--list')) list(); - else await main(); -} diff --git a/ui/scripts/check-test-shard-results.mjs b/ui/scripts/check-test-shard-results.mjs index c20dc9ee60e..9d3d7510076 100644 --- a/ui/scripts/check-test-shard-results.mjs +++ b/ui/scripts/check-test-shard-results.mjs @@ -201,8 +201,8 @@ * ## The shard COUNT is passed in, and pinned against the matrix * * `--shards` is the one number this file does not derive. Deriving it from the - * workflow would mean a second YAML parser answering a question - * `scripts/__tests__/workflow-checks.ts` already answers; instead + * workflow would mean a YAML parser here answering a question the workflow's + * own matrix already answers; instead * `scripts/__tests__/check-test-shard-results.test.ts` pins the argument * written in `ci.yml` against that same file's `shard: [...]` matrix, so the * two declarations cannot drift apart silently. diff --git a/ui/scripts/markdown-test-inputs.mjs b/ui/scripts/markdown-test-inputs.mjs index ae5eb466ad9..cda324444f6 100644 --- a/ui/scripts/markdown-test-inputs.mjs +++ b/ui/scripts/markdown-test-inputs.mjs @@ -7,14 +7,19 @@ * * node scripts/markdown-test-inputs.mjs --list the adjudicated class * node scripts/markdown-test-inputs.mjs --audit re-derive and compare - * node scripts/markdown-test-inputs.mjs --changed - read paths on stdin - * node scripts/markdown-test-inputs.mjs --changed a.md b.md * - * `--changed` prints the subset of the paths handed to it that this repository - * has recorded as a test's input, one per line, and exits 0 whether or not the - * subset is empty -- an EMPTY LIST IS AN ANSWER, and a non-zero exit means the - * question could not be answered at all. That distinction is the whole contract - * with the caller in `ci.yml`, which treats "could not answer" as RUN. + * ## What is left of it in the monorepo (objectstack#25712, #25753) + * + * It was written for objectui's `ci.yml`, whose `Decide whether this change + * needs a full run` step asked `--changed` which markdown paths a test reads. + * That workflow runs nowhere under the monorepo, and the root `Test UI` job + * starts on any change under `ui/` (the `filter` job's `ui` globs), so no CI + * step asks the question any more and `--changed` retired with its caller. The + * ledger stays as an AUDIT: `markdown-test-inputs.test.ts` holds the + * adjudicated class equal to what the scanner derives, and + * `check-pending-changeset-literals.mjs` resolves markdown literals through + * `deriveCandidates()`. The sections below record why the class has the shape + * it has; where they say "the decision step", read objectui's workflow. * * ## The blind spot this exists to close (objectui#8861) * @@ -1145,9 +1150,9 @@ export function readersByEntry() { * structural rather than incidental, so the union is taken here: `entries` is * every rule that covers the document and `readers` is every test behind them. * - * `should_run` does not depend on this -- one match is enough to run everything - * and the caller in `ci.yml` reads only the path. The readers do: they are what - * the log line names when it says which test is waiting on this document. + * objectui's decision step needed only the path -- one match was enough to run + * everything. The readers are what a caller names when it says which test is + * waiting on this document. */ export function markdownTestInputsAmong(paths) { const entries = declaredEntries(); @@ -1346,14 +1351,6 @@ export function missingDocuments({ root = REPO_ROOT } = {}) { // CLI // --------------------------------------------------------------------------- -function readStdin() { - try { - return readFileSync(0, 'utf8'); - } catch { - return ''; - } -} - function runList() { const readers = readersByEntry(); for (const [entry, tests] of readers) { @@ -1399,25 +1396,14 @@ function runAudit() { return 1; } -function runChanged(args) { - const paths = args.includes('-') - ? [...args.filter((a) => a !== '-'), ...readStdin().split('\n')] - : args; - for (const hit of markdownTestInputsAmong(paths)) { - process.stdout.write(`${hit.path}\n`); - } - return 0; -} - if (isEntrypoint(import.meta.url)) { const argv = process.argv.slice(2); let code; if (argv[0] === '--list') code = runList(); else if (argv[0] === '--audit') code = runAudit(); - else if (argv[0] === '--changed') code = runChanged(argv.slice(1)); else { process.stderr.write( - 'usage: markdown-test-inputs.mjs (--list | --audit | --changed | --changed -)\n', + 'usage: markdown-test-inputs.mjs (--list | --audit)\n', ); code = 2; } From 7115227bd74a15b8630ea7bf5f7c9a3594090b19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:49:15 +0000 Subject: [PATCH 02/11] chore(agents): the fleet's playbooks stop naming objectui as a sibling repo objectui lives in this repository's ui/ and its repo is archived. The pm-dispatch references, the os-dev agent, the checklist-test and dogfood-verification skills drop the sibling-repo rules (objectui's labeler, release PR title, required checks, pin chain, skip-changeset reading) and describe ui/ where a rule still applies. The settings allow-list drops its five objectui write rows. Ratchet ceilings fall to the landed counts. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibvE77Ed7MKdzPxJTQQ --- .claude/agents/os-dev.md | 8 ++---- .claude/settings.json | 5 ---- .claude/skills/checklist-test/SKILL.md | 4 +-- .claude/skills/dogfood-verification/SKILL.md | 28 +++++++------------ .../references/decision-analysis.md | 2 +- .../references/dispatch-runbook.md | 2 +- .../pm-dispatch/references/filing-gate.md | 3 +- .../pm-dispatch/references/lanes/director.md | 1 - .../pm-dispatch/references/lanes/hotcrm.md | 2 +- .../pm-dispatch/references/lanes/triage.md | 2 +- .../references/platform-readings.md | 2 -- .../pm-dispatch/references/rest-channel.md | 2 -- .../references/review-checklist.md | 1 - .../references/seat-post-protocol.md | 6 ++-- .../pm-dispatch/references/true-green.md | 6 ++-- scripts/pm/check-skill-line-ratchet.mjs | 21 +++++++++----- 16 files changed, 40 insertions(+), 55 deletions(-) diff --git a/.claude/agents/os-dev.md b/.claude/agents/os-dev.md index 42d30a3f233..c991692b2c3 100644 --- a/.claude/agents/os-dev.md +++ b/.claude/agents/os-dev.md @@ -70,7 +70,7 @@ model: opus - 优先扩展一个守卫关掉整个类;任一条不成立 ⇒ 回默认:报告列出交席位立单、不碰。 - 本轮改动令其变假或触碰的已发布缺陷必修;其余立卡并记明已发布面,PR 照常落地。 4. ⛔ 永不推 `main`、合并任何 PR、在代码 PR 里改 `content/docs/releases/`;改错另开 docs-only PR。 - - force-push 按落地仓 AGENTS.md:objectui/cloud 绝对禁;objectstack §3 五条全立才 `--force-with-lease`。 + - force-push 按落地仓 AGENTS.md:cloud 绝对禁;objectstack §3 五条全立才 `--force-with-lease`。 5. **Contract-first。** 修复若诱使你在消费端加宽容回退(`??` 别名、宽松解析),缺陷在上游。 - 去生产者或 spec 修,或返回 `needs_decision`。 6. **issue 正文是线索,不是规格。** 动手前对 `origin/main` 核验其前提。 @@ -122,7 +122,7 @@ model: opus - 从门禁绿跑清单里挑一个已 pin 的 fake 照抄。 - 缺一种该文件没有的 double 时,覆写文件里已有的 double 优于 pin 新的:不动台账。 - 匹配到零个脚本的 `pnpm --filter` 运行以 0 退出:什么都没跑,读上去却是通过。 -- objectui 把 typecheck 拼作 `type-check`(连字符);核对输出里确实回显了脚本名。 +- `ui/` 的包把 typecheck 拼作 `type-check`(连字符);核对输出里确实回显了脚本名。 - 浏览器验证:Chromium 已预装,`PLAYWRIGHT_BROWSERS_PATH` 指向 `/opt/pw-browsers`。 - launch 传 `executablePath: '/opt/pw-browsers/chromium'`;⛔ 永不跑 `playwright install`。 @@ -154,7 +154,7 @@ model: opus - 派生答哪些门禁读你改的文件,不答哪些测试测这个脚本。 - 跑同目录点名它的 `*.test.ts`,加同包 tests 下 `git grep` 该脚本文件名的全部命中。 - 该脚本只住在 objectstack,答案只关于它所在的树:每次推导第一行(stderr)点名仓与 commit。 -- 读之前先核对;姊妹仓未必有 `scripts/pm/dispatch-gates.mjs`:objectui 只有 `check-half-states.mjs`。 +- 读之前先核对;姊妹仓未必有 `scripts/pm/dispatch-gates.mjs`:hotcrm 两者皆无。 - 把它们的路径喂进 objectstack 检出,得到的是 objectstack 的门禁族:形态完整、退出 0、全错。 - 那边的清单从该仓自己的 `package.json` 与 `.github/workflows/` 手工推导。 - 加 `--repo /` 申报本次答案该属于哪个仓,不匹配即拒绝并同时点名两个仓。 @@ -306,8 +306,6 @@ model: opus - 关此步骤的是读回不是写入;读回只验写落了,验不出有门在读:幻影门标签读回照样成功。 - 被拒 ⇒ 报端点与状态码、席位代挂,⛔ 不报 `blocked`、不走 MCP;收尾再读一次。 - 分类器拒外部写 ⇒ 停手,`deviations` 记命令与拒因,席位只转维护者定夺;⛔ 不换路重发。 -- objectui:路径标签归 `labeler.yml`,逐 push 同步;无门禁读你打的标签 ⇒ 派发词未点名即零写。 -- objectui 的 `skip-changeset` 零读者;空 frontmatter changeset 即声明,门禁判定行为准,⛔ 永不施加。 - 报告在本地验证走完时交付,CI 收敛等待归 PM 不归你:⛔ 不为等 CI 结论推迟报告。 - 门禁状态如实记录,`in_progress` 是诚实值;PR 开出后 ⛔ 永不 sleep、定时等待或空转轮询 CI。 - 你报告之后才转红的门禁,会作为同一认领上的补丁轮回来。 diff --git a/.claude/settings.json b/.claude/settings.json index 7651c9a29ef..8306ed86992 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -29,7 +29,6 @@ "Bash(agent-browser snapshot *)", "Bash(agent-browser skills *)", "Bash(curl -sS -X POST https://api.github.com/repos/objectstack-ai/objectstack/issues/*/labels *)", - "Bash(curl -sS -X POST https://api.github.com/repos/objectstack-ai/objectui/issues/*/labels *)", "Bash(curl -sS -X POST https://api.github.com/repos/objectstack-ai/hotcrm/issues/*/labels *)", "Bash(curl -sS -X GET https://api.github.com/rate_limit *)", "Bash(curl -sS -X GET https://api.github.com/repos/objectstack-ai/objectstack/issues?* *)", @@ -57,13 +56,10 @@ "Bash(curl -sS -X GET https://api.github.com/repos/objectstack-ai/objectui/commits/*/check-runs* *)", "Bash(curl -sS -X GET https://api.github.com/repos/objectstack-ai/hotcrm/commits/*/check-runs* *)", "Bash(curl -sS -X PATCH https://api.github.com/repos/objectstack-ai/objectstack/pulls/* *)", - "Bash(curl -sS -X PATCH https://api.github.com/repos/objectstack-ai/objectui/pulls/* *)", "Bash(curl -sS -X PATCH https://api.github.com/repos/objectstack-ai/hotcrm/pulls/* *)", "Bash(curl -sS -X POST https://api.github.com/repos/objectstack-ai/objectstack/pulls/*/ccr/ready_for_review *)", "Bash(curl -sS -X PUT https://api.github.com/repos/objectstack-ai/objectstack/pulls/*/ccr/auto_merge *)", - "Bash(curl -sS -X POST https://api.github.com/repos/objectstack-ai/objectui/pulls/*/ccr/ready_for_review *)", "Bash(curl -sS -X POST https://api.github.com/repos/objectstack-ai/hotcrm/pulls/*/ccr/ready_for_review *)", - "Bash(curl -sS -X PUT https://api.github.com/repos/objectstack-ai/objectui/pulls/*/ccr/auto_merge *)", "Bash(curl -sS -X PUT https://api.github.com/repos/objectstack-ai/hotcrm/pulls/*/ccr/auto_merge *)", "Bash(node --use-env-proxy scripts/pm/post-stamped.mjs *)", "Bash(node --use-env-proxy scripts/pm/label-write.mjs *)", @@ -78,7 +74,6 @@ "Bash(node scripts/pm/issue-transfer.mjs *)", "Bash(node --use-env-proxy scripts/pm/issue-transfer.mjs *)", "Bash(curl -sS -X PATCH https://api.github.com/repos/objectstack-ai/objectstack/issues/* *)", - "Bash(curl -sS -X PATCH https://api.github.com/repos/objectstack-ai/objectui/issues/* *)", "Bash(curl -sS -X PATCH https://api.github.com/repos/objectstack-ai/hotcrm/issues/* *)" ], "deny": [ diff --git a/.claude/skills/checklist-test/SKILL.md b/.claude/skills/checklist-test/SKILL.md index e13930a42ca..80d7e3d7c8d 100644 --- a/.claude/skills/checklist-test/SKILL.md +++ b/.claude/skills/checklist-test/SKILL.md @@ -66,7 +66,7 @@ blocked / not-run,⛔ 不进标题计数;与 blocked 的分界是 fixture 债 vs - **全是 `api` / `build` / `cli`** → 不需要 console 构建。把框架拉起来 (`objectstack dev`),驱动 REST/CLI。快(分钟级)。 - **有任何 `browser` / `mixed`** → 需要 vendored console dist。它与 showcase 工作区 - 闭包**分开**构建(`pnpm objectui:build`,从钉住的 `.objectui-sha` 构建);它存在 + 闭包**分开**构建(`pnpm console:build`,从本仓 `ui/apps/console` 构建);它存在 之前,第一次启动对 `/_console/` 是 404(dogfood §2 —— 真实前置条件,记录它,不要 伪造 block)。给构建留预算(冷 monorepo 上 ~10–30 分钟);它占掉大头,浏览器驱动 只是分钟级。 @@ -132,7 +132,7 @@ expected-fail 探针 —— 永不在真实缺陷上把条款打成绿。 是协议载体、不入分诊 sweep,⛔ 不是可派发单元;工作标签随抽取出的缺陷卡走(RUNNER 抽取 义务条款)。**这条在记录的整个生命周期成立** —— 收口与关闭时同样不补挂。 - **正文**,按此顺序: - - **环境指纹** —— framework sha、`.objectui-sha`、端口、db、seed、时间戳。 + - **环境指纹** —— framework sha(`ui/` 在内)、端口、db、seed、时间戳。 - **范围** —— 选择器 + 每项所对的 `revision`。 - **逐条款判定表** —— 项 · 条款 · 判定 · 一行**文本** oracle 证据 (API/网络/构建/测试结果 —— 服务端真相,永不是像素)。 diff --git a/.claude/skills/dogfood-verification/SKILL.md b/.claude/skills/dogfood-verification/SKILL.md index eb950d97b3c..2efdf62f042 100644 --- a/.claude/skills/dogfood-verification/SKILL.md +++ b/.claude/skills/dogfood-verification/SKILL.md @@ -73,24 +73,17 @@ dev 工作树、dev-server 端口、preview 浏览器全是**共享的**:并行 - [ ] ⚠️ **消融的 mutate 腿与 restore 腿都要重建 —— 但那条规则不是 dogfood 专属**: 判据、preflight 与两次实测住在 `.claude/agents/os-dev.md`;⛔ 这里不留第二份拷贝。 - [ ] `dist/` 已 gitignore —— 安全;永不提交构建产物。 -- [ ] **`/_console` UI 是 *vendored objectui 构建*,与框架 `dist` 是两回事。** 它由 - `.objectui-sha` 钉住、按预构建 bundle 提供。已合并的 objectui 修复 —— *甚至 - bump 过的 `.objectui-sha`* —— 在 vendored console 重建之前**不会活在 :3000 - 上**:运行中的服务器继续提供旧构建产物(BUILD 陈旧 ≠ PIN 陈旧)。所以**在宣布 - :3000/_console 上发现 console/Studio UI 缺陷之前,先对当前 objectui 源码或全新 - 构建核一遍** —— vendored bundle 可能是旧的,缺陷可能上游已修。最快的权威核验 = - objectui 的 HMR console 指向你的服务器: +- [ ] **`/_console` UI 是 *Console 构建*,与框架包的 `dist` 是两回事。** 它由 + `pnpm console:build` 从本仓 `ui/apps/console` 构建,拷进 `packages/console/dist`。 + `ui/` 下已合并的修复在 Console 重建之前**不会活在 :3000 上**:运行中的服务器继续 + 提供旧构建产物。所以**在宣布 :3000/_console 上发现 console/Studio UI 缺陷之前,先 + 对当前 `ui/` 源码或全新构建核一遍** —— 构建可能是旧的,缺陷可能已修。最快的权威 + 核验 = `ui/` 的 HMR console 指向你的服务器: `VITE_SERVER_URL=http://localhost: DEV_PROXY_TARGET=http://localhost: pnpm --filter @object-ui/console dev` (独立 origin → 需要它自己的 `admin@objectos.ai/admin123` 登录)。跳过这步曾在 - 一个 objectui main 已修掉的 action-create 死路上白付一整圈 spawn-a-fix。 -- [ ] **漂移的构建不再静默启动(#7752)。** `build-console.sh` 把它构建所用的 SHA 盖 - 进 `packages/console/dist/.objectui-sha`;当这个章与仓库的 `.objectui-sha` pin - 不一致时,`os dev` **拒绝挂载 `/_console`** 并打印补救 —— `/_console/` 404,横 - 幅不再宣传 console URL。那是要修的前置条件,不是产品缺陷:跑 - **`pnpm objectui:build`**(在*钉住的* SHA 上重建 —— `objectui:refresh` 会把 - pin 重新 bump 到你本地 `../objectui` 的 HEAD)再重启。`pnpm check:console-sha` - 不启动也能报同一比较。`OS_ALLOW_CONSOLE_DRIFT=1` 是故意启动陈旧 bundle —— 那之 - 后你做的每个 console 观察,描述的都是一个仓库没有钉住的 commit。 + 一个上游已修掉的 action-create 死路上白付一整圈 spawn-a-fix。 +- [ ] **构建新鲜度由你自核。** `pnpm console:build` 产出的 dist 不盖 SHA 章,`os dev` + 无从判定它是否陈旧,照常挂载 `/_console`;改过 `ui/` 就先重建再观察。 ## 3. 核验 —— 视觉 / API 先行,DOM 殿后(防误报规则) @@ -104,8 +97,7 @@ dev 工作树、dev-server 端口、preview 浏览器全是**共享的**:并行 - [ ] **门的两侧都要测**:`requiresService`/`requiresObject`/权限门要在依赖存在*与* 缺席两种状态下各验一次。 - [ ] 服务端是权威可见性门(ADR-0124 D1)—— 客户端过滤只是「礼貌」。元数据开关不改 - UI 时,先查强制在服务端(框架,可在本仓修)还是客户端(objectui console,另一 - 个仓)。 + UI 时,先查强制在服务端(框架)还是客户端(`ui/` 的 console);两边都在本仓修。 - [ ] 用 `preview_screenshot`(API 改动用 `preview_network`)向用户证明;loading spinner / 异步数据状态要显式写明,而不是声称你没等到的最终值。 diff --git a/.claude/skills/pm-dispatch/references/decision-analysis.md b/.claude/skills/pm-dispatch/references/decision-analysis.md index 24a61d64c5d..95b0861b9b3 100644 --- a/.claude/skills/pm-dispatch/references/decision-analysis.md +++ b/.claude/skills/pm-dispatch/references/decision-analysis.md @@ -44,7 +44,7 @@ ## 应用仓推荐序特例 -- 能力先建在平台(spec、objectstack 运行时、objectui 渲染器);应用仓(cloud、hotcrm)不自建替代。 +- 能力先建在平台(spec、objectstack 运行时与其 `ui/` 渲染器);应用仓(cloud、hotcrm)不自建替代。 - 阻塞源是平台缺陷 ⇒ 恒荐等待;⛔ 不荐绕行(形状容错、复刻平台规则)、不荐劈半落地。 - 过渡唯维护者明裁;应用仓临时扩展平台契约,仅当平台卡与移除卡同一裁决立出。 - 应用仓裁决立出的平台卡取被裁卡优先级,⛔ 不降档。推论出处:cloud 2932 A、cloud 2924 A。 diff --git a/.claude/skills/pm-dispatch/references/dispatch-runbook.md b/.claude/skills/pm-dispatch/references/dispatch-runbook.md index 73f7271321c..e1cafcf8cb1 100644 --- a/.claude/skills/pm-dispatch/references/dispatch-runbook.md +++ b/.claude/skills/pm-dispatch/references/dispatch-runbook.md @@ -86,7 +86,7 @@ - 交付了一半的阻塞是判断不是 grep,归四件之③,⛔ 不进机械旗。 - 发布窗口判据:已上膛 = 开着的 head `changeset-release/main` PR,`is:pr is:open head:changeset-release`。 - 点亮对照 `git ls-remote origin changeset-release/main` 必中;⛔ title 永不是判据。 -- 两仓 title 不同、由 action 配置:objectui `chore: release packages`、objectstack `chore: version packages`。 +- title 由 action 配置,本仓为 `chore: version packages`。 - 上膛与否不足:真判据是本卡 changeset 已否折进该 head `CHANGELOG.md`,即「离永久还有多远」。 - 卡上 re-check 命令两条硬线:① 每条 grep 自带阳性对照。 - 对照取同文件里确定存在、且 ⛔ 绝不是被测词子串的一个词。 diff --git a/.claude/skills/pm-dispatch/references/filing-gate.md b/.claude/skills/pm-dispatch/references/filing-gate.md index 881cf47957f..c6938e36fca 100644 --- a/.claude/skills/pm-dispatch/references/filing-gate.md +++ b/.claude/skills/pm-dispatch/references/filing-gate.md @@ -39,8 +39,7 @@ - 协议类:修复改 spec 接受/声明面(含 ADR-0087 转换窗)或 `/meta` 读存删门的收答,宽窄皆算。 - REST/数据门收窄亦算;措辞、文档、账本、CLI 旗、lint/verify 导出、门不变的服务行为不算。 - 此类任何车道皆 p1 + 在研大版 `target:`,可退役键的条件卡同档;维护者裁决,本节余条不压。 -- 分诊轮的维护者清单点名等下个 `next.N` 的 objectui 卡数,堆积即请切版。 -- 此类的消费方跟进卡(objectui 类型镜像、渲染器、Studio)缺它即坏用户才 p1,余者守本档。 +- 此类的消费方跟进卡(`ui/` 类型镜像、渲染器、Studio)缺它即坏用户才 p1,余者守本档。 - 坏用户 = 设计器系统性写出协议拒收的元数据,或协议声明的块/键渲染为空;纯镜像不算。 - 此类的跟进卡全部列入消费方新大版的首发卡,赶在该版发布前落地。 - 清单项三态 ①:项会 fail ⇒ 断,继承其 `priority`;⛔「有人在等」不是定级判据。 diff --git a/.claude/skills/pm-dispatch/references/lanes/director.md b/.claude/skills/pm-dispatch/references/lanes/director.md index 9f8da571703..53327fcadf6 100644 --- a/.claude/skills/pm-dispatch/references/lanes/director.md +++ b/.claude/skills/pm-dispatch/references/lanes/director.md @@ -78,7 +78,6 @@ - ③ 存量数据/迁移形状(发布后修不回);④ 发布说明里要为它道歉的。 - 拆分/分票时 `target:*` 随工作走,对每一半重跑二元判据,默认继承,不继承按四类写明理由。 - 每张板恰好一个生产者;鲜度节奏、清板三选一、pin 前置见 `../seat-post-protocol.md`。 -- 跨仓 pin 链:objectui pin 落后且其队列已空 ⇒ 在本仓立/刷新 console bump 单;⛔ 只立单不 bump。 - 见新 tag 族发布 ⇒ 在 cloud 与每个应用仓立平台版本 bump 单;单张封顶,已有则追评刷新。 - bump 单等待卡清单 = hold 索引(`Restart-when:`/`Blocked-by:` 点名该发布的 open 卡),查询落正文。 - 车队总表随本摘要重生成,坐席/接管/收班同笔;一席一行,⛔ 不手改;见 `../project-forms.md`。 diff --git a/.claude/skills/pm-dispatch/references/lanes/hotcrm.md b/.claude/skills/pm-dispatch/references/lanes/hotcrm.md index 70715324d72..7d825b6b09c 100644 --- a/.claude/skills/pm-dispatch/references/lanes/hotcrm.md +++ b/.claude/skills/pm-dispatch/references/lanes/hotcrm.md @@ -12,7 +12,7 @@ - `objectstack-ai/hotcrm` 全仓:元数据优先的 CRM 样板工程,不入 spec 依赖链。 - 应用即展品,不是功能实验场 —— 收卡第一问恒为这张卡展现哪项平台能力。 - 边界:平台相关的功能在平台中实现。 -- 建设 hotcrm 时发现的平台能力缺口,立普通平台卡到 objectstack 或 objectui。 +- 建设 hotcrm 时发现的平台能力缺口,立普通平台卡到 objectstack(渲染器在其 `ui/`)。 - 那是 file-at-destination、非缝卡、待分诊;hotcrm 侧同笔挂 `pm:blocked` 加 `Blocked-by:` 回链。 - ⛔ 永不在 hotcrm 内绕行:绕行把平台缺口藏进样板,展品从此示范错误写法。 - `repo:hotcrm` 缝标签只给真协调卡,跨仓次序即卡的实质。 diff --git a/.claude/skills/pm-dispatch/references/lanes/triage.md b/.claude/skills/pm-dispatch/references/lanes/triage.md index 2a516466688..523e88b3070 100644 --- a/.claude/skills/pm-dispatch/references/lanes/triage.md +++ b/.claude/skills/pm-dispatch/references/lanes/triage.md @@ -2,7 +2,7 @@ 见 triage-duties.md;本文是本席的岗位说明,现值状态恒在座位贴,⛔ 不迁入本文。 - 由维护者一句召唤到任一项目,默认同总监席项目;只写标签与审计评论,⛔ 无 PR、无认领。 -- 范围 = objectstack、objectui 每张卡首触(业务理由与定级)与跨车道查重;应用仓无分诊席。 +- 范围 = objectstack(`ui/` 在内)每张卡首触(业务理由与定级)与跨车道查重;应用仓无分诊席。 - 应用仓业务理由由该项目召唤的总监席复核;第三方应用的总监是其自身维护者。 - 每 fire 只取一个仓,两仓轮替、仓内最旧优先;最旧未路由超 4h 的仓本轮优先。 - 补半状态前先确认无人在动,读后新现六态属他席即停手;⛔ 不加第二席、不改 fire 频率。 diff --git a/.claude/skills/pm-dispatch/references/platform-readings.md b/.claude/skills/pm-dispatch/references/platform-readings.md index 23b7bf343e4..82c82df1a96 100644 --- a/.claude/skills/pm-dispatch/references/platform-readings.md +++ b/.claude/skills/pm-dispatch/references/platform-readings.md @@ -236,7 +236,6 @@ - ⇒ ⛔ 读成功而标签空或缺席不读作没有标签:按 `data-name=` 确认,否则整集作 UNKNOWN。 - 可达时优先加法端点;⛔ 单读与单次即时读回都不决断。 - ⇒ union-write 欠一次延迟确认;必需标签(如 `skip-changeset`)其后每次触碰重核。 -- objectui labeler 带 `sync-labels: true`:PR 标签按路径整集重算,手打的下次 push 即失,⛔ 不重挂。 - `list_issues` 永不返回 assignees:`fields` 枚举无此成员,不传也没有。 - 已认领卡与空闲卡响应逐字节相同,清单只是候选名单 ⇒ 认领前必须过完整 `issue_read`。 - `comments` 计数会多读:实测 1 而列表与 timeline 双 0 ⇒ 线程空否读那两条,⛔ 不读计数。 @@ -386,7 +385,6 @@ - 强制档不因临缺授权降档:临缺非退役,退役唯维护者裁;不达档起隔离达档子代理或等档。 - 本车道强制多是过宽的回忆:`dispatch-gates.mjs --tier PATH` 逐路径现推,路径线是下限非放行。 - 该脚本只答自己那棵树:姊妹仓路径回 absent from this tree,姊妹仓的档位与条款②只能手推。 -- `os-verify-lock.sh` 只住 objectstack:objectui 无它,跑本仓副本读的是容器级锁,非该仓深度。 - required checks 的名单是每仓事实,objectstack 八个: `TypeScript Type Check` · `Lint & Repo Gates` · `Test Core` · `Build Core` · `Lint UI` · `Governed Surface Queue Guard` · `Type Check UI` · `Build & E2E UI`。 diff --git a/.claude/skills/pm-dispatch/references/rest-channel.md b/.claude/skills/pm-dispatch/references/rest-channel.md index 9c52fa05e5a..37fa94eafb8 100644 --- a/.claude/skills/pm-dispatch/references/rest-channel.md +++ b/.claude/skills/pm-dispatch/references/rest-channel.md @@ -75,8 +75,6 @@ - ①② 的拼写与边界住 `platform-readings.md` 队列段,本条只归拢判据。 - 队列 required 集按 job 与 check-run 名匹配,workflow 名从不作为 check context 出现,搜也搜不到。 - 改任一 job 名或 test 分片矩阵的形状,必须同一笔更新队列 required 集,否则队列静默挂起。 -- 姊妹仓 objectui 配置为 9 个:`Lint` · `Type Check` · `Test (shard 1/4 … 4/4)`。 -- 其余三个:`Build & E2E` · `Build Docs` · `Changeset Declaration`。 - ⛔ required 选择只 gate 等待、不 gate 触发:未列入的检查照跑、算力相同,红了不再挡队列。 - 配 required 集先排掉 push-only job:`if: github.event_name == 'push'` 的 job 在 `merge_group` 上不报到。 - 列为 required 即挂死队列;未展开的矩阵名(带字面 `${{ }}` 的串)是占位符,不是真 context。 diff --git a/.claude/skills/pm-dispatch/references/review-checklist.md b/.claude/skills/pm-dispatch/references/review-checklist.md index 38dfb316193..0cdc18fec9f 100644 --- a/.claude/skills/pm-dispatch/references/review-checklist.md +++ b/.claude/skills/pm-dispatch/references/review-checklist.md @@ -21,7 +21,6 @@ - 空 changeset 在本仓库滞留发布;含读者可见生成产物时 dev 改选 changeset 亦对。 - dev 挂 `skip-changeset` 遭分类器拒 ⇒ 席位转维护者定夺,⛔ 不径自代挂:代做即洗权限。 - 其言与席位自核 tests/docs-only 俱备方挂,评论载两者;dev 报告 `deviations` 须逐字载被拒命令。 -- objectui 无此标签,空 frontmatter changeset 即正确形态;⛔ 永不要求或铸出该标签。 - 就地修范围外的邻接缺陷 ⇒ 四条件逐条核:同缺陷类、机械、无他人认领、同门禁族。 - 再核 claim 文件面同轮已修订、PR 正文点名该修并载证据;缺一条即判 REWORK。 - 已发布 = 各包 `files[]` 实际发运的内容;spec 发运 `src/**/*.zod.ts`,假 TSDoc/`.describe()` 即缺陷。 diff --git a/.claude/skills/pm-dispatch/references/seat-post-protocol.md b/.claude/skills/pm-dispatch/references/seat-post-protocol.md index cfef90c05dd..b046ddc4f2b 100644 --- a/.claude/skills/pm-dispatch/references/seat-post-protocol.md +++ b/.claude/skills/pm-dispatch/references/seat-post-protocol.md @@ -77,9 +77,9 @@ ## 发版板运维(`target:`) -- 生产者:主仓归总监席,objectui 与 cloud 归各自整仓座位。 -- 其余座位不打不摘,误判走上报;⛔ 三个生产者各扫各的 backlog。 -- 消费者:维护者的发版清单 = 三仓三条查询 `label:target: is:open`。 +- 生产者:主仓归总监席,cloud 归其整仓座位。 +- 其余座位不打不摘,误判走上报;⛔ 两个生产者各扫各的 backlog。 +- 消费者:维护者的发版清单 = 两仓两条查询 `label:target: is:open`。 - 等价的 org 级搜索仅是备用写法;归零 = 可发版指三张板都空,单看主仓归零不是。 - ⛔ 不用姊妹仓命中即误标的旧读法:它教读者销毁真实阻塞的唯一证据。 - 鲜度:每 ~5 轮对板上 open 项做过时前提检查,已修或不成立的摘牌 + 一句评论。 diff --git a/.claude/skills/pm-dispatch/references/true-green.md b/.claude/skills/pm-dispatch/references/true-green.md index 7761a6e2715..ee2d35ba815 100644 --- a/.claude/skills/pm-dispatch/references/true-green.md +++ b/.claude/skills/pm-dispatch/references/true-green.md @@ -15,12 +15,12 @@ - 而那种误用退出码照样 0;该脚本只住本仓,喂姊妹仓路径得到的仍是本仓的门禁族。 - 那种答案形态完整、退出码 0、全错;`--repo /` 申报归属可机械拒错。 -## objectui(前端) +## `ui/`(前端) -- 正典跑法:仅仓根 `pnpm test`。 +- 正典跑法:仅 `ui/` 根(`pnpm --dir ui test`)。 - vitest 守卫拒绝 package 目录内的运行:那种跑法曾假绿,无关文件通过而目标用例 0 个在跑。 - 守卫的拒绝是保护,⛔ 不绕。 -- typecheck 脚本拼作 `type-check`(连字符);拼错脚本名的 `pnpm --filter` 匹配零脚本、退出码 0。 +- `ui/` 的包把 typecheck 拼作 `type-check`;拼错脚本名的 `pnpm --filter` 匹配零脚本、退出码 0。 - 那是静默假绿:核对输出确实回显了脚本名再信绿。 ## cloud diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index bce8b1d5007..8335b7a191e 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -1149,7 +1149,8 @@ export const CEILINGS = new Map([ // 2026-08-17 rule in any case. Landed count, headroom 0, same convention. // Lowered 469 → 460 by the tool-first diet: nine readings a `scripts/pm` tool // now holds were deleted whole (lowering is always legitimate). Landed count. - ['.claude/skills/pm-dispatch/references/platform-readings.md', 460], + // 460 → 458 (#25753): lines that named objectui as a sibling repo retired; landed count. + ['.claude/skills/pm-dispatch/references/platform-readings.md', 458], // Per-operation REST/GraphQL/git channel mapping — which fleet operation has // a REST twin (each row executed in a real session, provenance date carried // per row), the handful that are GraphQL-only, and the queue-routing @@ -1189,13 +1190,15 @@ export const CEILINGS = new Map([ // 「各条规则的出处叙事、事故复盘 根本不重要啊,不需要写入skills啊」) — provenance dates, // ruling citations and incident narrative left; every rule stayed. Landed count, // headroom 0, same convention (lowering is always legitimate). - ['.claude/skills/pm-dispatch/references/rest-channel.md', 82], + // 82 → 80 (#25753): lines that named objectui as a sibling repo retired; landed count. + ['.claude/skills/pm-dispatch/references/rest-channel.md', 80], // Lowered 84 → 77 by the rules-only rewrite of the seven core references // (maintainer ruling: provenance narratives, incident post-mortems and rationale // tails leave the corpus; one rule per ≤120-byte line, no rule already stated in // SKILL.md). Landed count, headroom 0, same convention (lowering is always // legitimate). - ['.claude/skills/pm-dispatch/references/review-checklist.md', 77], + // 77 → 76 (#25753): lines that named objectui as a sibling repo retired; landed count. + ['.claude/skills/pm-dispatch/references/review-checklist.md', 76], // Lowered 80 → 69 by the rules-only rewrite of the seven core references // (maintainer ruling: provenance narratives, incident post-mortems and rationale // tails leave the corpus; one rule per ≤120-byte line, no rule already stated in @@ -1381,7 +1384,8 @@ export const CEILINGS = new Map([ // in place (③ and ④ of the gate merged onto one line), so the measured delta // is +4. Not a CROSS_FILE_MOVES destination, so no `ruledRaises` record // applies. Landed count, headroom 0, same convention. - ['.claude/skills/pm-dispatch/references/filing-gate.md', 68], + // 68 → 66 (#25753): lines that named objectui as a sibling repo retired; landed count. + ['.claude/skills/pm-dispatch/references/filing-gate.md', 66], // The project layer above the seats (maintainer's word: ONE project form, the // instruction line 「pm-dispatch <席位…>」 is the summon, seats are read from the // seat posts' 🟢 line, takeover never waits for a walled account, mailbox PRs, @@ -1478,7 +1482,8 @@ export const CEILINGS = new Map([ // 「`lanes/director.md` 职责行」: the seat an app project summons reviews that // repo's business-rationale first touch. Measured +1, ceiling moved by +1; // recorded under the move below as a `ruledRaises` record. - ['.claude/skills/pm-dispatch/references/lanes/director.md', 88], + // 88 → 87 (#25753): lines that named objectui as a sibling repo retired; landed count. + ['.claude/skills/pm-dispatch/references/lanes/director.md', 87], // Triage seat charter (maintainer ruling: the triage seat stays single; pickup // alternates between the two multi-lane repos, oldest-first inside a repo, with a // 4-hour starvation guard). A seat charter is read per seat session like every @@ -1542,14 +1547,16 @@ export const CEILINGS = new Map([ // text needs, the ruling id and the delta quoted in the raising PR's body. // Not a CROSS_FILE_MOVES destination, so no `ruledRaises` record applies. // Landed count, headroom 0, same convention. - ['.claude/agents/os-dev.md', 404], + // 404 → 402 (#25753): lines that named objectui as a sibling repo retired; landed count. + ['.claude/agents/os-dev.md', 402], // #9473: the other four `.claude/skills/` are read in full by the sessions // that use them too — the erosion mechanism the ratchet exists to stop // isn't specific to the pm-dispatch surface. Set at current counts on // `origin/main` (headroom 0, same convention as the entries above). ['.claude/skills/checklist-test/SKILL.md', 238], ['.claude/skills/checklist-author/SKILL.md', 62], - ['.claude/skills/dogfood-verification/SKILL.md', 157], + // 157 → 140 (#25753): lines that named objectui as a sibling repo retired; landed count. + ['.claude/skills/dogfood-verification/SKILL.md', 140], // 328 → 334 (#10848): maintainer-ruled (2026-08-22, Option A) — convention 5 // replaced with the pin's house sentence AND the pin docblock's one allowed // variant shape carried into the skill, +6 lines within the card's budget. From 39962718b0105895b9c12c6377d14e662d047a19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 09:41:08 +0000 Subject: [PATCH 03/11] chore(ui-import): retire the one-time objectui lockfile splice tool The splice ran once, at the ui/ cutover. The pnpm-workspace.yaml comment that cited its step 6 now names the commit where the recipe can still be read. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibvE77Ed7MKdzPxJTQQ --- pnpm-workspace.yaml | 7 +- scripts/ui-import/README.md | 192 ---------------- scripts/ui-import/splice-lockfile.mjs | 320 -------------------------- 3 files changed, 4 insertions(+), 515 deletions(-) delete mode 100644 scripts/ui-import/README.md delete mode 100644 scripts/ui-import/splice-lockfile.mjs diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3a4f4296625..37225d71efd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -709,9 +709,10 @@ overrides: # 21.0.4, one major above what objectui locked. Kept on purpose: 20.x has # no fix, and no red class of the verification runs (objectstack#25718) # traces to happy-dom. It is the one override that still moves an - # objectui dependency (scripts/ui-import/README.md step 6, measured on - # objectui main 3b7b04007f); the react-router floor above is scoped to - # 8.x so objectui's 7.x line stays its own. + # objectui dependency (measured on objectui main 3b7b04007f by step 6 of + # the import recipe, scripts/ui-import/README.md, which retired with its + # splice tool in #25753; read it at 1c632311); the react-router floor + # above is scoped to 8.x so objectui's 7.x line stays its own. # NOT cleared: @azure/msal-node GHSA-xcx6-jx8p-x3h5 (high) — 5.4.1 from # @azure/identity 4.13.1 (^5.1.0) under tedious 18.6.2, driver-sql's # optional mssql peer. The fix is only in msal-node 7.1.0 and no diff --git a/scripts/ui-import/README.md b/scripts/ui-import/README.md deleted file mode 100644 index 9ead63ca877..00000000000 --- a/scripts/ui-import/README.md +++ /dev/null @@ -1,192 +0,0 @@ -# ui-import — dependency-neutral lockfile for the `ui/` subtree - -Importing objectui under `ui/` must not change which dependency versions objectui's -code runs on. Deleting objectui's lockfile and running `pnpm install` re-resolves every -`ui/*` dependency from its range and moves dozens of them; `splice-lockfile.mjs` instead -carries objectui's own resolutions into the root `pnpm-lock.yaml`. The script header is -the authority on what it writes and refuses. - -## Procedure (cutover, from the repository root) - -1. `git subtree add --prefix ui `, then the adaptation commits: ui - manifests take `workspace:^` for every `@objectstack/*`, objectui's `pnpm` field - (react pins, security floors, the focus-scope patch) moves to the root - `pnpm-workspace.yaml`. -2. Keep objectui's lockfile for the splice, then delete it from the tree: - `git show :pnpm-lock.yaml > /tmp/objectui-lock.yaml && git rm ui/pnpm-lock.yaml`. -3. Splice: - `node scripts/ui-import/splice-lockfile.mjs --base pnpm-lock.yaml --import /tmp/objectui-lock.yaml --prefix ui --out pnpm-lock.yaml` - It must end with `moved 0`. -4. Reconcile with pnpm: `pnpm install --lockfile-only`. pnpm applies the root - `overrides` (security floors) to the imported subgraph and re-resolves peer - suffixes; nothing else. Run it a second time: the file must not change. Then - `pnpm dedupe`, so one copy of a package serves both trees wherever both ranges admit it - (one `vite` and one `postcss` for ui/ and packages/, one `ai` 7.0.118 and one - `@ai-sdk/react` 4.0.121; objectstack#25752 item 16). `pnpm dedupe --check` then exits 0. -5. `pnpm install --frozen-lockfile` must pass. -6. Re-measure the reconciled file: - `node scripts/ui-import/splice-lockfile.mjs --measure pnpm-lock.yaml --import /tmp/objectui-lock.yaml --prefix ui`. Every row still moved must be an override the root - `pnpm-workspace.yaml` keeps on purpose, named in its comment block. -7. Regenerate nothing. With the ui importers on objectui's versions no generated file - (for example `ui/packages/components/src/lib/lucide-record-icon-names.ts`) changes; - if a `check:` gate asks for a regeneration, a dependency moved, and step 6 says which. - -An override that would move a dependency objectui pins onto another major, or split one -line (react-router under react-router-dom), is scoped in `pnpm-workspace.yaml` with a -comment naming the advisory range it still covers; dependency upgrades for objectui are -their own changes after the cutover. - -# ui-import: the objectui side of the release-lane merge (objectstack#25711) - -This is the record of the `ui/` half of sub-issue 4a of objectstack#25677. It removed -objectui's pin on objectstack (`.objectstack-sha`, `.objectstack-pin/`, the scripts that -read them), the dist-injection Vite probes, objectui's own release lane and its own -Changesets config. While `ui/` did not exist on `main` it was carried as a patch file, -`4a-ui-side.patch`. In the cutover sitting, right after the subtree PR (objectstack#25825) -landed on `main` (`fd40831416`), 4a's own PR (objectstack#25745) applied it as tree edits -together with the root half of 4a, and deleted the patch file in the same commit. - -## What it changed - -Deleted (step 2 below): - -- the pin: `.objectstack-sha`, `.objectstack-pin/` (7 packed manifests), - `scripts/bump-objectstack.sh`, `scripts/objectstack-pin.mjs`, - `scripts/objectstack-floor.mjs`, `scripts/check-spec-range-floors.mjs`, - `scripts/check-installed-spec-pin-claims.mjs`, and their tests; -- the dist-pin Vite probes: `scripts/vite-objectstack-spec-dist.ts`, - `scripts/vite-objectstack-client-dist.ts` (`OBJECTSTACK_SPEC_DIST` / - `OBJECTSTACK_CLIENT_DIST`), and their tests. The workspace arm - (`scripts/vite-objectstack-workspace.ts`) is what remains; -- objectui's release lane: `.github/workflows/changeset-release.yml`, - `.github/workflows/spec-range-floors.yml`, `scripts/release-github-releases.mjs`, and - their tests (the root `release.yml` publishes both scopes, and the root - `scripts/release-github-releases.mjs` cuts a Release per package tag of both); -- `.changeset/config.json`: changesets reads one config per repository, the root's; -- the QUICK_REFERENCE release sync (decision 17c, ruling B, carried here): - `scripts/sync-quick-reference-release.mjs`, its test, and the - `quick-reference-current-release-4143` pin. Its only release-path caller was - `changeset-release.yml`, deleted above. - -Changed (step 3 below): - -- `scripts/vite-single-zod.ts` (new): the single-zod build guard, moved out of the deleted - spec probe because it runs in every console build. `apps/console/vite.config.ts` drops - both injections and imports the guard from there. Its tests move to - `vite-single-zod.test.ts`, `console-optimize-deps.test.ts` and - `console-lazy-linter.test.ts` (the cases that did not depend on an injection); -- `scripts/workspace-root.mjs` gains `changesetConfigPath()`: the tree's own - `.changeset/config.json` standalone, the monorepo root's when nested. Every reader of - that config (`check-changeset-fixed`, `check-changeset-no-major`, - `check-changeset-presence`, `check-phantom-dependencies`, and the - `check-changeset-fixed` test) reads it through that helper; -- the private VS Code extension `object-ui` stays in the one fixed group (decision 17b, - ruling B): the root config lists it, and objectui's `check-changeset-fixed` and - `check-unused-dependencies` keep their extension rules and rows unchanged; -- `QUICK_REFERENCE.md`: "Current Release" holds no version literal any more and points - at the monorepo's fixed group; `package.json` drops `quick-reference:sync` / - `quick-reference:check` and the sync leg of `changeset:version`; -- `check-lockfile-dedupe.mjs`: the pinned-build precondition is gone; -- `package.json`: `setup` is a frozen install, `changeset:publish` loses the floor and - range-floor legs, `objectstack:build` / `objectstack:status` / `check:spec-floors` / - `check:installed-pin-claims` are gone; -- `.gitignore`, `eslint.config.js`, `turbo.objectui.json`, `scripts/setup.sh`, - `CONTRIBUTING.md`, `check-action-ref-convention.mjs`, `check-ui-root-lookups.mjs`, - `check-entry-guard.mjs`, `markdown-test-inputs.mjs`, - `quick-reference-commands-4149.test.ts`: their pin, release-lane and release-sync - rows. - -## How it was applied - -From the monorepo root, after `git merge origin/main` brought the subtree in: - -```sh -# 1. Not here: objectui's 65 pending ui/.changeset/*.md stay under ui/ for the -# aftercare card objectstack#25831, which conforms them to the root changeset -# gates and moves them before the first post-cutover Version PR. Measured on -# c04b36dcb9, moving them as they stand reds check-empty-changeset (3 empty -# frontmatters) and check-adr-0087-registration (41 breaking changesets with no -# `adr-0087:` disposition), and one name collides with a root changeset. - -# 2. The deletions. `--ignore-unmatch`: the cutover tree already dropped -# ui/.objectstack-pin. The nested pull_request_template.md never renders (GitHub -# reads the root one) and its only markdown-test-inputs reader was the claims test. -git rm -r -q --ignore-unmatch -- \ - ui/.github/pull_request_template.md \ - ui/.changeset/config.json \ - ui/.objectstack-sha ui/.objectstack-pin \ - ui/.github/workflows/changeset-release.yml ui/.github/workflows/spec-range-floors.yml \ - ui/scripts/bump-objectstack.sh ui/scripts/objectstack-pin.mjs ui/scripts/objectstack-floor.mjs \ - ui/scripts/check-spec-range-floors.mjs ui/scripts/check-installed-spec-pin-claims.mjs \ - ui/scripts/vite-objectstack-spec-dist.ts ui/scripts/vite-objectstack-client-dist.ts \ - ui/scripts/release-github-releases.mjs ui/scripts/sync-quick-reference-release.mjs \ - ui/scripts/__tests__/bump-objectstack.test.ts \ - ui/scripts/__tests__/changeset-release-action-ref-pin.test.ts \ - ui/scripts/__tests__/check-installed-spec-pin-claims.test.ts \ - ui/scripts/__tests__/check-spec-range-floors.test.ts \ - ui/scripts/__tests__/objectstack-floor.test.ts \ - ui/scripts/__tests__/objectstack-pin.test.ts \ - ui/scripts/__tests__/quick-reference-current-release-4143.test.ts \ - ui/scripts/__tests__/release-github-releases.test.ts \ - ui/scripts/__tests__/sync-quick-reference-release.test.ts \ - ui/scripts/__tests__/vite-objectstack-client-dist.test.ts \ - ui/scripts/__tests__/vite-objectstack-spec-dist.test.ts - -# 3. The modifications and the three new files: `git apply --directory=ui` of -# 4a-ui-side.patch, deleted in the same commit. - -# 4. The SDUI manifest regenerated from ui/ for the merged tree (107 -> 106 -# components; objectui#13449 retired `html`): sdui.manifest.json and -# scripts/sdui-manifest.record.json, from the patch the 4b PR 2a thread measured. -``` - -The patch was written against the verification branch head `9e37acd126` (objectstack#25697, -the run 8 tree) and re-checked with `git apply --check` on the cutover heads `1c0d02eda4` -and `c04b36dcb9` and on `main` at `fd40831416` before it was applied. - -## Verify - -```sh -pnpm --dir ui exec vitest run scripts/__tests__ # the whole suite, never a subset -pnpm --dir ui run type-check:scripts -pnpm --dir ui run type-check:vitest-config -pnpm --dir ui run lint:root -pnpm console:build # the single-zod and lazy-linter guards run inside it -``` - -Measured on 2026-10-25: - -- on `9e37acd126`, steps 1–3 apply cleanly and reproduce the edited `ui/` tree byte for - byte; the same edits on the earlier merged tree (objectui `cd70b7818`) did too - (60 files); -- on `9e37acd126` with steps 1–3 and the root half of 4a, after `pnpm install - --frozen-lockfile` and `pnpm run build`: `type-check:scripts`, - `type-check:vitest-config`, `lint:root` exit 0, and root and objectui - `check-changeset-fixed` both pass (112 public packages plus `object-ui` at that base). - `changeset version` moved all 113 to 18.2.0 together, `object-ui` included; -- on the cutover head `1c0d02eda4` (objectui frozen at `9e1fe03286`), with step 3 and - the root half of 4a: `@object-ui/runner` is private there and in objectui's `ignore` - (objectui#13506), so the root `fixed` group drops it and the root `ignore` takes it. - Root and objectui `check-changeset-fixed` exit 0 (111 public packages plus `object-ui`), - `check:lockstep-package-count` reads 111, and `changeset version` moves all 112 members - to 18.2.0 together while `@object-ui/runner` stays at 18.1.0; -- on the cutover head `c04b36dcb9` with steps 1–3 and the root half of 4a, against the - same head without them (both installed, neither built): `vitest run scripts/__tests__` - clears the 12 the cutover leaves to 4a (`check-installed-spec-pin-claims` ×4, - `quick-reference-current-release-4143` ×3, `sync-quick-reference-release` ×5) and the - rest of the deleted pin class with them. It adds 12 that read objectui's nested - `ui/.github/workflows/` (`ci-cd-pipeline-doc` ×9, `workflow-cache-save-bound` ×2, - `check-pre-install-import-graph` ×1: they still name the two deleted workflows and - `scripts/objectstack-pin.mjs`). 4b PR 2 (objectstack#25712) deletes those workflows with - the tests that read them. `markdown-test-inputs`, `check-changeset-fixed` and - `check-unused-dependencies` pass. The `optimizeDeps` case fails identically on both - sides, because there is no spec build, and only moved to `console-optimize-deps.test.ts`; -- earlier, on `9e37acd126`: `vitest run scripts/__tests__`: 17 failed / 5,573 passed. Two of them are 15 s - timeouts under load (`check-vi-mock-inherit`, `dollar-dialect-alias-census`), which - pass alone (125/125). Two failed the same way on the earlier merged tree without the - patch: `check-lockfile-integrity`, and `quick-reference-commands-4149` on the root - scripts the adaptation drops (cutover bill). The other 14 all read objectui's nested `ui/.github/` - (`ci-cd-pipeline-doc` ×10, `workflow-cache-save-bound` ×2, - `check-pre-install-import-graph`, `markdown-test-inputs` over - `.github/pull_request_template.md`). Those workflows never run nested, and sub-issue 4b - (objectstack#25712) PR 2 deletes them together with the tests that read them. diff --git a/scripts/ui-import/splice-lockfile.mjs b/scripts/ui-import/splice-lockfile.mjs deleted file mode 100644 index fd79d460607..00000000000 --- a/scripts/ui-import/splice-lockfile.mjs +++ /dev/null @@ -1,320 +0,0 @@ -#!/usr/bin/env node -// splice-lockfile — carries an imported workspace's own lockfile resolutions into -// this repository's single pnpm-lock.yaml, so that importing a tree (objectui under -// `ui/`) is dependency-neutral: every imported importer keeps the versions its own -// lockfile resolved, instead of whatever a fresh `pnpm install` would pick today. -// -// Usage: -// node scripts/ui-import/splice-lockfile.mjs \ -// --base pnpm-lock.yaml --import --prefix ui --out pnpm-lock.yaml -// [--root ] where the importers' package.json files live (default: this repo) -// node scripts/ui-import/splice-lockfile.mjs --measure pnpm-lock.yaml --import --prefix ui -// writes nothing; prints the moved-row count of an existing (e.g. reconciled) lockfile -// -// What the output holds: -// - every importer of --import, re-keyed under --prefix (`.` becomes `ui`, -// `packages/x` becomes `ui/packages/x`), with the specifier and resolved version -// its own lockfile recorded; -// - except a dependency on a package of THIS workspace (any `@objectstack/*` the -// base importers provide): it becomes the workspace link the adapted manifest -// asks for (`link:../../../packages/`, specifier read from the manifest); -// - `snapshots:` and `packages:` entries for everything reachable from the base's -// own importers and from the re-keyed importers. An entry both lockfiles hold -// under the same key is taken from --base, so the base graph stays byte-stable; -// - every importer of --base outside --prefix, unchanged (asserted before writing), -// plus the base's `settings`, `overrides` and `patchedDependencies`. -// -// The output is written in pnpm's own lockfile layout (codepoint-sorted keys, blank -// lines between entries, flow style for resolution/engines/cpu/os/libc); the run -// first proves the writer reproduces --base byte for byte and refuses if it does not. -// The splice is textual: run `pnpm install --lockfile-only` afterwards so pnpm -// reconciles what only it can decide (root overrides and patches that apply to the -// imported subgraph, peer suffixes); the procedure lives in this directory's README. -// -// It ends by printing the moved-row count: importer x dependency rows whose resolved -// version (peer suffix ignored) differs between --import and --out. 0 is the pass; -// `--measure` re-counts a lockfile pnpm has reconciled since. -// -// Exit codes: 0 written, 1 refused (invariant broken), 2 usage error. - -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { parseArgs, isDeepStrictEqual } from 'node:util'; - -const require = createRequire(import.meta.url); -const YAML = require('yaml'); // root devDependency; no new dependency - -const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..'); -const DEP_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies']; -const SPACED_SECTIONS = new Set(['importers', 'packages', 'snapshots']); -const FLOW_PACKAGE_FIELDS = new Set(['resolution', 'engines', 'cpu', 'os', 'libc']); - -function fail(code, msg) { - console.error(`splice-lockfile: ${msg}`); - process.exit(code); -} - -// ---------- pnpm lockfile layout ---------- - -const codepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0); - -function sortedObject(obj) { - const out = {}; - for (const k of Object.keys(obj).sort(codepoint)) out[k] = obj[k]; - return out; -} - -function renderEntry(key, value, section) { - const doc = new YAML.Document({ [key]: value }); - if (section === 'packages' && value && typeof value === 'object') { - for (const field of FLOW_PACKAGE_FIELDS) { - const node = doc.getIn([key, field], true); - if (node && typeof node === 'object' && 'items' in node) node.flow = true; - } - } - const text = doc.toString({ singleQuote: true, lineWidth: 0, flowCollectionPadding: false }); - return text.replace(/^(?=.)/gm, ' '); -} - -function renderLockfile(lock) { - const parts = []; - for (const [key, value] of Object.entries(lock)) { - if (value === null || typeof value !== 'object') { - parts.push(YAML.stringify({ [key]: value }, { singleQuote: true, lineWidth: 0 })); - continue; - } - const spaced = SPACED_SECTIONS.has(key); - const entries = Object.entries(value).map(([k, v]) => renderEntry(k, v, key)); - parts.push(`${key}:\n${spaced ? '\n' : ''}${entries.join(spaced ? '\n' : '')}`); - } - return parts.join('\n'); -} - -// ---------- dependency paths (mirrors @pnpm/dependency-path refToRelative) ---------- - -function refToKey(name, ref) { - if (typeof ref !== 'string' || ref.startsWith('link:')) return null; - if (ref.startsWith('@')) return ref; - const at = ref.indexOf('@'); - if (at === -1) return `${name}@${ref}`; - const colon = ref.indexOf(':'); - const paren = ref.indexOf('('); - if ((colon === -1 || at < colon) && (paren === -1 || at < paren)) return ref; // npm alias - return `${name}@${ref}`; -} - -function packageKey(snapshotKey) { - // `name@version(peer@x)(patch_hash=y)` -> `name@version`; a scoped name starts with '@'. - const paren = snapshotKey.indexOf('(', 1); - return paren === -1 ? snapshotKey : snapshotKey.slice(0, paren); -} - -const versionOf = (info) => (info && typeof info === 'object' ? info.version : info); - -// ---------- the splice ---------- - -function importerKeyFor(prefix, imp) { - return imp === '.' ? prefix : `${prefix}/${imp}`; -} - -function inPrefix(prefix, key) { - return key === prefix || key.startsWith(`${prefix}/`); -} - -function readManifest(root, importerKey) { - const file = path.join(root, importerKey, 'package.json'); - return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null; -} - -function splice({ base, imported, prefix, root }) { - const notes = []; - const out = {}; - for (const k of Object.keys(base)) out[k] = base[k]; - - // Workspace packages the base provides, by name -> importer dir. - const workspace = new Map(); - for (const key of Object.keys(base.importers)) { - if (inPrefix(prefix, key)) continue; - const manifest = readManifest(root, key); - if (manifest?.name) workspace.set(manifest.name, key); - } - - const importers = {}; - for (const [key, entry] of Object.entries(base.importers)) { - if (!inPrefix(prefix, key)) importers[key] = entry; - } - for (const [imp, entry] of Object.entries(imported.importers)) { - const key = importerKeyFor(prefix, imp); - const manifest = readManifest(root, key); - if (!manifest) fail(1, `no package.json for imported importer ${key} under ${root}`); - const next = structuredClone(entry); - for (const sect of DEP_SECTIONS) { - for (const [name, info] of Object.entries(next[sect] ?? {})) { - if (workspace.has(name)) { - // Re-placed below: the adapted manifest decides the section and specifier. - delete next[sect][name]; - continue; - } - const wanted = manifest[sect]?.[name]; - const declared = [...DEP_SECTIONS, 'peerDependencies'].some((s) => manifest[s]?.[name] !== undefined); - if (!declared) { - notes.push(`${key} ${name}: in the imported lock but not in package.json (pnpm reconciles)`); - } else if (wanted !== undefined && wanted !== info.specifier) { - notes.push(`${key} ${name}: package.json asks ${wanted}, the imported lock recorded ${info.specifier} (pnpm reconciles)`); - } - } - } - for (const [name, dir] of workspace) { - // pnpm's precedence: optionalDependencies, then dependencies, then devDependencies; - // a peer-only dependency is auto-installed into dependencies (autoInstallPeers). - const sect = ['optionalDependencies', 'dependencies', 'devDependencies'].find((s) => manifest[s]?.[name] !== undefined) - ?? (manifest.peerDependencies?.[name] !== undefined ? 'dependencies' : null); - if (!sect) continue; - const specifier = manifest[sect]?.[name] ?? manifest.peerDependencies[name]; - const rel = path.relative(key, dir).split(path.sep).join('/'); - next[sect] = sortedObject({ ...(next[sect] ?? {}), [name]: { specifier, version: `link:${rel}` } }); - const prior = base.importers[key]?.[sect]?.[name]; - if (prior && !isDeepStrictEqual(prior, next[sect][name])) { - notes.push(`${key} ${name}: base lock had ${JSON.stringify(prior)}, writing ${JSON.stringify(next[sect][name])}`); - } - } - for (const sect of DEP_SECTIONS) { - if (next[sect] && Object.keys(next[sect]).length === 0) delete next[sect]; - for (const name of Object.keys(manifest[sect] ?? {})) { - if (!workspace.has(name) && !DEP_SECTIONS.some((s) => next[s]?.[name])) { - notes.push(`${key} ${name}: in package.json ${sect} but not in the imported lock (pnpm reconciles)`); - } - } - } - importers[key] = next; - } - out.importers = sortedObject(importers); - - // Reachability over the union graph: a key both locks hold resolves to the base entry. - const snapshots = {}; - const packages = {}; - let sharedDiffering = 0; - const pending = []; - const visit = (name, ref, from) => { - const k = refToKey(name, ref); - if (k === null || k in snapshots) return; - if (k.includes('.objectstack-pin')) fail(1, `${from} still reaches the pin entry ${k}`); - const own = base.snapshots?.[k]; - const theirs = imported.snapshots?.[k]; - if (own === undefined && theirs === undefined) fail(1, `${from} needs ${k}, which neither lockfile holds`); - if (own !== undefined && theirs !== undefined && !isDeepStrictEqual(own, theirs)) sharedDiffering++; - snapshots[k] = own ?? theirs; - const pk = packageKey(k); - const ownPkg = base.packages?.[pk]; - const theirPkg = imported.packages?.[pk]; - if (ownPkg && theirPkg && !isDeepStrictEqual(ownPkg.resolution, theirPkg.resolution)) { - fail(1, `${pk} resolves differently in the two lockfiles (${JSON.stringify(ownPkg.resolution)} vs ${JSON.stringify(theirPkg.resolution)})`); - } - if (!ownPkg && !theirPkg) fail(1, `${pk} has a snapshot but no packages entry in either lockfile`); - packages[pk] = ownPkg ?? theirPkg; - pending.push(k); - }; - for (const [key, entry] of Object.entries(out.importers)) { - for (const sect of DEP_SECTIONS) { - for (const [name, info] of Object.entries(entry[sect] ?? {})) visit(name, versionOf(info), key); - } - } - while (pending.length) { - const k = pending.pop(); - const snap = snapshots[k]; - for (const sect of ['dependencies', 'optionalDependencies']) { - for (const [name, ref] of Object.entries(snap[sect] ?? {})) visit(name, ref, k); - } - } - out.packages = sortedObject(packages); - out.snapshots = sortedObject(snapshots); - - // Invariant: the base's own importers are untouched. - for (const [key, entry] of Object.entries(base.importers)) { - if (inPrefix(prefix, key)) continue; - if (!isDeepStrictEqual(out.importers[key], entry)) fail(1, `base importer ${key} changed`); - } - const baseOnly = Object.keys(out.importers).filter((k) => !inPrefix(prefix, k)).length; - if (baseOnly !== Object.keys(base.importers).filter((k) => !inPrefix(prefix, k)).length) { - fail(1, 'the set of base importers changed'); - } - - for (const [name, patch] of Object.entries(imported.patchedDependencies ?? {})) { - const covered = Object.values(base.patchedDependencies ?? {}).some((p) => p.hash === patch.hash); - if (!covered) notes.push(`patch ${name} (${patch.path}) is not in the base patchedDependencies: add it to the root pnpm-workspace.yaml before reconciling`); - } - return { out, notes, sharedDiffering }; -} - -// ---------- the moved-row measurement ---------- - -function movedRows(imported, merged, prefix) { - const moved = []; - let same = 0; - for (const [imp, spec] of Object.entries(imported.importers)) { - const target = merged.importers[importerKeyFor(prefix, imp)]; - if (!target) continue; - for (const sect of DEP_SECTIONS) { - for (const [name, info] of Object.entries(spec[sect] ?? {})) { - const va = versionOf(info); - const vb = versionOf(target[sect]?.[name]); - if (vb == null || va.startsWith('link:') || va.startsWith('file:')) continue; - const a = packageKey(`x@${va}`).slice(2); - const b = packageKey(`x@${vb}`).slice(2); - if (a !== b) moved.push(`${importerKeyFor(prefix, imp)} ${name} ${a} -> ${b}`); - else same++; - } - } - } - return { moved, same }; -} - -// ---------- main ---------- - -const { values } = parseArgs({ - options: { - base: { type: 'string' }, - import: { type: 'string' }, - prefix: { type: 'string', default: 'ui' }, - out: { type: 'string' }, - root: { type: 'string', default: REPO_ROOT }, - measure: { type: 'string' }, - }, -}); -const prefix = values.prefix.replace(/\/+$/, ''); -function report(imported, merged) { - const { moved, same } = movedRows(imported, merged, prefix); - for (const m of moved) console.log(`moved: ${m}`); - console.log(`moved ${moved.length} same ${same}`); -} -if (values.measure && values.import) { - report(YAML.parse(readFileSync(values.import, 'utf8')), YAML.parse(readFileSync(values.measure, 'utf8'))); - process.exit(0); -} -if (!values.base || !values.import || !values.out) { - fail(2, 'usage: --base --import --prefix --out [--root ] | --measure --import --prefix '); -} -const baseText = readFileSync(values.base, 'utf8'); -const base = YAML.parse(baseText); -const imported = YAML.parse(readFileSync(values.import, 'utf8')); -for (const [label, lock] of [['--base', base], ['--import', imported]]) { - if (lock?.lockfileVersion !== '9.0') fail(1, `${label} is lockfileVersion ${lock?.lockfileVersion}, expected 9.0`); -} -if (renderLockfile(base) !== baseText) { - fail(1, '--base does not round-trip through this writer byte for byte; the pnpm layout changed, update renderLockfile'); -} - -const { out, notes, sharedDiffering } = splice({ base, imported, prefix, root: path.resolve(values.root) }); -writeFileSync(values.out, renderLockfile(out)); - -for (const n of notes) console.log(`note: ${n}`); -const uiImporters = Object.keys(out.importers).filter((k) => inPrefix(prefix, k)).length; -console.log( - `wrote ${values.out}: ${Object.keys(out.importers).length} importers (${uiImporters} under ${prefix}/), ` + - `${Object.keys(out.packages).length} packages, ${Object.keys(out.snapshots).length} snapshots; ` + - `${sharedDiffering} shared snapshot keys taken from --base where the two locks differ`, -); -report(imported, out); From dbb70e1390384f14bc503b39b7dc81232fab22f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 09:41:08 +0000 Subject: [PATCH 04/11] chore(spec): regenerate the field liveness counts after the externalId re-grade Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibvE77Ed7MKdzPxJTQQ --- packages/spec/liveness/state-counts/field.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md index 48598f1aa23..44c27f7ea58 100644 --- a/packages/spec/liveness/state-counts/field.md +++ b/packages/spec/liveness/state-counts/field.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `field` | 100 | 0 | 0 | 1 | 6 | 107 | +| `field` | 99 | 0 | 0 | 2 | 6 | 107 | From 5d9b4d738a3e11ee54c5cdd853ad87549ce0051a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 09:59:35 +0000 Subject: [PATCH 05/11] test(lint): the shipped field ledger now warns on externalId too Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibvE77Ed7MKdzPxJTQQ --- packages/lint/src/lint-liveness-properties.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 3eac4ef5cd7..5dfed986aac 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -1673,9 +1673,12 @@ describe('the object/field walk, against a synthetic ledger directory (#19268)', // `planned` + `authorWarn` — the inbound-mail correspondent flag on an email // field, declared ahead of the mailbox ingest that reads it — and left this // set when that reader landed and the row flipped `live` (#22871): a - // verdict that moved, so it was never a stable subject either. The - // synthetic slot below is warned by nothing shipped. - expect([...authorWarnedProperties('field')]).toEqual(['conditionalRequired']); + // verdict that moved, so it was never a stable subject either. + // `externalId` joined the set when #25753 graded it `dead` (no upsert reads + // it). It parses, so it is a stable subject the block above could take + // back; it keeps the synthetic slot so that block does not move with one + // more verdict. The synthetic slot below is warned by nothing shipped. + expect([...authorWarnedProperties('field')]).toEqual(['conditionalRequired', 'externalId']); expect( lintLivenessProperties({ objects: [{ name: 'widget', fields: [{ name: 'a', synthWarnedSlot: true }] }], From 27f831513960812e34625145a1f7558843ec0106 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 10:09:42 +0000 Subject: [PATCH 06/11] docs(spec): a field's externalId description stops promising upserts The flag is graded dead: no upsert, import or seed reads it. The schema description, the generated reference pages and three hand-written field tables now say so and name the two keys that do match records. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibvE77Ed7MKdzPxJTQQ --- .changeset/25753-field-external-id-dead.md | 4 ++-- content/docs/data-modeling/field-types.mdx | 2 +- content/docs/data-modeling/fields.mdx | 2 +- content/docs/data-modeling/validation-rules.mdx | 2 +- content/docs/references/data/field.mdx | 2 +- content/docs/references/data/object.mdx | 4 ++-- content/docs/references/system/migration.mdx | 4 ++-- packages/spec/src/data/field.zod.ts | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.changeset/25753-field-external-id-dead.md b/.changeset/25753-field-external-id-dead.md index 63645526851..df724c8fa1a 100644 --- a/.changeset/25753-field-external-id-dead.md +++ b/.changeset/25753-field-external-id-dead.md @@ -4,10 +4,10 @@ fix(spec): `os lint` tells an author that a field's `externalId: true` does nothing -Clause-②: no (a liveness-ledger re-grade: no schema, accept set, served body or error code moves; only the advisory `liveness-dead-property` warning on an authored `externalId: true` starts) +Clause-②: no (a liveness-ledger re-grade: no schema, accept set, served body or error code moves; only the advisory `liveness-dead-property` warning on an authored `externalId: true` starts, and the flag's description is reworded) Metadata-protocol: none -The shipped liveness ledger (`liveness/field.json`) graded a field's `externalId` flag `live` on the strength of one renderer module that was deleted for having no importers. Re-measured across `packages/**` and `ui/**`, nothing reads the flag: the Studio field inspector edits it and a metadata summary copies it, and no upsert matches on it. The row is now `dead`, so `os lint` warns on a field that sets `externalId: true` (the default `false` is not warned). +The shipped liveness ledger (`liveness/field.json`) graded a field's `externalId` flag `live` on the strength of one renderer module that was deleted for having no importers. Re-measured across `packages/**` and `ui/**`, nothing reads the flag: the Studio field inspector edits it and a metadata summary copies it, and no upsert matches on it. The row is now `dead`, so `os lint` warns on a field that sets `externalId: true` (the default `false` is not warned). The flag's schema description, and the reference pages generated from it, no longer say it drives upserts. - **What to do.** Remove the flag. To match seed records on a field, name the field in the seed dataset's `externalId` (`name` when it is left out). For an import, name it in the mapping's `upsertKey`. - **Unchanged.** Parsing (the key is still accepted, default `false`), seed loading, imports and every upsert. diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index 464456dcc24..31c9d697f18 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -692,7 +692,7 @@ These properties are available on **all** field types: | `sortable` | `boolean` | `true` | Allow sorting by this field | | `hidden` | `boolean` | `false` | Hide from default views | | `readonly` | `boolean` | `false` | Server contract — non-system writes to this field are stripped on **both** INSERT and UPDATE (the value falls back to `defaultValue`), not just hidden in the UI (#2948 / #3043) | -| `externalId` | `boolean` | `false` | External system identifier | +| `externalId` | `boolean` | `false` | Not read: nothing matches on it. A seed dataset names its match field in its own `externalId`, an import in its mapping's `upsertKey` | | `defaultValue` | `any` | — | Default value for new records | | `group` | `string` | — | Field grouping / section | | `inlineHelpText` | `string` | — | Inline help tooltip | diff --git a/content/docs/data-modeling/fields.mdx b/content/docs/data-modeling/fields.mdx index 0eba1edbec4..e398fe756a2 100644 --- a/content/docs/data-modeling/fields.mdx +++ b/content/docs/data-modeling/fields.mdx @@ -369,7 +369,7 @@ These properties are available on all field types: | Property | Type | Default | Description | | :--- | :--- | :--- | :--- | | `searchable` | `boolean` | `false` | Include in full-text search | -| `externalId` | `boolean` | `false` | Mark as external system identifier | +| `externalId` | `boolean` | `false` | Not read: nothing matches on it. A seed dataset names its match field in its own `externalId`, an import in its mapping's `upsertKey` | Database indexes are declared at the object level in the object's `indexes[]` array — there is no per-field `index` flag. See [Indexing](/docs/data-modeling/indexing). diff --git a/content/docs/data-modeling/validation-rules.mdx b/content/docs/data-modeling/validation-rules.mdx index 5dafb264e30..4060fb960fd 100644 --- a/content/docs/data-modeling/validation-rules.mdx +++ b/content/docs/data-modeling/validation-rules.mdx @@ -28,7 +28,7 @@ These properties apply to **all** field types and are validated by the base `Fie | `hidden` | `boolean` | `false` | Excluded from default UI rendering | | `readonly` | `boolean` | `false` | Blocks user edits in UI forms; server-enforced on **both create and update** — a non-system write to the field is silently dropped from the payload (system-context writes such as import/seed/migration are exempt) | | `sortable` | `boolean` | `true` | Whether field appears in list view sort options | -| `externalId` | `boolean` | `false` | Marks field as external ID for upsert operations | +| `externalId` | `boolean` | `false` | Not read: nothing matches on it. A seed dataset names its match field in its own `externalId`, an import in its mapping's `upsertKey` | | `trackHistory` | `boolean` | — | Render this field's value changes as entries on the record activity timeline | | `visibleWhen` | `string \| Expression` | — | CEL predicate; field is shown only when `TRUE` | | `readonlyWhen` | `string \| Expression` | — | CEL predicate; field is read-only when `TRUE` | diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 0597c0bee21..bceaf3649d8 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -124,7 +124,7 @@ const result = CurrencyConfigSchema.parse(data); | **inlineHelpText** | `string` | optional | Help text displayed below the field in forms | | **placeholder** | `string` | optional | Placeholder text rendered inside the empty input (the HTML placeholder attribute); disappears once a value is entered. Distinct from `inlineHelpText` (always-visible help rendered beside/under the input) and `description` (tooltip/developer documentation). | | **autonumberFormat** | `string` | optional (default: `"{0000}"`) | Auto-number format: literal text + `{0000}` counter, `{YYYY}`/`{MM}`/`{DD}`/`{YYYYMMDD}` date tokens (business tz), and `{field_name}` interpolation. Counter resets per rendered prefix (e.g. AD`{YYYYMMDD}``{0000}` resets daily). Omitted on an `autonumber` field ⇒ the contract default `{0000}`, which every driver and the engine fallback read, so one field numbers alike on every backend. | -| **externalId** | `boolean` | optional (default: `false`) | Is external ID for upsert operations | +| **externalId** | `boolean` | optional (default: `false`) | Not read: no upsert, import or seed matches on this flag. A seed dataset names its match field in its own `externalId`; an import names it in the mapping's `upsertKey`. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 9a5f7f9556d..af381d60701 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -292,7 +292,7 @@ const result = ApiMethod.parse(data); | **inlineHelpText** | `string` | optional | Help text displayed below the field in forms | | **placeholder** | `string` | optional | Placeholder text rendered inside the empty input (the HTML placeholder attribute); disappears once a value is entered. Distinct from `inlineHelpText` (always-visible help rendered beside/under the input) and `description` (tooltip/developer documentation). | | **autonumberFormat** | `string` | optional (default: `"{0000}"`) | Auto-number format: literal text + `{0000}` counter, `{YYYY}`/`{MM}`/`{DD}`/`{YYYYMMDD}` date tokens (business tz), and `{field_name}` interpolation. Counter resets per rendered prefix (e.g. AD`{YYYYMMDD}``{0000}` resets daily). Omitted on an `autonumber` field ⇒ the contract default `{0000}`, which every driver and the engine fallback read, so one field numbers alike on every backend. | -| **externalId** | `boolean` | optional (default: `false`) | Is external ID for upsert operations | +| **externalId** | `boolean` | optional (default: `false`) | Not read: no upsert, import or seed matches on this flag. A seed dataset names its match field in its own `externalId`; an import names it in the mapping's `upsertKey`. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | @@ -664,7 +664,7 @@ const result = ApiMethod.parse(data); | **inlineHelpText** | `string` | optional | Help text displayed below the field in forms | | **placeholder** | `string` | optional | Placeholder text rendered inside the empty input (the HTML placeholder attribute); disappears once a value is entered. Distinct from `inlineHelpText` (always-visible help rendered beside/under the input) and `description` (tooltip/developer documentation). | | **autonumberFormat** | `string` | optional (default: `"{0000}"`) | Auto-number format: literal text + `{0000}` counter, `{YYYY}`/`{MM}`/`{DD}`/`{YYYYMMDD}` date tokens (business tz), and `{field_name}` interpolation. Counter resets per rendered prefix (e.g. AD`{YYYYMMDD}``{0000}` resets daily). Omitted on an `autonumber` field ⇒ the contract default `{0000}`, which every driver and the engine fallback read, so one field numbers alike on every backend. | -| **externalId** | `boolean` | optional (default: `false`) | Is external ID for upsert operations | +| **externalId** | `boolean` | optional (default: `false`) | Not read: no upsert, import or seed matches on this flag. A seed dataset names its match field in its own `externalId`; an import names it in the mapping's `upsertKey`. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 110c79bf40e..95560dee891 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -125,7 +125,7 @@ Add a new field to an existing object | **inlineHelpText** | `string` | optional | Help text displayed below the field in forms | | **placeholder** | `string` | optional | Placeholder text rendered inside the empty input (the HTML placeholder attribute); disappears once a value is entered. Distinct from `inlineHelpText` (always-visible help rendered beside/under the input) and `description` (tooltip/developer documentation). | | **autonumberFormat** | `string` | optional (default: `"{0000}"`) | Auto-number format: literal text + `{0000}` counter, `{YYYY}`/`{MM}`/`{DD}`/`{YYYYMMDD}` date tokens (business tz), and `{field_name}` interpolation. Counter resets per rendered prefix (e.g. AD`{YYYYMMDD}``{0000}` resets daily). Omitted on an `autonumber` field ⇒ the contract default `{0000}`, which every driver and the engine fallback read, so one field numbers alike on every backend. | -| **externalId** | `boolean` | optional (default: `false`) | Is external ID for upsert operations | +| **externalId** | `boolean` | optional (default: `false`) | Not read: no upsert, import or seed matches on this flag. A seed dataset names its match field in its own `externalId`; an import names it in the mapping's `upsertKey`. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | @@ -556,7 +556,7 @@ Add a new field to an existing object | **inlineHelpText** | `string` | optional | Help text displayed below the field in forms | | **placeholder** | `string` | optional | Placeholder text rendered inside the empty input (the HTML placeholder attribute); disappears once a value is entered. Distinct from `inlineHelpText` (always-visible help rendered beside/under the input) and `description` (tooltip/developer documentation). | | **autonumberFormat** | `string` | optional (default: `"{0000}"`) | Auto-number format: literal text + `{0000}` counter, `{YYYY}`/`{MM}`/`{DD}`/`{YYYYMMDD}` date tokens (business tz), and `{field_name}` interpolation. Counter resets per rendered prefix (e.g. AD`{YYYYMMDD}``{0000}` resets daily). Omitted on an `autonumber` field ⇒ the contract default `{0000}`, which every driver and the engine fallback read, so one field numbers alike on every backend. | -| **externalId** | `boolean` | optional (default: `false`) | Is external ID for upsert operations | +| **externalId** | `boolean` | optional (default: `false`) | Not read: no upsert, import or seed matches on this flag. A seed dataset names its match field in its own `externalId`; an import names it in the mapping's `upsertKey`. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 445bb270381..7ab16fb1350 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -2321,7 +2321,7 @@ export const FieldSchema = lazySchema(() => { // `index` (field-level bool) removed in the 16.x line (#2377, ADR-0049): the // driver builds indexes from the object's `indexes[]` array; a field-level // `index: true` created no index. Declare the index in object `indexes[]`. - externalId: z.boolean().default(false).describe('Is external ID for upsert operations'), + externalId: z.boolean().default(false).describe('Not read: no upsert, import or seed matches on this flag. A seed dataset names its match field in its own `externalId`; an import names it in the mapping\'s `upsertKey`.'), // ADR-0010 — runtime protection envelope (internal — set by the loader). // `field` is a registered metadata type, so `MetadataPlugin`'s loader stamps From 03efc6cd06aff1b46ee0d7b4e617f67a0c6bfe4c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 12:26:31 +0000 Subject: [PATCH 07/11] chore(spec): regenerate the reference pages on the merged tree Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibvE77Ed7MKdzPxJTQQ --- content/docs/references/data/field.mdx | 1 - content/docs/references/data/object.mdx | 16 ---------------- content/docs/references/system/migration.mdx | 2 -- 3 files changed, 19 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index bceaf3649d8..c319e88e320 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -108,7 +108,6 @@ const result = CurrencyConfigSchema.parse(data); | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index af381d60701..749fb14f461 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -67,8 +67,6 @@ const result = ApiMethod.parse(data); | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | | **unique** | `false \| 'global' \| 'organization'` | optional (default: `false`) | Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): state the scope. 'tenant'/'org' are rejected — the word is 'organization' | -| **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | --- @@ -276,7 +274,6 @@ const result = ApiMethod.parse(data); | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | @@ -308,8 +305,6 @@ const result = ApiMethod.parse(data); | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | | **unique** | `false \| 'global' \| 'organization'` | optional (default: `false`) | Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): state the scope. 'tenant'/'org' are rejected — the word is 'organization' | -| **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `Object.fieldGroups[number]` @@ -416,11 +411,6 @@ const result = ApiMethod.parse(data); | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `Object.enable` @@ -495,7 +485,6 @@ const result = ApiMethod.parse(data); | **operation** | `Enum<'update'>` | optional | The declarative single-record field write, mirroring a list view's `bulkActionDefs`: `'update'` applies `patch` (merged under the collected `params`) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. `type` stays at its default `'script'` (the platform action route the write is performed on); `target`/`body`/`method`/`bodyExtra` are refused beside it. `'delete'` and `'custom'` have no row-level form. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). | | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). | -| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. | @@ -511,8 +500,6 @@ const result = ApiMethod.parse(data); | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | -| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bulkEnabled** | `never` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (audit close-out) — the multi-select toolbar is driven by the LIST VIEW's `bulkActions` / `bulkActionDefs`, never by this flag, so setting it changed nothing. Delete the key and declare the action in the view's `bulkActions` instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | | **recordIdField** | `string` | optional | Row field whose value seeds recordIdParam. Defaults to "id". | @@ -648,7 +635,6 @@ const result = ApiMethod.parse(data); | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | @@ -680,8 +666,6 @@ const result = ApiMethod.parse(data); | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | | **unique** | `false \| 'global' \| 'organization'` | optional (default: `false`) | Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): state the scope. 'tenant'/'org' are rejected — the word is 'organization' | -| **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | --- diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 95560dee891..d4b40cce5c5 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -109,7 +109,6 @@ Add a new field to an existing object | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | @@ -540,7 +539,6 @@ Add a new field to an existing object | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | From 2241a6428de0c4f217801e3491a77ab8560d5e15 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 12:26:36 +0000 Subject: [PATCH 08/11] chore(agents): drop the pin-chain and three-repo words the objectui lines left behind The release-board section still counted three boards and kept a console pin precondition that no longer has a pin; the director lane kept the pin chain in its heading and summaries; triage still alternated two repos. The externalId ledger row now names every ui/ carrier and the preview search. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibvE77Ed7MKdzPxJTQQ --- .claude/agents/os-dev.md | 2 +- .claude/skills/pm-dispatch/references/dispatch-runbook.md | 2 +- .claude/skills/pm-dispatch/references/lanes/director.md | 8 ++++---- .claude/skills/pm-dispatch/references/lanes/triage.md | 2 +- .../skills/pm-dispatch/references/platform-readings.md | 2 +- .../skills/pm-dispatch/references/seat-post-protocol.md | 7 ++----- packages/spec/liveness/field.json | 2 +- 7 files changed, 11 insertions(+), 14 deletions(-) diff --git a/.claude/agents/os-dev.md b/.claude/agents/os-dev.md index c991692b2c3..9533924d50d 100644 --- a/.claude/agents/os-dev.md +++ b/.claude/agents/os-dev.md @@ -154,7 +154,7 @@ model: opus - 派生答哪些门禁读你改的文件,不答哪些测试测这个脚本。 - 跑同目录点名它的 `*.test.ts`,加同包 tests 下 `git grep` 该脚本文件名的全部命中。 - 该脚本只住在 objectstack,答案只关于它所在的树:每次推导第一行(stderr)点名仓与 commit。 -- 读之前先核对;姊妹仓未必有 `scripts/pm/dispatch-gates.mjs`:hotcrm 两者皆无。 +- 读之前先核对;姊妹仓未必有 `scripts/pm/dispatch-gates.mjs`:hotcrm 就没有。 - 把它们的路径喂进 objectstack 检出,得到的是 objectstack 的门禁族:形态完整、退出 0、全错。 - 那边的清单从该仓自己的 `package.json` 与 `.github/workflows/` 手工推导。 - 加 `--repo /` 申报本次答案该属于哪个仓,不匹配即拒绝并同时点名两个仓。 diff --git a/.claude/skills/pm-dispatch/references/dispatch-runbook.md b/.claude/skills/pm-dispatch/references/dispatch-runbook.md index e1cafcf8cb1..4d9e8f850fc 100644 --- a/.claude/skills/pm-dispatch/references/dispatch-runbook.md +++ b/.claude/skills/pm-dispatch/references/dispatch-runbook.md @@ -66,7 +66,7 @@ - 之二,解锁扫描反演 = 逐仓 `state=CLOSED` + `since` 与 `Blocked-by:` 反向索引求交,命中走双查。 - 之三,健康指标 = 逐标签 `perPage: 1` 只读 `totalCount`。 - 裸卡数例外:标签缺席无计数可读,取本轮析取①的枚举,⛔ 不另发查询。 -- 每日层(当日首 fire)= 两仓全量对账 + 归集本就日频的职责。 +- 每日层(当日首 fire)= 全量对账 + 归集本就日频的职责。 - 日频职责 = finding 集中轮;`Restart-when:` 批扫归车道巡查,决策箱回填归总监席。 - 成本靶:干活轮 ≤50k token,空转轮守既有探针预算。 - 每条枚举比对返回数与 `totalCount`;分页只治当次,一个谓词,⛔ 不加第二条代码路径。 diff --git a/.claude/skills/pm-dispatch/references/lanes/director.md b/.claude/skills/pm-dispatch/references/lanes/director.md index 53327fcadf6..3f824f7fe58 100644 --- a/.claude/skills/pm-dispatch/references/lanes/director.md +++ b/.claude/skills/pm-dispatch/references/lanes/director.md @@ -54,7 +54,7 @@ - 入箱勤务:校验/补全四棱块、速读与 `Prior rulings on this card:` 行;入箱同笔现读前提。 - 线程有 `Ruling:`/RULED 的卡再入箱,该行逐 id 各一句变化;缺行 ⇒ 拒入,`pm:retriage` 回立卡者。 - 一次只开一批:维护者对批 N 回字(一字即可)后,新到才合成批 N+1;续批至清零才收工。 -- 受管草稿批:两仓 `is:pr is:open is:draft label:needs-user-decision` 对照等人合清单,差集点名。 +- 受管草稿批:本仓 `is:pr is:open is:draft label:needs-user-decision` 对照等人合清单,差集点名。 - 有席内 ACCEPT 者呈一批 ≤5 行,写法见 `../decision-analysis.md`;四件缺一者列缺口不呈。 - 回批原话逐 PR 记到 PR 上,缺的请审补上即止步;批准与合并恒是维护者的点击,⛔ 不代。 - 呈报前逐卡逐仓重跑 `git grep -n -iE 'TERMS' origin/main -- AGENTS.md docs/adr packages/spec/src`。 @@ -69,15 +69,15 @@ - 待您清单与待字索引随所属批只呈一次,⛔ 不逐轮重印;座位贴散文降为指针,标签即状态。 - 出口执行:核验动作确已发生,再摘标加证据评论同笔;出口条款见 `../state-machine.md`。 -## 职责四:每日摘要、发版板、pin 链与总表 +## 职责四:每日摘要、发版板与总表 -- 每日摘要:在飞/待裁/待落地/撞墙、等复审逾 24h 的 PR、发版板、pin 滞后、无卡 fork PR。 +- 每日摘要:在飞/待裁/待落地/撞墙、等复审逾 24h 的 PR、发版板、无卡 fork PR。 - 座位贴与在飞活性复查归本摘要,见 `../dispatch-runbook.md` 巡查常设项与 `../triage-duties.md`。 - 发版板 `target:` 主仓归本席;判据二元,⛔ 不做优先级渐变:不修它,RC 能不能发。 - 判阻塞四类:① 用户今天就撞的已发布面缺陷;② 公开契约 declared≠enforced。 - ③ 存量数据/迁移形状(发布后修不回);④ 发布说明里要为它道歉的。 - 拆分/分票时 `target:*` 随工作走,对每一半重跑二元判据,默认继承,不继承按四类写明理由。 -- 每张板恰好一个生产者;鲜度节奏、清板三选一、pin 前置见 `../seat-post-protocol.md`。 +- 每张板恰好一个生产者;鲜度节奏、清板三选一见 `../seat-post-protocol.md`。 - 见新 tag 族发布 ⇒ 在 cloud 与每个应用仓立平台版本 bump 单;单张封顶,已有则追评刷新。 - bump 单等待卡清单 = hold 索引(`Restart-when:`/`Blocked-by:` 点名该发布的 open 卡),查询落正文。 - 车队总表随本摘要重生成,坐席/接管/收班同笔;一席一行,⛔ 不手改;见 `../project-forms.md`。 diff --git a/.claude/skills/pm-dispatch/references/lanes/triage.md b/.claude/skills/pm-dispatch/references/lanes/triage.md index 523e88b3070..d5ca9b0fc8f 100644 --- a/.claude/skills/pm-dispatch/references/lanes/triage.md +++ b/.claude/skills/pm-dispatch/references/lanes/triage.md @@ -4,5 +4,5 @@ - 由维护者一句召唤到任一项目,默认同总监席项目;只写标签与审计评论,⛔ 无 PR、无认领。 - 范围 = objectstack(`ui/` 在内)每张卡首触(业务理由与定级)与跨车道查重;应用仓无分诊席。 - 应用仓业务理由由该项目召唤的总监席复核;第三方应用的总监是其自身维护者。 -- 每 fire 只取一个仓,两仓轮替、仓内最旧优先;最旧未路由超 4h 的仓本轮优先。 +- 每 fire 最旧优先;最旧未路由超 4h 的卡本轮先清。 - 补半状态前先确认无人在动,读后新现六态属他席即停手;⛔ 不加第二席、不改 fire 频率。 diff --git a/.claude/skills/pm-dispatch/references/platform-readings.md b/.claude/skills/pm-dispatch/references/platform-readings.md index 6d0e78e3446..57720275aec 100644 --- a/.claude/skills/pm-dispatch/references/platform-readings.md +++ b/.claude/skills/pm-dispatch/references/platform-readings.md @@ -292,7 +292,7 @@ - 同一 head 上轻量兄弟 workflow `success` 加重量级载体 `cancelled` 是普通取代的预期签名。 - 同名 `failure` 也会被带另一诊断的后一次 `failure` 取代 ⇒ 任何判定前先按名取最新一次。 - cancel-in-progress 窗口只罩得住慢载体 ⇒ 先比对 run `head_sha` 与 PR 当前 head,不开调查。 -- 两仓 CI 并发组都按 PR 号不按 head:重跑过期 head 取消当前 head 的 run ⇒ 重跑是写不是读。 +- 本仓 CI 并发组按 PR 号不按 head:重跑过期 head 取消当前 head 的 run ⇒ 重跑是写不是读。 - CI 红了先取完整日志归档再下结论:断言文本只在归档里,直读工具拿不到。 - `get_check_run` 回空 `output.text`;`get_job_logs` 带 `tail_lines` 只回尾部的 service-container teardown。 - ⇒ 改带 `return_content: true` 整份日志回包(2026-09-20 一 job)⇒ REST 腿被拒只是该腿的读数。 diff --git a/.claude/skills/pm-dispatch/references/seat-post-protocol.md b/.claude/skills/pm-dispatch/references/seat-post-protocol.md index b046ddc4f2b..6d1cf698436 100644 --- a/.claude/skills/pm-dispatch/references/seat-post-protocol.md +++ b/.claude/skills/pm-dispatch/references/seat-post-protocol.md @@ -80,12 +80,9 @@ - 生产者:主仓归总监席,cloud 归其整仓座位。 - 其余座位不打不摘,误判走上报;⛔ 两个生产者各扫各的 backlog。 - 消费者:维护者的发版清单 = 两仓两条查询 `label:target: is:open`。 -- 等价的 org 级搜索仅是备用写法;归零 = 可发版指三张板都空,单看主仓归零不是。 +- 等价的 org 级搜索仅是备用写法;归零 = 可发版指两张板都空,单看主仓归零不是。 - ⛔ 不用姊妹仓命中即误标的旧读法:它教读者销毁真实阻塞的唯一证据。 - 鲜度:每 ~5 轮对板上 open 项做过时前提检查,已修或不成立的摘牌 + 一句评论。 - 发版时刻 = 清板不是重扫,板上每条三选一:修掉、摘牌、明示接受带病发布。 - 明示接受 = 摘标签 + accepted-for-GA 评论留痕,并进 release notes 的 known issues。 -- 三仓各自执行、读数回贴;⛔ 不因前端仓或独立部署整批默认接受。 -- 发版前置:清板动手之前先取一次 pin 读数,⛔ 一次即止不是重扫。 -- pin 滞后 ⇒ console bump 单必须已存在且已上板,或按标准形态明示接受。 -- 两者都不成立 ⇒ ⛔ 不 cut。 +- 两仓各自执行、读数回贴;⛔ 不因 `ui/` 前端或独立部署整批默认接受。 diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index acbc90d810e..5fa57a461d1 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -381,7 +381,7 @@ "status": "dead", "verifiedAt": "2026-10-27", "evidenceScope": "in-repo", - "evidence": "no reader: at 1c632311, outside packages/spec, nothing under packages/** or ui/** reads a field's `externalId`. The two `ui/` mentions only carry it: the Studio field inspector edits it (ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx#ObjectFieldInspector) and ui/packages/app-shell/src/services/MetadataService.ts copies it into a field summary; neither is a preview that shows it to a person. Every upsert matches on something else: a seed dataset's `externalId` (packages/metadata-protocol/src/seed-loader.ts, `name` by default) or an import mapping's `upsertKey` (packages/rest/src/import-prepare.ts)", + "evidence": "no reader: at 1c632311, outside packages/spec, nothing under packages/** or ui/** reads a field's `externalId`. The `ui/` mentions only carry it: the Studio field inspector edits it (ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx#ObjectFieldInspector), ui/packages/app-shell/src/services/MetadataService.ts copies it into a field summary, and the designer's field form edits and round-trips it (ui/packages/plugin-designer/src/FieldDesigner.tsx, ui/packages/plugin-designer/src/MetadataFieldsPage.tsx). None is a preview: no `registerMetadataPreview('field', …)` exists, and no registered preview (ui/packages/app-shell/src/views/metadata-admin/previews/, `object` included) reads `externalId`. Every upsert matches on something else: a seed dataset's `externalId` (packages/metadata-protocol/src/seed-loader.ts, `name` by default) or an import mapping's `upsertKey` (packages/rest/src/import-prepare.ts)", "note": "RE-GRADED live -> dead 2026-10-27 (#25753, follow-up F23 of #25712). The `live` grade rested on objectui console `metadataConverters.ts` (:125, commit not pinned), which objectui dc14bcb deleted for having zero importers; the re-measure above found no other reader. A boolean `default(false)`, so the author lint warns only on an authored `true`. Owed enforce-or-remove (ADR-0049).", "authorHint": "Remove it: no upsert reads a field's `externalId`. To match seed records on this field, name it in the seed dataset's `externalId`; for an import, in the mapping's `upsertKey`." }, From c260fddcd3e507d6949b812144307e562b6ee9c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 12:27:04 +0000 Subject: [PATCH 09/11] chore(pm): lower the seat-post-protocol line ceiling to its landed count Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibvE77Ed7MKdzPxJTQQ --- scripts/pm/check-skill-line-ratchet.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index 45fa24eae76..60940d0bf0c 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -1231,7 +1231,8 @@ export const CEILINGS = new Map([ // tails leave the corpus; one rule per ≤120-byte line, no rule already stated in // SKILL.md). Landed count, headroom 0, same convention (lowering is always // legitimate). - ['.claude/skills/pm-dispatch/references/seat-post-protocol.md', 91], + // 91 → 88 (#25753): the console-pin release precondition retired with the pin; landed count. + ['.claude/skills/pm-dispatch/references/seat-post-protocol.md', 88], // Per-repo「真绿」跑法索引 — the canonical test invocation, the gates a CI-log // grep cannot see, and the local preflight, one fact per line per repo. Added // by the protocol-text family PR, which could not carry its own ceiling: that From 6f64f2a7e57fd13475cff155e7cff865d179ea9b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:31:44 +0000 Subject: [PATCH 10/11] chore(pm): lower the director lane ceiling to its count after the main merge Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibvE77Ed7MKdzPxJTQQ --- scripts/pm/check-skill-line-ratchet.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index 60940d0bf0c..2602ce49868 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -1483,8 +1483,8 @@ export const CEILINGS = new Map([ // 「`lanes/director.md` 职责行」: the seat an app project summons reviews that // repo's business-rationale first touch. Measured +1, ceiling moved by +1; // recorded under the move below as a `ruledRaises` record. - // 88 → 87 (#25753): lines that named objectui as a sibling repo retired; landed count. - ['.claude/skills/pm-dispatch/references/lanes/director.md', 87], + // 88 → 86 (#25753): the objectui pin-chain line retired, and main's governed-draft rule (#26693) landed one line shorter; landed count. + ['.claude/skills/pm-dispatch/references/lanes/director.md', 86], // Triage seat charter (maintainer ruling: the triage seat stays single; pickup // alternates between the two multi-lane repos, oldest-first inside a repo, with a // 4-hour starvation guard). A seat charter is read per seat session like every From fab4534b1d23fb5a8713b4d1de75722ea2bc6642 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:28:50 +0000 Subject: [PATCH 11/11] chore(pm): the triage charter's ratchet note no longer says pickup alternates between two repos Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibvE77Ed7MKdzPxJTQQ --- scripts/pm/check-skill-line-ratchet.mjs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index fd328ea2b51..e03377ffc08 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -1486,11 +1486,10 @@ export const CEILINGS = new Map([ // 88 → 87 (#26765, main's governed-draft rule) → 86 (#25753): the objectui pin-chain line retired; landed count. ['.claude/skills/pm-dispatch/references/lanes/director.md', 86], // Triage seat charter (maintainer ruling: the triage seat stays single; pickup - // alternates between the two multi-lane repos, oldest-first inside a repo, with a - // 4-hour starvation guard). A seat charter is read per seat session like every - // entry above. Set at the landed line count read from this ratchet's own run - // (headroom 0, same convention as above); it is a NEW file, so this is an added - // row and no other row moves. + // is oldest-first, with a 4-hour starvation guard). A seat charter is read per + // seat session like every entry above. Set at the landed line count read from + // this ratchet's own run (headroom 0, same convention as above); it is a NEW + // file, so this is an added row and no other row moves. // 7 → 8 (#25558): ruling D of director batch #10, comment 6378083450 — // maintainer 「#25558 记为 D」 (2026-10-24), adopting the record's workload // 「`lanes/triage.md`「应用仓无分诊席」之后补「应用仓业务理由由该项目召唤的