From 7fda6ed38fbce2625f4468aa73d8ce362f24f518 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 14 Aug 2026 19:11:34 +0000 Subject: [PATCH 1/2] chore(skills): rewrite the rfc skill around this repo's RFC shape The skill taught the Polkadot Fellowship template while every RFC in this repo follows docs/rfcs/0001-template.md; four files in docs/rfcs/ carry the upstream sections as a result. Its `context: fork` frontmatter also ran it as a background subagent, so the clarifying-question interview it specified could never reach the author, and it hardcoded an author name into the output shape. The body is now a routing layer over four references, and template.md is gone: it was a stale copy of the upstream template. --- .claude/skills/rfc/SKILL.md | 144 ++++-------------- .../rfc/references/interview-questions.md | 36 +++++ .../rfc/references/normative-language.md | 34 +++++ .../skills/rfc/references/review-rubric.md | 38 +++++ .../skills/rfc/references/section-contract.md | 36 +++++ .claude/skills/rfc/template.md | 59 ------- 6 files changed, 177 insertions(+), 170 deletions(-) create mode 100644 .claude/skills/rfc/references/interview-questions.md create mode 100644 .claude/skills/rfc/references/normative-language.md create mode 100644 .claude/skills/rfc/references/review-rubric.md create mode 100644 .claude/skills/rfc/references/section-contract.md delete mode 100644 .claude/skills/rfc/template.md diff --git a/.claude/skills/rfc/SKILL.md b/.claude/skills/rfc/SKILL.md index 0c9d6f12d..622c1f74c 100644 --- a/.claude/skills/rfc/SKILL.md +++ b/.claude/skills/rfc/SKILL.md @@ -1,139 +1,61 @@ --- name: rfc -description: Write an RFC document based on user-provided notes and context. Use when the user wants to create, draft, or write an RFC. -argument-hint: [topic or brief description] -context: fork +description: Write, revise, or review an RFC in docs/rfcs/ — interview the author, draft the sections, check the shape. Use for "write an RFC", "draft an RFC", "turn these notes into an RFC", "review this RFC", a bare RFC number, or edits under docs/rfcs/. Not for design docs in docs/design/, PRDs, or issue drafts. +argument-hint: [topic, notes path, or RFC number] +allowed-tools: Read, Grep, Glob, Bash(deno run --allow-read=. -c ${CLAUDE_PROJECT_DIR}/.claude/skills/rfc/deno.json ${CLAUDE_PROJECT_DIR}/.claude/skills/rfc/scripts/check-rfc.ts *), Bash(deno test --allow-read=. -c ${CLAUDE_PROJECT_DIR}/.claude/skills/rfc/deno.json ${CLAUDE_PROJECT_DIR}/.claude/skills/rfc/scripts/check-rfc.test.ts *) --- -# RFC Writer +# RFC -You are an RFC writer. Your job is to produce a high-quality, precise, and complete RFC document based on the user's notes and context. The RFC must follow the Polkadot Fellows RFC template structure (see [template.md](template.md)). +RFCs live in this repo at `docs/rfcs/NNNN-kebab-title.md`, are listed in `docs/rfcs/_index.md`, and follow `docs/rfcs/0001-template.md`. Read the template and a nearby RFC of similar size before drafting — `0008-statement-store.md` for a compact interface change, `0026-supported-chains.md` for a new method with a rationale-heavy Motivation. -## Process +`$ARGUMENTS` is the topic, a path to notes, or the number of an existing RFC to revise. -### Phase 1: Gather Context +This skill covers writing the document. Getting it merged, its status, and its rollout are not its business. -The user will provide some combination of: -- Notes describing what the RFC should cover -- An existing spec or RFC that this new RFC aims to adjust -- A PRD or design document -- Verbal explanation of the problem and proposed solution -- Code references or technical context +## Workflow -**Your first action is to read and deeply understand everything the user provides.** +1. **Read every input before asking anything** — the user's notes, the design doc or PRD, the RFC being amended, the tracking issue, and the code the change lands in. An RFC that contradicts the code it changes dies on the first review pass. -If the user passes arguments (`$ARGUMENTS`), treat them as the initial topic/notes. +2. **Interview until nothing is hand-wavy.** Numbered batches of 5–8 questions, grouped by area, as many rounds as it takes; question bank in `references/interview-questions.md`. Skip what the notes already answer, quote the note you are asking about, and name any contradiction you found instead of quietly picking a side. Do not start drafting while a mechanism is still "somehow" — vague design is what reviewers reject, and it always traces to a question nobody asked. -### Phase 2: Clarifying Questions +3. **Allocate the number**: highest in `docs/rfcs/_index.md` plus one. Gaps stay unused; they belong to RFCs in sibling repos. -**This is the most critical phase. You MUST ask clarifying questions before writing the RFC.** +4. **Draft into the repo shape.** Per-section contract, and the rule for leaving a section out rather than padding it, in `references/section-contract.md`. Uppercase MUST/SHOULD/MAY carry RFC 2119 meanings and belong only in `## Detailed Design`: `references/normative-language.md`. -Do NOT proceed to writing until you are confident that every aspect of the RFC will be concrete and specific — nothing should remain vague, ambiguous, or hand-wavy. +5. **Add the `_index.md` row** in the same change: number, linked title, status, author, PR cell (`—` when there is no PR yet). An RFC missing from the index is invisible to everyone who looks for it. -Ask questions in focused, numbered batches (5-8 questions max per round). Group them logically. Continue asking rounds of questions until you have full clarity. +6. **Gate, then self-review.** Run the checker below, fix what it names, then walk `references/review-rubric.md`. -Areas you must have clarity on before writing: +7. **Revise by patch.** Once the draft exists, edit the sections a comment touches. Never regenerate the file — RFC text is negotiated line by line, and a rewrite silently drops wording that was already settled. -1. **Problem & Motivation**: What exact problem does this solve? Who is affected? What's the impact of not solving it? Are there concrete examples or incidents that motivate this? - -2. **Proposed Solution**: What specifically is being proposed? What are the exact mechanics? How does it work step-by-step? What are the key design decisions and why were they made? - -3. **Scope & Boundaries**: What is explicitly in scope? What is explicitly out of scope? Are there related problems this intentionally does NOT address? - -4. **Stakeholders**: Who are the primary stakeholders? Has this been discussed with anyone? What feedback has been received? - -5. **Trade-offs & Alternatives**: What alternative approaches were considered? Why were they rejected? What are the known drawbacks of the chosen approach? - -6. **Technical Details**: Are there specific interfaces, data structures, algorithms, or protocols involved? What are the exact parameters, thresholds, or configurations? - -7. **Compatibility & Migration**: Does this break anything existing? How do existing users/systems migrate? Is backwards compatibility maintained? - -8. **Edge Cases**: What happens in failure scenarios? What are the boundary conditions? Are there race conditions or ordering concerns? - -9. **Testing & Verification**: How can correctness be verified? What testing approach is appropriate? - -10. **Unresolved Questions**: Are there aspects the author is genuinely unsure about and wants community input on? - -**Rules for clarifying questions:** -- Be specific — don't ask "can you tell me more?" Ask "what happens when X occurs during Y?" -- Reference the user's notes when asking — show you've read and understood them -- If the user's notes already answer a question clearly, don't re-ask it -- If something seems implied but isn't explicit, ask to confirm your understanding -- Flag any contradictions or gaps you notice in the provided materials -- When the user provides an existing spec/RFC as context, ask how the new proposal interacts with or modifies it - -### Phase 3: Write the RFC - -Once you have sufficient clarity, write the complete RFC following this structure: +## Gate +```bash +deno run --allow-read=. -c ${CLAUDE_PROJECT_DIR}/.claude/skills/rfc/deno.json ${CLAUDE_PROJECT_DIR}/.claude/skills/rfc/scripts/check-rfc.ts docs/rfcs/0027-your-rfc.md ``` -# RFC: [Descriptive Title] - -| | | -| --------------- | ---------------------------------------- | -| **Start Date** | [Today's date] | -| **Description** | [One clear sentence] | -| **Authors** | Valentin Sergeev | - -## Summary -[One concise paragraph — the elevator pitch] - -## Motivation -[Problem statement + requirements. Be specific with examples.] - -## Stakeholders -[Who cares about this and why. Prior socialization.] - -## Explanation -[The meat of the RFC. Detailed, precise, implementer-friendly. -Address corner cases. Justify decisions. Show the reasoning.] -## Drawbacks -[Honest assessment of downsides] +Checks filename, frontmatter (`title`, `owner`), H1 number against filename, required sections, headings with nothing under them, surviving template text, `TODO`/`TBD` markers, RFC 2119 keywords in descriptive sections, and the `_index.md` row. `ERROR` blocks handing the draft back; a `WARN` needs a reason, not a fix. -## Testing, Security, and Privacy -[How to test. Security implications. Privacy considerations.] +Passing paths checks one RFC. Passing none audits all 16 — that run reports existing corpus drift, which is not yours to fix unless asked. -## Performance, Ergonomics, and Compatibility +The checks are pinned by fixtures — `deno test --allow-read=. -c ${CLAUDE_PROJECT_DIR}/.claude/skills/rfc/deno.json ${CLAUDE_PROJECT_DIR}/.claude/skills/rfc/scripts/check-rfc.test.ts` proves each one fires on a broken fixture and stays silent on two valid RFCs written in deliberately different styles. Change a check, extend the matrix in the same commit. -### Performance -[Impact analysis] +## Traps -### Ergonomics -[UX/DX impact] - -### Compatibility -[Breaking changes, migration path] - -## Prior Art and References -[What exists already. What informed this design.] - -## Unresolved Questions -[Genuine open questions for discussion] - -## Future Directions and Related Material -[What this enables next] -``` +**Attribution.** `owner` is the RFC's author — the person whose proposal this is. Take it from the user, or from `git config user.name` when they say it is theirs, and ask when neither is certain. Never carry a name over from the template or a neighbouring RFC: a wrong-but-plausible owner reads fine to everyone except the person it names. -**Writing quality standards:** -- Every claim must be specific and substantiated — no vague language like "improved performance" without explaining how and by how much -- Use precise technical language appropriate to the domain -- Include concrete examples where they aid understanding -- The Explanation section should be detailed enough that an implementer could build from it -- Drawbacks should be genuine, not strawmen — if there are real costs, state them honestly -- Unresolved Questions should reflect actual uncertainty, not false modesty +**Two shapes in one directory.** The repo shape is `Summary / Motivation / Detailed Design / Drawbacks / Alternatives / Unresolved Questions`. Four files in `docs/rfcs/` instead carry `## Explanation`, `## Stakeholders`, and `## Prior Art and References`, copied from the Polkadot Fellowship's template by an earlier version of this skill. Follow the repo template; the gate errors on a missing `## Detailed Design`. -### Phase 4: Review & Iterate +**Facts you cannot source.** A wire id, error variant, type name, threshold, or chain name that you did not read out of the code or hear from the author does not go in the draft. Ask, or put it under `## Unresolved Questions`. Everything an RFC asserts, an implementer will build. -After presenting the draft: -- Ask the user to review -- Be ready to revise specific sections based on feedback -- If revisions reveal new ambiguities, ask follow-up questions before rewriting +**Length is not thoroughness.** The corpus runs 2 KB to 37 KB and the short ones are not the weak ones — `0021-payment-topup-coins.md` adds one enum variant in 2 KB and says everything it needs to. Match the document to the change. -## Important Guidelines +## References -- **Never fabricate technical details.** If you don't know something, ask. -- **Never fill sections with generic placeholder text.** Every section must have real, specific content or be explicitly marked as needing input. -- **Sections can be omitted entirely** if they don't apply. For smaller or narrowly-scoped changes, skip sections like Testing/Security/Privacy, Performance/Ergonomics/Compatibility, or Future Directions rather than filling them with boilerplate. The core sections (Summary, Motivation, Explanation) are always required; everything else is included only when it adds real value. If additional sections are needed beyond the template, add them. -- **Match the technical depth to the audience.** These RFCs are read by protocol engineers and fellowship members — write accordingly. -- **Keep the tone professional and precise**, but not dry. Good RFCs are readable. \ No newline at end of file +| Decision | File | +| --- | --- | +| What must I ask before drafting? | `references/interview-questions.md` | +| What goes in each section, and which ones do I leave out? | `references/section-contract.md` | +| MUST, must, or should? | `references/normative-language.md` | +| Is the draft ready to hand back? | `references/review-rubric.md` | diff --git a/.claude/skills/rfc/references/interview-questions.md b/.claude/skills/rfc/references/interview-questions.md new file mode 100644 index 000000000..cb35dd06c --- /dev/null +++ b/.claude/skills/rfc/references/interview-questions.md @@ -0,0 +1,36 @@ +# What to ask before drafting + +Open with the questions whose answers the draft cannot be written without, and stop asking once the notes have answered them. Batches of 5–8, numbered, grouped by area. Quote the note you are asking about, so the user can see what you already understood. + +Two rules make the difference between an interview and an interrogation: + +- **Ask about mechanism, never about mood.** "What happens when the host holds no key for that product?" is answerable. "Can you tell me more about the design?" makes the user do your job. +- **Confirm what you inferred.** Anything you deduced but the notes never said becomes a question with your inference in it: "I read this as the host resolving the alias, not the product — correct?" + +## Areas + +**Problem.** What breaks today, for whom, and how often? Is there an incident, a bug, a tracking issue, or a product that hit it? What is the cost of not doing this? A Motivation section with no concrete failure reads as a preference. + +**Mechanism.** Step through the change end to end. Who calls what, with which arguments, in which order? What does the host do that it did not do before? Where does state live, and who owns it? What is the exact type of every new field? + +**Surface.** Which trait, method, request id, error variant, SCALE type, or config key changes? Names and shapes, not paraphrases — every repo RFC of consequence carries the literal signature (`0022-account-derivations.md` has 16 fenced blocks, `0017-coinage-payment.md` 12). + +**Boundaries.** What is deliberately not in scope? Which adjacent problem does this refuse to solve, and why? When scope is contested, this becomes a `## Non-goals` section rather than a sentence buried in Motivation. + +**Rejected designs.** What else was considered, and what killed each one? An Alternatives section that lists only strawmen tells a reviewer the design space was never explored. The strongest rejected option is the one worth writing down. + +**Failure and edges.** What happens on a malformed request, a missing key, a concurrent call, a host that does not implement the new method, a version skew between product and host? Which errors are new and what does a product do with each? + +**Compatibility.** Does this break an existing wire format, method, or type? Which versions interoperate? What does a host or product have to do to migrate, and can old and new coexist during the rollout? + +**Verification.** How does an implementer prove they got it right? Which existing test surface covers it, and what has to be added? For anything cryptographic or consensus-adjacent, what is the oracle — a reference implementation, a round-trip, a known-answer vector? + +**Security and privacy.** What can a hostile product or host do with this that it could not before? What new data crosses the trust boundary, and who can see it? + +**Prior work.** Which RFC does this amend, supersede, or depend on? Which RFC number, so the draft can link it? Has it been discussed anywhere — an issue, a PR thread, a call? Who has already pushed back, and on what? + +**Genuinely open.** What is the author actually unsure about? These become `## Unresolved Questions`. False modesty ("perhaps the naming could be improved") wastes a reviewer's attention; a real fork in the design earns it. + +## When to stop + +Stop when you can write the Detailed Design without the word "somehow", every new name has a type, and every failure path has a defined outcome. If a question stays unanswered because the user genuinely does not know yet, that is not a blocker — it is an Unresolved Question, and it goes in the draft as one. diff --git a/.claude/skills/rfc/references/normative-language.md b/.claude/skills/rfc/references/normative-language.md new file mode 100644 index 000000000..978cd5196 --- /dev/null +++ b/.claude/skills/rfc/references/normative-language.md @@ -0,0 +1,34 @@ +# MUST, must, or should + +Uppercase keywords are a contract with implementers, and their meanings are fixed by [IETF RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119): + +- **MUST, MUST NOT, SHALL, SHALL NOT, REQUIRED** — absolute; an implementation that ignores it is wrong. Reserve these for what interoperability and security actually require. +- **SHOULD, SHOULD NOT, RECOMMENDED, NOT RECOMMENDED** — there are limited valid circumstances for ignoring it, and the RFC should name them. +- **MAY, OPTIONAL** — genuinely free, and implementations that choose differently still interoperate. + +Uppercase is rare in the corpus and that is the house style: 7 of the 16 numbered RFCs in `docs/rfcs/` use any uppercase keyword (measured 2026-08-14). An RFC that sprinkles MUST over every paragraph has told an implementer nothing about which sentences are the load-bearing ones. + +## Where they belong + +In `## Detailed Design`, in the sentences that bind an implementation, next to the type or method they constrain: + +> The host must preserve delivery order: all `isComplete = false` pages precede the first `isComplete = true` page; no `isComplete = false` page may be emitted afterwards. +> — `docs/rfcs/0008-statement-store.md`, immediately below the struct it governs + +That one is written lowercase, which is the corpus norm and fine — placement is doing the work. Reach for the uppercase form when an implementation that disagrees would break interoperability or security, and the sentence needs to be unmistakable. + +Not in `Summary`, `Motivation`, `Drawbacks`, `Alternatives`, `Non-goals`, or `Unresolved Questions`. Those sections describe and argue. A requirement stated only there is one an implementer will never find; a requirement stated in both places drifts out of sync the first time the design changes. The gate warns on an uppercase keyword in any of them. + +## Choosing the strength + +Ask what happens when an implementation ignores it: + +- two implementations stop interoperating, or a security property breaks → **MUST**; +- the system still works but a product gets a worse outcome → **SHOULD**, plus the circumstances that justify the exception; +- both choices interoperate → **MAY**, or drop the keyword and describe the freedom in prose. + +Every MUST owes a failure mode: what the host does when a caller violates it, and which error variant it returns. A MUST with no defined violation behaviour is an assertion, and the implementer will invent one. + +## Lowercase is fine + +Ordinary modal verbs in explanation carry no contract: "a product must already hold a genesis hash to make a chain call" describes today rather than legislating tomorrow. Keeping the uppercase form for the sentences an implementer will be held to is what makes those sentences findable. diff --git a/.claude/skills/rfc/references/review-rubric.md b/.claude/skills/rfc/references/review-rubric.md new file mode 100644 index 000000000..14f39d304 --- /dev/null +++ b/.claude/skills/rfc/references/review-rubric.md @@ -0,0 +1,38 @@ +# Is the draft ready to hand back + +Run the gate first — it settles shape, and nothing below is worth doing while `## Detailed Design` is missing or template text is still in the file. What remains is judgement, and it is the part a reviewer will actually spend their attention on. + +Read the draft once as each of these three readers. + +## The implementer + +They have the codebase open and want to start. For every new name in the RFC, can they say what its type is, where it is defined, and what it returns on failure? Walk one request end to end: product calls X, host does Y, response carries Z, and on a missing key the caller sees error E. If any hop needs a guess, the RFC is not done. + +Specific things they will trip on: + +- a type described in prose but never written out; +- an error variant named in one section and absent from the signature; +- ordering left implicit between two async operations; +- "the host handles this" with no statement of how a product observes the result; +- a version skew case — old product, new host, and the reverse — that the RFC never addresses. + +## The sceptic + +They think the change may be unnecessary. Does `## Motivation` name a failure that has actually happened or will demonstrably happen, rather than an aesthetic preference? Is the cheapest alternative — do nothing, fix it in the product, extend an existing method — considered in `## Alternatives` and rejected with a reason? Are the drawbacks the real ones, or a list picked because it is easy to dismiss? + +An RFC that survives this reader states one cost it cannot argue away, and says why the change is still worth it. + +## The maintainer + +They will live with it. Is the new surface consistent with the surrounding API — naming, error shape, request/response symmetry, how existing methods carry a context? Does it add a second way to do something the API already does? What happens to it when the adjacent RFC in flight lands? If a migration is needed, does the RFC say who does what, and can old and new coexist while the rollout happens? + +## Before handing it back + +- Every claim traces to code you read, the author's words, or an explicit `## Unresolved Questions` entry — nothing plausible-but-invented. +- `owner` is the actual author. +- The `_index.md` row exists and links to the file. +- Uppercase RFC 2119 keywords appear only in `## Detailed Design`, and each one has a stated failure mode. +- Every section earns its place; the ones with nothing to say are gone rather than padded. +- Length matches the change. + +Then say what you are unsure about. A draft handed back with "the Alternatives section is thin because you did not mention what you rejected — tell me and I will fill it" is more useful than one handed back silently complete. diff --git a/.claude/skills/rfc/references/section-contract.md b/.claude/skills/rfc/references/section-contract.md new file mode 100644 index 000000000..450e54eb6 --- /dev/null +++ b/.claude/skills/rfc/references/section-contract.md @@ -0,0 +1,36 @@ +# What each section owes, and which ones to leave out + +Counts are the 16 numbered non-template RFCs in `docs/rfcs/`, measured 2026-08-14. They describe what the corpus does; they are not a quota. + +## Around the sections + +Frontmatter is `title` and `owner`, both quoted strings. Optional keys attested in the corpus: `type: rfc`, `status`, `pr`, `breaking`. Six of sixteen files carry no frontmatter at all — that is drift, not licence; the template has it and the gate errors without it. + +H1 is `# RFC NNNN — Title` (8/16) or `# RFC-NNNN: Title` (6/16). Either passes the gate; the em-dash form is the template's. The number must match the filename. + +An optional metadata table under the H1 (`**RFC Number**`, `**Start Date**`, `**Description**`, `**Authors**`) appears in the newest RFC, `0026-supported-chains.md`. Use it or don't. + +## The sections + +| Section | Present | Owes | +| --- | --- | --- | +| `## Summary` | 16/16 | One paragraph: what changes and what it buys. A reader who stops here can still say what the RFC does. | +| `## Motivation` | 16/16 | The failure that exists today, named concretely — a wiped testnet invalidating baked-in hashes, a product paying two round trips, a user seeing a cost they never agreed to. Then the requirements a solution has to meet. Link the tracking issue. A Motivation with no concrete failure reads as a preference. | +| `## Detailed Design` | 10/16 | Enough for someone who knows the codebase to implement it without asking a question: exact types, signatures, request ids, error variants, ordering guarantees, and the file each definition lands in. Fenced blocks in the language of the surface being changed. Subsections are normal — `### API changes`, `### Data model changes`, `### Migration strategy`. | +| `## Drawbacks` | 14/16 | Real costs, in the author's own voice: "one more round trip", "hosts carry a second filter path", "breaking for every product". Strawmen here read as advocacy and cost you the reviewer's trust in the rest. | +| `## Alternatives` | 12/16 | Designs considered and what killed each. Include the strongest rejected option — its absence tells a reviewer the design space was never explored. | +| `## Unresolved Questions` | 8/16 | Forks the author cannot settle alone. Leave it out when there are none; do not manufacture doubt. | + +Four files use `## Explanation`, `## Stakeholders`, and `## Prior Art and References` instead — the Polkadot Fellowship's section names, which an earlier version of this skill taught. Do not copy them. + +Add a section when the material demands one, and the corpus has precedent: `## Non-goals` or `## Out of Scope` (2/16) when scope is contested, `## Definitions` (2/16) when the RFC introduces vocabulary, `## Security Considerations`, `## Testing`, or an appendix for a derivation. Two RFCs use `mermaid` diagrams for multi-party flows; a diagram is worth it when the ordering between three or more parties is the point. + +## Interface changes state the interface + +Every consequential RFC in the corpus carries the literal signature rather than a description of it — `0022-account-derivations.md` has 16 fenced blocks, `0017-coinage-payment.md` 12, `0008-statement-store.md` 5. Write the Rust for a trait method or SCALE type, the TypeScript for a codec or adapter interface, and put both when the change crosses the wire in both directions. Doc comments on the signature carry the semantics that prose would otherwise repeat. + +## Omission over padding + +A section you have nothing to say in is deleted, not filled. "N/A", "None at this time", and a restatement of the heading each cost a reviewer the same attention as real content and return none. The gate errors on a heading with neither prose nor subsections and on surviving template sentences; it warns when `Drawbacks`, `Alternatives`, or `Unresolved Questions` is absent, which is a prompt to justify the omission rather than to invent content. + +`Summary`, `Motivation`, and `Detailed Design` are never omitted. diff --git a/.claude/skills/rfc/template.md b/.claude/skills/rfc/template.md deleted file mode 100644 index 8526d4a14..000000000 --- a/.claude/skills/rfc/template.md +++ /dev/null @@ -1,59 +0,0 @@ -# RFC: Feature Name Here - -| | | -| --------------- | ------------------------------------------------------------------------------------------- | -| **Start Date** | Date of initial proposal | -| **Description** | One-sentence description | -| **Authors** | | - -## Summary - -One paragraph summary of the RFC. - -## Motivation - -Longer motivation behind the content of the RFC, presented as a combination of both problems and requirements for the solution. - -## Stakeholders - -A brief catalogue of the primary stakeholder sets of this RFC, with some description of previous socialization of the proposal. - -## Explanation - -Detail-heavy explanation of the RFC, suitable for explanation to an implementer of the changeset. This should address corner cases in detail and provide justification behind decisions, and provide rationale for how the design meets the solution requirements. - -## Drawbacks - -Description of recognized drawbacks to the approach given in the RFC. Non-exhaustively, drawbacks relating to performance, ergonomics, user experience, security, or privacy. - -## Testing, Security, and Privacy - -Describe the the impact of the proposal on these three high-importance areas - how implementations can be tested for adherence, effects that the proposal has on security and privacy per-se, as well as any possible implementation pitfalls which should be clearly avoided. - -## Performance, Ergonomics, and Compatibility - -Describe the impact of the proposal on the exposed functionality of Polkadot. - -### Performance - -Is this an optimization or a necessary pessimization? What steps have been taken to minimize additional overhead? - -### Ergonomics - -If the proposal alters exposed interfaces to developers or end-users, which types of usage patterns have been optimized for? - -### Compatibility - -Does this proposal break compatibility with existing interfaces, older versions of implementations? Summarize necessary migrations or upgrade strategies, if any. - -## Prior Art and References - -Provide references to either prior art or other relevant research for the submitted design. - -## Unresolved Questions - -Provide specific questions to discuss and address before the RFC is voted on by the Fellowship. This should include, for example, alternatives to aspects of the proposed design where the appropriate trade-off to make is unclear. - -## Future Directions and Related Material - -Describe future work which could be enabled by this RFC, if it were accepted, as well as related RFCs. This is a place to brain-dump and explore possibilities, which themselves may become their own RFCs. \ No newline at end of file From 805d56bd68dcddc48d8f9fe4debd73fddfa4d740 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 14 Aug 2026 19:11:34 +0000 Subject: [PATCH 2/2] chore(skills): add a fixture-tested structural gate for RFC drafts check-rfc.ts checks a draft's filename, frontmatter, H1 number, required sections, empty headings, surviving template text, unresolved markers, RFC 2119 keywords in descriptive sections, and its docs/rfcs/_index.md row. Every check is pinned by a fixture that makes it fire plus two valid RFCs, worded unlike each other, that must leave it silent. That matrix caught two checks that were wrong as first written: a parent heading whose subsections carry the content read as empty, as did a section holding only a code fence. Parsing comes from pinned @std modules rather than hand-rolled regex. --- .claude/skills/rfc/deno.json | 12 + .claude/skills/rfc/deno.lock | 80 +++++++ .claude/skills/rfc/scripts/check-rfc.test.ts | 230 ++++++++++++++++++ .claude/skills/rfc/scripts/check-rfc.ts | 238 +++++++++++++++++++ 4 files changed, 560 insertions(+) create mode 100644 .claude/skills/rfc/deno.json create mode 100644 .claude/skills/rfc/deno.lock create mode 100644 .claude/skills/rfc/scripts/check-rfc.test.ts create mode 100755 .claude/skills/rfc/scripts/check-rfc.ts diff --git a/.claude/skills/rfc/deno.json b/.claude/skills/rfc/deno.json new file mode 100644 index 000000000..958be1a11 --- /dev/null +++ b/.claude/skills/rfc/deno.json @@ -0,0 +1,12 @@ +{ + "lint": { "rules": { "include": ["no-sync-fn-in-async-fn"] } }, + "fmt": { "lineWidth": 110 }, + "imports": { + "@std/assert": "jsr:@std/assert@^1.0.19", + "@std/cli": "jsr:@std/cli@^1.0.32", + "@std/fmt": "jsr:@std/fmt@^1.0.10", + "@std/front-matter": "jsr:@std/front-matter@^1.0.9", + "@std/fs": "jsr:@std/fs@^1.0.24", + "@std/path": "jsr:@std/path@^1.1.6" + } +} diff --git a/.claude/skills/rfc/deno.lock b/.claude/skills/rfc/deno.lock new file mode 100644 index 000000000..5ffa4baab --- /dev/null +++ b/.claude/skills/rfc/deno.lock @@ -0,0 +1,80 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@^1.0.19": "1.0.19", + "jsr:@std/cli@^1.0.32": "1.0.32", + "jsr:@std/collections@^1.1.3": "1.3.0", + "jsr:@std/fmt@^1.0.10": "1.0.10", + "jsr:@std/front-matter@^1.0.9": "1.0.9", + "jsr:@std/fs@^1.0.24": "1.0.24", + "jsr:@std/internal@^1.0.12": "1.0.14", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@^1.1.5": "1.1.6", + "jsr:@std/path@^1.1.6": "1.1.6", + "jsr:@std/toml@^1.0.3": "1.0.11", + "jsr:@std/yaml@^1.0.5": "1.2.0" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/cli@1.0.32": { + "integrity": "188b3a100d6202d64e3f5bd3d799c7fa4f6d77f92cc65eb7f641c1fa0aa92a66", + "dependencies": [ + "jsr:@std/fmt", + "jsr:@std/internal@^1.0.14" + ] + }, + "@std/collections@1.3.0": { + "integrity": "eb36b43d784477ea0b476483ac034a14bdd182aff921c812ecf662a1fcef9498" + }, + "@std/fmt@1.0.10": { + "integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5" + }, + "@std/front-matter@1.0.9": { + "integrity": "ee6201d06674cbef137dda2252f62477450b48249e7d8d9ab57a30f85ff6f051", + "dependencies": [ + "jsr:@std/toml", + "jsr:@std/yaml" + ] + }, + "@std/fs@1.0.24": { + "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", + "dependencies": [ + "jsr:@std/internal@^1.0.14", + "jsr:@std/path@^1.1.5" + ] + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal@^1.0.14" + ] + }, + "@std/toml@1.0.11": { + "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", + "dependencies": [ + "jsr:@std/collections" + ] + }, + "@std/yaml@1.2.0": { + "integrity": "20beb41e4983ba3437dbefac62b14061ab058e8a187596f19d28ff9035f6e6cf" + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@^1.0.19", + "jsr:@std/cli@^1.0.32", + "jsr:@std/fmt@^1.0.10", + "jsr:@std/front-matter@^1.0.9", + "jsr:@std/fs@^1.0.24", + "jsr:@std/path@^1.1.6" + ] + } +} diff --git a/.claude/skills/rfc/scripts/check-rfc.test.ts b/.claude/skills/rfc/scripts/check-rfc.test.ts new file mode 100644 index 000000000..f876987ee --- /dev/null +++ b/.claude/skills/rfc/scripts/check-rfc.test.ts @@ -0,0 +1,230 @@ +/** + * Fixture matrix for the RFC gate: every check must FIRE on a fixture that + * breaks it and stay SILENT on two differently-worded valid RFCs. + * + * deno test --allow-read=. -c .claude/skills/rfc/deno.json \ + * .claude/skills/rfc/scripts/check-rfc.test.ts + */ +import { assert, assertEquals } from "@std/assert"; +import { checkRfc, type Finding } from "./check-rfc.ts"; + +const PATH = "docs/rfcs/0027-payment-quote.md"; +const REGISTERED = new Set([27]); + +const ids = (f: Finding[]) => f.map((x) => x.id); +const errors = (f: Finding[]) => f.filter((x) => x.level === "ERROR").map((x) => x.id); +const check = (text: string, index: Set | undefined = REGISTERED, path = PATH) => + checkRfc(path, text, index); + +/** Known-good: em-dash title, flat sections, code in Detailed Design. */ +const GOOD_A = `--- +title: "Payment Host API v2" +owner: "@somehandle" +--- + +# RFC 0027 — Payment Host API v2 + +## Summary + +One method is added to the \`Payment\` trait so a product can quote a top-up before it commits to one. + +## Motivation + +Products today discover the fee only after submitting, so a user sees a cost they never agreed to. + +## Detailed Design + +The request carries the source and the amount; the host MUST reject a quote whose amount is zero. + +\`\`\`rust +#[wire(request_id = 200)] +async fn quote_top_up(&self, _cx: &CallContext, _request: QuoteRequest) + -> Result>; +\`\`\` + +## Drawbacks + +- One more round trip before a top-up. + +## Alternatives + +Returning the quote inside the existing submit response was rejected: the product needs it before committing. + +## Unresolved Questions + +Whether the quote should carry a validity window. +`; + +/** Known-good, deliberately unlike GOOD_A: colon title, metadata table, nested subsections. */ +const GOOD_B = `--- +title: "Statement store topic filters" +owner: "@someone-else" +type: rfc +status: draft +pr: +--- + +# RFC-0027: Statement store topic filters + +| | | +| --------------- | ---------------------------------------- | +| **Start Date** | 2026-08-14 | +| **Description** | OR semantics for statement-store topics. | +| **Authors** | Someone Else | + +## Summary + +Subscriptions gain OR semantics over topics, which today only match on every listed topic at once. + +## Motivation + +A product watching four channels opens four subscriptions, and each one costs a connection the host pays for. + +## Detailed Design + +### Wire shape + +\`\`\`rust +enum TopicFilter { MatchAll(Vec), MatchAny(Vec) } +\`\`\` + +### Delivery order + +Historical pages arrive before live pages, and the boundary is explicit. + +## Drawbacks + +Hosts carry a second filter path. + +## Alternatives + +Client-side fan-in was rejected because the connection cost is what this removes. + +## Unresolved Questions + +Whether the topic cap belongs in the protocol rather than the host. +`; + +function mutate(source: string, from: string | RegExp, to: string): string { + const out = source.replace(from, to); + assert(out !== source, `fixture mutation did not apply: ${from}`); + return out; +} + +Deno.test("known-good RFCs raise nothing", () => { + for (const [label, text] of [["GOOD_A", GOOD_A], ["GOOD_B", GOOD_B]] as const) { + const f = check(text); + assertEquals(f, [], `${label} should be clean, got ${JSON.stringify(ids(f))}`); + } +}); + +Deno.test("NAME fires on a filename that is not NNNN-kebab-title.md", () => { + assert(errors(check(GOOD_A, REGISTERED, "docs/rfcs/payment-quote.md")).includes("NAME")); + assert(errors(check(GOOD_A, REGISTERED, "docs/rfcs/27-payment-quote.md")).includes("NAME")); + assert(errors(check(GOOD_A, REGISTERED, "docs/rfcs/0027-Payment_Quote.md")).includes("NAME")); +}); + +Deno.test("FM fires on missing frontmatter and on an empty owner", () => { + assert(errors(check(GOOD_A.slice(GOOD_A.indexOf("\n# RFC")))).includes("FM")); + assert(errors(check(mutate(GOOD_A, 'owner: "@somehandle"', 'owner: ""'))).includes("FM")); +}); + +Deno.test("FMKEY and STATUS warn without blocking", () => { + const odd = mutate(GOOD_B, "status: draft", "status: in-review\nreviewer: someone"); + const f = check(odd); + assertEquals(errors(f), []); + assert(ids(f).includes("STATUS")); + assert(ids(f).includes("FMKEY")); +}); + +Deno.test("H1 fires on a number that disagrees with the filename", () => { + assert(errors(check(mutate(GOOD_A, "# RFC 0027 —", "# RFC 0031 —"))).includes("H1")); +}); + +Deno.test("H1 fires when the title line is not an RFC heading at all", () => { + const wrong = mutate(GOOD_A, "# RFC 0027 — Payment Host API v2", "# Payment Host API v2"); + assert(errors(check(wrong)).includes("H1")); +}); + +Deno.test("H1FORM warns on attested-but-odd title punctuation", () => { + const f = check(mutate(GOOD_A, "# RFC 0027 —", "# RFC 0027 -")); + assertEquals(errors(f), []); + assert(ids(f).includes("H1FORM")); +}); + +Deno.test("SEC errors on a missing required section", () => { + const upstreamShape = mutate(GOOD_A, "## Detailed Design", "## Explanation"); + assert(errors(check(upstreamShape)).includes("SEC")); +}); + +Deno.test("SEC warns on a missing expected section", () => { + const truncated = GOOD_A.slice(0, GOOD_A.indexOf("## Alternatives")); + const f = check(truncated); + assert(f.some((x) => x.id === "SEC" && x.level === "WARN")); +}); + +Deno.test("EMPTY fires on a heading with neither prose nor subsections", () => { + const empty = mutate(GOOD_A, "## Drawbacks\n\n- One more round trip before a top-up.", "## Drawbacks\n"); + assert(errors(check(empty)).includes("EMPTY")); +}); + +Deno.test("EMPTY stays silent on a parent heading whose children carry the content", () => { + assertEquals(errors(check(GOOD_B)), []); +}); + +Deno.test("EMPTY stays silent on a section whose only content is a code fence", () => { + const fenceOnly = mutate( + GOOD_A, + "## Drawbacks\n\n- One more round trip before a top-up.", + "## Drawbacks\n\n```text\nOne more round trip.\n```", + ); + assertEquals(errors(check(fenceOnly)), []); +}); + +Deno.test("PLACEHOLDER fires on surviving template prose and on the template owner handle", () => { + const lazyProse = mutate( + GOOD_A, + "Products today discover the fee only after submitting, so a user sees a cost they never agreed to.", + "Why are we doing this? What problem does it solve?", + ); + assert(errors(check(lazyProse)).includes("PLACEHOLDER")); + + const lazyOwner = mutate(GOOD_A, 'owner: "@somehandle"', 'owner: "@ownerhandle"'); + assert(errors(check(lazyOwner)).includes("PLACEHOLDER")); +}); + +Deno.test("MARKER fires on TODO in prose but not inside a code fence", () => { + const inProse = mutate(GOOD_A, "## Unresolved Questions\n", "## Unresolved Questions\n\nTODO\n"); + assert(errors(check(inProse)).includes("MARKER")); + + const inCode = mutate( + GOOD_A, + "#[wire(request_id = 200)]", + "// TODO(host): widen later\n#[wire(request_id = 200)]", + ); + assertEquals(errors(check(inCode)), []); +}); + +Deno.test("CODE warns when no fenced block states the interface", () => { + const noCode = GOOD_A.replace(/```rust[\s\S]*?```\n/, ""); + assert(ids(check(noCode)).includes("CODE")); +}); + +Deno.test("KEYWORD warns for an RFC 2119 keyword in a descriptive section only", () => { + const inDrawbacks = mutate( + GOOD_A, + "- One more round trip before a top-up.", + "- Products MUST NOT cache the quote.", + ); + const f = check(inDrawbacks); + assertEquals(errors(f), []); + assert(ids(f).includes("KEYWORD")); + + // GOOD_A already says "the host MUST reject" under Detailed Design; that is the normative home. + assert(!ids(check(GOOD_A)).includes("KEYWORD")); +}); + +Deno.test("INDEX fires when the number has no row, and is skipped when the index is unreadable", () => { + assert(errors(check(GOOD_A, new Set([26]))).includes("INDEX")); + assertEquals(errors(check(GOOD_A, undefined)), []); +}); diff --git a/.claude/skills/rfc/scripts/check-rfc.ts b/.claude/skills/rfc/scripts/check-rfc.ts new file mode 100755 index 000000000..850f0278f --- /dev/null +++ b/.claude/skills/rfc/scripts/check-rfc.ts @@ -0,0 +1,238 @@ +#!/usr/bin/env -S deno run --allow-read=. +/** + * Structural gate for RFCs in `docs/rfcs/`. + * + * Checks the shape `docs/rfcs/0001-template.md` and the existing corpus use: + * filename, frontmatter, H1, required sections, empty headings, surviving + * template prose, unresolved markers, RFC 2119 keywords in descriptive + * sections, and registration in `docs/rfcs/_index.md`. + * + * deno run --allow-read=. -c .claude/skills/rfc/deno.json \ + * .claude/skills/rfc/scripts/check-rfc.ts [paths...] + * + * No paths: every numbered RFC under `docs/rfcs/`. + * Exit 1 when any ERROR fired. + */ +import { parseArgs } from "@std/cli/parse-args"; +import { expandGlob } from "@std/fs/expand-glob"; +import { basename, relative } from "@std/path"; +import { bold, red, yellow } from "@std/fmt/colors"; +import { extract } from "@std/front-matter/yaml"; +import { test as hasFrontMatter } from "@std/front-matter/test"; + +export type Finding = { id: string; level: "ERROR" | "WARN"; message: string }; + +const INDEX = "docs/rfcs/_index.md"; + +/** Sentences from docs/rfcs/0001-template.md that must not survive into a real RFC. */ +const PLACEHOLDERS = [ + "One-paragraph explanation of the proposal.", + "Why are we doing this?", + "What problem does it solve? What use cases does it support?", + "Explain the design in enough detail that someone familiar with the codebase", + "Why should we _not_ do this?", + "What other designs were considered?", + "What parts of the design are still open?", + "RFC Title", + "@ownerhandle", +]; + +const MARKER = /\b(TODO|TBD|FIXME|XXX)\b|\bLorem ipsum\b/; + +/** Both attested in docs/rfcs: "RFC 0022 — Title" (8 files) and "RFC-0010: Title" (6). */ +const H1 = /^RFC[ -]0*(\d+)\s*(?:—|-|:)\s*\S/; +const H1_CANONICAL = /^(?:RFC \d{4} — |RFC-\d{4}: )\S/; +const H1_HINT = `RFC 0027 — Title" or "# RFC-0027: Title`; + +const REQUIRED = ["Summary", "Motivation", "Detailed Design"]; +const EXPECTED = ["Drawbacks", "Alternatives", "Unresolved Questions"]; +const NORMATIVE_SECTION = "Detailed Design"; + +/** Sections that describe rather than specify; an uppercase RFC 2119 keyword here is prose. */ +const NON_NORMATIVE = [ + "Summary", + "Motivation", + "Drawbacks", + "Alternatives", + "Unresolved Questions", + "Non-goals", + "Out of Scope", +]; + +const KNOWN_FM_KEYS = ["title", "owner", "author", "type", "status", "pr", "breaking", "created"]; +const KNOWN_STATUS = ["draft", "accepted", "rejected", "superseded"]; + +const FENCE = /^```[\s\S]*?^```[^\n]*$/gm; + +type Section = { + heading: string; + depth: number; + /** Everything under the heading, code fences included. */ + content: string; + /** Same span with fenced blocks removed, for prose-level checks. */ + prose: string; + hasChildren: boolean; +}; + +/** + * Split the body at ATX headings. `hasChildren` is true when a deeper heading + * follows before the next same-or-shallower one, so a parent that only + * introduces subsections is not mistaken for an empty section. + */ +function sections(body: string): Section[] { + const out: Section[] = []; + let inFence = false; + for (const line of body.split("\n")) { + if (line.startsWith("```")) inFence = !inFence; + const h = inFence ? null : /^(#{2,6})\s+(.*\S)\s*$/.exec(line); + if (!h) { + if (out.length) out[out.length - 1].content += line + "\n"; + continue; + } + const depth = h[1].length; + for (let i = out.length - 1; i >= 0; i--) { + if (out[i].depth < depth) { + out[i].hasChildren = true; + break; + } + } + out.push({ heading: h[2], depth, content: "", prose: "", hasChildren: false }); + } + for (const s of out) s.prose = s.content.replace(FENCE, ""); + return out; +} + +export function checkRfc(path: string, text: string, indexNumbers?: Set): Finding[] { + const f: Finding[] = []; + const err = (id: string, message: string) => f.push({ id, level: "ERROR", message }); + const warn = (id: string, message: string) => f.push({ id, level: "WARN", message }); + + const fileNumber = /^(\d{4})-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/.exec(basename(path)); + if (!fileNumber) err("NAME", `filename is not NNNN-kebab-title.md: ${basename(path)}`); + + const { attrs, body } = hasFrontMatter(text) + ? extract>(text) + : { attrs: {} as Record, body: text }; + + if (!hasFrontMatter(text)) { + err("FM", "no YAML frontmatter; needs title and owner"); + } else { + for (const key of ["title", "owner"]) { + if (!String(attrs[key] ?? "").trim()) err("FM", `frontmatter ${key} is missing or empty`); + } + for (const key of Object.keys(attrs)) { + if (!KNOWN_FM_KEYS.includes(key)) warn("FMKEY", `unknown frontmatter key: ${key}`); + } + const status = String(attrs.status ?? "").trim(); + if (status && !KNOWN_STATUS.includes(status)) { + warn("STATUS", `status "${status}" is not one of ${KNOWN_STATUS.join(", ")}`); + } + } + + const h1 = /^#\s+(.*\S)\s*$/m.exec(body)?.[1]; + if (!h1) { + err("H1", `no H1 title; expected "# ${H1_HINT}"`); + } else { + const m = H1.exec(h1); + if (!m) err("H1", `H1 is not an RFC title of the form "# ${H1_HINT}": ${h1}`); + else if (fileNumber && Number(m[1]) !== Number(fileNumber[1])) { + err("H1", `H1 number ${m[1]} does not match filename number ${fileNumber[1]}`); + } + if (m && !H1_CANONICAL.test(h1)) { + warn("H1FORM", `H1 is accepted but off-canonical; prefer "# ${H1_HINT}"`); + } + } + + const secs = sections(body); + const headings = secs.filter((s) => s.depth === 2).map((s) => s.heading); + for (const want of REQUIRED) { + if (!headings.includes(want)) err("SEC", `missing required section: ## ${want}`); + } + for (const want of EXPECTED) { + if (!headings.includes(want)) warn("SEC", `no ## ${want} — omit only if it has nothing to say`); + } + for (const s of secs) { + if (s.content.trim() || s.hasChildren) continue; + err("EMPTY", `heading with neither prose nor subsections: ${"#".repeat(s.depth)} ${s.heading}`); + } + + // Frontmatter included: "RFC Title" and "@ownerhandle" are template values. + const prose = text.replace(FENCE, ""); + for (const placeholder of PLACEHOLDERS) { + if (prose.includes(placeholder)) { + err("PLACEHOLDER", `template text survives: "${placeholder.slice(0, 48)}"`); + } + } + const marker = MARKER.exec(prose); + if (marker) err("MARKER", `unresolved marker in prose: ${marker[0]}`); + + if (!/^```/m.test(body)) { + warn("CODE", "no fenced code block — an interface change states the exact signature"); + } + + for (const s of secs) { + if (!NON_NORMATIVE.includes(s.heading)) continue; + const kw = /\b(MUST NOT|MUST|SHALL NOT|SHALL|SHOULD NOT|SHOULD|REQUIRED|RECOMMENDED)\b/.exec(s.prose); + if (kw) { + warn( + "KEYWORD", + `RFC 2119 "${kw[0]}" under ## ${s.heading}; requirements belong in ${NORMATIVE_SECTION}`, + ); + } + } + + if (indexNumbers && fileNumber && !indexNumbers.has(Number(fileNumber[1]))) { + err("INDEX", `RFC ${fileNumber[1]} has no row in ${INDEX}`); + } + + return f; +} + +/** Numbers registered in the RFC index, or undefined when it is unreadable. */ +async function indexNumbers(): Promise | undefined> { + try { + const text = await Deno.readTextFile(INDEX); + return new Set([...text.matchAll(/^\|\s*(\d{4})\s*\|/gm)].map((m) => Number(m[1]))); + } catch { + return undefined; + } +} + +if (import.meta.main) { + const args = parseArgs(Deno.args); + const paths = args._.map(String); + if (paths.length === 0) { + for await (const entry of expandGlob("docs/rfcs/[0-9][0-9][0-9][0-9]-*.md")) { + if (!entry.name.endsWith("-template.md")) paths.push(relative(Deno.cwd(), entry.path)); + } + paths.sort(); + } + + const index = await indexNumbers(); + if (!index) console.log(yellow(`WARN [INDEX] ${INDEX} unreadable — registration unchecked`)); + + let errors = 0; + let warnings = 0; + const seen = new Map(); + for (const path of paths) { + const findings = checkRfc(path, await Deno.readTextFile(path), index); + + const number = /^(\d{4})-/.exec(basename(path))?.[1]; + if (number) { + const prior = seen.get(number); + if (prior) { + findings.push({ id: "DUP", level: "ERROR", message: `number ${number} also claimed by ${prior}` }); + } else seen.set(number, path); + } + + if (findings.length) console.log(bold(path)); + for (const { id, level, message } of findings) { + const line = ` ${level.padEnd(5)} [${id}] ${message}`; + console.log(level === "ERROR" ? red(line) : yellow(line)); + level === "ERROR" ? errors++ : warnings++; + } + } + + console.log(`\n${paths.length} file(s), ${errors} error(s), ${warnings} warning(s)`); + Deno.exit(errors ? 1 : 0); +}