Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/pr-quality-comment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: PR Quality Comment

on:
workflow_run:
workflows: [PR Quality]
types: [completed]

permissions:
contents: read
actions: read
pull-requests: write

concurrency:
group: pr-quality-comment-${{ github.event.workflow_run.head_repository.id }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false

jobs:
comment:
if: >-
github.event.workflow_run.event == 'pull_request' &&
(github.event.workflow_run.head_repository.id != github.event.repository.id ||
github.event.workflow_run.actor.login == 'dependabot[bot]')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# workflow_run uses the default branch. Never check out the PR here, run
# npm install, or execute anything from its artifact with this write token.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
path: trusted
sparse-checkout-cone-mode: false
sparse-checkout: |
/scripts/publish-complexity-comment.mjs
/scripts/complexity-report.mjs
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
id: artifacts
with:
script: |
const { qualityArtifactNames } = await import(
`${process.env.GITHUB_WORKSPACE}/trusted/scripts/publish-complexity-comment.mjs`
);
const run = context.payload.workflow_run;
const names = await qualityArtifactNames({
github, repo: context.repo, runId: run.id, attempt: run.run_attempt,
});
core.setOutput('report', names.report);
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
if: steps.artifacts.outputs.report != ''
continue-on-error: true
with:
name: ${{ steps.artifacts.outputs.report }}
path: untrusted-report
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
const { publishComplexityComment } = await import(
`${process.env.GITHUB_WORKSPACE}/trusted/scripts/publish-complexity-comment.mjs`
);
await publishComplexityComment({
github, context,
reportPath: `${process.env.GITHUB_WORKSPACE}/untrusted-report/comparison.json`,
warn: core.warning,
});
231 changes: 231 additions & 0 deletions .github/workflows/pr-quality.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
name: PR Quality

on:
pull_request:

permissions:
contents: read
actions: read

concurrency:
group: pr-quality-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
revisions:
runs-on: ubuntu-latest
outputs:
merge-base: ${{ steps.refs.outputs.merge-base }}
coverage-needed: ${{ steps.refs.outputs.coverage-needed }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
persist-credentials: false
- id: refs
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
run: |
merge_base=$(git merge-base "$BASE_SHA" "$PR_SHA")
echo "merge-base=$merge_base" >> "$GITHUB_OUTPUT"
if git diff --quiet "$merge_base" "$PR_SHA" -- lambda/ test/ \
'package*.json' vitest.config.js eslint.complexity.config.mjs \
'scripts/*complexity*' 'scripts/test/*complexity*' '.github/workflows/pr-quality*'; then
echo "coverage-needed=false" >> "$GITHUB_OUTPUT"
else
echo "coverage-needed=true" >> "$GITHUB_OUTPUT"
fi

coverage:
name: coverage-${{ matrix.revision }}
needs: revisions
if: needs.revisions.outputs.coverage-needed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
revision: [base, pr]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha }}
path: tooling
persist-credentials: false
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ matrix.revision == 'base' && needs.revisions.outputs.merge-base || github.event.pull_request.head.sha }}
path: source
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: npm
cache-dependency-path: |
source/package-lock.json
source/lambda/yjs-server/package-lock.json
- name: Install revision dependencies
id: install
continue-on-error: true
working-directory: source
run: |
npm ci
npm ci --prefix lambda/yjs-server --ignore-scripts
- name: Measure backend coverage
id: tests
if: steps.install.outcome == 'success'
continue-on-error: true
working-directory: source
run: npm test -- --coverage --coverage.reporter=json --coverage.reportOnFailure
- name: Collect revision-bound coverage
if: always()
env:
TESTS_PASSED: ${{ steps.tests.outcome == 'success' }}
run: |
args=()
if [ "$TESTS_PASSED" = true ]; then args+=(--successful); fi
node tooling/scripts/complexity-coverage.mjs --cwd "$PWD/source" \
--input "$PWD/source/coverage/coverage-final.json" \
--output "$PWD/coverage.json" "${args[@]}"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: always()
with:
name: coverage-${{ matrix.revision }}-${{ github.run_attempt }}
path: coverage.json
if-no-files-found: error
retention-days: 7

comment:
needs: report
if: >-
always() &&
github.event.pull_request.head.repo.id == github.event.repository.id &&
github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
actions: read
pull-requests: write
concurrency:
group: pr-quality-comment-${{ github.event.pull_request.head.repo.id }}-${{ github.event.pull_request.head.ref }}
cancel-in-progress: false
steps:
# Same-repository authors already control this PR workflow. Forks and
# Dependabot use the default-branch publisher in pr-quality-comment.yml.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
sparse-checkout-cone-mode: false
sparse-checkout: |
/scripts/publish-complexity-comment.mjs
/scripts/complexity-report.mjs
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
id: artifacts
with:
script: |
const { qualityArtifactNames } = await import(
`${process.env.GITHUB_WORKSPACE}/scripts/publish-complexity-comment.mjs`
);
const names = await qualityArtifactNames({
github, repo: context.repo, runId: context.runId,
attempt: Number(process.env.GITHUB_RUN_ATTEMPT),
});
core.setOutput('report', names.report);
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
if: steps.artifacts.outputs.report != ''
continue-on-error: true
with:
name: ${{ steps.artifacts.outputs.report }}
path: quality-report
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
const { publishComplexityComment } = await import(
`${process.env.GITHUB_WORKSPACE}/scripts/publish-complexity-comment.mjs`
);
await publishComplexityComment({
github, context,
reportPath: `${process.env.GITHUB_WORKSPACE}/quality-report/comparison.json`,
warn: core.warning,
});

report:
needs: [revisions, coverage]
if: always() && needs.revisions.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: npm
- run: npm ci
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
id: artifacts
with:
script: |
const { qualityArtifactNames } = await import(
`${process.env.GITHUB_WORKSPACE}/scripts/publish-complexity-comment.mjs`
);
const names = await qualityArtifactNames({
github, repo: context.repo, runId: context.runId,
attempt: Number(process.env.GITHUB_RUN_ATTEMPT),
});
core.setOutput('base', names.base);
core.setOutput('pr', names.pr);
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
if: steps.artifacts.outputs.base != ''
continue-on-error: true
with:
name: ${{ steps.artifacts.outputs.base }}
path: coverage-input/base
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
if: steps.artifacts.outputs.pr != ''
continue-on-error: true
with:
name: ${{ steps.artifacts.outputs.pr }}
path: coverage-input/pr
- name: Compare complexity and coverage
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
COVERAGE_NEEDED: ${{ needs.revisions.outputs.coverage-needed }}
run: |
args=()
if [ "$COVERAGE_NEEDED" = false ]; then args+=(--coverage-not-needed); fi
if [ -f "coverage-input/base/coverage.json" ]; then
args+=(--base-coverage "coverage-input/base/coverage.json")
fi
if [ -f "coverage-input/pr/coverage.json" ]; then
args+=(--pr-coverage "coverage-input/pr/coverage.json")
fi
npm run complexity:compare -- --base "$BASE_SHA" --base-ref "$BASE_REF" --pr "$PR_SHA" "${args[@]}"
cat reports/complexity/comparison.md >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pr-quality-${{ github.run_attempt }}
path: reports/complexity/comparison.json
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: complexity-${{ github.run_attempt }}
path: reports/complexity/complexity.*
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: dependencies-${{ github.run_attempt }}
path: reports/complexity/dependencies.*
if-no-files-found: error
retention-days: 7
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,6 @@ lambda/agentcore/test/a7-mcp-config.json

# dependency-cruiser generated reports
reports/dependency/

# Local cognitive-complexity analysis
reports/complexity/
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,18 @@ npm run audit:prod:all # npm audit on production deps for root + frontend (hig
npm run typecheck:frontend # tsc -b on the frontend package
```

Inspect cognitive complexity with the free SonarJS ESLint plugin:

```bash
npm run complexity # advisory warnings for function scores above 15
npm run complexity:compare # compare origin/main's merge base with HEAD
```

CI combines complexity changes, backend branch coverage, and importer counts in one PR comment,
helping reviewers find new or worsening complex functions and coverage regressions.
These signals are advisory. See [PR quality reports](docs/development/pr-quality.md)
for coverage details, thresholds, matching limits, and CI behavior.

Analyze backend module coupling with [dependency-cruiser](https://github.com/sverweij/dependency-cruiser). The rules — no circular dependencies, no cross-workspace imports, `lambda/shared/` kept as a leaf foundation, and no production code importing test files — live in `.dependency-cruiser.cjs` and are enforced by the pre-commit hook and CI:

```bash
Expand Down
50 changes: 50 additions & 0 deletions docs/development/pr-quality.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# PR quality reports

CI updates one PR comment with cognitive-complexity, dependency counts, and backend branch-coverage changes
for source files changed between the merge base and PR commit. Findings are advisory: functions above 15 appear
when complexity or coverage worsens, or when the function is new/unmatched.
Unchanged debt is omitted; improvements appear separately.

## Reading the table

| Column | Meaning |
| ------------------- | ---------------------------------------------------------------------------------------------------- |
| Complexity / Δ | Before → after score and change |
| Depending files | Direct production importers at the PR commit; backend only, excluding tests and AWS/external callers |
| Branch coverage / Δ | Covered branch outcomes and change in percentage points |
| Uncovered branches | Outcomes not exercised by tests, before → after |
| Icon | 🟢 improving, 🟠 mixed/small regression, 🔴 large regression, 🟣 both metrics worsen substantially |

Without comparable measurements, icons use current complexity: orange above 15,
red above 50, purple above 100. Coverage below 50% raises this one level.
More than 10 depending files raises an existing warning one level, capped at purple,
when coverage is below 50% or regresses. Green improvements stay green.
These are review heuristics; use the numbers to judge each finding.

**Unknown** means coverage is missing, incompatible, or unsupported (frontend).
**n/a** means no branch outcomes; **\*** marks coverage from failing/incomplete tests.
**New or unmatched** means no reliable previous function match—not necessarily new code.
The comment shows up to 20 findings and 20 improvements; artifacts contain all results.

## CI

Backend/test/configuration/tooling changes run coverage at both revisions. Docs-only,
Terraform-only and frontend-only PRs skip these suites: “not needed for this change.”
Dependency analysis failures leave only those counts unknown. Partial reruns reuse artifacts only when their producers
were not rerun; missing or stale results are reported explicitly.

Same-repository PRs can comment immediately. Forks and Dependabot need the comment
workflow on the default branch; it validates report data without executing PR code.
Artifacts are retained for seven days: `complexity-*` lists every function in changed files with
base/PR scores and coverage; `dependencies-*` lists their direct production importers.
Both include Markdown and JSON. `pr-quality-*` holds the data used to publish the comment.

## Local use

```bash
npm run complexity
npm run complexity:compare -- --base origin/main --pr HEAD
```

The comparison reads committed source and writes JSON/Markdown to `reports/complexity/`.
Local hooks do not collect coverage or post comments.
Loading
Loading