fix(generate): stop docs-sync from losing facts and skipping skills - #86
fix(generate): stop docs-sync from losing facts and skipping skills#86Ethan-Arrowood wants to merge 3 commits into
Conversation
Three defects in the auto-sync pipeline, surfaced while reviewing #84. Fact retention. Nothing prevented a regeneration from deleting a documented constraint. validate-generated only asserted that each `must_cover` string appears somewhere in the body, which requires a human to predict every fact in advance and still passes when the term survives but the fact around it is gone. #81 responded by growing must_cover 76 -> 115 anchors; the very next run still dropped seven facts that main documents, all with anchors green. Add checkFactRetention: fail when an inline code span present in BOTH the previously committed body AND the current docs source disappears from the regenerated body. Requiring presence in the current source is what makes it safe to gate on — a fact deleted upstream is correctly dropped and never reported. Run against #84 it catches `Sec-WebSocket-Protocol: mqtt`, the `HdbError: <attribute> is not indexed...` string, and `instanceof`/`import()`/`logger` from the deleted moduleLoader behavior tables, with no false positives across all 22 generate rules. Intentional removals are recorded per rule under `allow_dropped`. Prose-only facts are still review's job; this gates the identifier, header, status-code and error-string class. Multi-skill staging. The commit step hardcoded `harper-best-practices`, so harper-mcp — added later, in #69 — was regenerated on every run and never staged. Both its rules have carried a stale sourceCommit since they were written, and an MCP-only docs change reported "No changes" and opened no PR. Stage every directory in the SKILLS registry instead, via a new skill-dirs.mjs so lib/manifest.mjs stays the single registry. Rolling-branch regeneration. The job checked out main and force-pushed over the sync branch, so every run re-sampled every drifted rule from main's stale baselines rather than skipping rules already current. That resampling is why facts appeared and disappeared between runs of the same PR, and it discarded fixes committed during review. Generate on top of the open sync branch when one exists, merging main in and failing loudly on conflict, and push with --force-with-lease. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A green PR-time check does not mean retention was verified: the validate-skills workflow runs plain `npm run validate` with no docs checkout, so the docs-dependent checks are skipped there. The gate bites in generate.yaml, which passes --docs-path before opening or updating the sync PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a new script skill-dirs.mjs to output skill directories and implements a fact-retention validation check (checkFactRetention) in validate-generated.mjs to ensure documented facts are not accidentally dropped during regeneration. The review feedback suggests improving the inlineCodeSpans helper to also strip tilde-based fenced code blocks, preventing potential false positives when extracting inline code spans.
| // Inline code spans, which is where this corpus keeps its facts: identifiers, | ||
| // config keys, status codes, header names, enum values, error strings. Prose | ||
| // is deliberately excluded — rewording prose is expected and legitimate; | ||
| // dropping `Sec-WebSocket-Protocol: mqtt` is not. |
There was a problem hiding this comment.
The inlineCodeSpans function currently only strips fenced code blocks that use backticks (```). However, the codebase (specifically sources.mjs) also supports tilde-based fences (~~~). If a rule body uses tildes for fenced blocks, any backticks inside them will be incorrectly parsed as inline code spans. Updating the regex to support both fence types ensures consistency and prevents false positives.
| // dropping `Sec-WebSocket-Protocol: mqtt` is not. | |
| const withoutFences = md.replace(/(\x60{3}|~{3})[\s\S]*?\1/g, ''); |
There was a problem hiding this comment.
Fixed in 1c650a1, though not with the suggested regex. You are right that sources.mjs supports tilde fences — sliceSection uses /^\s*(`{3,}|~{3,})/ — so the narrower regex was inconsistent with its own module.
The suggested /(\x60{3}|~{3})[\s\S]*?\1/g fixes the tilde case but still mishandles delimiter runs longer than three: the backreference matches exactly the 3 captured characters, so a ```` fence closes on the first 3 of 4 and leaves a stray backtick behind — which can then pair with a later one and manufacture a phantom span. It also matches fences that are not at line start.
Replaced with a line-based stripFencedBlocks in lib/sources.mjs, shared by inlineCodeSpans and stripCode, honouring delimiter character, run length, indent, and info strings per CommonMark. Covered by 13 tests under node --test.
sent with Claude Opus 5
kriszyp
left a comment
There was a problem hiding this comment.
Looks like worthwhile fixes, maybe a couple of updates to the scripts to apply.
🤖 Reviewed with Codex
| # Pick up anything that landed on main since the branch was cut. A | ||
| # conflict means the branch and main touched the same rules; stop | ||
| # loudly here rather than resolving it by discarding the branch. | ||
| if ! git merge --no-edit origin/main; then |
There was a problem hiding this comment.
This merge can require a merge commit whenever main advances without conflicts, but the bot's user.name and user.email are configured only in the later commit step. A fresh hosted checkout has no author identity, so Git exits with “Committer identity unknown”; the broad error handler then misreports this as a merge conflict and wedges sync. Please configure the App identity before this merge, while retaining the later generated-content commit behavior.
There was a problem hiding this comment.
Confirmed and fixed in 1c650a1 — this was a real wedge, and worse than "can require a merge commit": main advances routinely via semantic-release chore(release) commits, so the non-fast-forward path is the normal one, not an edge case.
Reproduced it locally with an empty HOME to mimic a fresh hosted checkout:
--- merge with NO identity ---
Committer identity unknown
fatal: unable to auto-detect email address (got 'ethan@Mac.(none)')
exit=128
--- unmerged paths: []
So my handler was not just misleading, it was asserting the opposite of the truth — exit 128 with zero unmerged paths, reported as a conflict.
Two changes:
- The App identity is now configured at the top of the branch-checkout step, before the merge. The later commit step still sets it, as you asked —
git configis idempotent, and I would rather that step keep working standalone. - The handler no longer guesses. A real conflict is detected via
git ls-files --unmergedand names the files with--diff-filter=U; anything else reports itself as a non-conflict merge failure. Verified both branches: identity set →Merge made by the 'ort' strategy, exit 0; genuine conflict → exit 1 withh.txtnamed.
sent with Claude Opus 5
| run: | | ||
| set -euo pipefail | ||
| BRANCH="auto/docs-sync" | ||
| if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then |
There was a problem hiding this comment.
Remote branch existence is not equivalent to an open rolling PR. Closing a rejected sync PR normally leaves its head branch, so the documented “close … then re-run” recovery checks out the same stale branch again; it can repeat the conflict or carry abandoned generated changes into a new PR. Please use open-PR state to decide whether to preserve the branch, and reset/start from origin/main when no open sync PR owns it (with the lease still protecting concurrent updates).
There was a problem hiding this comment.
Good catch — fixed in 1c650a1. You are right that this defeated the very recovery my own error message documented: gh pr close leaves the head branch, so git ls-remote --heads still succeeded, the run checked out the same stale branch, and it either replayed the conflict or carried abandoned generated content into the next PR.
Now gated on open-PR state rather than branch existence:
PR_STATE=$(gh pr view "$BRANCH" --json state --jq .state 2>/dev/null || echo "")
if [ "$PR_STATE" = "OPEN" ]; then
git checkout -B "$BRANCH" "origin/$BRANCH" # preserve review fixes
...
else
git checkout -B "$BRANCH" origin/main # start clean
fi--force-with-lease on the push keeps protecting concurrent updates in the reset case: the lease is taken against the origin/auto/docs-sync remote-tracking ref fetched at checkout, so overwriting an abandoned branch succeeds while anything pushed after this job started still fails.
I also corrected the recovery hint, since closing the PR is now sufficient on its own — it no longer tells people to delete the branch.
sent with Claude Opus 5
| // Inline code spans, which is where this corpus keeps its facts: identifiers, | ||
| // config keys, status codes, header names, enum values, error strings. Prose | ||
| // is deliberately excluded — rewording prose is expected and legitimate; | ||
| // dropping `Sec-WebSocket-Protocol: mqtt` is not. |
There was a problem hiding this comment.
The comment promises fenced blocks are excluded, but this removes only triple-backtick fences. Valid ~~~ fences remain, so backtick-delimited expressions inside such a code example become retained “facts”; removing the example later can falsely block generation whenever the token still occurs in the docs source. Please use a fence-aware scanner or Markdown parser that handles tilde fences and delimiter runs, and cover those cases with focused tests.
There was a problem hiding this comment.
Agreed, and the inconsistency was worse than the comment over-promising: sliceSection in the same module has handled `{3,}`/`~{3,}` all along, so the narrower regex disagreed with the fence logic sitting right next to it.
Fixed in 1c650a1 with a fence-aware scanner rather than a bigger regex — stripFencedBlocks, added to lib/sources.mjs where fence knowledge already lives, and now shared by both inlineCodeSpans and stripCode. It follows CommonMark on the parts that bite: fences close only on the same character with a run at least as long as the opener, at most 3 spaces of indent (4 is an indented code block, not a fence), nothing but whitespace after a closer (an info string is opener-only), and an unterminated fence runs to EOF.
I skipped a Markdown parser deliberately — it would be a new runtime dependency for one helper, and the failure mode here is a false positive that would get the whole check switched off, so I wanted the rule set small enough to read and test exhaustively.
13 focused tests under node --test (no new dependency), wired into npm run validate via a test script, covering exactly the cases the old regex got wrong:
- backtick run inside a
~~~fence is content, not a boundary - tilde run inside a
``` fence, likewise opens/closes; ``` ``` ``` does not close;````` does- unterminated fence; 3-space indent vs 4-space; trailing whitespace vs info string on a closer
On behaviour: this is provably a no-op for the current corpus — old and new stripping produce identical fact sets across all 33 rule bodies, and there are zero tilde or 4+ backtick fences in the rule bodies or the docs build today. Re-verified end to end against #84 after the refactor, same result as before: Sec-WebSocket-Protocol: mqtt, the HdbError: <attribute> is not indexed... string, and instanceof/import()/logger from the deleted moduleLoader tables, with no false positives on a clean tree.
sent with Claude Opus 5
Three fixes from Kris's review on #86. Git identity before the merge. actions/checkout configures no identity, and `git merge origin/main` writes a commit whenever it is not a fast-forward — which is the normal path, since semantic-release advances main routinely. Git then exits 128 with "Committer identity unknown" and the broad handler misreported it as a merge conflict, wedging sync. Reproduced locally: exit 128, and `git ls-files --unmerged` empty, so the old message was actively wrong. Configure the App identity before the merge (the commit step still sets it; git config is idempotent), and split the handler so a real conflict names its files via --diff-filter=U while any other failure says so instead of guessing. Reuse the branch only while an open PR owns it. Remote branch existence is not the same thing: closing a rejected sync PR leaves its head branch behind, so the documented "close the PR and re-run" recovery checked out the same stale branch and replayed the conflict, or carried abandoned generated content into the next PR. Gate on `gh pr view --json state` and start clean from origin/main otherwise; --force-with-lease still refuses to clobber a push that landed after checkout. The recovery hint now matches the behaviour. Fence-aware stripping. inlineCodeSpans stripped only triple-backtick fences while its comment promised fenced blocks were excluded, so a backtick expression inside a ~~~ fence would register as a fact and deleting that example later could falsely block generation. Add stripFencedBlocks to lib/sources.mjs, where fence knowledge already lives — sliceSection has handled `{3,}`/`~{3,}` all along, so the narrower regex was also inconsistent with the module it sits next to. It follows CommonMark on delimiter character, run length, indent and info strings, and stripCode now shares it. Covered by 13 focused tests under node --test (no new dependency), wired into `npm run validate` via a `test` script. Behaviour on the current corpus is provably unchanged: old and new stripping produce identical fact sets across all 33 rule bodies, so the losses this caught on #84 still get caught. The scanner only differs on inputs the corpus does not yet contain, which is the point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while reviewing #84. Three defects in the auto-sync pipeline, in descending order of consequence.
1. Nothing prevented a regeneration from deleting a documented fact
validate-generated.mjsasserted only that eachmust_coverstring appears somewhere in the body. That requires a human to have predicted every fact in advance, and it still passes when the term survives but the fact around it is gone. #81 diagnosed this correctly and responded by growingmust_cover76 → 115 anchors — and the very next regeneration (#84) dropped seven facts thatmaindocuments, with every anchor green.This adds
checkFactRetention: fail when an inline code span present in both the previously committed body and the current docs source disappears from the regenerated body.Requiring presence in the current source is what makes it safe to gate on — a fact deleted upstream is correctly dropped and never reported. Only facts the docs still assert, and that the rule used to carry, are enforced. It is derived from the diff rather than enumerated by hand, so it covers facts nobody thought to anchor.
Run against #84's head it catches:
automatic-apis`Sec-WebSocket-Protocol: mqtt`programmatic-table-requests`HdbError: <attribute> is not indexed and not combined with any other conditions`v5-upgrade`instanceof`,`import()`,`logger`— from the deletedmoduleLoaderbehavior tablesNo false positives across all 22
generaterules in both skills with the docs build attached.Scope, stated honestly: this gates the identifier / header / status-code / error-string class, which is where this corpus keeps its facts. Prose-only losses (for example the by-ref "cluster builds from source, use a payload deploy instead" caveat) are not caught and remain review's job. Deliberate removals are recorded per rule under
allow_dropped, so the decision lands in review rather than in a silent diff.2. harper-mcp could never sync
The commit step hardcoded
git add harper-best-practices.harper-mcpwas added later (#69), so it was regenerated on every run and then discarded with the runner — both its rules have carried a stalesourceCommitsince they were written, and an MCP-only docs change reported "No changes" and opened no PR at all.Now staged from the
SKILLSregistry via a newskill-dirs.mjs, keepinglib/manifest.mjsthe single place a skill is registered — asdocs/plans-archive/docs-driven-skills.mdintended ("handles every skill directory it finds — no per-skill plumbing needed").3. Every run re-sampled from main instead of continuing the branch
The job checked out
main(noref:) and force-pushed overauto/docs-sync. Because rules are skipped only wheninputHashmatches, and main's baselines are stale, nothing was ever skipped: every run re-generated every drifted rule from scratch.That is the mechanism behind the fact flicker on #84 — between two revisions with byte-identical
inputHashvalues,409and theghCLI scope warning vanished and came back while the MQTT header stayed gone. It also discarded a completed review.Now: generate on top of the open sync branch when one exists, merge
mainin, and fail loudly on conflict rather than resolving it by discarding the branch. Push with--force-with-lease. The branch stays rolling — one open sync PR, updated in place.Verification
npm run validategreen.validate-generated.mjs --docs-path <docs>green on a clean tree; fires as tabled above when docs: regenerate rules from documentation@0d151a2 #84's bodies are staged againstmain's.runblock passesbash -n; the new step is ordered before provenance capture and generation.Note on merge order
#84 should not merge before this lands — it is a net content regression on seven facts. With retention enforced, re-running generation is cheaper than reconciling #84 by hand.
🤖 Generated with Claude Code