Skip to content

feat: add js_rule_kind directive for per-group rule kind override#222

Open
sallustfire wants to merge 3 commits intoaspect-build:mainfrom
sallustfire:feat/js-rule-kind-override
Open

feat: add js_rule_kind directive for per-group rule kind override#222
sallustfire wants to merge 3 commits intoaspect-build:mainfrom
sallustfire:feat/js-rule-kind-override

Conversation

@sallustfire
Copy link
Copy Markdown
Contributor

Per-group rule kind override via js_rule_kind directive

The gap

The JS/TS gazelle extension generates ts_project(testonly=True) for test files. This compiles tests but does not produce a bazel test-able target. There is no way to get an executable test rule like jest_test or vitest_test from gazelle, analogous to how the Go extension generates go_test rather than just go_library.

The existing map_kind directive cannot solve this because it remaps all rules of a given kind. Writing # gazelle:map_kind ts_project jest_test //:defs.bzl would remap both library and test targets, which is not the intent.

The root cause is structural: the Go extension distinguishes go_library from go_test as separate static kinds that can be independently remapped. The JS extension uses ts_project for both, distinguished only by testonly = True.

The fix

Register js_test (from @aspect_rules_js//js:defs.bzl) as a static source rule kind, the test-target counterpart to ts_project/js_library. A new js_rule_kind directive selects the rule kind per target group. Since js_test is statically registered in Kinds() and ApparentLoads(), the gazelle framework's resolver, merger, and load-statement generation all work without workarounds.

Users who want a specific test runner combine this with the existing map_kind:

# gazelle:js_rule_kind {dirname}_tests js_test
# gazelle:map_kind js_test jest_test @aspect_rules_jest//jest:defs.bzl

This is the same two-layer pattern the Go extension uses: gazelle emits a canonical kind (go_test), users remap it to their preferred macro.

Backwards compatible: default behavior is unchanged. No directive means ts_project(testonly=True) as before.

The directive inherits to subdirectories and can be reset in a child package with # gazelle:js_rule_kind {dirname}_tests (group name only, no kind).


Changes are visible to end-users: yes

  • Searched for relevant documentation and updated as needed: no (README table needs a row for js_rule_kind)
  • Breaking change (forces users to change their own code or config): no
  • Suggested release notes appear below: yes

Added js_rule_kind directive to override the generated rule kind for a target group. Use # gazelle:js_rule_kind {dirname}_tests js_test to generate js_test instead of ts_project(testonly=True) for test targets. Combine with map_kind to remap to jest_test, vitest_test, or other test runners.

Test plan

  • Covered by existing test cases (120 pre-existing tests pass, confirming no regressions)
  • New test cases added:
    • rule_kind_override - js_rule_kind on default test group with subdirectory inheritance
    • rule_kind_custom_group - js_rule_kind on a custom group with map_kind remapping to jest_test

Copilot AI review requested due to automatic review settings March 21, 2026 19:28
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 21, 2026

CLA assistant check
All committers have signed the CLA.

@aspect-workflows
Copy link
Copy Markdown

aspect-workflows bot commented Mar 21, 2026

Test

All tests were cache hits

4 tests (100.0%) were fully cached saving 377ms.


Test

language/js

121 test targets passed

Targets
//:js_test [k8-fastbuild]68ms
//tests:bad_lockfile_test [k8-fastbuild]71ms
//tests:bad_package_json-invalid_test [k8-fastbuild]190ms
//tests:bad_package_json_test [k8-fastbuild]350ms
//tests:bad_ts_syntax_test [k8-fastbuild]260ms
//tests:declare_module_test [k8-fastbuild]318ms
//tests:declare_module_types_test [k8-fastbuild]280ms
//tests:dynamic_import_test [k8-fastbuild]283ms
//tests:gazelle_disable_conflict_test [k8-fastbuild]86ms
//tests:gazelle_disable_test [k8-fastbuild]188ms
//tests:gazelle_generate_build_test [k8-fastbuild]203ms
//tests:gazelle_generation_mode_legacy_test [k8-fastbuild]71ms
//tests:gazelle_generation_mode_test [k8-fastbuild]241ms
//tests:gazelle_ignore_directive_test [k8-fastbuild]229ms
//tests:gazelle_map_kind_directive_test [k8-fastbuild]191ms
//tests:groups_add_remove_rules_test [k8-fastbuild]223ms
//tests:groups_configs_test [k8-fastbuild]295ms
//tests:groups_simple_files_test [k8-fastbuild]303ms
//tests:ignore_config_files_test [k8-fastbuild]231ms
//tests:incremental_lazy_lockdir_test [k8-fastbuild]177ms
//tests:incremental_lazy_test [k8-fastbuild]201ms
//tests:isolated_typecheck_test [k8-fastbuild]197ms
//tests:node_native_test [k8-fastbuild]189ms
//tests:npm_changed_deps_test [k8-fastbuild]219ms
//tests:npm_link_all_packages_test [k8-fastbuild]73ms
//tests:npm_package_deps_lib_test [k8-fastbuild]225ms
//tests:npm_package_deps_test [k8-fastbuild]249ms
//tests:npm_package_deps_tsconfig_test [k8-fastbuild]192ms
//tests:npm_package_lib_target_name_test [k8-fastbuild]223ms
//tests:npm_package_target_enabled_test [k8-fastbuild]211ms
//tests:npm_package_target_referenced_test [k8-fastbuild]208ms
//tests:npm_simple_deps_cjs_test [k8-fastbuild]193ms
//tests:npm_simple_deps_test [k8-fastbuild]409ms
//tests:npm_types_package_test [k8-fastbuild]215ms
//tests:parse_errors_test [k8-fastbuild]202ms
//tests:pnpm_project_refs_lock5_test [k8-fastbuild]308ms
//tests:pnpm_project_refs_lock6_test [k8-fastbuild]182ms
//tests:pnpm_project_refs_lock9_test [k8-fastbuild]218ms
//tests:pnpm_workspace_rerooted_subdir_test [k8-fastbuild]196ms
//tests:pnpm_workspace_test [k8-fastbuild]256ms
//tests:resolve_order_test [k8-fastbuild]229ms
//tests:rule_kind_custom_group_test [k8-fastbuild]245ms
//tests:rule_kind_override_test [k8-fastbuild]201ms
//tests:rules_conflicting_name_mapped_kind_test [k8-fastbuild]128ms
//tests:rules_conflicting_name_nojs_test [k8-fastbuild]82ms
//tests:rules_conflicting_name_test [k8-fastbuild]81ms
//tests:simple_dts_only_dep_test [k8-fastbuild]237ms
//tests:simple_dts_only_test [k8-fastbuild]251ms
//tests:simple_empty_test [k8-fastbuild]112ms
//tests:simple_extra_files_test [k8-fastbuild]275ms
//tests:simple_file_exts_test [k8-fastbuild]371ms
//tests:simple_file_test [k8-fastbuild]171ms
//tests:simple_globs_keep_test [k8-fastbuild]214ms
//tests:simple_import_disabled_test [k8-fastbuild]198ms
//tests:simple_import_generated_test [k8-fastbuild]220ms
//tests:simple_imports_test [k8-fastbuild]458ms
//tests:simple_module_repo_name_test [k8-fastbuild]171ms
//tests:simple_module_test [k8-fastbuild]179ms
//tests:simple_new_file_test [k8-fastbuild]232ms
//tests:simple_rule_naming_directives_test [k8-fastbuild]238ms
//tests:source_no_target_err_test [k8-fastbuild]66ms
//tests:tests_initial_test [k8-fastbuild]186ms
//tests:tests_new_test [k8-fastbuild]195ms
//tests:tests_simple_test [k8-fastbuild]185ms
//tests:tests_subdir_test [k8-fastbuild]192ms
//tests:tests_subproject_test [k8-fastbuild]172ms
//tests:ts_proto_library_error_test [k8-fastbuild]61ms
//tests:ts_proto_library_ignore_test [k8-fastbuild]136ms
//tests:ts_proto_library_imported_test [k8-fastbuild]210ms
//tests:ts_proto_library_test [k8-fastbuild]238ms
//tests:tsconfig_attrs_inherited_test [k8-fastbuild]225ms
//tests:tsconfig_baseurl_test [k8-fastbuild]197ms
//tests:tsconfig_composite_test [k8-fastbuild]261ms
//tests:tsconfig_custom_file_name_test [k8-fastbuild]268ms
//tests:tsconfig_declaration_dir_test [k8-fastbuild]154ms
//tests:tsconfig_deps_test [k8-fastbuild]203ms
//tests:tsconfig_disabled_manual_test [k8-fastbuild]186ms
//tests:tsconfig_emit_declaration_only_test [k8-fastbuild]237ms
//tests:tsconfig_invalid_test [k8-fastbuild]157ms
//tests:tsconfig_jsx_test [k8-fastbuild]360ms
//tests:tsconfig_lax_json_test [k8-fastbuild]221ms
//tests:tsconfig_noEmit_test [k8-fastbuild]187ms
//tests:tsconfig_nomore_configs_test [k8-fastbuild]214ms
//tests:tsconfig_optout_test [k8-fastbuild]214ms
//tests:tsconfig_outdir_genfiles_test [k8-fastbuild]212ms
//tests:tsconfig_outdir_test [k8-fastbuild]186ms
//tests:tsconfig_paths_test [k8-fastbuild]242ms
//tests:tsconfig_pnpm_ref-incremental_test [k8-fastbuild]162ms
//tests:tsconfig_pnpm_ref_rerooted_test [k8-fastbuild]117ms
//tests:tsconfig_rootdir_test [k8-fastbuild]248ms
//tests:tsconfig_rootdirs_test [k8-fastbuild]207ms
//tests:tsconfig_tsbuildinfo_configdir_test [k8-fastbuild]206ms
//tests:tsconfig_tsbuildinfo_test [k8-fastbuild]174ms
//tests:tsconfig_tslib_test [k8-fastbuild]300ms
//tests:tsx_css_not-found_test [k8-fastbuild]215ms
//tests:tsx_css_opt-out_test [k8-fastbuild]246ms
//tests:tsx_css_test [k8-fastbuild]357ms
//tests:validate_import_statements_off_test [k8-fastbuild]234ms
//tests:validate_import_statements_test [k8-fastbuild]245ms
//tests:visibility_test [k8-fastbuild]235ms
+ 21 other targets

Total test execution time was 25s. 5 tests (4.0%) were fully cached saving 1s.


Test

language/kotlin

All tests were cache hits

19 tests (100.0%) were fully cached saving 4s.


Test

language/orion

All tests were cache hits

52 tests (100.0%) were fully cached saving 9s.


Test

runner

14 test targets passed

Targets
//pkg/git/tests:ignore_config_files_test [k8-fastbuild]303ms
//tests:bad_build_test [k8-fastbuild]446ms
//tests:branded-directives_test [k8-fastbuild]426ms
//tests:buf_test [k8-fastbuild]310ms
//tests:cc_test [k8-fastbuild]436ms
//tests:crossresolve-js_test [k8-fastbuild]699ms
//tests:crossresolve-pnpm-names-lazy_test [k8-fastbuild]605ms
//tests:crossresolve-pnpm-names_test [k8-fastbuild]791ms
//tests:crossresolve-pnpm_test [k8-fastbuild]220ms
//tests:golang_test [k8-fastbuild]599ms
//tests:imports-missing_test [k8-fastbuild]296ms
//tests:js_binary-main_test [k8-fastbuild]577ms
//tests:npm_bin_wrapper_test [k8-fastbuild]435ms
//tests:python-simple_test_test [k8-fastbuild]339ms

Total test execution time was 6s. 16 tests (53.3%) were fully cached saving 1s.


Test

runner/e2e/bin

All tests were cache hits

1 test (100.0%) was fully cached saving 61ms.


Buildifier      Gazelle      Gazelle [language/js]      Gazelle [language/kotlin]      Gazelle [language/orion]      Gazelle [runner]      Gazelle [runner/e2e/bin]

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a per-target-group rule-kind override mechanism to the JS/TS Gazelle extension via a new js_rule_kind directive, enabling generation of a canonical test rule kind (js_test) instead of relying on ts_project(testonly=True) for test groups.

Changes:

  • Register js_test as a first-class (static) kind with load metadata, import resolution, and dependency resolution support.
  • Add # gazelle:js_rule_kind <group> [kind] directive parsing and config plumbing to override the generated kind per target group (with inheritance + reset).
  • Add new golden test cases covering default test-group override + inheritance and custom-group override combined with map_kind remapping.

Reviewed changes

Copilot reviewed 19 out of 20 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
language/js/config.go Adds js_rule_kind directive constant and per-group ruleKind config + setter.
language/js/configure.go Parses js_rule_kind directive and applies it to a target group.
language/js/generate.go Uses per-group kind override when generating source/test targets and adjusts testonly emission.
language/js/kinds.go Introduces JsTestKind and wires it into kind metadata and apparent loads.
language/js/resolve.go Includes js_test in import/resolve switch cases.
language/js/tests/rule_kind_override/** New fixture verifying default test group override to js_test and inheritance to subpackages.
language/js/tests/rule_kind_custom_group/** New fixture verifying custom group override to js_test plus map_kind remap to jest_test.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread language/js/configure.go Outdated
Comment thread language/js/config.go Outdated
Copilot AI review requested due to automatic review settings March 21, 2026 19:40
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 19 out of 20 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread language/js/configure.go
Comment thread language/js/generate.go
Comment on lines 485 to 492
func (ts *typeScriptLang) addProjectRule(cfg *JsGazelleConfig, tsconfigRel string, tsconfig *typescript.TsConfig, args language.GenerateArgs, group *TargetGroup, targetName string, sourceFiles, genFiles, dataFiles []string, result *language.GenerateResult) (*rule.Rule, error) {
// Check for name-collisions with the rule being generated.
colError := ruleUtils.CheckCollisionErrors(targetName, TsProjectKind, sourceRuleKinds, args)
expectedKind := TsProjectKind
if group.ruleKind != "" {
expectedKind = group.ruleKind
}
colError := ruleUtils.CheckCollisionErrors(targetName, expectedKind, sourceRuleKinds, args)
if colError != nil {
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

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

expectedKind used for collision checks is set to ts_project unless a js_rule_kind override is present, but the actual generated kind may still become js_library when !hasTranspiledSources(info.sources). This can produce misleading collision error messages (reporting ts_project when the rule would be js_library). Consider computing expectedKind from the same logic as defaultKind (and then overriding with group.ruleKind), so collisions report the kind that will actually be generated.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is pre-existing behavior. The original code also hardcoded TsProjectKind here. The expectedKind is only used in the error message, not the collision logic itself. Fixing it to account for js_library (which requires inspecting source file extensions) is a separate concern and scope creep. Happy to entertain it in a follow-up if desired.

sallustfire and others added 3 commits March 28, 2026 10:33
Register js_test as a static source rule kind so test target groups can
generate js_test instead of ts_project(testonly=True). The new
js_rule_kind directive selects the rule kind per target group, and
map_kind can further remap js_test to jest_test, vitest_test, etc.

Backwards compatible: default behavior unchanged. Opt-in via:
  # gazelle:js_rule_kind {dirname}_tests js_test
  # gazelle:map_kind js_test jest_test @aspect_rules_jest//jest:defs.bzl

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@sallustfire sallustfire force-pushed the feat/js-rule-kind-override branch from 7ae3dac to a7bc543 Compare March 28, 2026 16:33
Comment thread language/js/kinds.go
JsTestKind: {
MatchAny: false,
NonEmptyAttrs: map[string]bool{
"srcs": true,
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.

Isn't this incorrect?

js_test is just js_binary which has data and entry_point but no srcs or deps...?

@sallustfire
Copy link
Copy Markdown
Contributor Author

Thanks for engaging here. This PR is a trial balloon to explore some DX changes to improve the JS/TS test ergonomics. I've got a fork of the plugin extending from this that's used in a 500k LoC TS codebase and it works well. Ideally, you can help me figure out what's upstreamable while keeping the plugin unopinionated yet flexible.

In Go, Python, Rust, and Java, the test rule is the compilation/execution rule. go_test compiles and runs. py_test interprets and runs. Gazelle identifies test files by convention, emits a single target with srcs and deps, and bazel test works immediately. One rule, one step, zero manual wiring.

JS/TS is different because the rules separate compilation (tsc via ts_project) from test execution (jest, vitest, etc. with custom js_test wrappers). There's no single canonical test rule, and test runners have framework-specific configuration (jest.config, node_modules, etc.). I assume this is why the JS extension generates ts_project(testonly=True) and stops; it can't assume a test runner.

Regardless of how the test runner works, you still want typechecking and first-class test targets without extra intermediate rules or hand-written boilerplate. The ecosystem is moving in a direction that makes this easier: runtimes increasingly execute .ts directly (Node --experimental-strip-types, Deno, Bun, tsx), so ts_project is becoming purely the typechecker rather than a prerequisite for running tests. But even for teams still transpiling, the user shouldn't have to hand-wire a test runner in every package.

Before and after

Today: Gazelle generates a compilation target. The test runner is manual, per-package:

# Generated by gazelle
ts_project(
    name = "widget_tests",
    testonly = True,
    srcs = ["widget.test.ts"],
    deps = [":widget"],
)

# Hand-written in every package with tests
jest_test(
    name = "test",
    data = [":widget_tests"],
    config = "//tools:jest.config.js",
    node_modules = ":node_modules",
)

With js_rule_kind: Gazelle generates a runnable target. One-time project setup, zero per-package work:

# Root BUILD (one-time setup):
# gazelle:js_rule_kind {dirname}_tests js_test
# gazelle:map_kind js_test jest_test //tools:defs.bzl

# Generated by gazelle. bazel test //widget:widget_tests works
jest_test(
    name = "widget_tests",
    srcs = ["widget.test.ts"],
    deps = [":widget"],
)

js_rule_kind + map_kind

The directive lets users express their test runner choice as inheritable project config:

# gazelle:js_rule_kind {dirname}_tests js_test
# gazelle:map_kind js_test jest_test //tools:defs.bzl

Gazelle emits a single target per test group with srcs/deps, structurally identical to go_test or py_test. The user's macro handles framework-specific concerns. This reuses existing infrastructure (source grouping, import resolution, map_kind) with minimal new surface.

Why js_test as a source kind

The concern that js_test's real rule shape is entry_point/data rather than srcs/deps is valid for today's @aspect_rules_js. But js_test here is a Gazelle-managed source kind, an intermediate expected to be remapped via map_kind, consistent with how map_kind already treats pre-remap kinds as Gazelle concepts.

A test kind with srcs/deps is the idiomatic cross-language shape. When a project needs typechecking or transpilation, a map_kind macro can wrap ts_project internally while preserving that interface.

tsconfig attribute forwarding

For teams not yet on direct execution, the map_kind macro typically wraps ts_project internally and needs tsconfig-reflected attributes to flow through. Our branch extends js_tsconfig_ignore with per-group, bidirectional overrides:

# gazelle:js_rule_kind {dirname}_tests js_test
# gazelle:js_tsconfig_ignore {dirname}_tests -all
# gazelle:map_kind js_test jest_test //tools:defs.bzl

This opts a group into receiving all tsconfig attributes. The mechanism is per-attribute, per-group, and inheritable.

data vs srcs routing

Our fork routes test sources to srcs/deps because that's the idiomatic cross-language shape and what map_kind macros expect. But the real js_test takes data and entry_point, not srcs, so bare js_test without map_kind produces an invalid rule.

An alternative worth considering: js_test defaults to data/entry_point (matching the real rule), and a directive opts into srcs/deps for macros that wrap ts_project. This way bare js_test works out of the box for direct-execution cases (.test.js, or .test.ts with a TS-native runtime), and the macro case is an explicit opt-in. Would be interested in your thinking on which default is right here.

Why not Orion alone

The crossresolve-js fixture demonstrates solving this with an Orion plugin. That approach has specific friction points for this use case:

  • Duplicated logic. The plugin re-implements test file detection (SourceExtensions(".test.ts", ".spec.ts", ...)) that the JS extension already performs via js_test_files. The plugin's own comments acknowledge globs would be cleaner but are slower. With directives, Gazelle's existing file grouping is reused directly.
  • Two targets instead of one. Orion post-processes ts_project(testonly=True) into a separate vitest_test(data=[...]). Every package gets two rules where the directive approach produces one. This is the same per-package boilerplate the feature is trying to eliminate, just auto-generated instead of hand-written.
  • Aspect CLI dependency. language/js/ works with any Gazelle binary. Test generation is a core language extension concern and shouldn't require a specific cli tool.
  • 60 lines of plugin vs 2 lines of directives. For the single most common JS/TS Gazelle gap, the directive is dramatically less ceremony.

Orion is the right tool for complex cross-language wiring beyond what directives can express. The directive approach is opt-in and composes: users with complex requirements can still use Orion. But the common case deserves a first-class affordance in the core extension.

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.

4 participants