Skip to content

Commit dc7b40a

Browse files
authored
1.2.0: feed measured coverage to the fallow-health report (#12)
## Summary `report --coverage <path>` (or `report.coverage` in `.code-quality.yml`; the flag wins) gives Fallow an Istanbul `coverage-final.json` so the advisory CRAP values use measured coverage instead of the 0 % estimate. The blocking gate does not change. - `src/report/coverage.ts`: repository-relative path validation, directory form, `realpath` containment, regular file + 256 MiB cap, Istanbul shape check (`path`/`s`/`f`/`fnMap`), `--coverage-root` derived by scoring ancestor prefixes over the sampled keys (order independent, longest prefix wins ties). - fallow-health: `--coverage`/`--coverage-root`, exit 2 accepted only with coverage, `coverage:` errors get an Istanbul hint, the coverage directory is excluded from Fallow scans unless it overlaps a configured path (then only the file is excluded). - Unmatched coverage paths → one notice, CRAP stays estimated, exit 0. Missing / malformed / raw V8 input → exit 1 before any tool runs. - `quality.yml`: opt-in `report` and `coverage-artifact` inputs; `Validate coverage artifact` before `Check`; `Download coverage`, `Report`, `Upload quality reports` gated on step outcomes. - `report.coverage` is kept out of `configHash`; `check` untouched. - `scripts/integration.ts`: `ts-project report --coverage` row against a committed Istanbul fixture asserting `istanbul_files_matched >= 1`. - Docs: README report paragraph, config row, workflow inputs and "Coverage for CRAP" (same-job and artifact patterns), quality-gate.md. Version 1.2.0. ## Test plan - [x] tsc, oxlint, vitest (59 files / 532 tests), build, actionlint, dogfood `check` PASS - [x] `node scripts/integration.ts` against the branch image: 14/14 rows ok including `report --coverage` - [x] launcher + branch image on a fixture copy: matched 1/1 with a `/home/runner/work/...` prefix, dir-form via config, absolute/missing/raw-V8 → exit 1, unmatched → notice + exit 0 - [ ] after merge: push `v1.2.0`, approve the staged npm version
2 parents ffaa252 + bf55328 commit dc7b40a

25 files changed

Lines changed: 1005 additions & 78 deletions

File tree

.github/workflows/quality.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ on:
1919
description: Consumer working directory inside /work
2020
type: string
2121
default: "."
22+
report:
23+
description: Run code-quality report after check and upload artifacts/quality
24+
type: boolean
25+
default: false
26+
coverage-artifact:
27+
description: Artifact holding coverage/coverage-final.json for the fallow-health report
28+
type: string
29+
default: ""
2230

2331
permissions:
2432
contents: read
@@ -52,11 +60,51 @@ jobs:
5260
echo "Invalid checks input: only lowercase check ids separated by spaces are allowed" >&2
5361
exit 1
5462
fi
63+
- name: Validate coverage artifact
64+
id: validate-coverage
65+
if: ${{ inputs.report && inputs.coverage-artifact != '' }}
66+
env:
67+
CODE_QUALITY_COVERAGE_ARTIFACT: ${{ inputs.coverage-artifact }}
68+
shell: bash
69+
run: |
70+
if ! printf '%s' "$CODE_QUALITY_COVERAGE_ARTIFACT" | grep -Eq '^[A-Za-z0-9._-]+$'; then
71+
echo "Invalid coverage artifact input" >&2
72+
exit 1
73+
fi
5574
- name: Check
75+
id: check
5676
working-directory: ${{ inputs.working-directory }}
5777
env:
5878
CODE_QUALITY_CHECKS: ${{ inputs.checks }}
5979
shell: bash
6080
run: |
6181
read -ra CHECKS <<< "$CODE_QUALITY_CHECKS"
6282
code-quality check "${CHECKS[@]}"
83+
- name: Download coverage
84+
id: download-coverage
85+
if: ${{ inputs.report && inputs.coverage-artifact != '' && !cancelled() && steps.validate-coverage.outcome == 'success' }}
86+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
87+
with:
88+
name: ${{ inputs.coverage-artifact }}
89+
path: ${{ inputs.working-directory }}/coverage
90+
- name: Report
91+
id: report
92+
if: ${{ inputs.report && !cancelled() && steps.check.outcome != 'skipped' && steps.validate-coverage.outcome != 'failure' && (inputs.coverage-artifact == '' || steps.download-coverage.outcome == 'success') }}
93+
working-directory: ${{ inputs.working-directory }}
94+
env:
95+
CODE_QUALITY_COVERAGE_ARTIFACT: ${{ inputs.coverage-artifact }}
96+
shell: bash
97+
run: |
98+
if [[ -n "$CODE_QUALITY_COVERAGE_ARTIFACT" ]]; then
99+
code-quality report --coverage coverage/coverage-final.json
100+
else
101+
code-quality report
102+
fi
103+
- name: Upload quality reports
104+
if: ${{ inputs.report && !cancelled() && steps.report.outcome != 'skipped' }}
105+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
106+
with:
107+
name: quality-reports
108+
path: ${{ inputs.working-directory }}/artifacts/quality
109+
if-no-files-found: warn
110+
retention-days: 14

README.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,19 +120,24 @@ The caller can provide the supported inputs. For example, this selects checks, i
120120
| `checks` | string | empty | Space-separated lowercase check IDs; empty runs all detected checks |
121121
| `setup` | string | empty | Consumer shell command run before checking |
122122
| `working-directory` | string | `.` | Consumer working directory inside `/work` |
123+
| `report` | boolean | `false` | Run the advisory report after the check and upload `artifacts/quality` |
124+
| `coverage-artifact` | string | empty | Artifact containing `coverage/coverage-final.json` for Fallow health |
123125

124126
```yaml
125127
jobs:
126128
quality:
129+
needs: test
127130
uses: Runroom/code-quality/.github/workflows/quality.yml@v1
128131
with:
129132
checks: "complexity duplication unused"
130133
setup: composer install --no-interaction
131134
image-tag: v1
132135
working-directory: "."
136+
report: true
137+
coverage-artifact: test-coverage
133138
```
134139

135-
The reusable job checks out the full history, runs the optional setup command, and invokes plain `code-quality check`. Failing findings produce GitHub annotations, and the job summary is written to `GITHUB_STEP_SUMMARY`. It never passes `--update` or `--initialize`, so CI never writes baselines. Knip and the PHP unused checks need installed dependencies before they run: use `setup: pnpm install --frozen-lockfile` for pnpm, `setup: npm ci` for npm, or `setup: composer install` for Composer. The first two create `node_modules/` for Knip; Composer creates `vendor/` for the PHP unused checks.
140+
The reusable job checks out the full history, runs the optional setup command, and invokes plain `code-quality check`. With `report` enabled, it also downloads the coverage artifact, runs `code-quality report`, and uploads `artifacts/quality` as the `quality-reports` artifact. Failing findings produce GitHub annotations, and the job summary is written to `GITHUB_STEP_SUMMARY`. It never passes `--update` or `--initialize`, so CI never writes baselines. Knip and the PHP unused checks need installed dependencies before they run: use `setup: pnpm install --frozen-lockfile` for pnpm, `setup: npm ci` for npm, or `setup: composer install` for Composer. The first two create `node_modules/` for Knip; Composer creates `vendor/` for the PHP unused checks.
136141

137142
## Check selection
138143

@@ -165,7 +170,7 @@ Initialize a check once with `check --initialize` after reviewing its current fi
165170
A realistic output block is:
166171

167172
```text
168-
code-quality 1.1.7 · ts, python · 5 checks
173+
code-quality 1.2.0 · ts, python · 5 checks
169174
170175
✔ ts-complexity oxlint 1.82.0 280 findings
171176
✖ ts-cognitive fallow 3.23.0 18 findings · 2 new
@@ -187,6 +192,8 @@ A realistic output block is:
187192

188193
`check`, `baseline`, and `init` accept `--artifacts <dir>` to retain raw tool output plus `stdout.log` and `stderr.log` per adapter. Without it, adapter output stays in a temporary directory and no adapter artifacts or logs are written by default. `report --output <dir>` selects the advisory report directory and defaults to `artifacts/quality/`.
189194

195+
`report --coverage <path>` gives Fallow an Istanbul `coverage-final.json` map, or a directory containing that file, so its advisory CRAP values use measured coverage. The repository-relative `report.coverage` configuration key does the same and requires code-quality 1.2.0 or newer; an explicit flag wins. Coverage paths that match no repository file produce a notice and CRAP stays estimated; missing or malformed coverage files still fail. The coverage file's directory is excluded from Fallow scans unless it overlaps a configured source path, in which case only the coverage file is excluded.
196+
190197
In GitHub Actions, each regression also emits a `::error file=…` annotation. The current Markdown summary is appended to `GITHUB_STEP_SUMMARY` when that environment variable is available.
191198

192199
## Configuration reference
@@ -205,6 +212,7 @@ In GitHub Actions, each regression also emits a `::error file=…` annotation. T
205212
| `architecture.ts.rulesFile` | repository-relative file path | `.dependency-cruiser.cjs` when it exists; otherwise skipped |
206213
| `architecture.php.rulesFile` | repository-relative file path | `deptrac.yaml` when it exists; otherwise skipped |
207214
| `architecture.python.rulesFile` | repository-relative file path | `.importlinter` when it exists; otherwise skipped |
215+
| `report.coverage` | repository-relative Istanbul map or directory | Unset; requires code-quality 1.2.0 or newer |
208216

209217
An explicitly configured architecture file that is missing is an error. Consumer exclusions apply to applicable checks, while tests remain excluded from duplication regardless of the consumer paths.
210218

@@ -232,6 +240,17 @@ exclude:
232240
233241
The Drupal profile discovers custom modules, themes, and profiles and prints a `Notice:` describing the applied defaults. Normal applicable complexity, cognitive-complexity, duplication, and architecture checks run for the detected custom-code languages; PHP `composer-unused` and `composer-require-checker` run with installed Composer dependencies, while PHPStan dead-code analysis is skipped because consumer PHPStan extensions are incompatible with the image.
234242

243+
### Coverage for CRAP
244+
245+
When tests and the report run in the same job, configure Vitest's `json` coverage reporter (with either the V8 or Istanbul provider), then run:
246+
247+
```sh
248+
vitest run --coverage
249+
npx @runroom/code-quality report --coverage coverage/coverage-final.json
250+
```
251+
252+
With the reusable workflow, have the tests job upload the `coverage/` directory as an artifact named `test-coverage`. The quality job must declare `needs: test`, then set `report: true` and `coverage-artifact: test-coverage` as shown above. Raw V8 output is not supported; the input must be the Istanbul map written by the Vitest or Jest JSON reporter.
253+
235254
## Version pinning
236255

237256
Pin npm consumers to the major launcher version with `npx @runroom/code-quality@1`. Pin Docker consumers to the major image tag `ghcr.io/runroom/code-quality:v1` and the reusable workflow reference `Runroom/code-quality/.github/workflows/quality.yml@v1`. The image carries the exact v1 tool matrix documented in [quality-gate.md](docs/quality-gate.md#v1-tool-pins); the workflow accepts only an `image-tag`, while the registry and repository remain fixed.

docs/quality-gate.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ Some supported tree-sitter grammars reject otherwise valid newer syntax. When th
165165

166166
`report` is advisory: it retains full Fallow health and semantic-duplication output, complexipy JSON, and jscpd HTML for investigation. Advisory similarity and health reports do not alter the exact jscpd or complexity baselines and do not turn a report-only measurement into an accepted regression.
167167

168+
Coverage-backed CRAP remains advisory only and never changes the blocking quality gate.
169+
168170
### Output
169171

170172
`check` prints one line per failing finding as `file:line:col rule message [new]`, `[worsened P → V]`, or `[stale: was P]`; duplication uses `a:start-end ↔ b:start-end`. `check --all` also prints every current finding with `[baselined]`.

launcher/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@runroom/code-quality",
3-
"version": "1.1.7",
3+
"version": "1.2.0",
44
"description": "Runroom incremental quality gate — runs the pinned ghcr.io/runroom/code-quality image",
55
"type": "module",
66
"scripts": {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@runroom/code-quality",
3-
"version": "1.1.7",
3+
"version": "1.2.0",
44
"private": true,
55
"type": "module",
66
"engines": { "node": ">=24" },

scripts/integration.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
existsSync,
88
mkdirSync,
99
mkdtempSync,
10+
readFileSync,
1011
readdirSync,
1112
rmSync,
1213
writeFileSync,
@@ -75,6 +76,7 @@ export interface ResultRow {
7576
expectedExitCode: number;
7677
stderr: string;
7778
expectedStderr?: string;
79+
evidenceOk?: boolean;
7880
}
7981

8082
interface DockerResult {
@@ -242,7 +244,8 @@ function resultRow(
242244

243245
function rowIsOk(row: ResultRow): boolean {
244246
return row.exitCode === row.expectedExitCode &&
245-
(row.expectedStderr === undefined || row.stderr.includes(row.expectedStderr));
247+
(row.expectedStderr === undefined || row.stderr.includes(row.expectedStderr)) &&
248+
row.evidenceOk !== false;
246249
}
247250

248251
export function formatResultRow(row: ResultRow): string {
@@ -300,6 +303,29 @@ function fixtureReportRow(repositoryRoot: string, image: string, fixture: string
300303
}
301304
}
302305

306+
function fixtureCoverageReportRow(repositoryRoot: string, image: string): ResultRow {
307+
const root = temporaryFixture(repositoryRoot, "ts-project");
308+
try {
309+
const coverageDirectory = join(root, "coverage");
310+
mkdirSync(coverageDirectory, { recursive: true });
311+
copyFileSync(
312+
join(repositoryRoot, "tests/fixtures/istanbul/ts-project.coverage-final.json"),
313+
join(coverageDirectory, "coverage-final.json"),
314+
);
315+
const result = runDocker(image, ["report", "--coverage", "coverage/coverage-final.json"], root);
316+
const report = JSON.parse(readFileSync(
317+
join(root, "artifacts/quality/fallow-health/fallow-health.json"),
318+
"utf8",
319+
)) as { summary?: { istanbul_files_matched?: number } };
320+
return {
321+
...resultRow("ts-project report --coverage", result, 0),
322+
evidenceOk: (report.summary?.istanbul_files_matched ?? 0) >= 1,
323+
};
324+
} finally {
325+
rmSync(root, { recursive: true, force: true });
326+
}
327+
}
328+
303329
function mutationRow(repositoryRoot: string, image: string, mutation: Mutation): ResultRow {
304330
const root = temporaryFixture(repositoryRoot, mutation.fixture);
305331
try {
@@ -316,6 +342,7 @@ function integrationRows(repositoryRoot: string, image: string): ResultRow[] {
316342
installMissingFixtureDependencies(repositoryRoot, image);
317343
for (const fixture of FIXTURES) rows.push(fixtureCheckRow(repositoryRoot, image, fixture));
318344
for (const fixture of REPORT_FIXTURES) rows.push(fixtureReportRow(repositoryRoot, image, fixture));
345+
rows.push(fixtureCoverageReportRow(repositoryRoot, image));
319346
for (const mutation of MUTATIONS) rows.push(mutationRow(repositoryRoot, image, mutation));
320347
return rows;
321348
}

src/checks/ts/fallow.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ export const fallowErrorSchema = z.looseObject({
2929
message: z.string(),
3030
});
3131

32-
export function fallowConfig(ctx: CheckContext): string {
32+
export function fallowConfig(ctx: CheckContext, extraIgnorePatterns: readonly string[] = []): string {
3333
return JSON.stringify({
34-
ignorePatterns: [...excludeGlobs(ctx.config, true), "artifacts/**"],
34+
ignorePatterns: [...excludeGlobs(ctx.config, true), "artifacts/**", ...extraIgnorePatterns],
3535
health: { maxCyclomatic: POLICY.complexity, maxCognitive: POLICY.cognitive },
3636
duplicates: POLICY.advisoryDuplication,
3737
}, null, 2);

src/cli/commands/report.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { CliDeps } from "../deps.ts";
55

66
interface ReportOptions {
77
output?: string | undefined;
8+
coverage?: string | undefined;
89
}
910

1011
const DEFAULT_OUTPUT = "artifacts/quality";
@@ -13,5 +14,6 @@ export function reportCommand(deps: CliDeps, options: ReportOptions = {}): Promi
1314
return runReports({
1415
...deps,
1516
output: resolve(deps.cwd, options.output ?? DEFAULT_OUTPUT),
17+
coverage: options.coverage,
1618
});
1719
}

src/cli/program.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface GateFlags {
2828

2929
interface ReportFlags {
3030
output?: string;
31+
coverage?: string;
3132
}
3233

3334
const PROGRAM_STATES = new WeakMap<Command, ProgramState>();
@@ -103,6 +104,7 @@ function registerCommands(program: Command, deps: CliDeps, state: ProgramState):
103104
program.command("report")
104105
.description("Generate advisory reports")
105106
.option("--output <dir>", "report output directory")
107+
.option("--coverage <path>", "Istanbul coverage map (coverage-final.json) or its directory")
106108
.action(async (flags: ReportFlags) => {
107109
state.exitCode = await reportCommand(deps, flags);
108110
});

src/cli/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export const CLI_VERSION = "1.1.7";
1+
export const CLI_VERSION = "1.2.0";

0 commit comments

Comments
 (0)