-
Notifications
You must be signed in to change notification settings - Fork 20
fix(lint): lint-skill.sh recognizes read as a variable binding, not just VAR= #2490
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
6916b71
fix(lint): lint-skill.sh recognizes `read` as a variable binding, not…
carlos-alm 227c8db
fix(lint): restrict read-binding scan to actual destination operands
carlos-alm c0a2df8
fix(lint): strip -i (readline initial text) from read-binding scan too
carlos-alm 48e5dc7
fix(lint): handle combined short-flag clusters ending in a value-taki…
carlos-alm 072890d
fix(lint): replace per-line subprocess pipeline with pure bash builtins
carlos-alm File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| /** | ||
| * Regression test for issue #2344: `lint-skill.sh`'s cross-fence variable | ||
| * check (Pattern 1) only recognised `VAR=value` as a variable assignment. | ||
| * `read`/`read -r VAR1 VAR2 ...` binds variables just as validly, but with | ||
| * no `=` after the name — e.g. `while IFS=$'\t' read -r F COUNT LINE; do`. | ||
| * | ||
| * `fixer/SKILL.md`'s own I4 integrity check does exactly this: a loop-local | ||
| * `$COUNT` (the per-line expected occurrence count, bound via `read`) that | ||
| * collides in name only with the unrelated batch-size `$COUNT` set up in | ||
| * Phase 0. Since each ```bash fence is a separate bash invocation, there is | ||
| * no real collision at runtime — but the linter didn't know the later block | ||
| * had its own fresh `$COUNT` via `read`, so it flagged the block's own local | ||
| * variable as if it were a stale reference leaking from Phase 0. | ||
| */ | ||
| import { execFileSync } from 'node:child_process'; | ||
| import fs from 'node:fs'; | ||
| import os from 'node:os'; | ||
| import path from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| const REPO_ROOT = path.resolve(__dirname, '..', '..'); | ||
| const LINT_SCRIPT = path.join( | ||
| REPO_ROOT, | ||
| '.claude', | ||
| 'skills', | ||
| 'create-skill', | ||
| 'scripts', | ||
| 'lint-skill.sh', | ||
| ); | ||
|
|
||
| // lint-skill.sh requires bash 4+ (associative arrays). macOS ships bash 3.2 | ||
| // as the `bash` on PATH, including on GitHub Actions' macos-latest runner — | ||
| // resolve a real bash 4+ explicitly rather than assuming plain "bash" works. | ||
| function resolveBash4(): string | null { | ||
| const candidates = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', 'bash']; | ||
| for (const candidate of candidates) { | ||
| try { | ||
| const version = execFileSync(candidate, ['--version'], { encoding: 'utf8' }); | ||
| const match = version.match(/version (\d+)\./); | ||
| if (match && Number(match[1]) >= 4) return candidate; | ||
| } catch { | ||
| // candidate not on PATH — try the next one | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| const BASH4 = resolveBash4(); | ||
|
|
||
| const FRONTMATTER = `--- | ||
| name: lint-test-skill | ||
| description: test | ||
| argument-hint: none | ||
| allowed-tools: Bash | ||
| --- | ||
|
|
||
| ## Phase 0 | ||
|
|
||
| pre-flight | ||
| `; | ||
|
|
||
| // Unlike the mktemp test's single-block helper, this bug is inherently | ||
| // cross-block: it only reproduces across two or more separate \`\`\`bash | ||
| // fences (each its own bash invocation), so the fixture builder takes a | ||
| // list of blocks rather than one. | ||
| function runLint(bashBlocks: string[]): { stdout: string; ranSuccessfully: boolean } { | ||
| const blocksMarkdown = bashBlocks.map((b) => `\`\`\`bash\n${b}\n\`\`\`\n`).join('\n'); | ||
| const content = `${FRONTMATTER}\n${blocksMarkdown}\n## Rules\n\nrules here\n\n## Examples\n\nexample here\n\n**Exit condition:** done\n`; | ||
| const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lint-skill-read-')); | ||
| const skillPath = path.join(dir, 'SKILL.md'); | ||
| fs.writeFileSync(skillPath, content); | ||
| try { | ||
| const result = execFileSync(BASH4!, [LINT_SCRIPT, skillPath], { encoding: 'utf8' }); | ||
| return { stdout: result, ranSuccessfully: true }; | ||
| } catch (err) { | ||
| const e = err as { stdout?: string }; | ||
| const stdout = e.stdout ?? ''; | ||
| return { stdout, ranSuccessfully: /lint-skill: \d+ error/.test(stdout) }; | ||
| } finally { | ||
| fs.rmSync(dir, { recursive: true, force: true }); | ||
| } | ||
| } | ||
|
|
||
| describe.skipIf(BASH4 === null)( | ||
| 'lint-skill.sh recognises `read` as a variable binding, not just VAR= (#2344)', | ||
| () => { | ||
| it('does not flag a var re-bound via `read` and referenced in the same later block', () => { | ||
| const { stdout, ranSuccessfully } = runLint([ | ||
| 'COUNT=5\nprintf \'%s\\n\' "$COUNT" > .codegraph/count', | ||
| [ | ||
| "while IFS=$'\\t' read -r F COUNT LINE; do", | ||
| ' if [ "$LINE" != "" ] && [ 0 -lt "$COUNT" ]; then', | ||
| ' echo "seen $COUNT for $F"', | ||
| ' fi', | ||
| 'done < .codegraph/data.tsv', | ||
| ].join('\n'), | ||
| ]); | ||
| expect(ranSuccessfully).toBe(true); | ||
| expect(stdout).not.toContain('Cross-fence variable: $COUNT'); | ||
| }); | ||
|
|
||
| it('still flags a genuine cross-fence leak (no read-rebinding in the referencing block)', () => { | ||
| const { stdout, ranSuccessfully } = runLint(['FOO=hello', 'echo "leak: $FOO"']); | ||
| expect(ranSuccessfully).toBe(true); | ||
| expect(stdout).toContain( | ||
| 'Cross-fence variable: $FOO assigned in bash block 1, referenced in block 2', | ||
| ); | ||
| }); | ||
|
|
||
| it('still flags a reference in a block whose own `read` binds a different variable', () => { | ||
| const { stdout, ranSuccessfully } = runLint([ | ||
| 'FOO=hello', | ||
| 'read -r OTHER < .codegraph/other\necho "leak: $FOO"', | ||
| ]); | ||
| expect(ranSuccessfully).toBe(true); | ||
| expect(stdout).toContain( | ||
| 'Cross-fence variable: $FOO assigned in bash block 1, referenced in block 2', | ||
| ); | ||
| }); | ||
|
|
||
| // Greptile review on PR #2490: the read-binding scan must not treat every | ||
| // uppercase token after `read` as a destination — a prompt string, or a | ||
| // value-taking flag's own argument, can contain an unrelated uppercase | ||
| // word that is not actually being bound. | ||
| it('still flags a leak past a `read -p` prompt string containing an unrelated uppercase word', () => { | ||
| const { stdout, ranSuccessfully } = runLint([ | ||
| 'FOO=hello', | ||
| 'read -p "Enter FOO value: " BAR\necho "leak: $FOO"', | ||
| ]); | ||
| expect(ranSuccessfully).toBe(true); | ||
| expect(stdout).toContain( | ||
| 'Cross-fence variable: $FOO assigned in bash block 1, referenced in block 2', | ||
| ); | ||
| }); | ||
|
|
||
| it('still flags a leak past a value-taking flag argument that is an uppercase word', () => { | ||
| const { stdout, ranSuccessfully } = runLint([ | ||
| 'FOO=hello', | ||
| 'read -u FOO BAR\necho "leak: $FOO"', | ||
| ]); | ||
| expect(ranSuccessfully).toBe(true); | ||
| expect(stdout).toContain( | ||
| 'Cross-fence variable: $FOO assigned in bash block 1, referenced in block 2', | ||
| ); | ||
| }); | ||
|
|
||
| // Greptile round 2 on PR #2490: `-i` (readline initial text) is a | ||
| // value-taking flag too, and was missing from the first fix's flag list. | ||
| it('still flags a leak past a `read -i` initial-text argument that is an uppercase word', () => { | ||
| const { stdout, ranSuccessfully } = runLint([ | ||
| 'FOO=hello', | ||
| 'read -e -i FOO BAR\necho "leak: $FOO"', | ||
| ]); | ||
| expect(ranSuccessfully).toBe(true); | ||
| expect(stdout).toContain( | ||
| 'Cross-fence variable: $FOO assigned in bash block 1, referenced in block 2', | ||
| ); | ||
| }); | ||
|
|
||
| it('does not flag `-a` (its argument is a genuine array destination, not a value)', () => { | ||
| const { stdout, ranSuccessfully } = runLint(['read -a ARR <<< "one two three"']); | ||
| expect(ranSuccessfully).toBe(true); | ||
| expect(stdout).not.toContain('Cross-fence variable: $ARR'); | ||
| }); | ||
|
|
||
| // Greptile round 3 on PR #2490: a value-taking flag combined into a | ||
| // multi-letter cluster (e.g. `-ei`, where `-e` takes no argument but | ||
| // the trailing `-i` does) wasn't recognized — only a standalone | ||
| // single-letter flag was. | ||
| it('still flags a leak past a combined short-flag cluster ending in a value-taking flag', () => { | ||
| const { stdout, ranSuccessfully } = runLint([ | ||
| 'FOO=hello', | ||
| 'read -ei FOO BAR\necho "leak: $FOO"', | ||
| ]); | ||
| expect(ranSuccessfully).toBe(true); | ||
| expect(stdout).toContain( | ||
| 'Cross-fence variable: $FOO assigned in bash block 1, referenced in block 2', | ||
| ); | ||
| }); | ||
|
|
||
| it('does not flag a combined cluster ending in `-a` (still a genuine array destination)', () => { | ||
| const { stdout, ranSuccessfully } = runLint(['read -ra ARR <<< "one two three"']); | ||
| expect(ranSuccessfully).toBe(true); | ||
| expect(stdout).not.toContain('Cross-fence variable: $ARR'); | ||
| }); | ||
| }, | ||
| ); | ||
|
|
||
| describe.skipIf(BASH4 === null)('.claude/skills/fixer/SKILL.md itself (#2344 repro)', () => { | ||
| // fixer/SKILL.md is by far the largest SKILL.md in the repo (~1600 lines, | ||
| // ~1000 non-comment bash-block lines), and Check 1 forks a handful of | ||
| // subprocesses per line. That's a few seconds on macOS/Linux but can blow | ||
| // past the 30s default testTimeout on Windows CI, where process creation | ||
| // is ~100x slower (see this script's own header comment) — this is the | ||
| // first test to run lint-skill.sh against this specific file directly, so | ||
| // it's the first to hit that pre-existing characteristic head-on. | ||
| it('passes lint-skill.sh with no Cross-fence $COUNT error', { timeout: 120_000 }, () => { | ||
| const realSkillPath = path.join(REPO_ROOT, '.claude', 'skills', 'fixer', 'SKILL.md'); | ||
| const stdout = execFileSync(BASH4!, [LINT_SCRIPT, realSkillPath], { encoding: 'utf8' }); | ||
| expect(stdout).not.toContain('Cross-fence variable: $COUNT'); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.