Skip to content

feat(extensions): add quality command and opportunistic package cache (swamp-club#163)#1218

Merged
stack72 merged 1 commit intomainfrom
feat-extension-quality-command
Apr 24, 2026
Merged

feat(extensions): add quality command and opportunistic package cache (swamp-club#163)#1218
stack72 merged 1 commit intomainfrom
feat-extension-quality-command

Conversation

@stack72
Copy link
Copy Markdown
Contributor

@stack72 stack72 commented Apr 24, 2026

Summary

Implements swamp-club#163 — a new swamp extension quality <manifest> CLI that scores an extension against the Swamp Club rubric locally, mirroring the server's scorecard pipeline so local results match the score the registry will compute after publish.

  • New CLI command: swamp extension quality manifest.yaml [--json]. Exits non-zero if any factor misses; suitable for CI self-checks.
  • Server-mirrored scorer: extension_rubric_scorer.ts mirrors analysis-factors.ts and the rubric-v2 composition from score.ts — same slow-type code set, same JSDoc coverage shape, same README/LICENSE lookup in the extracted tarball, same 12-point weighting.
  • Parity tests: 24 tests marked [parity] port the upstream swamp-club test suite so logic drift is caught before release.
  • Opportunistic package cache at .swamp/cache/packages/<hash>/: keyed on source inputs (manifest + resolved file contents + deno config). extension quality writes the packaged tarball; extension push checks the cache before packaging and reuses the bytes on a hit. Any source change invalidates the entry by construction — authors who run quality before push don't pay twice, and the bytes scored equal the bytes uploaded.

Opt-in by design

quality is uniformly opt-in: push's behaviour is unchanged, no new flags, no mandatory gate. The swamp-extension-publish and swamp-extension-quality skills gain brief pointers at the new CLI between states 6 and 7 of the publish flow.

Known caveat

repository-verified is a structural check on the CLI side (HTTPS + allowlisted host). The server additionally performs an HTTP HEAD to confirm the repo is public — so a local "earned" can come back "missing" from the registry if the repo URL is malformed or private. The log renderer surfaces this caveat. Follow-up issue: narrow the gap (HTTP HEAD from the CLI, or a shared scoring package between this repo and swamp-club).

Smoke test

Ran against systeminit/swamp-extensions/datastore/s3 — reports 12/12 points, 100%, all factors earned, matching the registry.

Test Plan

  • 10 unit tests for extension_package_cache.ts (hit/miss, determinism, cache invalidation)
  • 39 unit tests for extension_rubric_scorer.ts covering every factor and edge case
  • 24 [parity] tests ported from swamp-club's analysis-factors_test.ts and scorecard_test.ts
  • 6 integration tests in integration/extension_quality_test.ts covering the full CLI pipeline (package → cache → extract → deno doc → compose), including cache hit/miss, source mutation invalidating cache, log and JSON modes
  • Smoke test against real @si/s3-datastore extension — 12/12 (100%), matches registry
  • deno check clean
  • deno lint clean
  • deno fmt --check clean
  • deno run test (4,802 passed, 1 pre-existing flaky unrelated to this change, 1 ignored)
  • deno run compile succeeds

Follow-ups (not in this PR)

  • File issue in systeminit/swamp-uat for CLI UAT coverage (tests/cli/extension/quality/)
  • File issue in systeminit/swamp-club to document the new command in content/manual/how-to
  • Consider a shared scoring package between systeminit/swamp and systeminit/swamp-club to eliminate the current ~100 lines of mirrored logic

🤖 Generated with Claude Code

Implements swamp-club#163 — a new `swamp extension quality <manifest>`
CLI that scores an extension against the Swamp Club rubric locally,
mirroring the server's scorecard pipeline byte-for-byte so local results
match the score the registry will compute after publish.

The scorer mirrors swamp-club/lib/domain/scorecard/analysis-factors.ts
and score.ts (rubric v2, 10 factors, 12 points): extracts the built
tarball to a temp dir, strips attacker-controlled config files, writes
a controlled deno.json, runs `deno doc --json` + `deno doc --lint` with
CWD at the extraction root, and composes the factors via the same logic
the server uses. 24 parity tests (marked [parity]) port the upstream
test suite so any logic drift is caught.

Adds an opportunistic content-hash-keyed package cache at
.swamp/cache/packages/<hash>/. Keyed on source inputs (manifest + file
contents + deno config) so any source change invalidates the entry by
construction. `extension quality` writes the packaged tarball to the
cache; `extension push` checks the cache before packaging and reuses
the bytes on a hit — so authors who run quality before push don't pay
for two packaging passes, and the bytes scored equal the bytes
uploaded.

Caveat: `repository-verified` is a structural check on our side
(HTTPS + allowlisted host). The server additionally performs an HTTP
HEAD to confirm the repo is public; the log renderer surfaces that
caveat so users know why a local "earned" could come back "missing"
from the registry. Follow-up issue will file the structural-vs-live
divergence as a known gap.

The swamp-extension-publish and swamp-extension-quality skills gain
brief pointers at the new CLI. `quality` is uniformly opt-in — push's
behaviour is unchanged, no new flags, no mandatory gate.
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Adversarial Review

Critical / High

No critical or high severity findings.

Medium

  1. Unhandled cache.put failure in quality.tssrc/libswamp/extensions/quality.ts:132

    In the quality generator, await deps.cache.put(hash, archiveBytes, {...}) is not wrapped in a try-catch. If the disk is full, permissions are denied, or any filesystem error occurs, this will throw an unhandled error that crashes the quality command after packaging succeeded but before scoring runs. By contrast, the push command (extension_push.ts:423-432) correctly wraps cache.put in a try-catch with a debug log.

    Breaking example: Run swamp extension quality on a filesystem where .swamp/cache/packages/ is on a read-only mount or quota-exceeded volume. The command crashes instead of gracefully continuing to score.

    Suggested fix: Wrap the cache.put call the same way push does:

    try {
      await deps.cache.put(hash, archiveBytes, { ... });
    } catch (cacheError) {
      ctx.logger.debug`Failed to write package cache (continuing): ${cacheError}`;
    }
  2. packageJsonDir not passed in quality command — src/cli/commands/extension_quality.ts:124-149

    The quality command's prepareInput never sets packageJsonDir. The push command (extension_push.ts:233-265) has an entire block to detect package.json and resolve node_modules for extensions that use bare npm specifiers. The quality command skips all of this. For extensions that depend on package.json-based npm resolution, swamp extension quality will fail during bundling with a confusing module-not-found error, while swamp extension push succeeds.

    Breaking example: An extension with import Foo from "some-npm-package" in its entrypoint, a package.json with that dependency, and no deno.json. Push succeeds (finds package.json, sets packageJsonDir); quality fails.

    Suggested fix: Port the findPackageJson + bare-specifier detection block from extension_push.ts into extension_quality.ts, or extract it into a shared helper.

  3. packageJsonPath always undefined in cache hash — src/cli/commands/extension_push.ts:290, src/cli/commands/extension_quality.ts:162

    Both commands hardcode packageJsonPath: undefined in the hash input. The PackageCacheHashInput interface has the field and computePackageCacheHash would include its contents if set. If a user changes a package.json dependency version (which affects bundling output), the cache hash stays the same and stale bytes are served. This is consistent between quality and push (both get the same wrong hash), but the cache contract — "any source change invalidates by construction" — is violated for package.json changes.

    Suggested fix: When packageJsonDir is set, resolve the package.json path and pass it in the hash input.

Low

  1. toFileUrl misparses paths with #, ?, or unescaped %src/domain/extensions/extension_rubric_scorer.ts:687-688

    new URL("file://" + abs) treats # as a fragment delimiter and ? as a query delimiter. A path like /tmp/my#project/model.ts would produce a URL with pathname /tmp/my and hash #project/model.ts, silently looking up the wrong deno doc node. In practice this requires # in the temp dir path (created with prefix: "swamp_quality_") or in the manifest entrypoint name, which is unlikely.

    Suggested fix: Use Deno's toFileUrl() from @std/path/to-file-url which handles special characters correctly.

  2. platforms-one is a tautology — src/domain/extensions/extension_rubric_scorer.ts:347

    manifest.platforms.length === 0 || manifest.platforms.length >= 1 is always true for any non-negative number. This factor can never be "missing". The parity tests confirm this matches the server's behavior (the server also always earns this factor), so it's not a bug per se — but the condition is misleading. A reader might think it's possible to fail this factor. No fix needed if server parity is the priority.

Verdict

PASS — No critical or high severity issues. The medium findings are real functional gaps (cache.put crash, missing packageJson support in quality, stale cache on package.json changes) but are edge-case paths that won't affect the majority of users. The code is well-structured with thorough testing (especially the parity tests), proper path-traversal protection in collectEntrypoints, and appropriate temp-dir cleanup in finally blocks. The refactoring of push.ts to extract bundleAndArchive is clean and preserves behavior.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Code Review

Clean, well-layered PR with excellent test coverage (80 tests across unit and integration). DDD separation is solid: pure domain logic in extension_rubric_scorer.ts, repository-pattern cache in extension_package_cache.ts, orchestration in the libswamp generator, and decoupled presentation renderers.

Blocking Issues

None.

Suggestions

  1. findDenoConfig duplicationextension_quality.ts and extension_push.ts each have their own findDenoConfig helper (the quality command's comment acknowledges this). Consider extracting to a shared utility in src/cli/ if a third caller ever appears.

  2. Custom toFileUrl / encodeHexextension_rubric_scorer.ts has a hand-rolled toFileUrl (new URL("file://" + abs).href) and extension_package_cache.ts has a hand-rolled encodeHex. The standard library provides toFileUrl from @std/path and encodeHex from @std/encoding/hex, which handle edge cases (paths with spaces/special chars, etc.) more robustly. Low risk since paths are temp-dir generated, but swapping in the std-lib versions would be more idiomatic.

  3. packageJsonPath always undefined — Both the quality command and push command pass packageJsonPath: undefined in their cache hash inputs. If an extension project uses a package.json that affects the build, changes to it won't invalidate the cache. The field is in the interface for future use, but a comment noting the gap would help.

  4. platforms-one is tautologically truemanifest.platforms.length === 0 || manifest.platforms.length >= 1 is always true (any array satisfies this). This mirrors the server's behavior (the factor is effectively a free point), but a one-line comment confirming the intentional parity would help future readers not "fix" it.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

CLI UX Review

Blocking

None.

Suggestions

  1. JSON mode emits two documents on failure (src/presentation/renderers/extension_quality.ts, JsonExtensionQualityRenderer.completed). When factors are missing the output is:

    • stdout: score JSON object, then the framework's error JSON object (two separate top-level documents)
    • The integration test acknowledges this with a custom parseFirstJson helper whose docstring explicitly says "exits non-zero in JSON mode, it prints the score object followed by an error object"

    A script doing result=$(swamp extension quality manifest.yaml --json); echo "$result" | jq .factors will get a jq parse error when any factor fails. The fix would be to buffer the score in the renderer and not console.log it in the completed handler, then let the action handler emit one final document (either the score on pass, or an error on failure). For now, the primary CI use case—check the exit code and ignore stdout—is unaffected.

  2. repository-verified caveat is always printed in log mode (extension_quality.ts, LogExtensionQualityRenderer.completed). The note appears unconditionally after every scoring run, even on a 12/12 perfect score. Consider printing it only when repository-verified was earned (local structural check passed, server may still reject) rather than emitting it on every run including full-pass runs where it reads as a warning for something that didn't fail.

  3. Minor inconsistency in log renderer (extension_quality.ts): packaging and scoring handlers use the call form logger.info("…") while cache_hit uses the template literal form logger.info\…``. One form or the other throughout is cleaner.

  4. Command description "10 client-earnable factors" vs. 12 max points (extension_quality.ts, .description(...)): the description is accurate for factor count, but users who see 12/12 in output may wonder why it doesn't say 12. A parenthetical like "10 factors, 12 total points" in the description would head off confusion.

Verdict

PASS — both output modes are implemented, exit codes are correct, flag names are consistent with extension push and extension fmt, error messages are actionable, and the help text and examples are clear. Suggestions above are all minor and none block merge.

@stack72 stack72 merged commit 31006b6 into main Apr 24, 2026
10 checks passed
@stack72 stack72 deleted the feat-extension-quality-command branch April 24, 2026 16:45
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