Skip to content

feat(docs): resolve version-matched Spring docs to a shared cache - #13

Merged
amondnet merged 7 commits into
mainfrom
feat/spring-docs-skill
Sep 14, 2026
Merged

amondnet merged 7 commits into
mainfrom
feat/spring-docs-skill

Conversation

@amondnet

@amondnet amondnet commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the spring-docs skill: given a project and a version (boot 3.5.16), it resolves the
published archive from pleaseai/spring-docs, unpacks it
once into ~/.cache/pleaseai-spring/docs/<tag>/, and hands Claude the path.

Nothing is written into the user's project — no .claude/skills/spring-*/ tree, no CLAUDE.md
block. One version is 150-250 files (2.4 MB for Boot 3.5.16, 3.6 MB for 4.1.1); in a project tree
that is a permanent diff, a .gitignore entry and a branch-switch hazard, paid again by every
project. A cache path costs none of that and is shared across projects and branches.

What landed

File Role
skills/spring-docs/SKILL.md the skill — args <project> <version>, reads from the resolved path
scripts/docs.ts Domain Layer: catalog lookup, download, checksum, unpack, CLI
scripts/lib/docs-cache.ts Library Layer, no I/O: catalog lookup, asset naming, cache paths, checksum parsing
scripts/__tests__/docs.test.ts resolution behavior, against an injected fetch and real tar fixtures
scripts/__tests__/docs-cache.test.ts the pure helpers

Design points worth reviewing

  • Cache keyed by tag, not version. A corrected archive is republished as
    <project>-<version>+rebuild.N; a version-keyed cache would serve the superseded tree forever.
  • The catalog is fetched on every run, including cache hits. It is a few kilobytes and is the
    only thing that reports a rebuild having moved a version. Only the archive download — the
    expensive half — is skipped. --no-fetch serves the last resolution from a pointer file instead.
  • Asset name comes from (project, version), not from the tag. boot-4.1.1+rebuild.1 still
    ships boot-4.1.1.tar.gz; deriving the filename from the tag would 404 on every rebuild.
  • Staging directory renamed into place, so an interrupted unpack never leaves a partial tree
    under the name callers read. A checksum mismatch writes nothing at all (asserted in tests).
  • No silent version substitution. An unpublished version returns kind: "unavailable" with the
    issue tracker in suggestion; the skill is explicitly told not to answer from a different minor.

Verification

  • bun test — 194 pass, 0 fail (13 new); bun run typecheck, bun run lint clean.
  • End to end against the real releases: boot 3.5.16 → 146 pages / 2.4 MB unpacked, second run a
    cache hit in 65 ms; boot 3.2.0unavailable with exit 1; missing argument → exit 2.

Docs

  • README.md rewritten for what the plugin now is. The previous text described an unimplemented
    product — /spring:list, /spring:update, /spring:remove, /spring:add, .spring-skill.json,
    a live HTML→Markdown conversion fallback, and an eval results table citing an evals/spring/
    directory that does not exist. Those claims are dropped rather than carried forward.
  • ARCHITECTURE.md gets a status note: the install/resolve/convert pipeline it describes is the
    earlier design and is superseded for the documentation path. A full rewrite belongs with the
    remaining stages, not with this change.

Follow-ups (not here)

  • A /spring:docs slash command; commands/ is still empty.
  • BOM resolution — it earns its place when spring-docs publishes a second project. Today the
    catalog holds boot only, so there is nothing to resolve a Boot version into.

Summary by cubic

Implements the spring-docs skill, which resolves a project's declared Spring version to the matching reference documentation: the published archive is downloaded once into a shared cache and Claude is given the path to read. Nothing is written into the user's project, and answers come from the declared version, not the newest release.

The skill also ships as a standalone npx skills install, which copies only the skill directory and runs no dependency install, so its scripts are bundled into dependency-free committed .mjs files under skills/spring-docs/scripts/ and executed with plain node. README.md and ARCHITECTURE.md are updated to match what exists, dropping earlier claims about slash commands, a config file, and an eval suite that were never implemented.

Distribution

  • bun run build:skill bundles scripts/docs.ts and scripts/detect.ts; CI's build:skill:check fails on a stale bundle.
  • The bundles are .mjs because the skill directory has no package.json to declare ESM, and .js would be treated as CommonJS on Node versions that don't auto-detect module syntax.
  • The build pins import.meta.main via define because Bun lowers it to a __require comparison that throws under plain node.
  • Bundling forced dropping consola (TD-001) and the empty commands/ placeholder; engines.bun and CI are pinned to Bun 1.4.2 while the lockfile itself only needs 1.3.

Caching and correctness

  • The cache is keyed by release tag rather than version, so a corrected archive (boot-4.1.1+rebuild.1) supersedes the old tree instead of serving stale bytes.
  • The catalog is fetched on every run because it is the only thing that reports a rebuild; only the archive download is skipped on a cache hit.
  • Downloads are verified against the published SHA-256 sidecar; a checksum mismatch writes nothing.
  • Publication is atomic: a staging directory is renamed into place, a concurrent loser discards its displaced tree, and leftover debris is swept on every resolve before the cache-hit return.
  • Cache cleanup guards its own directory read, so an absent, unreadable, or concurrently deleted cache path can't reject a resolution.
  • Untrusted path segments are validated at the boundary, catalog.json is shape-checked (including released_at), and a cached tree counts as ready only when it contains a regular _index.md file.
  • Unpublished versions return kind: "unavailable" with a suggestion; a null released_at is treated as unpublished rather than downloading toward a 404.

Written for commit b3deb20. Summary will update on new commits.

The skill takes a project and a version, resolves them through the
spring-docs catalog, and hands back a filesystem path. Nothing is written
into the user's project: an archive is unpacked once under
~/.cache/pleaseai-spring/docs/<tag>/, so a project keeps no documentation in
version control, CLAUDE.md is never rewritten, and two projects on the same
Spring version share one copy.

The cache is keyed by release tag rather than by version, because a corrected
archive is republished as <project>-<version>+rebuild.N and a version-keyed
cache would serve the superseded tree forever. The catalog is consulted on
every run for the same reason — it is a few kilobytes, and it is the only
thing that reports the move; only the archive download is skipped on a hit.

Downloads are checked against the .sha256 published beside them, and unpacked
through a staging directory renamed into place, so neither a corrupt download
nor an interrupted run leaves a tree under the name callers read from.
@codacy-production

codacy-production Bot commented Sep 12, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 5 critical · 70 high

Alerts:
⚠ 75 issues (≤ 0 issues of at least minor severity)

Results:
75 new issues

Category Results
ErrorProne 2 high
Security 5 critical
68 high

View in Codacy

🟢 Metrics 168 complexity · 25 duplication

Metric Results
Complexity 168
Duplication 25

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

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

Copy link
Copy Markdown
Contributor

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 implements version-matched Spring reference documentation resolution by downloading, verifying, and unpacking archives into a shared cache instead of installing them directly into the project. It introduces the docs.ts script, cache helpers, tests, and the spring-docs skill. The review feedback focuses on critical security and robustness improvements in scripts/docs.ts, specifically recommending input validation for project, version, and cached tags to prevent path traversal vulnerabilities, as well as adding defensive checks on Bun.spawnSync's stderr to avoid potential runtime TypeErrors.

Comment thread scripts/docs.ts
Comment thread scripts/docs.ts
Comment thread scripts/docs.ts Outdated
…nstall

The skill ships through two channels. As a Claude Code plugin it gets
${CLAUDE_PLUGIN_ROOT} and an automatic bun install of the root package.json.
Installed with `npx skills` it gets neither: only skills/spring-docs/ is
copied, so nothing above it exists and no dependency install runs.

SKILL.md addressed its scripts with a bare relative path, which resolves
against the user's project in both channels — the scripts were never reachable
from an installed skill. It now uses ${CLAUDE_SKILL_DIR}, the one variable
substituted at the personal, project and plugin level, matched by allowed-tools
rules so the commands run without a prompt.

scripts/build-skill.ts bundles docs.ts and detect.ts into dependency-free files
under the skill directory. The output is committed because `npx skills` copies
straight from the repository, and `bun run build:skill:check` byte-compares it
in CI so it cannot drift.

Two runtime defects surfaced while verifying the bundles under plain node:

- docs.ts extracted archives with Bun.spawnSync, unavailable outside bun. Now
  node:child_process, verified against a real download and extraction.
- import.meta.main does not survive bundling. Bun lowers it to a __require
  comparison that resolves only when a dependency happens to pull in the CJS
  interop helper, so detect.js worked by accident while docs.js threw
  ReferenceError. The build pins it with define; a bundle is always the
  entrypoint.

Also drops consola (zero call sites, TD-001; it would have been inlined into
every bundle) and the empty commands/ placeholder, and pins CI to the bun that
produced the committed bundles.
@amondnet
amondnet marked this pull request as ready for review September 14, 2026 00:23
@greptile-apps

greptile-apps Bot commented Sep 14, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

The PR should not merge until cache publication is made safe for concurrent resolver processes; the remaining validation findings are lower-impact hardening work.

Fix All in Claude CodeFindings

  1. P1 Cache publication can race
  2. P2 Catalog shape is unchecked
  3. P2 Incomplete caches report ready
Fix with agent prompt
### Issue 1
scripts/docs.ts:172-174
Concurrent cache misses or `--refresh` calls for the same tag can both pass the existence check and enter this remove-then-rename sequence. One process can remove the tree another process just installed, or lose the race at `renameSync` and return `kind: "unavailable"` despite downloading and verifying a valid archive. Readers can also see the shared cache path while it is temporarily absent. Serialize installation per tag or publish the replacement without first removing the readable target.

### Issue 2
scripts/docs.ts:216
The remote catalog is cast directly to `Catalog` after JSON parsing without runtime shape validation. A valid JSON response such as `null` or `{"version":"1"}` therefore throws when `lookupTag` reads `version` or `projects`. This bypasses the documented `UnavailableResult` behavior and makes the CLI emit an internal stack trace with exit code 2. Validate the parsed object and its nested project entries before lookup.

### Issue 3
scripts/docs.ts:249-252
Cache hits are considered ready based only on the target directory's existence, and newly extracted archives are also published without checking their required `_index.md`. An incomplete cached tree or a validly checksummed but mispackaged archive therefore returns `kind: "ready"` with a dangling `index` path, causing the skill's first documentation read to fail. Validate `_index.md` before accepting either cached or newly unpacked trees.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

  • Fetches the documentation catalog and resolves exact project/version pairs to release tags.
  • Verifies release archives before staging and publishing them into a shared cache.
  • Bundles TypeScript entry points into dependency-free scripts committed with the skill.
  • Adds resolver/helper tests and a CI stale-bundle check.
  • The cache publication path still has a cross-process race, while catalog and cached-tree validation need hardening.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Spring question] --> B[Detect project version]
  B --> C[Fetch spring-docs catalog]
  C --> D[Resolve exact release tag]
  D --> E{Tag cache exists?}
  E -- Yes --> F[Return docs path and index]
  E -- No --> G[Fetch checksum and archive]
  G --> H[Verify SHA-256]
  H --> I[Extract into staging directory]
  I --> J[Publish staging as tag cache]
  J --> K[Write version-to-tag pointer]
  K --> F
  F --> L[Read _index.md and relevant pages]
Loading

Reviews (1) · Last reviewed commit: "feat(skill): bundle scripts into the ski..."

Comment thread scripts/docs.ts Outdated
Comment thread scripts/docs.ts Outdated
Comment thread scripts/docs.ts Outdated
…mically

Applies the gemini, greptile, gpt and ocr review findings on #13.

Every segment joined into a cache path is now checked at the boundary:
`project` and `version` from the CLI, the tag a pointer file names, and the
tag `catalog.json` maps a version to. The last one is remote input that names
the directory `publish` renames aside and `rmSync` deletes, and a charset test
alone does not cover it — `.` and `..` are spelled entirely in allowed
characters, so `isSafeSegment` rejects both by name.

`catalog.json` is shape-checked before use. A valid-JSON body like `null` or
`{"version":"1"}` used to throw a TypeError inside the lookup and surface as an
internal error instead of the documented unavailable result; an array where a
keyed map belongs now fails the same way rather than reading as empty.

Publication swaps rather than clears: `renameSync` refuses a non-empty target
outright, so remove-then-rename lost a concurrent race with ENOTEMPTY after a
good download. The loser of that race discards its displaced tree instead of
orphaning it, deleting the replaced tree can no longer turn a published tree
into a failed download, and debris from a run killed mid-publication is
reclaimed on the next download once it is too old to still have an owner.

A cached tree counts as ready only when it carries `_index.md`, so an archive
that checksums correctly but is mispackaged fails instead of caching a path
that does not resolve, and an incomplete tree re-downloads instead of
reporting ready.

Replaces `Bun.spawnSync` with `node:child_process` so the bundled skill runs
under plain node, and reports `result.error` rather than reading `stderr` off
a spawn that never started.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 5 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread skills/spring-docs/scripts/docs.js Outdated
Comment thread scripts/docs.ts Outdated
Comment thread scripts/docs.ts
Comment thread scripts/lib/docs-cache.ts Outdated
Comment thread scripts/__tests__/docs.test.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review completed against the latest diff

Not reviewed (too large): skills/spring-docs/scripts/detect.js (~4,587 lines) - if these are generated or fixture files, add them to ignored paths to exclude them from future reviews.

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread skills/spring-docs/scripts/docs.mjs
Comment thread scripts/lib/docs-cache.ts
Comment thread scripts/lib/docs-cache.ts
Comment thread .github/workflows/ci.yml
Comment thread .please/docs/knowledge/tech-stack.md Outdated
Comment thread skills/spring-docs/SKILL.md Outdated
Comment thread scripts/__tests__/docs.test.ts Outdated
Comment thread scripts/__tests__/docs.test.ts Outdated
…ecks

Applies the cubic review findings on #13.

`sweepLeftovers` moves from `unpack` to `resolveDocs`, before the cache-hit
return. A refresh that died while the previous tree was still usable leaves
debris that every later run skipped past, because a cache hit never reaches
the download the sweep was attached to — the one case the sweep exists for.

`isUsableTree` requires a regular file. `existsSync` is equally true of a
directory named `_index.md`, and a tree published on that answer is served as
ready forever while no caller can read the index out of it.

`isCatalog` validates `released_at`. `CatalogEntry` promises `string | null`
and `lookupTag` hands the value straight to callers, so an absent key put
`undefined` behind a type that excludes it.

The index-less archive test asserted on the archive built before the tree was
stripped rather than on the bytes it serves, so an empty fixture would still
have passed.
Applies the second round of cubic review findings on #13.

The bundles are ESM and the skill directory carries no `package.json` to say
so, which makes a `.js` bundle a CommonJS file to every Node that does not
detect module syntax on its own — unflagged only since 22.7. On anything older
`docs.js` died on its first `import` before resolving any docs, and the
standalone channel is exactly the one where no `package.json` can be added.
`.mjs` says it in the name instead.

A catalog entry with a null `released_at` is a tag reserved without assets, so
it now reports `unpublished` with the issue-tracker suggestion rather than
downloading from it and surfacing the 404 as an unreachable network.

`engines.bun` rises to the version CI pins. The `build:skill:check` gate
byte-compares against CI's Bun, so a floor of 1.3.0 invited a contributor to
regenerate bundles that fail the gate on an untouched tree.

Test fixes: `respond()` slices its Buffer to the view rather than handing over
a pooled slab far larger than the payload, and the staging-leftovers assertion
reads `DOCS_CACHE_SUBDIR` instead of rebuilding the path by hand, where a
changed constant would have made the check pass by missing the directory.

Docs: `tech-stack.md` no longer claims "No bundler" beside the section
describing the bundler, and SKILL.md's frontmatter gives the same Boot range
as its own Coverage section and the README.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 5 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread scripts/docs.ts
Comment thread skills/spring-docs/scripts/docs.mjs

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 13 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread package.json
Applies the third round of cubic review findings on #13.

`sweepLeftovers` guards its own `readdirSync` instead of testing the directory
first. The cache path can be absent on a first run, be a file, be unreadable,
or vanish under a concurrent delete, and an `existsSync` check answers only the
first of those while still losing the race to the last. Throwing there rejected
`resolveDocs` before it could return the `unavailable` result it documents —
for a cache it would otherwise have repaired by re-downloading.
Applies a cubic review finding on #13.

`engines.bun` rose to `>=1.4.2` for the bundle gate, but two knowledge-base
lines still told contributors the floor was `>=1.3.0`. Both now say which
constraint sets which number: the lockfile needs 1.3, and the byte-compared
bundles need CI's Bun.
@amondnet
amondnet merged commit aa6cbe6 into main Sep 14, 2026
4 of 5 checks passed
@amondnet
amondnet deleted the feat/spring-docs-skill branch September 14, 2026 01:41
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.

1 participant