Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/commands/github-review-comments.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ No unresolved threads → report and stop.
1. Read the actual file and line
2. Check the suggestion is right for THIS codebase — the pin-line contract, both asset pipelines, Ruby 3.1 and Rails 6.1 floors
3. Check what else would break: every rewrite path (`pin`, `update`, `pristine`, `unpin`) and `Npm`'s parsing share the regexes
4. Check `CLAUDE.md` and `.claude/rules/*.md` — project conventions override reviewer preference
4. Check `AGENTS.md` and `.claude/rules/*.md` — project conventions override reviewer preference
5. Check the fork constraints — a suggestion fine in a normal gem can be wrong here

### Fork constraints (push back on sight)
Expand Down
2 changes: 1 addition & 1 deletion .claude/commands/github-review-pr.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ Failures caused by this branch's changes persist → do NOT proceed. Report what

## Phase B: Run `/github-review-comments`

Invoke `/github-review-comments` with the same `$ARGUMENTS` (`.claude/commands/github-review-comments.md`): fetch unresolved threads → categorise against the codebase and `CLAUDE.md` → implement accepted fixes → verify → commit and push → reply with SHAs or reasoning → resolve threads → verify none remain.
Invoke `/github-review-comments` with the same `$ARGUMENTS` (`.claude/commands/github-review-comments.md`): fetch unresolved threads → categorise against the codebase and `AGENTS.md` → implement accepted fixes → verify → commit and push → reply with SHAs or reasoning → resolve threads → verify none remain.

### Phase B exit criteria

Expand Down
2 changes: 1 addition & 1 deletion .claude/commands/lfg.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ allowed-tools: Bash(gh issue view:*), Bash(gh search:*), Bash(gh issue list:*),

# LFG — Full Autonomous Workflow

Execute a complete engineering workflow with verification at each phase. Read `CLAUDE.md` first: the never-do list there (constant surface, no new deps, transform-only minify, provenance survives rewrites, retries on every request) is the set of constraints every phase below assumes.
Execute a complete engineering workflow with verification at each phase. Read `AGENTS.md` first: the never-do list there (constant surface, no new deps, transform-only minify, provenance survives rewrites, retries on every request) is the set of constraints every phase below assumes.

## Phase 0: Branch setup

Expand Down
2 changes: 1 addition & 1 deletion .claude/commands/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Investigation tells you what the codebase says; this finds what the REQUEST does
- downgrade story: what does importmap-rails do with a `config/importmap.rb` this feature has written?
- anything with no precedent in this repo — flag it as unknown-unknown territory
2. **Interview the user** with AskUserQuestion, one question at a time, ordered by blast radius: public CLI/DSL surface first, then pin-file format (it is persisted in every app), then output wording. Rules:
- Skip anything `CLAUDE.md`, the rules, or an existing issue already answers.
- Skip anything `AGENTS.md`, the rules, or an existing issue already answers.
- 2–5 questions is the sweet spot; zero is fine when the request is unambiguous — say so.
- Every question offers concrete options with a recommended default.
3. **Record the answers** in the plan's Decision section as `Settled in interview:` bullets — constraints the executor must not re-litigate.
Expand Down
167 changes: 167 additions & 0 deletions AGENTS.md

Large diffs are not rendered by default.

133 changes: 4 additions & 129 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,131 +1,6 @@
# importmap-plus
# importmap-plus — Claude Code entry point

A drop-in replacement for [rails/importmap-rails](https://github.com/rails/importmap-rails) — repo `zoolutions/importmap-plus`, published on rubygems.org as `importmap-plus`. Same `Importmap::` constants, same engine, same helpers and pin DSL; an app switches by changing one `Gemfile` line. Everything this gem adds lives in the vendoring path: `bin/importmap pin --minify`, `--from esm.run` for jsDelivr's bundled builds, version locks, `update` by package name, and a pin comment that records where each package came from.
@AGENTS.md

This is a **maintained fork that still tracks upstream**. The `upstream` remote points at rails/importmap-rails, `Importmap::UPSTREAM_VERSION` names the release currently merged in, and `Importmap::VERSION` is this gem's own semver. The fork rules are in `.claude/rules/upstream-sync.md`.

## Tech Stack

- **Ruby**: >= 3.1 (CI: 3.1–4.0) | **Rails**: >= 6.0 (CI: 6.1 → main, sprockets and propshaft)
- **Runtime deps**: railties, activesupport, actionpack — nothing else. Thor comes with railties.
- **CLI**: Thor (`lib/importmap/commands.rb`, installed into apps as `bin/importmap`)
- **Testing**: Minitest via `ActiveSupport::TestCase` against `test/dummy`; the CI matrix is Appraisal gemfiles under `gemfiles/`
- **Linting**: none at the gem root (upstream has none — don't introduce one in a feature PR). `docs/` has its own RuboCop.
- **Docs**: `docs/` — a docs-kit Rails app deployed to https://importmap-plus.zoolutions.llc

## Critical Rules

### Never Do

1. **NO breaking the importmap-rails surface** — every constant, helper, DSL method, option, generator and rake task importmap-rails ships must keep working unchanged. Additions only; a rename or removal is a fork-breaking change.
2. **NO new runtime dependencies** — the gemspec lists railties, activesupport and actionpack. Minification shells out to a bun/esbuild/terser already on the machine; it does not bundle one.
3. **NO touching import specifiers in vendored files** — minification is transform-only (`--no-bundle`, `--format=esm`), so bare specifiers stay exactly as the CDN resolved them and the import map keeps resolving them. There are two deliberate exceptions, both turning a URL the import map can't resolve into a bare key it can: `rewrite_esm_run_imports`, for a jsDelivr bundle's `/npm/dep@ver/+esm` imports, and `Importmap::PackageGraph#rewrite_specifiers`, for the relative imports of a package vendored with its file graph.
4. **NO losing a pin's provenance or options on rewrite** — the `# @<version> (<provider>[, minified][, vendored][, remote[: <reason>]][, locked])` comment plus `preload:` and `integrity:` must survive `pin`, `update`, `pristine` and `unpin`. Dropping them silently drifts a package back to jspm on the next update.
5. **NO raw `Net::HTTP` calls in Packager or Npm** — every outbound request goes through `with_retries` (`Importmap::HttpRetries`) and raises the class's own `HTTPError` once the attempts are spent.
6. **NO network access on the request path** — engine → `Map` → helpers never reach a CDN or registry. Only the CLI (`Commands` → `Packager` / `Npm`) does.
7. **NO hand-merging `Gemfile.lock`, `docs/Gemfile.lock` or `docs/bun.lock`** — regenerate them (`.claude/rules/git-workflow.md`). `gemfiles/*.lock` are gitignored; CI deletes and re-resolves them.
8. **NO moving `UPSTREAM_VERSION` outside a sync PR** — it names the importmap-rails release merged in, and only `/upstream-sync` changes it. `VERSION` is different: a feature PR that opens a new minor bumps it (that is how 1.1.0 landed), and `bin/release X.Y.Z` then tags the version already in the file. Never bump it twice for one release.

### Always Do

1. **TDD** — a failing Minitest first, then the code (`.claude/rules/testing.md`)
2. **Keep upstream-owned files diff-minimal** — new behaviour goes in new files (`minifier.rb`, `http_retries.rb` are the precedent) or in small, clearly bounded additions, so the next `git merge upstream/main` stays mergeable
3. **Match upstream's Ruby style in upstream-owned files** — `[ a, b ]` array spacing, `private` followed by indented methods, `# :nodoc:` on internals. A style-only diff is conflict surface for no gain.
4. **A pin this gem writes must be a pin importmap-rails can read** — an app can go back to importmap-rails and its `config/importmap.rb` must still parse. Comments carry the fork's metadata precisely because upstream ignores them.
5. **Document user-facing changes** — update the matching page under `docs/app/views/docs/pages/` and add a `CHANGELOG.md` entry under the next version heading
6. **Run the CDN-backed tests before pushing** anything that touches `commands.rb`, `packager.rb` or `npm.rb` — `test/commands_test.rb` and the two `*_integration_test.rb` files are the only coverage of the real jspm, jsDelivr and npm-registry contracts

## Commands

```bash
bundle exec rake test # full suite (talks to live CDNs: jspm, jsDelivr, npm registry)
bundle exec ruby -Itest test/packager_test.rb # one file (bin/test, upstream's rails/plugin/test runner, runs 0 tests on Rails 8.1 — don't use it)
bundle exec ruby -Itest test/commands_test.rb -n /minify/ # tests matching a name pattern
BUNDLE_GEMFILE=gemfiles/rails_7.1_sprockets.gemfile bundle install && \
BUNDLE_GEMFILE=gemfiles/rails_7.1_sprockets.gemfile ASSETS_PIPELINE=sprockets bundle exec rake test # one CI matrix cell
bundle exec appraisal generate # regenerate gemfiles/ after editing Appraisals
cd docs && bundle exec rake lint && bundle exec rspec # docs site: RuboCop + request specs (render every registered page)
cd docs && bin/dev # docs site locally
bin/release --dry-run # print the next patch version
bin/release [minor|major|X.Y.Z] # bump + tag vX.Y.Z + GitHub Release → release.yml publishes the gem, deploy-docs.yml ships the docs
```

The `--minify` tests **skip** unless bun, esbuild or terser is on `PATH` or in `node_modules/.bin`. CI installs bun; install it locally or those paths go unexercised.

## Slash Commands

| Command | Purpose |
|---------|---------|
| `/lfg` | Full autonomous workflow: branch off `main` → understand → explore → plan → TDD → verify → PR |
| `/plan` | Fable-powered, read-only planning → GitHub issue or `docs/plans/` markdown (execute with `/lfg`) |
| `/architect` | Order multi-layer work across engine → Map → helpers → CLI → Packager/Npm → docs |
| `/tdd` | Enforce RED → GREEN → REFACTOR with Minitest |
| `/security` | Audit CDN and registry input handling, vendored-file paths, shell-outs, SRI |
| `/perf` | Baseline the request path (`Map#to_json`, preload resolution) against `main` in a worktree |
| `/review-pr` | Review a PR for pattern and fork-constraint compliance |
| `/github-review-pr` | Full PR pass: resolve conflicts with `main`, fix CI failures, then process review comments |
| `/github-review-failures` | Diagnose and fix CI failures until green |
| `/github-review-comments` | Process unresolved PR review comments |
| `/finish-prs` | Drive a stack of open PRs to merge-ready, one at a time, in order |
| `/debug-flaky` | Root-cause an intermittent test — evidence → repro → stress-proofed fix; never skip/retry |
| `/upstream-sync` | Merge a new importmap-rails release, bump `UPSTREAM_VERSION`, re-sync the docs |

Commands pin a model tier via frontmatter aliases — `sonnet` for pattern-following implementation, `opus` for orchestration, security and full review, `fable` for read-only planning — so they track the latest model per tier. Subagents doing mechanical work (file finding, pattern scans) get a cheaper model passed explicitly.

## Architecture

```
Rails engine lib/importmap/engine.rb config.importmap, draws config/importmap.rb, reloader, cache sweeper, asset paths, helpers
Map (DSL) lib/importmap/map.rb pin / pin_all_from, to_json, preloaded_module_paths, integrity, digest, per-request cache
Reloader lib/importmap/reloader.rb re-draws the map when config/importmap.rb changes
View helpers app/helpers/importmap/importmap_tags_helper.rb javascript_importmap_tags and friends
Freshness app/controllers/importmap/freshness.rb stale_when_importmap_changes — ETag from the map digest
CLI lib/importmap/commands.rb Thor: pin, unpin, pristine, json, audit, outdated, update, packages
Packager lib/importmap/packager.rb resolves via api.jspm.io or esm.run (jsDelivr), downloads to vendor/javascript, rewrites pin lines
Npm lib/importmap/npm.rb registry.npmjs.org: outdated, audit, packages_with_versions
Minifier lib/importmap/minifier.rb bun / esbuild / terser, transform-only (fork-only file)
HttpRetries lib/importmap/http_retries.rb bounded retries shared by Packager and Npm (fork-only file)
ModuleInspector lib/importmap/module_inspector.rb whether a download can be served as one file (fork-only file)
PackageGraph lib/importmap/package_graph.rb crawls a chunked package's siblings, rewrites them (fork-only file)
VendoredGraph lib/importmap/vendored_graph.rb the graph directory and the pin_all_from line (fork-only file)
EsmRun lib/importmap/esm_run.rb the esm.run provider: URLs, import rewrite, versions (fork-only file)
Installer lib/install/, lib/tasks/importmap_tasks.rake rails importmap:install
```

Two paths, kept apart: the **request path** (engine → Map → helpers, no I/O beyond the asset resolver) and the **command path** (CLI → Packager/Npm → CDN or registry → `vendor/javascript` + `config/importmap.rb`).

## The pin-line contract

`config/importmap.rb` is both the app's source of truth and the file the CLI rewrites in place. The Packager's regexes — `PIN_REGEX`, `PRELOAD_OPTION_REGEXP`, `TO_OPTION_REGEXP`, `PIN_PROVENANCE_REGEXP`, `PACKAGE_SPEC_REGEXP` — are the parser; there is no AST. Every rewrite must:

- match the pin with `Importmap::Map.pin_line_regexp_for(package)` and replace only that line
- preserve `preload:` and `integrity:` exactly, including `preload: false` and array preloads
- keep a remote pin (`to: "https://…"`) remote and re-resolve it from the same CDN; leave a custom URL alone
- re-emit the provenance comment `# @<version> (<provider>[, minified][, locked])` — `jspm.io` is the default provider and is omitted
- name a vendored file `package.gsub("/", "--") + ".js"` (`@hotwired/stimulus` → `@hotwired--stimulus.js`)
- leave the one line that isn't a pin alone unless it is the graph directory being written: `pin_all_from "<vendor>/<file without .js>", under: "<package>"[, to: …][, preload: …] # @<version> (graph of <package>)`, matched by its own directory and its own comment (`Importmap::VendoredGraph`), and invisible to every regex above

## Fork tracking

| | Upstream (rails/importmap-rails) | This gem |
|---|---|---|
| Version | `Importmap::UPSTREAM_VERSION` | `Importmap::VERSION` (own semver) |
| Gemspec | `importmap-rails.gemspec` (deleted here) | `importmap-plus.gemspec` |
| Entry point | `lib/importmap-rails.rb` (kept — still the real entry) | `lib/importmap-plus.rb` requires it |
| Release | `bin/release` pushed from a laptop with an API key | `bin/release` → GitHub Release → trusted publishing (`release.yml`) |
| Fork-only files | — | `minifier.rb`, `http_retries.rb`, `module_inspector.rb`, `package_graph.rb`, `vendored_graph.rb`, `provider_chain.rb`, `integrity.rb`, `esm_run.rb`, `CHANGELOG.md`, `release.yml`, `deploy-docs.yml`, `docs-ci.yml`, `docs/` |

Upstream files this fork has modified heavily, which WILL conflict on sync: `commands.rb`, `packager.rb`, `npm.rb`, `README.md`, `ci.yml`, `test/commands_test.rb`, `test/packager_test.rb`. Per-file resolution rules: `.claude/rules/upstream-sync.md`.

## Docs site (`docs/`)

A self-contained docs-kit Rails app with its own bundle, RuboCop and RSpec. `docs-ci.yml` runs it only when `docs/**` changes; `deploy-docs.yml` ships it on every GitHub Release, so the docs go live with the gem. Pages are registered in `docs/app/models/doc.rb`; add one with `cd docs && bin/rails g docs_kit:page "Title" --group=…`. The authoring contract is `docs/AGENTS.md`.

`docs/Gemfile` depends on the gem through `path: ".."`, so `docs/Gemfile.lock` pins `importmap-plus (X.Y.Z)`. `bin/release` bumps the root `Gemfile.lock` but not this one — after a release, `cd docs && bundle install` and commit the new pin, or the frozen docs bundle install fails on the next `docs/**` PR.

## More Documentation

- `.claude/rules/` — coding-style, git-workflow, testing, agents, upstream-sync, striving-for-excellence
- `.claude/commands/` — the slash commands above
- `CHANGELOG.md` — what each release adds over importmap-rails
- Upstream README (shared basics): https://github.com/rails/importmap-rails#readme
`AGENTS.md` is the single project-instruction file, shared with Grok, Cursor, Copilot and Codex.
Claude-only material lives in `.claude/` (`rules/` and `commands/`).
64 changes: 64 additions & 0 deletions docs/.rtk/filters.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Project-local RTK filters (https://github.com/rtk-ai/rtk#custom-filters).
#
# The `rtk hook claude` PreToolUse hook rewrites a matching command to
# `rtk <cmd>`; rtk runs it and drops the lines listed here before the agent
# sees the output. Filters are line-based: they strip noise, never reformat.
#
# `match_command` is matched against the command with the first word reduced
# to its basename, so `bin/x`, `./bin/x` and `x` all hit `^x\b`.
#
# Trust is a SHA-256 of this file: after every edit run `rtk trust --yes`,
# then `rtk verify` to run the inline tests.
schema_version = 1

[filters.docs-lint]
filter_stderr = true
description = "docs/: bundle exec rake lint[:fix] — drop the echoed rubocop file-list command and the rake/bundler backtrace, keep the inspection summary and offenses"
match_command = "^(?:bundle\\s+exec\\s+)?rake\\s+lint(?::fix)?\\b"
strip_lines_matching = [
"^\\s*$",
"^bundle exec rubocop ",
"^Command failed with status ",
"^/.*:\\d+:in ",
"^\\(See full trace by running task with --trace\\)$",
]
on_empty = "docs lint: no output left after filtering (check the exit code)"

[[tests.docs-lint]]
name = "clean pass drops the echoed command, keeps the inspection summary"
input = """bundle exec rubocop app/controllers/application_controller.rb app/models/doc.rb spec/rails_helper.rb config/application.rb Rakefile config.ru
Inspecting 53 files
.....................................................

53 files inspected, no offenses detected
"""
expected = """Inspecting 53 files
.....................................................
53 files inspected, no offenses detected"""

[[tests.docs-lint]]
name = "offense keeps the offense detail and failure markers, drops the backtrace"
input = """bundle exec rubocop app/controllers/application_controller.rb app/helpers/application_helper.rb Rakefile config.ru
Inspecting 53 files
...C.................................................

Offenses:

app/helpers/application_helper.rb:3:1: C: [Correctable] Layout/TrailingEmptyLines: 3 trailing blank lines detected.

53 files inspected, 1 offense detected, 1 offense autocorrectable
rake aborted!
Command failed with status (1): [bundle exec rubocop app/controllers/application_controller.rb app/helpers/application_helper.rb Rakefile config.ru]
/private/tmp/importmap-plus/docs/Rakefile:17:in 'block in <top (required)>'
/Users/x/.gem/ruby/4.0.7/gems/rake-13.4.2/exe/rake:27:in '<top (required)>'
/Users/x/.gem/ruby/4.0.7/gems/bundler-4.0.19/exe/bundle:20:in '<top (required)>'
Tasks: TOP => lint
(See full trace by running task with --trace)
"""
expected = """Inspecting 53 files
...C.................................................
Offenses:
app/helpers/application_helper.rb:3:1: C: [Correctable] Layout/TrailingEmptyLines: 3 trailing blank lines detected.
53 files inspected, 1 offense detected, 1 offense autocorrectable
rake aborted!
Tasks: TOP => lint"""
5 changes: 5 additions & 0 deletions docs/bin/setup
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ FileUtils.chdir APP_ROOT do
puts "== Installing dependencies =="
system("bundle check") || system!("bundle install")

# rtk (https://github.com/rtk-ai/rtk) condenses agent command output via a
# PreToolUse hook; .rtk/filters.toml adds this app's `rake lint` task. Trust
# is a SHA-256 of that file, so every edit needs a re-trust — done here.
system("rtk trust --yes > /dev/null 2>&1") if system("which rtk > /dev/null 2>&1")

puts "\n== Removing old logs and tempfiles =="
system! "bin/rails log:clear tmp:clear"

Expand Down
Loading