-
Notifications
You must be signed in to change notification settings - Fork 102
215 lines (193 loc) · 8.98 KB
/
Copy pathci.yml
File metadata and controls
215 lines (193 loc) · 8.98 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
name: CI
on:
push:
branches: [main, dev]
# Run on every PR regardless of base branch. The `branches` filter on
# pull_request only matches base, so stacked / long-lived branches
# (e.g. `optimizations`) would otherwise skip the whole CI job.
pull_request:
permissions:
contents: read
pull-requests: write
jobs:
duplication:
# Code-duplication regression guard. Pulled out of the `test` job so
# the PR checks table shows a dedicated pass/fail row — reviewers see
# at a glance whether the change introduced duplicated code without
# having to open the combined "Typecheck and Test" log.
name: Duplication check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm install
- name: Run jscpd
# Threshold 7% is the current baseline (see .jscpd.json). The job
# fails if a future change pushes duplication above it, so the
# number is a regression guard — reviewers can see the exact
# clones in the markdown report uploaded below.
run: npm run dup
- name: Upload jscpd report
if: always()
uses: actions/upload-artifact@v4
with:
name: jscpd-report
path: jscpd-report/
if-no-files-found: ignore
test:
name: Typecheck and Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Full history so the "Build PR coverage comment" step can do
# `git diff origin/<base>...HEAD` to detect touched src/ files.
# Default shallow checkout (depth=1) produces "no merge base".
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm install
- name: Typecheck
run: npm run typecheck
- name: Run tests with coverage
# Per-file 80% thresholds for PR #60 files are declared in
# vitest.config.ts under `coverage.thresholds`. Vitest exits non-zero
# if any of them regress below 80%, which fails the job.
run: npx vitest run --coverage
- name: Write coverage summary to job page
if: always()
run: |
if [ -f coverage/coverage-summary.json ]; then
echo "### Test Coverage (overall)" >> $GITHUB_STEP_SUMMARY
node -e "
const c = require('./coverage/coverage-summary.json').total;
const fmt = (v) => v.pct.toFixed(1) + '%';
console.log('| Metric | Coverage |');
console.log('|--------|----------|');
console.log('| Statements | ' + fmt(c.statements) + ' |');
console.log('| Branches | ' + fmt(c.branches) + ' |');
console.log('| Functions | ' + fmt(c.functions) + ' |');
console.log('| Lines | ' + fmt(c.lines) + ' |');
" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### PR-tracked files (must stay ≥ 80 %)" >> $GITHUB_STEP_SUMMARY
node -e "
const summary = require('./coverage/coverage-summary.json');
const tracked = [
'src/shell/grep-core.ts',
'src/shell/grep-interceptor.ts',
'src/hooks/grep-direct.ts',
];
console.log('| File | Stmts | Branch | Funcs | Lines |');
console.log('|------|------:|-------:|------:|------:|');
const fmt = v => v == null ? '—' : v.toFixed(1) + '%';
for (const rel of tracked) {
const key = Object.keys(summary).find(k => k.endsWith(rel));
const c = key ? summary[key] : null;
if (!c) { console.log('| \`' + rel + '\` | — | — | — | — |'); continue; }
console.log('| \`' + rel + '\` | ' + fmt(c.statements.pct) + ' | ' + fmt(c.branches.pct) + ' | ' + fmt(c.functions.pct) + ' | ' + fmt(c.lines.pct) + ' |');
}
" >> $GITHUB_STEP_SUMMARY
fi
- name: Build PR coverage comment
if: github.event_name == 'pull_request' && always()
id: pr-coverage
continue-on-error: true
env:
BASE_REF: ${{ github.base_ref }}
run: |
if [ ! -f coverage/coverage-summary.json ]; then
echo "no coverage summary — skipping PR comment"
echo "body-file=" >> "$GITHUB_OUTPUT"
exit 0
fi
node <<'NODE' > /tmp/pr-coverage.md
const { execSync } = require('node:child_process');
const fs = require('node:fs');
const summary = JSON.parse(fs.readFileSync('coverage/coverage-summary.json', 'utf8'));
const baseRef = process.env.BASE_REF || 'main';
const diff = execSync(`git diff --name-only origin/${baseRef}...HEAD`, { encoding: 'utf8' });
const changed = diff.split('\n')
.filter(f => f.startsWith('src/') && f.endsWith('.ts') && !f.endsWith('.test.ts') && !f.endsWith('.d.ts'))
.sort();
// Aggregate totals across ONLY the PR-touched files. Same shape
// as the davelosert "Coverage Report" box, but scoped to this PR
// instead of the whole src/ tree.
const agg = {
statements: { total: 0, covered: 0 },
branches: { total: 0, covered: 0 },
functions: { total: 0, covered: 0 },
lines: { total: 0, covered: 0 },
};
const perFile = [];
for (const f of changed) {
const key = Object.keys(summary).find(k => k.endsWith(f));
if (!key) { perFile.push({ f, c: null }); continue; }
const c = summary[key];
for (const k of Object.keys(agg)) {
agg[k].total += c[k].total;
agg[k].covered += c[k].covered;
}
perFile.push({ f, c });
}
const THRESHOLD = 90;
const pct = (m) => m.total === 0 ? null : (m.covered / m.total) * 100;
const icon = (p) => p == null ? '⚪' : (p >= THRESHOLD ? '🟢' : '🔴');
const fmtPct = (p) => p == null ? '—' : p.toFixed(2) + '%';
const fmtCell = (c, k) => c ? ((c[k].pct >= THRESHOLD ? '🟢 ' : '🔴 ') + c[k].pct.toFixed(1) + '%') : '—';
const out = [];
out.push('## Coverage Report');
out.push('');
if (changed.length === 0) {
out.push('_No `src/*.ts` files changed in this PR._');
} else {
out.push('Scope: files changed in this PR. Enforced threshold: **' + THRESHOLD + '%** per metric (per file via `vitest.config.ts`).');
out.push('');
out.push('| Status | Category | Percentage | Covered / Total |');
out.push('|--------|----------|-----------:|----------------:|');
for (const [label, key] of [["Lines","lines"],["Statements","statements"],["Functions","functions"],["Branches","branches"]]) {
const p = pct(agg[key]);
out.push(`| ${icon(p)} | ${label} | ${fmtPct(p)} (🎯 ${THRESHOLD}%) | ${agg[key].covered} / ${agg[key].total} |`);
}
out.push('');
// File-level breakdown inside a <details> dropdown so a PR that
// touches dozens/hundreds of files does not produce an endless
// comment. Summary text shows the file count so you can see at
// a glance how much is inside before expanding.
out.push('<details>');
out.push(`<summary><strong>File Coverage</strong> — ${perFile.length} file${perFile.length === 1 ? '' : 's'} changed</summary>`);
out.push('');
out.push('| File | Stmts | Branches | Functions | Lines |');
out.push('|------|------:|---------:|----------:|------:|');
for (const { f, c } of perFile) {
out.push(`| \`${f}\` | ${fmtCell(c, 'statements')} | ${fmtCell(c, 'branches')} | ${fmtCell(c, 'functions')} | ${fmtCell(c, 'lines')} |`);
}
out.push('');
out.push('</details>');
}
out.push('');
out.push(`<sub>Generated for commit ${(process.env.GITHUB_SHA || '?').slice(0,7)}.</sub>`);
console.log(out.join('\n'));
NODE
echo "body-file=/tmp/pr-coverage.md" >> "$GITHUB_OUTPUT"
- name: Post coverage comment on PR
if: github.event_name == 'pull_request' && always() && steps.pr-coverage.outputs.body-file != ''
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pr-coverage-report
path: /tmp/pr-coverage.md
- name: Build bundles
run: npm run build
- name: Verify bundle/ directory is up to date
run: |
git diff --exit-code claude-code/bundle/ || {
echo "::error::bundle/ is out of date. Run 'npm run build' and commit the bundle/ directory."
exit 1
}