@codyswann/lisa 2.188.4 → 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 (72) 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/rules/reference/integration-access-layer.md +2 -0
  5. package/plugins/lisa-agy/plugin.json +1 -1
  6. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  7. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  8. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  9. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  10. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  11. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-copilot/rules/reference/integration-access-layer.md +2 -0
  13. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cursor/rules/integration-access-layer-reference.mdc +2 -0
  15. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa-expo/skills/expo-deployment/SKILL.md +3 -0
  18. package/plugins/lisa-expo/skills/expo-deployment/references/play-store.md +18 -0
  19. package/plugins/lisa-expo/skills/play-store-access/SKILL.md +226 -0
  20. package/plugins/lisa-expo/skills/play-store-access/agents/openai.yaml +4 -0
  21. package/plugins/lisa-expo-agy/plugin.json +1 -1
  22. package/plugins/lisa-expo-agy/skills/expo-deployment/SKILL.md +3 -0
  23. package/plugins/lisa-expo-agy/skills/expo-deployment/references/play-store.md +18 -0
  24. package/plugins/lisa-expo-agy/skills/play-store-access/SKILL.md +226 -0
  25. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-expo-copilot/skills/expo-deployment/SKILL.md +3 -0
  27. package/plugins/lisa-expo-copilot/skills/expo-deployment/references/play-store.md +18 -0
  28. package/plugins/lisa-expo-copilot/skills/play-store-access/SKILL.md +226 -0
  29. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo-cursor/skills/expo-deployment/SKILL.md +3 -0
  31. package/plugins/lisa-expo-cursor/skills/expo-deployment/references/play-store.md +18 -0
  32. package/plugins/lisa-expo-cursor/skills/play-store-access/SKILL.md +226 -0
  33. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  51. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails-agy/plugin.json +1 -1
  56. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  61. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  66. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  68. package/plugins/src/base/rules/reference/integration-access-layer.md +2 -0
  69. package/plugins/src/expo/skills/expo-deployment/SKILL.md +3 -0
  70. package/plugins/src/expo/skills/expo-deployment/references/play-store.md +18 -0
  71. package/plugins/src/expo/skills/play-store-access/SKILL.md +226 -0
  72. package/scripts/detect-stale-workflow-inputs.mjs +455 -0
@@ -0,0 +1,226 @@
1
+ ---
2
+ name: play-store-access
3
+ description: "Read-only Google Play Console access layer for Expo projects. Use when an operator or agent needs to set up Play Developer API credentials, resolve the Android package name, or check the production track release state without using the Play Console UI. Reads the androidpublisher v3 API only; track promotion, rollout changes, and submissions are out of scope."
4
+ version: 1.0.0
5
+ license: MIT
6
+ allowed-tools:
7
+ - Bash
8
+ - Read
9
+ ---
10
+
11
+ # Play Store Access: $ARGUMENTS
12
+
13
+ Read-only access layer for Google Play release state in Expo projects. Use it to
14
+ answer whether the Android app is live, halted, draft, or in staged rollout on a
15
+ Play track after EAS submission.
16
+
17
+ This skill MUST NOT promote tracks, change rollout percentages, create releases,
18
+ or submit builds. EAS remains the submission path.
19
+
20
+ ## Invocation Contract
21
+
22
+ ```text
23
+ operation: setup
24
+ operation: production-status [track:production] [package:<android.package>]
25
+ operation: track-status track:<track> [package:<android.package>]
26
+ operation: resolve-package
27
+ ```
28
+
29
+ Return a concise result with the package name, track, release `name` /
30
+ `versionCodes`, `status`, `userFraction` when present, and any `countryTargeting`
31
+ or in-app update priority fields present in the API response.
32
+
33
+ ## Configuration
34
+
35
+ Project-safe config can live in `.lisa.config.json`:
36
+
37
+ ```json
38
+ {
39
+ "playStore": {
40
+ "packageName": "com.example.app",
41
+ "track": "production"
42
+ }
43
+ }
44
+ ```
45
+
46
+ Local credential config belongs in `.lisa.config.local.json`, which must stay
47
+ gitignored:
48
+
49
+ ```json
50
+ {
51
+ "playStore": {
52
+ "serviceAccountKeyPath": "./google-play-service-account.json"
53
+ }
54
+ }
55
+ ```
56
+
57
+ Supported credential locations, in resolution order:
58
+
59
+ 1. `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` containing the raw service-account JSON.
60
+ 2. `GOOGLE_PLAY_SERVICE_ACCOUNT_KEY_BASE64` containing base64 JSON.
61
+ 3. `.lisa.config.local.json` `playStore.serviceAccountKeyPath`.
62
+ 4. Darwin keychain item `lisa-google-play` account `<packageName>`.
63
+
64
+ Fail closed if no credential is available. Never prompt indefinitely and never
65
+ fall back to Play Console UI scraping.
66
+
67
+ ## One-Time Setup Flow
68
+
69
+ For `operation: setup`:
70
+
71
+ 1. Confirm the project is an Expo app by checking for `app.json`,
72
+ `app.config.json`, `app.config.js`, or `app.config.ts`.
73
+ 2. Resolve or ask the operator to provide the Android package name. Prefer
74
+ `.lisa.config.json` `playStore.packageName`, then `expo.android.package` from
75
+ app config.
76
+ 3. In Google Cloud Console, create a service account dedicated to read-only Play
77
+ release visibility and create a JSON key for it.
78
+ 4. In Play Console, open **Setup -> API access**, link the Google Cloud project if
79
+ needed, then grant the service-account email app access with read-level
80
+ release visibility. Do not grant release-management write permissions for v1.
81
+ 5. Store the key locally using one of the credential locations above. If using a
82
+ path, add the path to `.gitignore` and record only the path in
83
+ `.lisa.config.local.json`.
84
+ 6. Record non-secret defaults in `.lisa.config.json`:
85
+
86
+ ```bash
87
+ jq '.playStore.packageName = "com.example.app" | .playStore.track = "production"' \
88
+ .lisa.config.json > /tmp/lisa-config.json && mv /tmp/lisa-config.json .lisa.config.json
89
+ ```
90
+
91
+ The key material itself must never be committed.
92
+
93
+ ## Resolve Package Name
94
+
95
+ Resolution order:
96
+
97
+ 1. Explicit `package:<value>` argument.
98
+ 2. `.lisa.config.json` `playStore.packageName`.
99
+ 3. `app.json` / `app.config.json` `expo.android.package`.
100
+ 4. `app.config.ts` / `app.config.js` by running Expo config export:
101
+
102
+ ```bash
103
+ npx expo config --json | jq -r '.android.package // empty'
104
+ ```
105
+
106
+ If the package still cannot be resolved, fail with:
107
+
108
+ ```text
109
+ Error: Google Play package name is not configured. Set playStore.packageName in .lisa.config.json or pass package:<name>.
110
+ ```
111
+
112
+ ## Read Track State
113
+
114
+ The androidpublisher API requires an edit even for track reads. Create the edit,
115
+ list the track, then delete the edit. Do not commit the edit.
116
+
117
+ ```bash
118
+ PACKAGE_NAME="${PACKAGE_NAME:-$(jq -r '.playStore.packageName // empty' .lisa.config.json)}"
119
+ TRACK="${TRACK:-$(jq -r '.playStore.track // "production"' .lisa.config.json)}"
120
+ KEY_PATH="$(jq -r '.playStore.serviceAccountKeyPath // empty' .lisa.config.local.json 2>/dev/null)"
121
+
122
+ read_service_account_json() {
123
+ if [ -n "${GOOGLE_PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then
124
+ printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"
125
+ return
126
+ fi
127
+ if [ -n "${GOOGLE_PLAY_SERVICE_ACCOUNT_KEY_BASE64:-}" ]; then
128
+ printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_KEY_BASE64" | base64 --decode 2>/dev/null ||
129
+ printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_KEY_BASE64" | base64 -D
130
+ return
131
+ fi
132
+ if [ -n "$KEY_PATH" ] && [ -f "$KEY_PATH" ]; then
133
+ cat "$KEY_PATH"
134
+ return
135
+ fi
136
+ if [ "$(uname -s)" = "Darwin" ] && [ -n "$PACKAGE_NAME" ]; then
137
+ security find-generic-password -s lisa-google-play -a "$PACKAGE_NAME" -w 2>/dev/null
138
+ return
139
+ fi
140
+ }
141
+
142
+ SERVICE_ACCOUNT_JSON="$(read_service_account_json)"
143
+ [ -n "$SERVICE_ACCOUNT_JSON" ] || {
144
+ echo "Error: no Google Play service-account key found. Run play-store-access operation: setup." >&2
145
+ exit 1
146
+ }
147
+ [ -n "$PACKAGE_NAME" ] || {
148
+ echo "Error: Google Play package name is not configured. Set playStore.packageName or pass package:<name>." >&2
149
+ exit 1
150
+ }
151
+
152
+ ACCESS_TOKEN="$(
153
+ SERVICE_ACCOUNT_JSON="$SERVICE_ACCOUNT_JSON" node <<'NODE'
154
+ const crypto = require("crypto");
155
+
156
+ const account = JSON.parse(process.env.SERVICE_ACCOUNT_JSON);
157
+ const now = Math.floor(Date.now() / 1000);
158
+ const encode = (value) =>
159
+ Buffer.from(JSON.stringify(value)).toString("base64url");
160
+ const unsigned = `${encode({ alg: "RS256", typ: "JWT" })}.${encode({
161
+ iss: account.client_email,
162
+ scope: "https://www.googleapis.com/auth/androidpublisher",
163
+ aud: "https://oauth2.googleapis.com/token",
164
+ iat: now,
165
+ exp: now + 3600,
166
+ })}`;
167
+ const signature = crypto
168
+ .createSign("RSA-SHA256")
169
+ .update(unsigned)
170
+ .sign(account.private_key, "base64url");
171
+
172
+ fetch("https://oauth2.googleapis.com/token", {
173
+ method: "POST",
174
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
175
+ body: new URLSearchParams({
176
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
177
+ assertion: `${unsigned}.${signature}`,
178
+ }),
179
+ })
180
+ .then(async (response) => {
181
+ const body = await response.json();
182
+ if (!response.ok) {
183
+ throw new Error(JSON.stringify(body));
184
+ }
185
+ process.stdout.write(body.access_token);
186
+ })
187
+ .catch((error) => {
188
+ console.error(`Error: failed to obtain Google Play access token: ${error.message}`);
189
+ process.exit(1);
190
+ });
191
+ NODE
192
+ )"
193
+
194
+ BASE="https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${PACKAGE_NAME}"
195
+ EDIT_ID="$(curl -fsS -X POST "${BASE}/edits" -H "Authorization: Bearer ${ACCESS_TOKEN}" | jq -r '.id')"
196
+ trap 'curl -fsS -X DELETE "${BASE}/edits/${EDIT_ID}" -H "Authorization: Bearer ${ACCESS_TOKEN}" >/dev/null 2>&1 || true' EXIT
197
+
198
+ curl -fsS "${BASE}/edits/${EDIT_ID}/tracks/${TRACK}" \
199
+ -H "Authorization: Bearer ${ACCESS_TOKEN}" |
200
+ jq --arg packageName "$PACKAGE_NAME" \
201
+ '{packageName: $packageName, track: .track, releases: (.releases // [])}'
202
+ ```
203
+
204
+ Interpretation:
205
+
206
+ - `status: completed` means the release is live on that track.
207
+ - `status: inProgress` means staged rollout; inspect `userFraction`.
208
+ - `status: halted` means rollout was stopped.
209
+ - `status: draft` means it is not live.
210
+ - Absence of a production release means the app has no watchable production-track
211
+ release through the API.
212
+
213
+ Known limitation: androidpublisher exposes track/release state well, but
214
+ pre-publication review state is coarse. The reliable watchable signal is whether
215
+ the target version is live on the production track.
216
+
217
+ ## Error Handling
218
+
219
+ - `401` / token exchange failure: service-account key is invalid or expired.
220
+ - `403`: service account is not invited to the Play Console app or lacks read
221
+ access to releases.
222
+ - `404`: package name is wrong, the app is not created in Play Console, or the
223
+ service account has no access to that app.
224
+
225
+ In all cases, report the package name and credential source used, but never print
226
+ private key material or the access token.
@@ -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
+ }