test(sections): pin every parse-refusal message a user can see - #419
Conversation
File size check0 over a hard cap (fails), 33 warning(s).
Split the file, wrap the line, shorten or exempt the comment, or list the path in 5 managed file(s) skipped; repo-platform owns them. |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The census can accept unasserted strings as pins and omits user-visible messages produced by imported helpers.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 2
Open (2)
What changed in this PR
Adds exact coverage for user-visible parse-refusal messages and an AST-based census to detect unpinned messages.
Changes:
- Adds section and shared-schema refusal tests.
- Adds invalid-state e2e scenarios for milestones and workflows.
- Adds an AST census for refusal-message coverage.
| File | Description |
|---|---|
test/sections/actions/schema.test.ts |
Pins actions refusal messages. |
test/sections/branches/schema.test.ts |
Replaces partial message matches with exact wording. |
test/sections/check_suite_preferences/schema.test.ts |
Pins app ID errors. |
test/sections/collaborators/schema.test.ts |
Covers unsupported collaborator keys. |
test/sections/custom_properties/schema.test.ts |
Covers ambiguous and invalid property values. |
test/sections/deploy_keys/schema.test.ts |
Pins deploy-key validation messages. |
test/sections/environments/schema.test.ts |
Pins environment validation messages. |
test/sections/interaction_limits/schema.test.ts |
Replaces regex checks with exact messages. |
test/sections/labels/schema.test.ts |
Pins color and description errors. |
test/sections/list-wrapper-schema.test.ts |
Covers shared wrapper and mapping errors. |
test/sections/milestones/scenarios/milestones-invalid-state-rejected.yml |
Adds invalid milestone state coverage. |
test/sections/milestones/schema.test.ts |
Pins due-date errors. |
test/sections/pages/schema.test.ts |
Covers read-only Pages fields. |
test/sections/refusal-messages.test.ts |
Adds the AST-based message census. |
test/sections/repository/schema.test.ts |
Pins repository-setting refusals. |
test/sections/roles.test.ts |
Pins shared permission errors. |
test/sections/rulesets/schema.test.ts |
Expands exact ruleset-message assertions. |
test/sections/secret_scanning_custom_patterns/schema.test.ts |
Pins pattern validation output. |
test/sections/secret-variable-schema.test.ts |
Covers unsupported secret-entry keys. |
test/sections/teams/schema.test.ts |
Pins team slug and permission errors. |
test/sections/webhooks/schema.test.ts |
Covers misplaced webhook secrets. |
test/sections/workflows/scenarios/workflows-invalid-state-rejected.yml |
Adds invalid workflow state coverage. |
test/sections/workflows/schema.test.ts |
Covers unsupported workflow keys. |
test/problem.test.ts |
Pins document-level issue builders. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
264724a to
21a2709
Compare
21a2709 to
9f2ff05
Compare
9f2ff05 to
e3eb4a7
Compare
e3eb4a7 to
38da58e
Compare
38da58e to
eed7b98
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The census omits parse-refusal messages generated by validateSectionShapes itself, leaving its claimed coverage incomplete.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Open (1)
Resolved since last review (1)
eed7b98 to
fc2ecaf
Compare
68ba8aa to
00770c9
Compare
00770c9 to
6510df0
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The census skips a real parse-refusal message emitted inside the routed list-shape transform.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Resolved since last review (2)
6510df0 to
261d37f
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The census misses user-visible wording produced by local helpers called from issue builders.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Resolved since last review (1)
261d37f to
8e76e77
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The census omits current validate-helper message arguments and refusal-producing source files, leaving gaps in its coverage guarantee.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 2
Open (2)
Resolved since last review (1)
8e76e77 to
ee633c9
Compare
ee633c9 to
3ba90d6
Compare
Every custom refusal literal in the section schema slices, the shared schema helpers, the file-only validate hooks, and the document-level issue builders is now spelled by a test through validateSectionShapes, the entry point the action uses. A census test reads every string literal of the schema slices and shared helpers off the source AST with oxc-parser and estree-walker, minus a closed list of data positions. It fails naming the file, the line, and the literal of any message no test string pins, so a new refusal cannot ship unpinned. Before, 239 of the 366 message literals had a sentence-level pin; now all 366 do, each in the section's test/sections/<key>/ tests or the shared test that owns the wording.
3ba90d6 to
16d301c
Compare

What this changes
key.path: messageline, throughvalidateSectionShapesThe census failure, as
test/sections/refusal-messages.test.tsprints it:A pinned line, as the run prints it:
How
compilable-form.tsis a refusal message unless its position is in the listed exclusions. In section modules,module.ts,repo-secrets.ts, andproblem.ts, every literal inside a message-shaped object ({path, message}), anerror:/consequence:property, or an issue-builder body is a message. No reach or helper analysis: a literal cannot hide from a file scan.oxc-parser(already a dependency) walked byestree-walker(new devDependency), never by regex over source text.src/sections/*/schema.ts(26 slices),shared/{schema-helpers,setup-schema,roles,renamed-key,raw-values}.ts,compilable-form.tssrc/sections/*/index.ts(26 modules),environments/{nested,branch-policies,protection-rules}.ts,contract/module.ts,shared/repo-secrets.ts,problem.ts{ "error": ... }, the key itselfimport { z } from "zod"z.enum([...]),z.literal("web"),.default(...),.includes("<num>").split(","),.join(", "),.startsWith(...),new RegExp(...),new Set([...])caselabelname === "",case "boolean":path,code,id,route,kind,check,nameKey,example, and the arrays under oneas constvocabulary["a", "b"] as const, reached through arrays, objects, and properties only.meta({...})orconditional(...)String.rawtemplateBUG:messageDATA_CONSTANTSDATA_FUNCTIONSDATA_ARGUMENTSbareRule("creation"))+operand, a template hole, a conditional arm, a slice helper's return, a registered piece argument such asclosedKeyError("security_and_analysis", ...)) pins by containment. Prose (16 characters or more with a space) pins by containment; anything shorter or spaceless pins only as the whole message of a rendered line, after:and up to the end of the string or line.test/sections/<key>/schema.test.tsfor actions, collaborators, custom_properties, deploy_keys, environments, milestones, pages, repository, workflows. The eight existing ones (branches, check_suite_preferences, interaction_limits, labels, rulesets, secret_scanning_custom_patterns, teams, webhooks) now spell the exact line where a custom message is at stake; zod's own default wording stays matched loosely.test/sections/list-wrapper-schema.test.ts(wrapper directives, the renamed policy key, the list-or-wrapper and plain-mapping lines),roles.test.ts(permissions),secret-variable-schema.test.ts(secret entry keys),test/problem.test.ts(the four document-level issue builders).Proof
test/sections/<key>/tests or the shared test that owns the wording.env -u NODE_OPTIONS bun run typecheck,bun run knip,bun run lintgreen.bun test test/sections test/problem.test.ts --timeout 120000: 1557 pass.bun run test:e2e --sections labels,webhooks,milestones,workflows: 89/89;--sections secret_scanning_custom_patterns,milestones: 21/21;--sections milestones,workflows: 16/16.milestones-invalid-state-rejected.ymlandworkflows-invalid-state-rejected.yml(astate: pausedentry exits 1 before any request, naming the entry and the accepted values).Technical details
Reviewer notes
path:line: "fragment". Either spell the message in the section'stest/sections/<key>/schema.test.ts(the renderedkey.path: messageline, throughvalidateSectionShapes) or, for a shared helper's wording, in the shared test that owns it.test/sections/<key>/layout from refactor(test): fold the section test mirror into test/sections #402; the PR is based onmain.z.toJSONSchemadoes not export messages, and neither zod nor zod-to-json-schema offers a message extractor, so the census is the AST walk.estree-walker(3.0.3, no runtime dependencies; its one dependency is @types/estree): it walks any subtree, skips on demand, starts at any node, and hands each step its parent and key, which the reference and scope tracking need. oxc's ownVisitorhas neither skip nor a sub-tree start.src/engine/validate.ts(null state, truncation, cycle, closed surface),src/engine/secret-refs.ts, andsrc/plain-data.ts(nonPlainKind, rendered through the engine and pinned intest/engine/validate.test.ts). Two defensive defaults there (a value of the key's own type) are unreachable through the engine, so they cannot be pinned by an input; a note for the owner.requirePlainMapping's line is pinned through the section shape directly: the engine's plainness walk refuses a tagged value first, so no document reaches it.dependabot_security_updates, a squash message without its title, an invalid squash pair) already lived inrepository/repository.test.ts, sorepository/schema.test.tsdoes not repeat them.src/problem.tsandcompilable-form.ts: inproblem.tsthe message positions are the...Issuebuilders' bodies and the advice constants they name; incompilable-form.tsevery literal outsiderenderandcodePointEscape(regex builders) and the data constants is a message, the reason column ofMALFORMED_ESCAPESincluded. The RegExp engine's messages pass through unread.params.legalis a message position: the engine frames the line (has no empty state; write ...) and the module supplies the legal text, so the census reads the module's literal and the engine test pins the framed line.compilable-form.test.tswith independent literals, so the pattern refusal's inner text is a deliberate change too."key[0].path: " + String(KEY) + "...": theString(...)operands keep the+chain a plain concatenation the census reads as one asserted string, where a template would hide the text in a hole.<key>.test.ts(autolinks, the four secret and two variable families, code scanning and code quality setup).Message positions in the modules
In
src/sections/*/index.ts,environments/{nested,branch-policies,protection-rules}.ts,contract/module.ts,shared/repo-secrets.ts, andproblem.ts, a message is:message:/error:/consequence:value;legal:value of an issue'sparams;...Issuebuilder's body;noun:, and the noun argument of a registered duplicate-issue helper (identifiedBy(..., "workflow"),duplicateVariableNameIssues(entries, "...")).A
Live*shape's transform (a GET body outside the documented API) is not a parse position.Exclusions in full, one planted control literal each
z.enum([...]),z.literal("web"),.default(...),.includes("<num>"),z.discriminatedUnion("type", ...); the message argument after it is a message. The value methodssplit,join,replace,startsWith,new RegExp(...),new Set([...]), and the receiver they are called on (the.join(" or ")conjunction in the squash-pair hint is pinned today through the pair table);caselabel; a data field (path,code,id,route,kind,check,nameKey,example, ...) and the arrays under one;nameis not one (ECHO_CARRIER.nameis user text), and thenameKey/exampletokens of the branches actor table are data the surrounding template pieces pin;as constvocabulary, reached through arrays, objects, and properties only (a call inside it,rule("creation", { error }), is read), and never a prose-length literal (the wrapper directives text inWRAPPER_KINDSis a message); a JSON Schema twin under.meta({...})orconditional(...); aString.rawtemplate (a regex source; any other tag is a message); aBUG:invariant;DATA_CONSTANTS(key lists, vocabularies, pattern pieces); a regex-building function inDATA_FUNCTIONS;bareRule("creation"),actorList(holder, "users"),boundedString(max, "code points", ...),sectionFailure("live-shape", ...)).Tripwire controls
.metaschema twin, a regex-building function,new RegExp(...),new Set([...]), and aString.rawsource among them); theDATA_CONSTANTSnames are checked against the constants the sources declare, so the list cannot go stale;+chain, a type assertion, aletassigned later, a computed key, a local helper's argument, a helper whose message parameter is called, a helper three calls deep, a message read off a local table, aforloop over messages, a message-shaped object built by a module helper, a module function a message names, an issue builder's return and the shape helper it names, a tag other thanString.raw, a message inside anas consttable, the message argument ofz.enum(values, message)andz.literal(value, message);+chain pass; a scenario pins through itsexpectlists only;Red census, each spelling planted in a real source then reverted
error: <string>"Color required"(a type assertion):"Color required";function called(message: () => string) { return z.string({ error: message() }) }withcalled(() => "probe2"):"probe2";function plantedIssues() { return [{ path: "[0].state", message: "probe3" }] }inworkflows/index.ts:"probe3";function chainA() { return chainB() } function chainB() { return "probe4" }:"probe4";function titled(message: string) { return z.string().min(1, message) }withtitled("probe5"):"probe5";for (const m of ["probe6"]) { ctx.addIssue({ message: m }) }:"probe6", no crash;error: \probe7`(a hole-free template):"probe7"`;const reasons = { bad: "probe8" }; error: reasons.bad:"probe8";{ ["error"]: "probe9" }:"probe9";let msg: string; msg = "probe10"; error: msg:"probe10";error: "Please " + "enter text":"enter text"(the"Please "piece is contained in another pin);.refine(..., "<sentence>")inlabels/schema.tsand a new reason row incompilable-form.ts: each named by file and line;z.custom(...).transform((value, ctx) => { ctx.addIssue({ message: "probe11" }) })incontract/module.ts(a routed shape, not aLive*one):"probe11";identifiedBy("workflows", "path", "planted workflow noun")inworkflows/index.tsand a planted noun inenvironments/nested.ts: each named by file and line;plantedTag\probe tagged`,[rule("creation", { error: "..." })] as const,z.enum(values, "Please pick a planted state"),z.literal(v, "probe lit"),message: plantedWhy()in a module, anddescribePlantedShape()` named by an issue builder: each named by file and line;WRAPPER_KINDS(as const), a plantedbesidearm inroutedListShape(a function-local constant), a plantedparams.legalvalue, and"a plist"indescribeValue: each named by file and line;refusalSourcesfails naming its path.Known limits
Each an over-count (a pin found where a reader might not expect one; none lets a message through):
test.eachrow label counts as one;0.protection...) counts for the engine'sbranches[0].protection...line too;Invalid input) reads as pinned by that line;:in an asserted line, not only the one that follows its key path;a listinsidecomma listno longer pins it; a longer piece pins by containment;stdout_lacks,never) counts as a pin like anyexpectlist;type Verdict = "probe") counts as a pin;const message = "..."that nomessage:property names is not read;.tsand.ymlundertest/, harness files included (the e2e runner, mocks, generators), so a short piece can be pinned by one of their strings.Not read
By the owner's ruling (parse refusals only): plan text, outcome strings, and notes in the section modules,
module.ts,repo-secrets.ts, and thedescribeProblemlines ofproblem.tsoutside the issue builders; the RegExp engine's own error text, whichcompileFailurepasses through; regex sources spelled asString.rawtemplates.Recorded, not built
Text on the receiver of a
.join()as a message; a message table keyedtext:.Wording review
Every message was read against the criteria "names the key, gives the fix, agrees with the docs". The key path is on every rendered line, every message names a fix or the accepted forms, and the caps and ranges (wait timer 43200, 6 reviewers, 10 pinned, 100-character descriptions, 100 bypass users, 1 to 1000, 20 topics, 0 to 6 reviews, the deploy-key algorithms) agree with
docs/sections/*.docs.yml.Line accounting
+2368 / -96 across 27 files; every added line is under
test/except the estree-walker devDependency inpackage.jsonandbun.lock.BEGIN_COMMIT_OVERRIDE
test(sections): pin every parse-refusal message a user can see
Every custom refusal literal in the section schema slices, the shared schema helpers, the file-only validate hooks, and the document-level issue builders is now spelled by a test through validateSectionShapes, the entry point the action uses.
A census test reads every string literal of the schema slices and shared helpers off the source AST with oxc-parser and estree-walker, minus a closed list of data positions.
It fails naming the file, the line, and the literal of any message no test string pins, so a new refusal cannot ship unpinned.
Before, 239 of the 366 message literals had a sentence-level pin; now all 366 do, each in the section's test/sections// tests or the shared test that owns the wording.
END_COMMIT_OVERRIDE