Skip to content

feat(gates): check the marked fenced examples in the skill guides - #7427

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-7359-skill-fence-check
Sep 3, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-7359-skill-fence-check

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #7359

Nothing in this repository read inside a code fence under skills/. At the branch point, skills/objectui/ carried 112 ts/tsx/typescript fences and 56 json fences, and os:check occurred 0 times. Every gate that could have reached them is scoped elsewhere by construction — check-skills-paths.mjs deliberately reads inline code spans in prose only ("a fence is a worked example"), check-doc-links.mjs has no skills row in SCAN_ROOTS, and the three content/docs gates say so in their own headers. So a fence could import a symbol that does not exist and stay green forever, and two rounds of exactly that had already been cleaned by hand.

This ports objectstack's opt-in marker convention from packages/spec/scripts/check-skill-examples.ts, adds an objectui runner, wires it into CI, and lands the marker on exactly the fences that already hold up.

Branch point: 75e7436. Head at the readings below: 70fdb0e.

Design

The marker, and adjacency. A fence is checked only when the line immediately above it is the marker: an HTML comment whose entire content is os:check. (The exact spelling is MARKER in scripts/check-skill-examples.mjs and is not reproduced in this body — GitHub's body sanitizer eats angle-bracket fragments, backticked ones included.) Two properties make that spelling the right one, both inherited from objectstack's reasoning: it renders to nothing, and it leaves the fence info string a bare ```typescript, so the three gates in this repo that key on the info string still see the block. A fence-meta tag like ```ts check would have punched a hole in every one of them.

Adjacency is strict — not "the nearest non-blank line above". A marker anywhere else is an orphan and fails the run. This is the one rule where lenience costs everything: a marker separated from its fence by a blank line, or left behind when its example was rewritten, opts in nothing while its author believes their example is gated. That is this repository's recurring "looks like enforcement, isn't" class (#3009, #3181, #3494, #4846, #7115).

Fence-awareness. A marker that sits inside some other fenced block — a ```markdown illustration of the convention — is at once not an opt-in and not an orphan: it is not at top level, so it claims nothing. One walk (fenceSpans) answers both "is this marker at top level?" and "does this line open a fence?", and it also supplies each fence's closing line, so the body's end is decided by exactly the predicate that decided it opened. objectstack measured a second, looser closer disagreeing with the walk's in both directions (an indented closer; a CRLF file), so there is deliberately only one here.

How imports resolve: the BUILT dist/*.d.ts. A marked ts fence compiles --strict against each package's built declarations, resolved through the paths map check-doc-snippet-types.mjs derives from every package's own exports/types, plus that gate's declared-dependency rule for third-party specifiers. The alternative — mapping @object-ui/* to src/ through tsconfig.base.json — is rejected for the reason that gate's header already states: the root config maps the workspace to source, so a harness that inherited it would judge the guides against code no reader resolves. The reader of a skill guide is a consumer who installs @object-ui/react from npm; that is the surface, so that is what is compiled against.

The declared precondition. The cost of that decision is an exit code, never a silent green:

exit meaning
0 every marked fence held up, the controls held, and something was marked
1 the gate ran and found errors — a verdict about a guide
2 the gate could not run: packages unbuilt or typed from source, a harness control failed, or the marked population is empty

Exit 2 prints PRECONDITION NOT MET and is never a pass — zero with nothing run reads as coverage, the failure shape this gate family exists to prevent (#4846). The 1-vs-2 split is this repository's convention, not a new one (check-eager-closure-budget.mjs, check-doc-snippet-types.mjs).

The harness is imported, not re-rolled. derivePackageTypePaths, deriveDeclaredDependencyPaths and compileSnippets come from check-doc-snippet-types.mjs, so the syntax/semantics split (one parse error suppresses every semantic diagnostic in a tsc program) and its four self-controls — RESOLUTION lands on a built artifact, a planted SENTINEL must produce TS2305, a POSITIVE control must be clean, an UNDECLARED specifier must still fail — are shared rather than duplicated. That gate's header records what hand-rolling the harness three times cost, including one false green. scripts/__tests__/check-skill-examples.test.ts pins the import and pins that this script builds no ts.createProgram of its own.

One honest edge, printed on every run. A bare specifier that neither map covers still resolves if the repository root declares it, because pnpm symlinks the root's own dependency set into /node_modules. In the marked population that is vitest and @playwright/test. The guides do tell the reader to install vitest, so the examples are not wrong — but the gate is not what proves it. The inherited UNDECLARED control does not close this: it bounds resolution against transitive packages, a different leak. So every run prints an Unmapped specifiers: line naming them, rather than letting it be a silent property of a green. Tightening it is a follow-up below.

Starting population

Measured by the gate's own --measure (which judges every candidate, marked or not) at the branch point, and re-measured at head:

language passing total marked
ts / tsx / typescript 17 112 17
json / jsonc 39 56 39
total 56 168 56

The 95 unmarked TypeScript fences and 17 unmarked JSON fences are fragments by construction — a columns: [...] subtree, a block continuing the one above it, a json fence carrying // comments and ... elisions. ⛔ No fence's content was edited to make it pass. The only edit inside skills/objectui/** is marker lines: 56 insertions, 0 deletions, every added line byte-identical.

Files that gained markers:

file markers lines before after
skills/objectui/SKILL.md 1 136 137
skills/objectui/guides/auth-permissions.md 3 345 348
skills/objectui/guides/data-integration.md 8 443 451
skills/objectui/guides/i18n.md 2 185 187
skills/objectui/guides/mobile.md 2 166 168
skills/objectui/guides/page-builder.md 10 321 331
skills/objectui/guides/plugin-development.md 4 442 446
skills/objectui/guides/project-setup.md 5 340 345
skills/objectui/guides/schema-expressions.md 7 511 518
skills/objectui/guides/testing.md 4 341 345
skills/objectui/rules/composition.md 1 277 278
skills/objectui/rules/protocol.md 6 314 320
skills/objectui/rules/styling.md 3 316 319

Whole published bundle (skills/objectui/**/*.md): 4356 → 4412 lines, +56 — one inert comment line per marked fence, no prose added or rewritten.

⛔ No ratchet, no count pin. Whether the marked population becomes shrink-only, the way check-doc-fence-languages.mjs treats its declared-file population, is recorded as a follow-up rather than decided here. What is enforced is a floor: a run in which nothing is marked exits 2, because a gate that checks nothing must not report success.

CI wiring

Its own workflow, .github/workflows/skill-examples.yml, with no paths / paths-ignore — modelled on skills-paths.yml.

Not the lint.yml route: pnpm check in this repo is node packages/cli/dist/cli.js check, the product's own command, not a gate aggregation — there is no check:* aggregation to join. The house route for a scripts gate here is a dedicated workflow, and the reason the trigger carries no filter is stated in skills-paths.yml's own header, quoted rather than re-derived: "both of those workflows list '**/*.md', content/** and docs/** under the paths-ignore of their push trigger, with no per-job path filter available in GitHub Actions. A push that only edits a guide would therefore start neither — and editing only a guide is the single most likely way a stated path goes dead." check-skill-examples.test.ts pins that, the same way check-skills-paths.test.ts pins it for its own gate — no paths, no paths-ignore, and exactly one home.

Not a second step inside skills-paths.yml: that file's header asks that it stay a checkout plus one node call, and this gate cannot be that. Its criterion is the published type surface, so the packages the marked fences import must exist as dist/*.d.ts first. The workflow body therefore follows doc-snippet-types.yml: install, derive the filter from the gate (--build-filter), refuse an empty filter rather than expanding to a whole-workspace build, build, self-test, run. Today the filter names 11 packages, and it grows only as the marked population grows — which is what keeps this inside the 2026-08-16 ruling on #4846 that rejected a per-PR full-repo build.

Because it is unfiltered and blocking, the check is classified rather than left to default into silence: Skill Example Check is added to REQUIRED_CONTEXTS in scripts/dependabot-merge-gate.mjs (whose test asserts its three buckets partition the produced check set exactly), the workflow subscribes merge_group (merge-queue-reporting.test.ts derives that floor from REQUIRED_CONTEXTS), and content/docs/guide/ci-cd-pipeline.md gains an inventory row and a section (ci-cd-pipeline-doc.test.ts requires a heading naming every workflow file).

Reverse verification

Every leg: mutate → prove the mutation landed on disk by counting the injected and the removed text (never a bare git diff --stat) → run the gate → restore with git checkout HEAD -- PATH → prove the restore by blob hash and an empty git diff HEAD. Absolute paths throughout, with a trap … EXIT INT TERM restoring all three files. No build step sits between mutation and measurement — the artifact the gate reads is the markdown being mutated, so there is no dist for a stale copy to hide in.

leg mutation (proof) expected observed
baseline — 0 0 — Every marked skill example holds up against the built types.
A — marked ts fence useObjectTranslation → useObjectTranslationNoSuchExport in guides/i18n.md; removed-text count 7 → 0, injected-text count → 7 red, naming file and fence line 1 — [semantic] skills/objectui/guides/i18n.md:40:10 TS2305: Module '"@object-ui/i18n"' has no exported member 'useObjectTranslationNoSuchExport'. · Semantic phase: 17 of 17 ts fence(s) judged, 1 failed.
B — marked json fence trailing comma injected in rules/styling.md; removed-text 1 → 0, injected-text → 1 red 1 — [json] skills/objectui/rules/styling.md:183 Expected double-quoted property name in JSON at position 86 · JSON phase: 39 fence(s) parsed, 1 failed.
C — marker adjacency one blank line inserted between the marker and its fence in rules/composition.md; marker-followed-by-blank occurrences → 1 orphan reported 1 — skills/objectui/rules/composition.md:203 [orphan-marker]
restored — 0 0, worktree clean, all three blob hashes match HEAD

The opt-in half is measured too, not assumed. On the same tree, in the same runs: --measure reports 12 parse failures + 83 semantic failures + 17 JSON failures across all 168 candidates, while the gate itself reports 0 failed and exits 0. An unmarked broken fence is ignored, by measurement.

Harness controls, from the head run — a probe that cannot fail is not a probe:

resolution   resolved to '…/packages/types/dist/index.d.ts'
sentinel     a planted missing export produced 1 diagnostic(s) (TS2305)
positive     a real export produced 0 diagnostic(s)
undeclared   a specifier no imported package declares … produced 1 diagnostic(s) (TS2307)

Gates

All run at head 70fdb0e, on the tree this PR pushes. Exit codes captured by redirect before any pipe.

command exit verdict line
node scripts/check-skill-examples.mjs 0 Every marked skill example holds up against the built types. (Semantic phase: 17 of 17 … 0 failed · JSON phase: 39 … 0 failed)
node scripts/check-skill-examples.mjs --self-test 0 check-skill-examples self-test: 19 cases pass (marker adjacency both directions, orphans, nested illustration, json/jsonc, and the compiler legs through the real harness)
node scripts/check-skill-examples.mjs --measure 0 Starting population — ts: 17/112 pass; json: 39/56 pass.
pnpm exec vitest run × 12 wiring/pin suites 0 Test Files 12 passed (12) · Tests 318 passed (318) — includes check-skill-examples, check-skills-paths, check-doc-snippet-types, merge-queue-reporting, dependabot-merge-gate, ci-cd-pipeline-doc, workflow-cache-save-bound, lint-workflow, scripts-type-check, entry-guard-wiring
node scripts/check-skills-paths.mjs 0 check-skills-paths: OK (88/89 stated path(s) resolve across 20 guide file(s); 1 baselined)
node scripts/check-doc-links.mjs 0 ran clean
node scripts/check-control-bytes.mjs 0 ran clean
node scripts/check-shell-escape-residue.mjs 0 OK (5/5 root(s) resolved … skills: 16 file(s), 196 fence(s) … 1304 fenced block(s) examined) — the marker lines do not disturb its fence walk
node scripts/check-doc-fence-languages.mjs 0 every TypeScript block in 225 document(s) is fenced ts/tsx/typescript, except 80 declared file(s) … (⛔ SHRINK-ONLY)
node scripts/check-doc-snippet-types.mjs 0 Every covered documentation snippet compiles against the built types. (Semantic phase: 417 of 417 block(s) judged, 0 failed)
node scripts/check-changeset-presence.mjs 0 No source or published contract of a released package changed in this range, so no changeset is owed. — its own verdict decides; no changeset added
node scripts/check-lint-coverage.mjs 0 ran clean
node scripts/check-governed-queue-guard.mjs --self-test 0 132 cases pass
node scripts/check-governed-queue-guard.mjs --test CHANGE-SET-PATHS 3 GOVERNED — the correct verdict for this diff, not a red: Do not flip it ready … Park it as a DRAFT

ESLint narrowing. pnpm lint is turbo run lint, i.e. each workspace package running its own eslint .; scripts/ has no package.json, so it is in no package's lint run at all — the same structural gap scripts-type-check.test.ts records for type-check and closes with tsconfig.scripts.json. Ran ESLint directly on the lintable files this PR touches: pnpm exec eslint --format json scripts/__tests__/check-skill-examples.test.ts scripts/__tests__/merge-queue-reporting.test.ts → exit 0, 2 files linted, 0 errors, 0 warnings. Invariance for untouched files: eslint.config.js configures no type-aware linting (no project, no projectService anywhere in it), so no edit in this PR can move the verdict for a file it does not touch. The new .mjs is also clean (0/0), though no config block in eslint.config.js matches **/*.mjs — its blocks match **/*.{ts,tsx} only.

Follow-ups, recorded not taken

  1. Shrink-only? Whether the marked population becomes a ratchet is No gate reads inside a code fence in skills/objectui/ — 123 TypeScript fences and 63 JSON fences are checked by nothing #7359 step 3, explicitly deferred. The number is re-derivable at any time with --measure, so the decision does not need this PR's memory.
  2. Bare any in a marked block. objectstack's runner treats it as a third assertion — a marker is the claim "this compiles", and every property access on an any proves nothing. Worth porting; it is a new assertion rather than the convention, so it is not in this flight.
  3. Root-devDependency resolution. vitest and @playwright/test resolve out of the repository root's node_modules. Printed on every run today; a control that bounds it the way the UNDECLARED control bounds transitives would close it.
  4. .claude/skills/**. check-skills-paths.mjs scans it (check-skills-paths lost 55 of its 93 assertions when the contributor guides moved out of skills/ — widen SCAN_ROOT to cover .claude/skills #7358 widened it there); this gate's root is the published bundle. Widening is a separate, visible edit with its own measurement.
  5. Two pre-existing suites fail on a BUILT tree, unrelated to this change set (which touches no packages/** file): check-sdui-registration-pins.test.ts derives packages/app-shell/dist/console/connect/ConnectAgentWidget.js where it expects the src/…tsx path, and check-readme-exports.test.ts reports excerptsNotJudged 1 where it expects 3. Both suites claim to hold "in BOTH build states"; CI's Test (shard N/4) runs on an unbuilt tree, so neither is visible there. Reported here rather than fixed.

Draft

Draft, and it stays draft. skills/objectui/** is a governed surface (ui#6866 批 #7) and check-governed-queue-guard.mjs --test returns exit 3 GOVERNED over this change set. No ready flip, no auto-merge, no reviewer requests — a human merge is the review record for a governed surface.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LraLgQVGq8egUwfYZpbYt1


Generated by Claude Code

Nothing in this repository read inside a code fence under `skills/`: 112
TypeScript fences and 56 JSON fences, checked by no gate at all. Every gate
that could reach them is scoped elsewhere by construction — the paths gate
deliberately reads inline code spans in prose only, and the three doc gates
walk `content/docs` plus the package READMEs. So a fence could import a symbol
that does not exist and stay green forever, and two rounds of exactly that had
already been cleaned by hand.

Ports objectstack's opt-in marker convention: a fence is checked only when the
line immediately above it is the marker HTML comment. TypeScript fences compile
`--strict` against the packages' built `dist/*.d.ts` — the surface a reader who
installs `@object-ui/*` actually resolves — and JSON fences must parse. The
type-check harness is imported from `check-doc-snippet-types.mjs` rather than
re-rolled, so the syntax/semantics split and its four self-controls are shared
rather than duplicated.

Adjacency is strict and an orphan marker fails the run: a marker that opts
nothing in while its author believes otherwise is this repository's recurring
"looks like enforcement, isn't" class. Unmarked fences are ignored, so the gate
starts at zero red; a run in which nothing is marked exits 2 rather than 0,
because a gate that checks nothing must not report success.

Starting population, measured by the gate's own `--measure` at the branch point
and landed as 56 marker lines: 17 of 112 TypeScript fences and 39 of 56 JSON
fences already hold up. No ratchet and no count pin — whether the marked
population becomes shrink-only is a separate decision.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LraLgQVGq8egUwfYZpbYt1
@os-zhuang
os-zhuang marked this pull request as ready for review September 3, 2026 06:27
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 3, 2026
Merged via the queue into main with commit c92dc41 Sep 3, 2026
30 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-7359-skill-fence-check branch September 3, 2026 06:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No gate reads inside a code fence in skills/objectui/ — 123 TypeScript fences and 63 JSON fences are checked by nothing

3 participants