Skip to content

fix(generate): stop docs-sync from losing facts and skipping skills - #86

Open
Ethan-Arrowood wants to merge 3 commits into
mainfrom
fix/sync-retention
Open

fix(generate): stop docs-sync from losing facts and skipping skills#86
Ethan-Arrowood wants to merge 3 commits into
mainfrom
fix/sync-retention

Conversation

@Ethan-Arrowood

Copy link
Copy Markdown
Member

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.mjs asserted only that each must_cover string 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 growing must_cover 76 → 115 anchors — and the very next regeneration (#84) dropped seven facts that main documents, 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:

Rule Dropped fact
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 deleted moduleLoader behavior tables

No false positives across all 22 generate rules 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-mcp was added later (#69), so it was regenerated on every run and then discarded with the runner — 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 at all.

Now staged from the SKILLS registry via a new skill-dirs.mjs, keeping lib/manifest.mjs the single place a skill is registered — as docs/plans-archive/docs-driven-skills.md intended ("handles every skill directory it finds — no per-skill plumbing needed").

Consequence worth flagging separately: harper-mcp/rules/custom-mcp-tools.md:50 still tells agents that data access inside a custom MCP tool "still hits per-record allow* predicates". The docs now say the transport "invokes the custom instance method directly" and "does not open a Resource transaction or run an allow* gate automatically". That is live incorrect security guidance on main and needs its own content PR — this change only makes it reachable by automation.

3. Every run re-sampled from main instead of continuing the branch

The job checked out main (no ref:) and force-pushed over auto/docs-sync. Because rules are skipped only when inputHash matches, 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 inputHash values, 409 and the gh CLI 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 main in, 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 validate green.
  • 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 against main's.
  • Workflow YAML parses; every run block passes bash -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

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>
@Ethan-Arrowood
Ethan-Arrowood requested a review from a team as a code owner September 2, 2026 22:07
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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
// dropping `Sec-WebSocket-Protocol: mqtt` is not.
const withoutFences = md.replace(/(\x60{3}|~{3})[\s\S]*?\1/g, '');

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 config is idempotent, and I would rather that step keep working standalone.
  • The handler no longer guesses. A real conflict is detected via git ls-files --unmerged and 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 with h.txt named.

sent with Claude Opus 5

Comment thread .github/workflows/generate.yaml Outdated
run: |
set -euo pipefail
BRANCH="auto/docs-sync"
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants