@softspark/ai-toolkit 1.3.14 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +62 -16
  3. package/app/agents/backend-specialist.md +8 -0
  4. package/app/agents/code-reviewer.md +9 -0
  5. package/app/agents/database-architect.md +8 -0
  6. package/app/agents/debugger.md +8 -0
  7. package/app/agents/devops-implementer.md +8 -0
  8. package/app/agents/documenter.md +8 -0
  9. package/app/agents/frontend-specialist.md +8 -0
  10. package/app/agents/performance-optimizer.md +8 -0
  11. package/app/agents/security-auditor.md +9 -0
  12. package/app/agents/test-engineer.md +9 -0
  13. package/app/skills/analyze/SKILL.md +15 -0
  14. package/app/skills/api-patterns/SKILL.md +10 -0
  15. package/app/skills/ci-cd-patterns/SKILL.md +10 -0
  16. package/app/skills/clean-code/SKILL.md +10 -0
  17. package/app/skills/database-patterns/SKILL.md +10 -0
  18. package/app/skills/debug/SKILL.md +16 -0
  19. package/app/skills/docs/SKILL.md +16 -0
  20. package/app/skills/git-mastery/SKILL.md +10 -0
  21. package/app/skills/onboard/SKILL.md +15 -0
  22. package/app/skills/performance-profiling/SKILL.md +10 -0
  23. package/app/skills/plan/SKILL.md +16 -0
  24. package/app/skills/refactor/SKILL.md +16 -0
  25. package/app/skills/review/SKILL.md +58 -3
  26. package/app/skills/security-patterns/SKILL.md +10 -0
  27. package/app/skills/tdd/SKILL.md +6 -0
  28. package/app/skills/testing-patterns/SKILL.md +10 -0
  29. package/bin/ai-toolkit.js +33 -5
  30. package/kb/procedures/release-verification-sop.md +283 -0
  31. package/kb/reference/architecture-overview.md +36 -7
  32. package/kb/reference/competitive-features-implementation.md +51 -52
  33. package/kb/reference/language-rules.md +18 -4
  34. package/kb/reference/skills-catalog.md +57 -1
  35. package/llms-full.txt +451 -64
  36. package/llms.txt +1 -0
  37. package/manifest.json +1 -1
  38. package/package.json +4 -2
  39. package/scripts/dir_rules_shared.py +441 -0
  40. package/scripts/generate_antigravity.py +36 -0
  41. package/scripts/generate_augment_rules.py +107 -0
  42. package/scripts/generate_cline_rules.py +31 -0
  43. package/scripts/generate_conventions.py +37 -0
  44. package/scripts/generate_cursor_mdc.py +127 -0
  45. package/scripts/generate_roo_rules.py +30 -0
  46. package/scripts/generate_windsurf_rules.py +31 -0
  47. package/scripts/install.py +26 -1
  48. package/scripts/install_steps/ai_tools.py +149 -31
  49. package/scripts/install_steps/detect_language.py +68 -5
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """Generate .cursor/rules/*.mdc files for Cursor IDE.
3
+
4
+ Cursor reads rules from .cursor/rules/*.mdc (since Cursor 0.45).
5
+ Each .mdc file has YAML frontmatter controlling when the rule applies:
6
+ - alwaysApply: true — always in context
7
+ - globs: ["**/*.ts"] — auto-attached for matching files
8
+ - description: "..." — AI decides whether to include (Agent Requested)
9
+
10
+ The legacy .cursorrules format is still generated separately by
11
+ generate_cursor_rules.py for backwards compatibility.
12
+
13
+ Usage:
14
+ python3 scripts/generate_cursor_mdc.py [target-dir]
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
+ from dir_rules_shared import (
23
+ PREFIX,
24
+ cleanup_stale,
25
+ rule_agents_and_skills,
26
+ rule_code_style,
27
+ rule_quality_standards,
28
+ rule_security,
29
+ rule_testing,
30
+ rule_workflow,
31
+ )
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # .mdc wrapper: prepends YAML frontmatter to markdown content
36
+ # ---------------------------------------------------------------------------
37
+
38
+ def _mdc(content: str, *, description: str = "",
39
+ globs: list[str] | None = None,
40
+ always_apply: bool = False) -> str:
41
+ """Wrap markdown content with Cursor .mdc YAML frontmatter."""
42
+ lines = ["---"]
43
+ if description:
44
+ lines.append(f"description: {description}")
45
+ if globs:
46
+ globs_str = ", ".join(f'"{g}"' for g in globs)
47
+ lines.append(f"globs: [{globs_str}]")
48
+ lines.append(f"alwaysApply: {'true' if always_apply else 'false'}")
49
+ lines.append("---")
50
+ lines.append("")
51
+ lines.append(content.rstrip("\n"))
52
+ lines.append("")
53
+ return "\n".join(lines)
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Rule files registry
58
+ # ---------------------------------------------------------------------------
59
+
60
+ def _make_rules() -> dict[str, callable]:
61
+ """Build the .mdc rule file registry."""
62
+ return {
63
+ # Always active
64
+ f"{PREFIX}agents-and-skills.mdc": lambda: _mdc(
65
+ rule_agents_and_skills(),
66
+ description="AI toolkit agents, skills, and guidelines catalog",
67
+ always_apply=True,
68
+ ),
69
+ f"{PREFIX}security.mdc": lambda: _mdc(
70
+ rule_security(),
71
+ description="Security rules — OWASP, secrets, input validation",
72
+ always_apply=True,
73
+ ),
74
+ f"{PREFIX}quality-standards.mdc": lambda: _mdc(
75
+ rule_quality_standards(),
76
+ description="Quality standards — tests, safety, operational integrity",
77
+ always_apply=True,
78
+ ),
79
+ # Auto-attached by file type
80
+ f"{PREFIX}code-style.mdc": lambda: _mdc(
81
+ rule_code_style(),
82
+ description="Code style conventions for all languages",
83
+ ),
84
+ f"{PREFIX}testing.mdc": lambda: _mdc(
85
+ rule_testing(),
86
+ description="Testing standards and patterns",
87
+ globs=["**/*.test.*", "**/*.spec.*", "**/test_*", "**/tests/**"],
88
+ ),
89
+ f"{PREFIX}workflow.mdc": lambda: _mdc(
90
+ rule_workflow(),
91
+ description="Development workflow — planning, commits, quality gates",
92
+ ),
93
+ }
94
+
95
+
96
+ RULES = _make_rules()
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Main
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def generate(target_dir: Path) -> None:
104
+ """Write .cursor/rules/*.mdc files to target_dir."""
105
+ rules_dir = target_dir / ".cursor" / "rules"
106
+ rules_dir.mkdir(parents=True, exist_ok=True)
107
+
108
+ # Clean stale ai-toolkit-*.mdc files
109
+ current = set(RULES.keys())
110
+ if rules_dir.is_dir():
111
+ for f in rules_dir.iterdir():
112
+ if f.name.startswith(PREFIX) and f.suffix == ".mdc" and f.name not in current:
113
+ f.unlink()
114
+ print(f" Removed stale: .cursor/rules/{f.name}")
115
+
116
+ for filename, content_fn in RULES.items():
117
+ (rules_dir / filename).write_text(content_fn(), encoding="utf-8")
118
+ print(f" Generated: .cursor/rules/{filename}")
119
+
120
+
121
+ def main() -> None:
122
+ target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
123
+ generate(target)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env python3
2
+ """Generate .roo/rules/*.md shared rules for Roo Code.
3
+
4
+ Roo Code reads shared rules from .roo/rules/*.md (applied to all modes).
5
+ The .roomodes JSON is still generated separately by generate_roo_modes.py.
6
+
7
+ Usage:
8
+ python3 scripts/generate_roo_rules.py [target-dir]
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
16
+ from dir_rules_shared import STANDARD_RULES, write_rules
17
+
18
+
19
+ def generate(target_dir: Path) -> None:
20
+ """Write .roo/rules/*.md files to target_dir."""
21
+ write_rules(target_dir, STANDARD_RULES, ".roo/rules")
22
+
23
+
24
+ def main() -> None:
25
+ target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
26
+ generate(target)
27
+
28
+
29
+ if __name__ == "__main__":
30
+ main()
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env python3
2
+ """Generate .windsurf/rules/*.md files for Windsurf IDE.
3
+
4
+ Windsurf reads directory-based rules from .windsurf/rules/*.md (since mid-2025).
5
+ The legacy .windsurfrules single-file format is still generated separately
6
+ by generate_windsurf.py for backwards compatibility.
7
+
8
+ Usage:
9
+ python3 scripts/generate_windsurf_rules.py [target-dir]
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
17
+ from dir_rules_shared import STANDARD_RULES, write_rules
18
+
19
+
20
+ def generate(target_dir: Path) -> None:
21
+ """Write .windsurf/rules/*.md files to target_dir."""
22
+ write_rules(target_dir, STANDARD_RULES, ".windsurf/rules")
23
+
24
+
25
+ def main() -> None:
26
+ target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
27
+ generate(target)
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main()
@@ -197,6 +197,8 @@ def parse_args(argv: list[str]) -> dict:
197
197
  "modules": "",
198
198
  "auto_detect": False,
199
199
  "status": False,
200
+ "lang": "",
201
+ "editors": "",
200
202
  }
201
203
  i = 0
202
204
  while i < len(argv):
@@ -236,6 +238,16 @@ def parse_args(argv: list[str]) -> dict:
236
238
  elif arg == "--modules":
237
239
  i += 1
238
240
  cfg["modules"] = argv[i] if i < len(argv) else ""
241
+ elif arg.startswith("--lang="):
242
+ cfg["lang"] = arg.split("=", 1)[1]
243
+ elif arg == "--lang":
244
+ i += 1
245
+ cfg["lang"] = argv[i] if i < len(argv) else ""
246
+ elif arg.startswith("--editors="):
247
+ cfg["editors"] = arg.split("=", 1)[1]
248
+ elif arg == "--editors":
249
+ i += 1
250
+ cfg["editors"] = argv[i] if i < len(argv) else ""
239
251
  elif arg.startswith("-"):
240
252
  print(f"Unknown option: {arg}")
241
253
  sys.exit(1)
@@ -405,6 +417,17 @@ def main() -> None:
405
417
  persona: str = cfg["persona"]
406
418
  modules_arg: str = cfg["modules"]
407
419
  auto_detect: bool = cfg["auto_detect"]
420
+ lang_arg: str = cfg["lang"]
421
+
422
+ # --lang <list> → merge into --modules as rules-<lang> entries
423
+ _LANG_ALIASES = {"go": "golang", "c++": "cpp", "c#": "csharp", "cs": "csharp"}
424
+ if lang_arg:
425
+ langs = [_LANG_ALIASES.get(l.strip(), l.strip()) for l in lang_arg.split(",") if l.strip()]
426
+ lang_modules = ",".join(f"rules-{l}" for l in langs)
427
+ modules_arg = f"{modules_arg},{lang_modules}" if modules_arg else lang_modules
428
+ auto_detect = False # explicit --lang overrides auto-detect
429
+ if not local:
430
+ local = True # language rules are project-local
408
431
 
409
432
  rules_dir = Path.home() / ".ai-toolkit" / "rules"
410
433
  hooks_scripts_dir = Path.home() / ".ai-toolkit" / "hooks"
@@ -447,7 +470,9 @@ def main() -> None:
447
470
  if local:
448
471
  # Pass language modules for --auto-detect / --modules rules-*
449
472
  lang_modules = [m for m in (resolved_modules or []) if m.startswith("rules-")]
450
- install_local_project(rules_dir, dry_run, reset, lang_modules or None)
473
+ editors_arg: str = cfg["editors"]
474
+ install_local_project(rules_dir, dry_run, reset, lang_modules or None,
475
+ editors=editors_arg)
451
476
 
452
477
  install_persona(target_dir, persona, dry_run)
453
478
  install_strict_git_hooks(profile, local, dry_run)
@@ -120,18 +120,81 @@ def run_script(script_name: str, *args: str, capture: bool = False) -> str:
120
120
  return result.stdout if capture else ""
121
121
 
122
122
 
123
+ # All known editor identifiers for --editors flag
124
+ ALL_EDITORS = [
125
+ "copilot", "cursor", "windsurf", "cline", "roo",
126
+ "aider", "augment", "antigravity",
127
+ ]
128
+
129
+ # Map of project files/dirs → editor names for auto-detection
130
+ _EDITOR_MARKERS: dict[str, str] = {
131
+ ".github/copilot-instructions.md": "copilot",
132
+ ".cursorrules": "cursor",
133
+ ".cursor/rules": "cursor",
134
+ ".windsurfrules": "windsurf",
135
+ ".windsurf/rules": "windsurf",
136
+ ".clinerules": "cline",
137
+ ".cline/rules": "cline",
138
+ ".roomodes": "roo",
139
+ ".roo/rules": "roo",
140
+ ".aider.conf.yml": "aider",
141
+ "CONVENTIONS.md": "aider",
142
+ ".augment/rules": "augment",
143
+ ".agent/rules": "antigravity",
144
+ }
145
+
146
+
147
+ def _detect_editors(cwd: Path) -> list[str]:
148
+ """Detect which editors have configs in the project directory."""
149
+ found: set[str] = set()
150
+ for marker, editor in _EDITOR_MARKERS.items():
151
+ p = cwd / marker
152
+ if p.exists():
153
+ found.add(editor)
154
+ return sorted(found)
155
+
156
+
157
+ def _resolve_editors(editors_arg: str, cwd: Path) -> list[str]:
158
+ """Resolve --editors argument to a list of editor names.
159
+
160
+ - "" → auto-detect from existing project files (empty if none found)
161
+ - "all" → all editors
162
+ - "cursor,aider" → explicit list
163
+ """
164
+ if editors_arg == "all":
165
+ return list(ALL_EDITORS)
166
+ if editors_arg:
167
+ return [e.strip() for e in editors_arg.split(",") if e.strip()]
168
+ # Auto-detect: return editors that already have configs in the project
169
+ return _detect_editors(cwd)
170
+
171
+
123
172
  def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
124
- language_modules: list[str] | None = None) -> None:
125
- """Install project-local configs."""
173
+ language_modules: list[str] | None = None,
174
+ editors: str = "") -> None:
175
+ """Install project-local configs.
176
+
177
+ Claude Code configs (CLAUDE.md, settings, constitution) are always installed.
178
+ Editor configs are installed based on ``--editors`` flag:
179
+ - ``--editors all``: install all editors
180
+ - ``--editors cursor,aider``: install only these
181
+ - (empty): auto-detect from existing project files, install only those
182
+ """
126
183
  cwd = Path.cwd()
184
+ resolved_editors = _resolve_editors(editors, cwd)
185
+
127
186
  print()
128
187
  print(f"## Project-local ({cwd})")
129
188
  if reset:
130
189
  print(" Mode: RESET (all local configs will be wiped and recreated)")
190
+ if resolved_editors:
191
+ print(f" Editors: {', '.join(resolved_editors)}")
192
+ else:
193
+ print(" Editors: none (use --editors <list> or --editors all to enable)")
131
194
  print()
132
195
 
133
196
  if dry_run:
134
- _install_local_dry_run(reset)
197
+ _install_local_dry_run(reset, resolved_editors)
135
198
  if language_modules:
136
199
  print(f" Would inject language rules: {', '.join(language_modules)}")
137
200
  return
@@ -162,7 +225,8 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
162
225
  # Inject language-specific rules into project CLAUDE.md
163
226
  _inject_language_rules(cwd, language_modules)
164
227
 
165
- _create_local_ai_tool_configs(cwd, rules_dir)
228
+ # Install editor configs only for resolved editors
229
+ _create_local_ai_tool_configs(cwd, rules_dir, resolved_editors)
166
230
 
167
231
 
168
232
  def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> None:
@@ -226,21 +290,36 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
226
290
  tmp_path.unlink(missing_ok=True)
227
291
 
228
292
 
229
- def _install_local_dry_run(reset: bool) -> None:
293
+ def _install_local_dry_run(reset: bool, editors: list[str] | None = None) -> None:
294
+ eds = set(editors or [])
230
295
  if reset:
231
296
  print(" Would remove: CLAUDE.md, .claude/settings.local.json")
232
- print(" Would remove: .claude/constitution.md, .github/copilot-instructions.md, .clinerules, .roomodes, .aider.conf.yml")
297
+ print(" Would remove: .claude/constitution.md and all editor configs")
233
298
  print(" Would recreate all from templates (clean slate)")
234
- print(" Would install git hooks (if .git/hooks exists)")
235
299
  else:
236
300
  print(" Would create: CLAUDE.md (if missing)")
237
301
  print(" Would create: .claude/settings.local.json (if missing)")
238
302
  print(" Would inject: .claude/constitution.md")
239
- print(" Would inject: .github/copilot-instructions.md")
240
- print(" Would inject: .clinerules")
241
- print(" Would inject: .roomodes")
242
- print(" Would inject: .aider.conf.yml")
243
- print(" Would install: .git/hooks/pre-commit")
303
+
304
+ # Editor-specific dry-run messages
305
+ _EDITOR_DRY_RUN = {
306
+ "copilot": " Would inject: .github/copilot-instructions.md",
307
+ "cursor": " Would generate: .cursorrules + .cursor/rules/*.mdc",
308
+ "windsurf": " Would generate: .windsurfrules + .windsurf/rules/*.md",
309
+ "cline": " Would generate: .clinerules + .cline/rules/*.md",
310
+ "roo": " Would generate: .roomodes + .roo/rules/*.md",
311
+ "aider": " Would generate: .aider.conf.yml + CONVENTIONS.md",
312
+ "augment": " Would generate: .augment/rules/ai-toolkit-*.md",
313
+ "antigravity": " Would generate: .agent/rules/ + .agent/workflows/",
314
+ }
315
+ for ed, msg in _EDITOR_DRY_RUN.items():
316
+ if ed in eds:
317
+ print(msg)
318
+
319
+ if not eds:
320
+ print(" No editors selected (use --editors <list> or --editors all)")
321
+
322
+ print(" Would install: .git/hooks/pre-commit")
244
323
 
245
324
 
246
325
  def _reset_local_configs(cwd: Path) -> None:
@@ -314,24 +393,63 @@ def _create_local_settings(cwd: Path, reset: bool) -> None:
314
393
  print(" Kept: .claude/settings.local.json (already exists)")
315
394
 
316
395
 
317
- def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path) -> None:
318
- inject_with_rules(
319
- "generate-copilot.sh",
320
- cwd / ".github" / "copilot-instructions.md",
321
- rules_dir,
322
- )
323
- inject_with_rules(
324
- "generate-cline.sh",
325
- cwd / ".clinerules",
326
- rules_dir,
327
- )
328
-
329
- roo_output = run_script("generate-roo-modes.sh", capture=True)
330
- (cwd / ".roomodes").write_text(roo_output, encoding="utf-8")
331
- print(" Created: .roomodes")
332
-
333
- aider_output = run_script("generate-aider-conf.sh", capture=True)
334
- (cwd / ".aider.conf.yml").write_text(aider_output, encoding="utf-8")
335
- print(" Created: .aider.conf.yml")
396
+ def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
397
+ editors: list[str]) -> None:
398
+ eds = set(editors)
399
+
400
+ if "copilot" in eds:
401
+ inject_with_rules(
402
+ "generate-copilot.sh",
403
+ cwd / ".github" / "copilot-instructions.md",
404
+ rules_dir,
405
+ )
406
+
407
+ if "cursor" in eds:
408
+ inject_with_rules(
409
+ "generate-cursor-rules.sh",
410
+ cwd / ".cursorrules",
411
+ rules_dir,
412
+ )
413
+ from generate_cursor_mdc import generate as gen_cursor_mdc
414
+ gen_cursor_mdc(cwd)
415
+
416
+ if "windsurf" in eds:
417
+ inject_with_rules(
418
+ "generate-windsurf.sh",
419
+ cwd / ".windsurfrules",
420
+ rules_dir,
421
+ )
422
+ from generate_windsurf_rules import generate as gen_windsurf_rules
423
+ gen_windsurf_rules(cwd)
424
+
425
+ if "cline" in eds:
426
+ inject_with_rules(
427
+ "generate-cline.sh",
428
+ cwd / ".clinerules",
429
+ rules_dir,
430
+ )
431
+ from generate_cline_rules import generate as gen_cline_rules
432
+ gen_cline_rules(cwd)
433
+
434
+ if "roo" in eds:
435
+ roo_output = run_script("generate-roo-modes.sh", capture=True)
436
+ (cwd / ".roomodes").write_text(roo_output, encoding="utf-8")
437
+ print(" Created: .roomodes")
438
+ from generate_roo_rules import generate as gen_roo_rules
439
+ gen_roo_rules(cwd)
440
+
441
+ if "aider" in eds:
442
+ aider_output = run_script("generate-aider-conf.sh", capture=True)
443
+ (cwd / ".aider.conf.yml").write_text(aider_output, encoding="utf-8")
444
+ print(" Created: .aider.conf.yml")
445
+ inject_with_rules("generate_conventions.py", cwd / "CONVENTIONS.md", rules_dir)
446
+
447
+ if "augment" in eds:
448
+ from generate_augment_rules import generate as gen_augment_rules
449
+ gen_augment_rules(cwd)
450
+
451
+ if "antigravity" in eds:
452
+ from generate_antigravity import generate as gen_antigravity
453
+ gen_antigravity(cwd)
336
454
 
337
455
  run_script("install-git-hooks.sh", str(cwd))
@@ -1,10 +1,40 @@
1
- """Detect project language from file markers in the manifest."""
1
+ """Detect project language from file markers and source file extensions."""
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
5
  from pathlib import Path
6
6
 
7
7
 
8
+ # Map file extensions to manifest module names.
9
+ # Only extensions that unambiguously identify a language are listed.
10
+ _EXT_TO_MODULE: dict[str, str] = {
11
+ ".py": "rules-python",
12
+ ".ts": "rules-typescript",
13
+ ".tsx": "rules-typescript",
14
+ ".go": "rules-golang",
15
+ ".rs": "rules-rust",
16
+ ".java": "rules-java",
17
+ ".kt": "rules-kotlin",
18
+ ".kts": "rules-kotlin",
19
+ ".swift": "rules-swift",
20
+ ".dart": "rules-dart",
21
+ ".cs": "rules-csharp",
22
+ ".php": "rules-php",
23
+ ".cpp": "rules-cpp",
24
+ ".cc": "rules-cpp",
25
+ ".cxx": "rules-cpp",
26
+ ".hpp": "rules-cpp",
27
+ ".rb": "rules-ruby",
28
+ }
29
+
30
+ # Directories that should never be scanned (dependency dirs, build output, etc.)
31
+ _SKIP_DIRS = frozenset({
32
+ "node_modules", ".git", "__pycache__", "venv", ".venv", "env",
33
+ "dist", "build", ".tox", ".mypy_cache", ".pytest_cache",
34
+ "vendor", "target", ".next", ".nuxt", "coverage",
35
+ })
36
+
37
+
8
38
  def _load_manifest_modules(toolkit_dir: Path) -> dict:
9
39
  """Load the modules section from manifest.json."""
10
40
  manifest_path = toolkit_dir / "manifest.json"
@@ -24,19 +54,49 @@ def _glob_matches(project_dir: Path, pattern: str) -> bool:
24
54
  return any(project_dir.glob(pattern))
25
55
 
26
56
 
57
+ def _detect_by_extensions(project_dir: Path) -> set[str]:
58
+ """Scan source files (top-level + 1 level deep) and return detected modules.
59
+
60
+ Skips dependency/build directories for speed. Stops scanning for a
61
+ given extension once one match is found.
62
+ """
63
+ found: set[str] = set()
64
+ seen_exts: set[str] = set()
65
+
66
+ for child in project_dir.iterdir():
67
+ if child.is_file():
68
+ ext = child.suffix
69
+ if ext in _EXT_TO_MODULE and ext not in seen_exts:
70
+ seen_exts.add(ext)
71
+ found.add(_EXT_TO_MODULE[ext])
72
+ elif child.is_dir() and child.name not in _SKIP_DIRS:
73
+ for grandchild in child.iterdir():
74
+ if grandchild.is_file():
75
+ ext = grandchild.suffix
76
+ if ext in _EXT_TO_MODULE and ext not in seen_exts:
77
+ seen_exts.add(ext)
78
+ found.add(_EXT_TO_MODULE[ext])
79
+
80
+ return found
81
+
82
+
27
83
  def detect_languages(project_dir: Path, toolkit_dir: Path) -> list[str]:
28
84
  """Return list of detected language module names.
29
85
 
30
- Scans ``project_dir`` for marker files defined in each module's
31
- ``auto_detect`` list inside ``manifest.json``.
86
+ Two-phase detection:
87
+ 1. Marker files (``auto_detect`` in manifest.json) — config-level signals.
88
+ 2. Source file extensions (top-level + 1 deep) — actual code presence.
89
+
90
+ Both phases contribute; duplicates are merged.
32
91
 
33
92
  Returns:
34
93
  Sorted list of matching module names, e.g.
35
94
  ``["rules-python", "rules-typescript"]``.
36
95
  """
37
96
  modules = _load_manifest_modules(toolkit_dir)
38
- detected: list[str] = []
97
+ detected: set[str] = set()
39
98
 
99
+ # Phase 1: marker files from manifest
40
100
  for module_name, module_cfg in modules.items():
41
101
  markers = module_cfg.get("auto_detect")
42
102
  if not markers:
@@ -44,7 +104,10 @@ def detect_languages(project_dir: Path, toolkit_dir: Path) -> list[str]:
44
104
 
45
105
  for pattern in markers:
46
106
  if _glob_matches(project_dir, pattern):
47
- detected.append(module_name)
107
+ detected.add(module_name)
48
108
  break # one match is enough for this module
49
109
 
110
+ # Phase 2: source file extensions
111
+ detected |= _detect_by_extensions(project_dir)
112
+
50
113
  return sorted(detected)