โพสต์ภาพรวมของ lint และ formatter เล่าไว้ว่าเครื่องมือแต่ละตัวทำอะไร และเราให้ Claude Code จัดไฟล์ทันทีหลังแก้ด้วย hook โพสต์นี้เป็นอีกชั้นหนึ่ง คือให้ CI ตรวจซ้ำตอนเปิด PR เผื่อไฟล์ไหนหลุดมา
ตรวจทั้ง repo แล้วแดงทุก PR
โปรเจกต์ที่เขียนมาหลายปีแล้วเพิ่งเริ่มใช้ Pint ถ้าสั่ง pint --test ทั้ง repo ใน CI จะเจอไฟล์ที่ไม่ผ่านเป็นร้อย PR ที่แก้แค่สามไฟล์ก็แดงด้วยไฟล์ที่คนเปิด PR ไม่ได้แตะเลย สุดท้ายทีมก็เลิกดูผล
จะสั่ง Pint จัดทั้ง repo ใน commit เดียวก็ได้ แต่ diff ก้อนใหญ่ทำให้ git blame อ่านยาก และชนกับ branch ที่คนอื่นทำค้างอยู่ อีกทางคือตรวจเฉพาะไฟล์ที่ PR แก้ ไฟล์ไหนมีคนแตะก็ต้องผ่าน ไฟล์ที่เหลือค่อย ๆ สะอาดขึ้นเองตามงานที่เข้ามา
workflow นี้รันเฉพาะ pull request
name: pr-lint
on:
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: pr-lint-${{ github.event.pull_request.number }}
cancel-in-progress: true
- ไม่รันตอน push เพราะ CI หลักรัน static analysis ทั้งโปรเจกต์อยู่แล้ว (GitHub Actions สำหรับ Laravel)
permissions: contents: readให้ token ของ job อ่าน repo ได้อย่างเดียว workflow นี้ไม่ต้องเขียนอะไรกลับ- concurrency ต่อ PR push เพิ่มเข้า PR เดิมแล้วรอบเก่ายกเลิกเอง
หาไฟล์ที่ PR แก้ด้วย git diff
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: List changed files
id: changed
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
php_files=() js_files=()
while IFS= read -r file; do
case "$file" in
_ide_helper*.php|.phpstorm.meta.php) ;;
*.php) php_files+=("$file") ;;
resources/js/*.js|resources/js/*.jsx) js_files+=("$file") ;;
esac
done < <(git diff --name-only --diff-filter=ACMR "$BASE_SHA" "$HEAD_SHA")
echo "php=${php_files[*]}" >> "$GITHUB_OUTPUT"
echo "js=${js_files[*]}" >> "$GITHUB_OUTPUT"
fetch-depth: 0ดึงประวัติทั้งหมด ค่าเริ่มต้นของactions/checkoutดึงมา commit เดียวgit diffจะหา base ไม่เจอ--diff-filter=ACMRเอาเฉพาะไฟล์ที่เพิ่ม คัดลอก แก้ หรือเปลี่ยนชื่อ ไม่เอาไฟล์ที่ลบ ถ้าส่งชื่อไฟล์ที่ไม่มีแล้วให้ Pint ก็จะ error- ข้ามไฟล์ที่ generate เอง อย่าง
_ide_helper.phpเครื่องมือสร้างไฟล์นี้ใหม่ทุกครั้งอยู่แล้ว ไม่มีประโยชน์ที่จะตรวจรูปแบบ - อ่าน SHA ผ่าน
env:แทนการเขียน${{ }}ไว้ในคำสั่งตรง ๆ เหตุผลอยู่ในหัวข้อถัดไป
ส่งรายชื่อไฟล์ผ่าน env ไม่ใช่ ${{ }}
GitHub แทนค่า ${{ ... }} ลงในข้อความของ script ก่อนส่งให้ bash รัน ค่าที่แทนลงไปจึงกลายเป็นโค้ด shell ส่วนชื่อไฟล์มาจากคนเปิด PR ไฟล์ชื่อ $(curl …).php ก็ commit ได้ ถ้าเขียนแบบนี้
# อย่าเขียนแบบนี้ ชื่อไฟล์จะกลายเป็นคำสั่ง shell
run: vendor/bin/pint --test ${{ steps.changed.outputs.php }}
ส่งผ่าน environment variable แทน bash จะเห็นเป็นข้อความธรรมดา ไม่รันสิ่งที่อยู่ในนั้น
- name: Pint
if: steps.changed.outputs.php != ''
env:
PHP_FILES: ${{ steps.changed.outputs.php }}
run: vendor/bin/pint --test $PHP_FILES
$PHP_FILES ไม่ใส่เครื่องหมายคำพูด เพราะอยากให้ bash แยกเป็นหลายไฟล์ตามช่องว่าง ข้อเสียคือชื่อไฟล์ที่มีช่องว่างจะแตกเป็นสองชื่อ ในโปรเจกต์ Laravel แทบไม่มีไฟล์แบบนั้น ถ้ามีก็ใช้ xargs -0 กับรายชื่อที่คั่นด้วย null แทน
เอกสารของ GitHub เรียกปัญหานี้ว่า script injection และแนะนำวิธีเดียวกันกับทุกค่าที่คนนอกกำหนดได้ เช่น ชื่อ branch และชื่อ PR
ข้ามเครื่องมือที่ไม่มีไฟล์ให้ตรวจ
PR ที่แก้แค่ไฟล์ PHP ไม่ต้องติดตั้ง Node และ PR ที่แก้แค่ JavaScript ก็ไม่ต้องรัน composer install เราจึงใส่ if: ไว้ทุกขั้น
- uses: shivammathur/setup-php@v2
if: steps.changed.outputs.php != ''
with:
php-version: '8.5'
- name: Install PHP dependencies
if: steps.changed.outputs.php != ''
run: composer install --no-interaction --prefer-dist --no-progress --no-scripts
- name: Rector
if: steps.changed.outputs.php != ''
env:
PHP_FILES: ${{ steps.changed.outputs.php }}
run: vendor/bin/rector process --dry-run --no-progress-bar --output-format=github $PHP_FILES
- name: ESLint
if: steps.changed.outputs.js != ''
env:
JS_FILES: ${{ steps.changed.outputs.js }}
run: npx eslint $JS_FILES
- name: Prettier
if: steps.changed.outputs.js != ''
env:
JS_FILES: ${{ steps.changed.outputs.js }}
run: npx prettier --check $JS_FILES
--no-scripts ข้าม script ใน composer.json อย่าง package:discover workflow นี้ไม่ต้องบูตแอป จึงไม่ต้องมี .env หรือฐานข้อมูล ส่วนขั้นติดตั้ง Node กับ npm ci ก็ใส่ if: steps.changed.outputs.js != '' แบบเดียวกัน
ทุกตัวรันในโหมดตรวจอย่างเดียว ไม่แก้ไฟล์ให้
| เครื่องมือ | โหมดตรวจ |
|---|---|
| Pint | --test |
| Rector | process --dry-run |
| ESLint | ค่าเริ่มต้น ไม่ใส่ --fix |
| Prettier | --check |
ถ้าไม่ผ่าน คนเปิด PR รันคำสั่งเดียวกันโดยไม่ใส่ flag โหมดตรวจในเครื่องตัวเอง แล้ว push ไฟล์ที่จัดแล้วขึ้นไป
ให้ผลไปขึ้นที่บรรทัดใน PR
ถ้า lint ไม่ผ่าน คนเปิด PR ต้องเปิด log ของ job มาไล่หาว่าไฟล์ไหนพัง ถ้าให้ผลไปขึ้นเป็น annotation ในหน้า Files changed ของ PR ตรงบรรทัดที่มีปัญหาเลย จะเห็นง่ายกว่ามาก
- Rector มี
--output-format=githubในตัว สั่งrector process --dry-run --output-format=githubแล้ว GitHub จะแสดงผลตรงไฟล์ที่ Rector อยากแก้ - ESLint ไม่ต้องตั้งอะไรเพิ่ม
actions/setup-nodeลงทะเบียน problem matcher ของ ESLint ไว้แล้ว ผลแบบปกติของ ESLint จึงขึ้นเป็น annotation ให้เอง - Pint ยังไม่มีรูปแบบผลของ GitHub (
--format=githubขึ้นว่า not supported) แต่ log ก็บอกชื่อไฟล์และกฎที่ไม่ผ่านไว้ชัดอยู่แล้ว
ไฟล์เก่าที่ PR แตะเป็นครั้งแรก
PR ที่แก้บรรทัดเดียวในไฟล์เก่าก็ต้องจัดทั้งไฟล์ให้ผ่าน diff จึงมีบรรทัดที่เปลี่ยนแค่ช่องว่างปนอยู่กับงานจริง ทางที่ทำให้รีวิวง่ายคือแยกเป็นสอง commit ใน PR เดียวกัน commit แรกจัดรูปแบบอย่างเดียว commit ที่สองค่อยเป็นงานจริง คนรีวิวจะข้าม commit แรกไปได้เลย
ถ้าวันหนึ่งตัดสินใจจัดทั้ง repo ใน commit เดียว ให้ใส่ hash ของ commit นั้นไว้ในไฟล์ .git-blame-ignore-revs ที่ root ของ repo หน้า blame ของ GitHub จะข้าม commit นั้นให้ ส่วนในเครื่องตั้ง git config blame.ignoreRevsFile .git-blame-ignore-revs ครั้งเดียว git blame ก็จะยังชี้ไปที่คนที่เขียนบรรทัดนั้นจริง
PHPStan ยังรันทั้งโปรเจกต์
PHPStan ไม่อยู่ใน workflow นี้ เพราะไฟล์ที่ PR ไม่ได้แตะอาจพังจากไฟล์ที่แตะก็ได้ เช่น เปลี่ยนชนิดที่เมธอดคืนค่า แล้วไฟล์ที่เรียกเมธอดนั้นใช้ค่าผิดชนิด ถ้าตรวจแค่ไฟล์ที่แก้ก็จะไม่เห็น PHPStan จึงรันทั้งโปรเจกต์ใน CI หลัก และใช้ baseline รับ error เก่าที่มีอยู่แล้ว แทนการตรวจเฉพาะไฟล์
ให้ AI agent ตรวจชุดเดียวกันก่อน push
ถ้าให้ Claude Code หรือ agent ตัวอื่นทำงานใน branch แล้วเปิด PR ก็ให้ agent ตรวจไฟล์ชุดเดียวกับที่ CI จะตรวจก่อน push ได้ Pint มี flag สำหรับเรื่องนี้อยู่แล้ว
vendor/bin/pint --test --diff=main
--diff=main ตรวจเฉพาะไฟล์ที่เปลี่ยนตั้งแต่แยกจาก main ส่วน --dirty ตรวจเฉพาะไฟล์ที่ยังไม่ commit Rector, ESLint และ Prettier ไม่มี flag แบบนี้ ก็ส่งรายชื่อจาก git diff --name-only main... ให้เหมือนใน workflow
เขียนคำสั่งนี้ไว้ใน CLAUDE.md หรือ AGENTS.md ว่าให้รันก่อน push agent จะเจอปัญหาเองตั้งแต่ในเครื่อง ไม่ต้องรอ CI แดงแล้วค่อยกลับมาแก้ครับ






