@codyswann/lisa 2.189.0 → 2.189.1

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 (52) hide show
  1. package/package.json +3 -2
  2. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  3. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  4. package/plugins/lisa-agy/plugin.json +1 -1
  5. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  6. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  7. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  8. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  9. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  10. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  11. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  14. package/plugins/lisa-expo-agy/plugin.json +1 -1
  15. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  20. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  25. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  30. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  35. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-rails-agy/plugin.json +1 -1
  40. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  45. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  50. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  52. package/scripts/detect-stale-workflow-inputs.mjs +455 -0
package/package.json CHANGED
@@ -31,7 +31,8 @@
31
31
  "lisa:commit-and-pr:local": "bash scripts/lisa-commit-and-pr-local.sh",
32
32
  "prepublishOnly": "$npm_execpath run build",
33
33
  "check:plugins": "bash scripts/check-plugins-sync.sh",
34
- "check:rules-pairing": "bash scripts/check-rules-pairing.sh"
34
+ "check:rules-pairing": "bash scripts/check-rules-pairing.sh",
35
+ "check:workflow-inputs": "node scripts/detect-stale-workflow-inputs.mjs --project . --project typescript/create-only --project rails/create-only --json"
35
36
  },
36
37
  "engines": {
37
38
  "npm": "please-use-bun",
@@ -93,7 +94,7 @@
93
94
  "ws": ">=8.20.1"
94
95
  },
95
96
  "name": "@codyswann/lisa",
96
- "version": "2.189.0",
97
+ "version": "2.189.1",
97
98
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
98
99
  "main": "dist/index.js",
99
100
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Expo and React Native-specific skills, agents, rules, and MCP servers.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Harper/Fabric-specific Lisa rules for TypeScript component apps.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "NestJS-specific skills and migration write-protection hooks.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Ruby on Rails-specific skills and hooks for RuboCop and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "TypeScript-specific hooks for formatting, linting, and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.189.0",
3
+ "version": "2.189.1",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -0,0 +1,455 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deterministic detector for stale reusable-workflow inputs (issue #1423).
4
+ *
5
+ * Context: a caller workflow (e.g. a consumer repo's `.github/workflows/claude.yml`)
6
+ * invokes one of Lisa's reusable workflows via `uses: <owner>/<repo>/.github/workflows/
7
+ * reusable-*.yml@<ref>` and passes a `with:` block. GitHub hard-fails the run at
8
+ * startup if that `with:` block names an input the reusable workflow's
9
+ * `workflow_call.inputs` no longer declares (e.g. an early-generation caller still
10
+ * passing `package_manager`, which `reusable-claude.yml` never declared). Because
11
+ * `claude.yml` is a create-only file Lisa never overwrites, an affected repo can
12
+ * never self-heal — the drift has to be found and reported (or migrated) instead.
13
+ *
14
+ * This script scans every workflow file directly under a project's
15
+ * `.github/workflows/` for such callers, resolves each referenced reusable
16
+ * workflow's declared inputs from a contracts root (defaults to this repo's own
17
+ * `.github/workflows/`, the source of truth for the current contract), and reports
18
+ * any caller `with:` key absent from the declared set as `stale`.
19
+ *
20
+ * It is intentionally NOT a general YAML parser — like `migrate-deploy-order.sh`,
21
+ * it does targeted, indentation-aware line scanning of the two shapes it cares
22
+ * about (`uses:` + sibling `with:`, and `workflow_call: inputs:`). That is
23
+ * sufficient for Lisa-authored workflow files, which have a stable, predictable
24
+ * structure, and keeps the detector dependency-free (Node built-ins only, no
25
+ * network access — mirrors `scripts/plugin-parity-drift.mjs`'s determinism goals).
26
+ *
27
+ * It never edits a caller file — it only reports, so `lisa-update-projects` (or
28
+ * CI) can decide SAFE/REVIEW/migrate from the result.
29
+ *
30
+ * CLI:
31
+ * node scripts/detect-stale-workflow-inputs.mjs [--project <dir>]... [--contracts-root <dir>] [--json]
32
+ *
33
+ * Exit codes:
34
+ * 0 — no stale inputs found (includes the case of zero callers scanned).
35
+ * 1 — stale input(s) found in at least one caller.
36
+ * 2 — operational/usage error: unknown flag, a flag missing its value, a
37
+ * `--project` that isn't a directory, or a filesystem error while scanning.
38
+ *
39
+ * @module scripts/detect-stale-workflow-inputs
40
+ */
41
+ import fs from "node:fs";
42
+ import path from "node:path";
43
+ import process from "node:process";
44
+ import { fileURLToPath, pathToFileURL } from "node:url";
45
+
46
+ const REPO_ROOT = path.resolve(
47
+ path.dirname(fileURLToPath(import.meta.url)),
48
+ ".."
49
+ );
50
+
51
+ /**
52
+ * Usage error — thrown by `parseArgs` for an invalid invocation so `main` can
53
+ * distinguish it (exit 2) from a drift result (exit 1).
54
+ */
55
+ export class UsageError extends Error {}
56
+
57
+ /**
58
+ * True iff `target` is an existing directory.
59
+ *
60
+ * @param {string} target - filesystem path.
61
+ * @returns {boolean} whether `target` resolves to a directory.
62
+ */
63
+ function isDirectory(target) {
64
+ try {
65
+ return fs.statSync(target).isDirectory();
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Leading-whitespace width of a line (its indentation level).
73
+ *
74
+ * @param {string} line - a single source line.
75
+ * @returns {number} number of leading whitespace characters.
76
+ */
77
+ function indentOf(line) {
78
+ return line.length - line.trimStart().length;
79
+ }
80
+
81
+ /**
82
+ * Collect the immediate child `key:` names nested directly under a block
83
+ * opener line, given the opener's own indentation. Stops at the first line
84
+ * whose indentation is `<= parentIndent` (blank lines are skipped).
85
+ *
86
+ * @param {readonly string[]} lines - full file, split into lines.
87
+ * @param {number} openerIndex - index of the block-opener line (e.g. `with:`).
88
+ * @param {number} parentIndent - indentation width of the opener line itself.
89
+ * @returns {string[]} the immediate child key names, in file order.
90
+ */
91
+ function collectImmediateChildKeys(lines, openerIndex, parentIndent) {
92
+ const keys = [];
93
+ for (let k = openerIndex + 1; k < lines.length; k++) {
94
+ const line = lines[k];
95
+ if (line.trim() === "") {
96
+ continue;
97
+ }
98
+ const lineIndent = indentOf(line);
99
+ if (lineIndent <= parentIndent) {
100
+ break;
101
+ }
102
+ const match = /^\s*([A-Za-z0-9_-]+):/.exec(line);
103
+ if (match && lineIndent === parentIndent + 2) {
104
+ keys.push(match[1]);
105
+ }
106
+ }
107
+ return keys;
108
+ }
109
+
110
+ /**
111
+ * Parse a `uses:` value referencing a reusable workflow into its basename and
112
+ * ref, or `null` if it doesn't match the `.../.github/workflows/<file>@<ref>`
113
+ * shape (e.g. a plain action reference like `actions/checkout@v6`).
114
+ *
115
+ * @param {string} usesValue - the raw value after `uses:`.
116
+ * @returns {{ file: string, ref: string } | null} the parsed reference.
117
+ */
118
+ export function parseReusableReference(usesValue) {
119
+ const match = /\.github\/workflows\/([\w.-]+\.ya?ml)@(\S+)$/.exec(
120
+ usesValue.trim()
121
+ );
122
+ if (!match) {
123
+ return null;
124
+ }
125
+ return { file: match[1], ref: match[2] };
126
+ }
127
+
128
+ /**
129
+ * Scan a caller workflow file's contents for every job that invokes a
130
+ * reusable workflow, returning the `with:` keys each job passes.
131
+ *
132
+ * @param {string} content - full caller workflow file contents.
133
+ * @returns {{ reusableFile: string, ref: string, withKeys: string[] }[]}
134
+ * one entry per job that calls a reusable workflow (in file order).
135
+ */
136
+ export function extractCallerJobs(content) {
137
+ const lines = content.split(/\r?\n/);
138
+ const jobs = [];
139
+ for (let i = 0; i < lines.length; i++) {
140
+ const usesMatch = /^(\s*)uses:\s*(\S+)\s*$/.exec(lines[i]);
141
+ if (!usesMatch) {
142
+ continue;
143
+ }
144
+ const [, indentStr, usesValue] = usesMatch;
145
+ const ref = parseReusableReference(usesValue);
146
+ if (!ref) {
147
+ continue;
148
+ }
149
+ const indent = indentStr.length;
150
+ let withKeys = [];
151
+ for (let j = i + 1; j < lines.length; j++) {
152
+ const line = lines[j];
153
+ if (line.trim() === "") {
154
+ continue;
155
+ }
156
+ const lineIndent = indentOf(line);
157
+ if (lineIndent < indent) {
158
+ break;
159
+ }
160
+ if (lineIndent === indent && /^\s*with:\s*$/.test(line)) {
161
+ withKeys = collectImmediateChildKeys(lines, j, indent).sort();
162
+ break;
163
+ }
164
+ }
165
+ jobs.push({ reusableFile: ref.file, ref: ref.ref, withKeys });
166
+ }
167
+ return jobs;
168
+ }
169
+
170
+ /**
171
+ * Extract the declared `on.workflow_call.inputs` key names from a reusable
172
+ * workflow's contents.
173
+ *
174
+ * @param {string} content - full reusable workflow file contents.
175
+ * @returns {string[] | null} declared input names (sorted), an empty array when
176
+ * the reusable workflow declares no inputs, or `null` if the file has no
177
+ * `workflow_call:` block.
178
+ */
179
+ export function extractDeclaredInputs(content) {
180
+ const lines = content.split(/\r?\n/);
181
+ for (let i = 0; i < lines.length; i++) {
182
+ if (!/^\s*workflow_call:\s*$/.test(lines[i])) {
183
+ continue;
184
+ }
185
+ const wcIndent = indentOf(lines[i]);
186
+ for (let j = i + 1; j < lines.length; j++) {
187
+ const line = lines[j];
188
+ if (line.trim() === "") {
189
+ continue;
190
+ }
191
+ const lineIndent = indentOf(line);
192
+ if (lineIndent <= wcIndent) {
193
+ break;
194
+ }
195
+ if (lineIndent === wcIndent + 2 && /^\s*inputs:\s*$/.test(line)) {
196
+ return collectImmediateChildKeys(lines, j, lineIndent).sort();
197
+ }
198
+ }
199
+ return [];
200
+ }
201
+ return null;
202
+ }
203
+
204
+ /**
205
+ * Keys present in `used` but absent from `declared` — i.e. inputs a caller
206
+ * passes that the reusable workflow's contract no longer accepts.
207
+ *
208
+ * @param {readonly string[]} used - `with:` keys a caller passes.
209
+ * @param {readonly string[]} declared - `workflow_call.inputs` keys the
210
+ * reusable workflow declares.
211
+ * @returns {string[]} the stale keys, sorted.
212
+ */
213
+ export function diffStaleKeys(used, declared) {
214
+ const declaredSet = new Set(declared);
215
+ return used.filter(key => !declaredSet.has(key)).sort();
216
+ }
217
+
218
+ /**
219
+ * Resolve a reusable workflow's declared inputs from the contracts root.
220
+ *
221
+ * @param {string} contractsRoot - directory holding the current reusable
222
+ * workflow files (basename-addressed).
223
+ * @param {string} reusableFile - basename of the referenced reusable
224
+ * workflow file (e.g. `reusable-claude.yml`).
225
+ * @returns {string[] | null} declared inputs, or `null` if the contract file
226
+ * isn't present under `contractsRoot` (unresolvable, not necessarily stale).
227
+ */
228
+ function resolveContractInputs(contractsRoot, reusableFile) {
229
+ const contractPath = path.join(contractsRoot, reusableFile);
230
+ if (!fs.existsSync(contractPath)) {
231
+ return null;
232
+ }
233
+ return extractDeclaredInputs(fs.readFileSync(contractPath, "utf8"));
234
+ }
235
+
236
+ /**
237
+ * Scan one caller workflow file and classify each of its reusable-workflow
238
+ * jobs against the contracts root.
239
+ *
240
+ * @param {string} filePath - absolute path to the caller workflow file.
241
+ * @param {string} contractsRoot - directory holding current reusable workflows.
242
+ * @returns {{ reusableFile: string, ref: string, withKeys: string[], staleInputs: string[], status: "ok" | "stale" | "unknown-contract" }[]}
243
+ * one row per reusable-workflow job found in the file.
244
+ */
245
+ function scanCallerFile(filePath, contractsRoot) {
246
+ const jobs = extractCallerJobs(fs.readFileSync(filePath, "utf8"));
247
+ return jobs.map(job => {
248
+ const declared = resolveContractInputs(contractsRoot, job.reusableFile);
249
+ if (declared === null) {
250
+ return { ...job, staleInputs: [], status: "unknown-contract" };
251
+ }
252
+ const staleInputs = diffStaleKeys(job.withKeys, declared);
253
+ return {
254
+ ...job,
255
+ staleInputs,
256
+ status: staleInputs.length > 0 ? "stale" : "ok",
257
+ };
258
+ });
259
+ }
260
+
261
+ /**
262
+ * List every `.yml`/`.yaml` file directly under `<root>/.github/workflows`
263
+ * (non-recursive — GitHub Actions never nests workflow files deeper).
264
+ *
265
+ * @param {string} root - a project root directory.
266
+ * @returns {string[]} absolute file paths, sorted.
267
+ */
268
+ function collectWorkflowFiles(root) {
269
+ const dir = path.join(root, ".github", "workflows");
270
+ if (!isDirectory(dir)) {
271
+ return [];
272
+ }
273
+ return fs
274
+ .readdirSync(dir, { withFileTypes: true })
275
+ .filter(entry => entry.isFile() && /\.ya?ml$/.test(entry.name))
276
+ .map(entry => path.join(dir, entry.name))
277
+ .sort((a, b) => a.localeCompare(b));
278
+ }
279
+
280
+ /**
281
+ * Render a filesystem path relative to `root` using POSIX separators.
282
+ *
283
+ * @param {string} absPath - an absolute path.
284
+ * @param {string} root - the root to relativize against.
285
+ * @returns {string} a stable, display-friendly relative path.
286
+ */
287
+ function displayPath(absPath, root) {
288
+ return path.relative(root, absPath).split(path.sep).join("/");
289
+ }
290
+
291
+ /**
292
+ * Assemble the machine-readable report.
293
+ *
294
+ * @param {ReadonlyArray<Record<string, unknown>>} results - per-job results.
295
+ * @param {{ projects: readonly string[], contractsRoot: string }} opts - options.
296
+ * @returns {Record<string, unknown>} the report object.
297
+ */
298
+ export function buildReport(results, opts) {
299
+ const stale = results.filter(r => r.status === "stale").length;
300
+ const unknownContract = results.filter(
301
+ r => r.status === "unknown-contract"
302
+ ).length;
303
+ return {
304
+ contractsRoot: opts.contractsRoot,
305
+ projects: opts.projects,
306
+ results,
307
+ schemaVersion: 1,
308
+ summary: {
309
+ ok: results.length - stale - unknownContract,
310
+ scanned: results.length,
311
+ stale,
312
+ unknownContract,
313
+ },
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Sanitize a value for a markdown-table cell.
319
+ *
320
+ * @param {string | readonly string[] | null | undefined} value - raw cell value.
321
+ * @returns {string} a table-safe string (`-` for null/undefined/empty array).
322
+ */
323
+ function cell(value) {
324
+ if (value === null || value === undefined) {
325
+ return "-";
326
+ }
327
+ const text = Array.isArray(value) ? value.join(", ") : String(value);
328
+ return text === "" ? "-" : text.replace(/\r?\n/g, " ").replace(/\|/g, "\\|");
329
+ }
330
+
331
+ /**
332
+ * Render the human-readable markdown table + summary line.
333
+ *
334
+ * @param {{ results: ReadonlyArray<Record<string, unknown>>, summary: { scanned: number, stale: number, unknownContract: number } }} report
335
+ * the report object.
336
+ * @returns {string} the rendered table.
337
+ */
338
+ function humanTable(report) {
339
+ const header =
340
+ "| project | file | reusable | stale inputs | status |\n" +
341
+ "| --- | --- | --- | --- | --- |";
342
+ const rows = report.results.map(
343
+ r =>
344
+ `| ${cell(r.project)} | ${cell(r.file)} | ${cell(r.reusableFile)} | ${cell(r.staleInputs)} | ${cell(r.status)} |`
345
+ );
346
+ const summary = `\n${report.summary.stale} of ${report.summary.scanned} caller jobs have stale inputs (${report.summary.unknownContract} unresolvable contract(s))`;
347
+ return [header, ...rows].join("\n") + summary;
348
+ }
349
+
350
+ /**
351
+ * Render a report to the chosen stream.
352
+ *
353
+ * @param {{ write(s: string): void }} out - the output stream.
354
+ * @param {Record<string, unknown>} report - the report object.
355
+ * @param {boolean} json - whether to emit JSON instead of the human table.
356
+ * @returns {void}
357
+ */
358
+ function emitReport(out, report, json) {
359
+ out.write(
360
+ (json ? JSON.stringify(report, null, 2) : humanTable(report)) + "\n"
361
+ );
362
+ }
363
+
364
+ /**
365
+ * Parse argv into resolved options. Throws `UsageError` on a bad invocation.
366
+ *
367
+ * @param {readonly string[]} argv - arguments (without node/script prefix).
368
+ * @returns {{ projects: string[], contractsRoot: string, json: boolean }} options.
369
+ */
370
+ export function parseArgs(argv) {
371
+ const projects = [];
372
+ let contractsRoot = null;
373
+ let json = false;
374
+ for (let i = 0; i < argv.length; i++) {
375
+ const arg = argv[i];
376
+ if (arg === "--json") {
377
+ json = true;
378
+ } else if (arg === "--project") {
379
+ const next = argv[i + 1];
380
+ if (next === undefined || next.startsWith("--")) {
381
+ throw new UsageError("--project requires a value");
382
+ }
383
+ projects.push(next);
384
+ i += 1;
385
+ } else if (arg === "--contracts-root") {
386
+ const next = argv[i + 1];
387
+ if (next === undefined || next.startsWith("--")) {
388
+ throw new UsageError("--contracts-root requires a value");
389
+ }
390
+ contractsRoot = next;
391
+ i += 1;
392
+ } else {
393
+ throw new UsageError(`unknown argument: ${arg}`);
394
+ }
395
+ }
396
+ const resolvedProjects = projects.length > 0 ? projects : [process.cwd()];
397
+ return {
398
+ contractsRoot: path.resolve(
399
+ contractsRoot ?? path.join(REPO_ROOT, ".github", "workflows")
400
+ ),
401
+ json,
402
+ projects: resolvedProjects.map(p => path.resolve(p)),
403
+ };
404
+ }
405
+
406
+ /**
407
+ * Run the detector. Returns the process exit code (does not call `exit`).
408
+ *
409
+ * @param {readonly string[]} argv - arguments (without node/script prefix).
410
+ * @param {{ stdout?: { write(s: string): void }, stderr?: { write(s: string): void } }} [io]
411
+ * injectable streams (defaults to process streams).
412
+ * @returns {number} the exit code (0 ok, 1 stale found, 2 usage error).
413
+ */
414
+ export function main(argv, io = {}) {
415
+ const out = io.stdout ?? process.stdout;
416
+ const err = io.stderr ?? process.stderr;
417
+ let opts;
418
+ try {
419
+ opts = parseArgs(argv);
420
+ } catch (error) {
421
+ err.write(`error: ${error.message}\n`);
422
+ return 2;
423
+ }
424
+ const missing = opts.projects.filter(p => !isDirectory(p));
425
+ if (missing.length > 0) {
426
+ err.write(`error: --project is not a directory: ${missing.join(", ")}\n`);
427
+ return 2;
428
+ }
429
+ let results;
430
+ try {
431
+ results = opts.projects.flatMap(project => {
432
+ const files = collectWorkflowFiles(project);
433
+ return files.flatMap(file =>
434
+ scanCallerFile(file, opts.contractsRoot).map(job => ({
435
+ file: displayPath(file, project),
436
+ project,
437
+ ...job,
438
+ }))
439
+ );
440
+ });
441
+ } catch (error) {
442
+ err.write(`error: failed to scan workflow files: ${error.message}\n`);
443
+ return 2;
444
+ }
445
+ const report = buildReport(results, opts);
446
+ emitReport(out, report, opts.json);
447
+ return results.some(r => r.status === "stale") ? 1 : 0;
448
+ }
449
+
450
+ if (
451
+ process.argv[1] &&
452
+ import.meta.url === pathToFileURL(process.argv[1]).href
453
+ ) {
454
+ process.exit(main(process.argv.slice(2)));
455
+ }