Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/workflows/line-citation-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: Line Citation Gate

# The DIFFERENTIAL cross-file line-address citation gate (objectui#8875, ruling
# clause 2). It reads what THIS pull request ADDED against its base — never a
# tree-wide total. An absolute count was explicitly refused: PR #8887's line
# shifts flipped one citation from `drifted` to `resolves` by accident, moving
# the tree-wide false count 540 -> 539, and that unearned green belonged to
# nobody. The 540 existing citations are not this gate's denominator.
#
# ── REPORT-ONLY, and the workflow does not decide that ───────────────────────
# The enforcement state lives in the script (`ENFORCEMENT`), not here, so there
# is one place to read it and one place to flip it. Report-only means findings
# exit 0; a failed CONTROL still exits 1 in both modes, because a differential
# gate reporting zero through a broken differ is indistinguishable from a clean
# branch — the failure this card names one level up.
#
# ── Why its own workflow rather than a step in `lint.yml` ────────────────────
# `lint.yml` decides inside the job whether the change "needs a full run", and
# its exclusion list skips every expensive step on a markdown-only, docs-only or
# changeset-only change. Those are precisely the changes that carry citations:
# the ruling names 注释、消息串、文档、changeset、脚本 in that order. A gate
# that cannot see the pull request shape most likely to trip it rebuilds the
# hole it exists to close — the same conclusion `shell-escape-residue.yml`,
# `docs-links.yml`, `control-bytes.yml` and `changeset-presence.yml` each
# reached in their own headers. One gate, one home.
#
# Hence no `paths` and no `paths-ignore`, deliberately.
#
# ── Why `pull_request` only ──────────────────────────────────────────────────
# This gate needs a BASE to be differential at all, and only a pull request has
# one. It is therefore ⛔ not requirable while it is report-only, and
# `scripts/dependabot-merge-gate.mjs` classifies it in `NOT_A_GATE` with that
# reason. When `ENFORCEMENT` flips to `blocking` this workflow owes a
# `merge_group` leg before the context may be required — a required check that
# does not report on a queue build stalls the queue until the ruleset's
# 60-minute timeout fails it (objectui#3523).
#
# It needs no install and no build: a checkout plus one `node` call over this
# branch's own diff. Keep it that way; the import graph is node builtins plus
# repo-relative modules only, which `pre-install-import-graph.yml` enforces.

on:
pull_request:
branches: [main, develop]
workflow_dispatch:

concurrency:
group: line-citation-gate-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
line-citation-gate:
name: Line Citation Gate
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
# `fetch-depth: 0` is load-bearing, not caution: the gate resolves the
# merge base with the target branch, and a shallow clone cannot. When it
# cannot, the gate exits 2 naming PREREQUISITE NOT MET rather than
# printing a zero — "the diff could not be computed" must never be
# indistinguishable from "nothing was added" (objectstack#4928).
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22.x'

- name: Report cross-file line-address citations this pull request adds
env:
CITATION_GATE_BASE: origin/${{ github.event.pull_request.base.ref }}
run: node scripts/check-new-cross-file-line-citations.mjs
73 changes: 73 additions & 0 deletions content/docs/guide/ci-cd-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ one has its own section below.
| `shell-escape-residue.yml` | Shell Escape Residue Scan | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a fenced block in `AGENTS.md`, `CLAUDE.md`, `skills/**` or `content/docs/**` carries the enumerated machine-produced shell escape, or a scan root fails to resolve |
| `readme-exports.yml` | README Export Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `packages/**/README.md` imports a name from its own package that the package does not export, or the scan's population collapses |
| `docs-route-eager-closure.yml` | Docs Route Eager Closure Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a package named in `apps/site/app/components/registerCatalogBlocks.ts` is not already reachable from the docs route's module graph (exit 1), or when the gate's own gauge cannot be trusted (exit 2) |
| `line-citation-gate.yml` | Line Citation Gate | PR to `main`, `develop` — **no path filter**; manual | No — **report-only** while it beds in; it exits 0 whatever it finds, and exits 1 only when one of its own synthetic controls fails. It declares no `merge_group` trigger, so it cannot be a required context in its current state |
| `governed-surface-guard.yml` | Governed Surface Queue Guard | PR to `main`, `develop` (incl. `ready_for_review`) — **no path filter**; merge-queue builds | **Yes on a queue build only** — a governed-surface diff with no authorized approval record (on any commit) is refused there; on the pull request itself it is deliberately green and prints an early warning |
| `performance-budget.yml` | Bundle Analysis | Push / PR touching `packages/**`, `apps/console/**`, `pnpm-lock.yaml` | **Yes** — the console entry gzip budget |
| `lockfile-integrity.yml` | Lockfile Integrity Check | PR to `main`, `develop` touching `pnpm-lock.yaml` or the gate's own two files; manual | No — **deliberately not a blocking context** ([#8326](https://github.com/objectstack-ai/objectui/issues/8326)); it names the packages and the Dependabot merge gate classifies it `NOT_A_GATE` |
Expand Down Expand Up @@ -1597,6 +1598,78 @@ workflow runs as its own first step because a rotted predicate must redden rathe
governed diff through, and the wiring is pinned by
`scripts/__tests__/check-governed-queue-guard.test.ts`.

## Line Citations (`line-citation-gate.yml`)

**Triggers:** Pull requests to `main`/`develop`, with **no path filter**, plus manual dispatch. It
appears in the checks list as **Line Citation Gate**.

**What it runs:** `node scripts/check-new-cross-file-line-citations.mjs` — one `node` call over the
pull request's own diff. No install, no build.

**Report-only.** This step **exits 0 regardless of what it finds**. It is not a required context, it
declares no `merge_group` trigger, and `scripts/dependabot-merge-gate.mjs` classifies it
`NOT_A_GATE` for that reason. The one thing that does make it exit 1 is a failure of its own
synthetic controls — a differential gate reporting zero through a broken differ is indistinguishable
from a clean branch, so the instrument is checked on every run.

### What it reads, and the word that decides its shape

The maintainer ruled the class on 2026-09-10, verbatim: 「跨文件的「某文件第几行」引用,
这种完全没必要吧,是否应该避免」. A cross-file line address points somewhere the reader is not
looking, and nothing tells them when it moves. Five spellings are read, written here with `NNN`
standing in for the digits **on purpose** — a real address in this paragraph would be one more
citation for the gate to report, which is the shape of the problem rather than a description of it:

| spelling | example, digits elided |
|---|---|
| the dominant form | `NAME.ts:NNN` |
| the GitHub permalink form | `NAME.ts#LNNN` |
| the address written first | `line NNN of NAME.ts` |
| the address written second | `NAME.ts line NNN` |
| the **continuation** form, which carries no filename at all | a bare `:NNN` beside an address written on a neighbouring line |

The continuation form is the one no basename-anchored probe can see, and it is why a one-syntax
count is not a reading. Measured on this tree: 1,267 such citations, 540 of them already false.

The gate is **differential**. It reads only what a pull request **adds**, against its merge base with
the target branch. The 540 existing citations are **not** its denominator and it does **not** sweep
them in: shifting an already-false address by a hunk delta moves a wrong pointer to a differently
wrong place while making the diff look diligent.

An absolute count was refused on a measurement rather than on taste. PR #8887's line shifts flipped
one citation from `drifted` to `resolves` by accident, moving the tree-wide false count 540 → 539 —
an unearned green that belonged to nobody, and one a total-reading gate would have scored as
progress.

### The three verdicts, and the one that must not collapse

Every added citation is a finding — the convention is that the address is not written, not that it
is written accurately — but each carries the verdict a reader would reach by following it:

| verdict | meaning |
|---|---|
| `false` | the cited line does not carry what the citing prose says it does |
| `resolving` | it does, today |
| `unresolvable` | nothing can ever decide it — chiefly citations into regenerated `dist/*.d.ts`, which are untracked and rebuilt, plus bare basenames that name several tracked files at once |

`unresolvable` is **never** counted as `false`. Calling an undecidable citation wrong is an
assertion, and the split is pinned by a synthetic control so it cannot quietly regress.

### What flips it to blocking

`ENFORCEMENT` in the script is the whole switch, and its test reads the landed value, so the flip
cannot happen without the pin moving with it. It flips once the gate reads zero new citations across
the in-flight population and the convention text has landed in `AGENTS.md` — an author failed by a
rule is owed a document to be failed against. When it does flip, this workflow owes a `merge_group`
leg before the context may be required: a required check that never reports on a queue build stalls
the queue until the ruleset's 60-minute timeout fails it.

### The related report

`pnpm census:cross-file-line-citations` is the tree-wide census the differential gate was derived
from. It runs in no workflow, prints the whole population with its per-directory split, and is the
right instrument for asking how large the existing class is — never for deciding a pull request.

## Lockfile Integrity (`lockfile-integrity.yml`)

**Triggers:** Pull requests to `main`/`develop` that touch `pnpm-lock.yaml`,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"census:body-dialect": "node scripts/body-dialect-census.mjs",
"census:tsconfig-test-parity": "node scripts/tsconfig-test-parity-census.mjs",
"census:cross-file-line-citations": "node scripts/cross-file-line-citation-census.mjs",
"check:new-line-citations": "node scripts/check-new-cross-file-line-citations.mjs",
"check:control-bytes": "node scripts/check-control-bytes.mjs",
"check:action-ref-convention": "node scripts/check-action-ref-convention.mjs",
"check:published-dist": "node scripts/check-published-dist-tooling.mjs",
Expand Down
61 changes: 60 additions & 1 deletion scripts/__tests__/check-doc-example-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,12 +339,71 @@ describe('the real ledger', () => {
expect(row.card, key).toMatch(/^objectui#\d+$/);
}
});

/**
* objectui#8875 clause 3. The key used to be `path:line symbol`, and the line
* number in it was not decoration — it was a STORED literal compared for
* equality, so an edit anywhere above a documented symbol invalidated every
* row below it in that file and reddened `main` on a branch that had not
* touched a single example. PR #8895 added three imports to
* `packages/types/src/objectql.ts` and did exactly that; objectui#8614 is the
* same failure one card earlier.
*
* The maintainer ruled the class on 2026-09-10 — 跨文件的「某文件第几行」引用,
* 这种完全没必要吧,是否应该避免 — and the repair the ruling names is to stop
* storing the number, ⛔ not to recompute it after every shift. So the shape
* is pinned in BOTH directions: the key generator may not produce one, and the
* ledger may not carry one.
*/
it('keys carry no line address, in either direction', () => {
const LINE_ADDRESS = /\.[A-Za-z]+:\d+/;

for (const key of Object.keys(UNGATED_EXAMPLES)) {
expect(
key,
`${key} embeds a line address. objectui#8875 clause 3 retired that key shape: a stored ` +
`line number is a snapshot of a moving quantity, so an unrelated edit above the block ` +
`invalidates the row and reddens a branch that changed nothing. Key by ` +
`\`path symbol #ordinal\` — see \`ledgerKey\`.`,
).not.toMatch(LINE_ADDRESS);
expect(key, `${key} is not in the \`path symbol #ordinal\` shape`).toMatch(/ #\d+$/);
}

// The generator, not only today's ledger: a ledger cleaned by hand while the
// generator still emits addresses would go red on the next collected block
// instead of here.
for (const block of census.blocks) {
expect(ledgerKey(block), 'ledgerKey emitted a line address').not.toMatch(LINE_ADDRESS);
}

// Anti-vacuity. A regex that matched nothing would pass both loops above on
// an empty tree, so it is shown FIRING on the shape it is written to reject.
//
// ⛔ ASSEMBLED, not written out. A literal address here would itself be a
// cross-file line citation, and the differential gate landed alongside this
// change would report it as newly added — correctly. A control for a shape
// does not need to be an instance of the thing the shape names.
const RETIRED_KEY_SHAPE = ['packages/types/src/objectql.ts', ':', '1618', ' ObjectFormSchema'].join('');
expect(RETIRED_KEY_SHAPE).toMatch(LINE_ADDRESS);
});

it('the ordinal discriminates the symbols that document more than one example', () => {
// The ordinal is not ceremony: five symbols in this tree carry several
// `@example` blocks, and `path symbol` alone would collapse them onto one
// key — silently, by making several rows the same row. Keys are checked for
// uniqueness against the block count so that collapse cannot happen quietly.
const generated = census.blocks.map((b) => ledgerKey(b));
expect(new Set(generated).size).toBe(census.blocks.length);
expect(new Set(census.blocks.map((b) => `${b.file} ${b.symbol}`)).size).toBeLessThan(
census.blocks.length,
);
});
});

// ── the card's own acceptance criterion ──────────────────────────────────────

describe('objectui#7974 — the defect this gate was filed for', () => {
const key = 'packages/mobile/src/useSpecGesture.ts:69 useSpecGesture';
const key = 'packages/mobile/src/useSpecGesture.ts useSpecGesture #1';

it('its example is IN the compiled tier — the gate reaches the block the card named', () => {
const census = exampleCensus({ root: repoRoot });
Expand Down
40 changes: 36 additions & 4 deletions scripts/__tests__/check-i18n-designer-table-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,15 +192,47 @@ describe('the documented zh-only families', () => {
for (const family of ZH_ONLY_FAMILIES) {
expect(family.prefix, 'a family must be a prefix').toMatch(/\.$/);
expect(family.reason.trim().length, `${family.prefix} has no reason`).toBeGreaterThan(20);
expect(family.citation, `${family.prefix} has no citation`).toMatch(/^[\w./-]+:\d+(-\d+)?$/);
expect(family.citation, `${family.prefix} has no citation`).toBeTruthy();
expect(family.citation.file, `${family.prefix} cites no file`).toMatch(/^[\w./-]+$/);
expect(
family.citation.anchor.trim().length,
`${family.prefix} cites no anchor text`,
).toBeGreaterThan(8);
// objectui#8875 clause 3: the citation used to be `path:line`, a STORED
// line number pointing into another file. It was one unrelated edit away
// from naming the wrong line, and nothing here ever followed it, so the
// drift would have been silent. An anchor is text, so it can be CHECKED —
// which the next assertion does.
expect(
`${family.citation.file} ${family.citation.anchor}`,
`${family.prefix} still cites a line address`,
).not.toMatch(/\.[A-Za-z]+:\d+/);
}
});

it('every citation names a file that exists in this repo', () => {
it('every citation resolves: the file exists and the anchor text is in it, exactly once', () => {
for (const family of ZH_ONLY_FAMILIES) {
const file = family.citation.slice(0, family.citation.lastIndexOf(':'));
expect(fs.existsSync(path.join(repoRoot, file)), `${family.citation} names no file`).toBe(true);
const full = path.join(repoRoot, family.citation.file);
expect(fs.existsSync(full), `${family.citation.file} names no file`).toBe(true);

const source = fs.readFileSync(full, 'utf8');
const occurrences = source.split(family.citation.anchor).length - 1;
expect(
occurrences,
`${family.prefix} cites "${family.citation.anchor}" in ${family.citation.file}, which ` +
`contains it ${occurrences} time(s). A citation that locates nothing — or several ` +
`things — is the failure objectui#8875 retired the line numbers for; it is not fixed ` +
`by writing a number back.`,
).toBe(1);
}

// Anti-vacuity: the same reader, on a string that is deliberately absent,
// must return zero. Without it a broken read would agree with every anchor.
const control = fs.readFileSync(
path.join(repoRoot, ZH_ONLY_FAMILIES[0].citation.file),
'utf8',
);
expect(control.includes('zzz-this-anchor-does-not-exist')).toBe(false);
});

it('every family still subtracts at least one real key', () => {
Expand Down
Loading
Loading