Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e12f4e1
feat(quality-gate): add quality-gate.config.json
AlexandreCamillo May 29, 2026
63de229
feat(quality-gate): scaffold adapter directory + stub
AlexandreCamillo May 29, 2026
3687471
feat(quality-gate): adapter — coverage section
AlexandreCamillo May 29, 2026
548ab08
feat(quality-gate): adapter — lint section (biome)
AlexandreCamillo May 29, 2026
c5d3ce8
feat(quality-gate): adapter — duplication section (jscpd)
AlexandreCamillo May 29, 2026
fc12966
feat(quality-gate): adapter — file_size section
AlexandreCamillo May 29, 2026
3d7ebc6
feat(quality-gate): adapter — security section (pnpm audit)
AlexandreCamillo May 29, 2026
5f60afa
feat(quality-gate): finalize adapter _meta + verify schema compliance
AlexandreCamillo May 29, 2026
64f201c
feat(quality-gate): add PR-mode workflow
AlexandreCamillo May 29, 2026
8dcf147
feat(quality-gate): add main-mode baseline updater workflow
AlexandreCamillo May 29, 2026
f02cf35
ci: remove bespoke coverage ratchet from test.yml (replaced by qualit…
AlexandreCamillo May 29, 2026
dbc60f0
chore: delete bespoke coverage-ratchet script + test (replaced by qua…
AlexandreCamillo May 29, 2026
837fef5
docs: document quality-gate and remove references to bespoke ratchet
AlexandreCamillo May 29, 2026
41ab30f
docs(readme): point coverage/quality badges at the quality-metrics or…
AlexandreCamillo May 29, 2026
19911ca
simplify(quality-gate): cache engine build, derive _meta from config,…
AlexandreCamillo May 29, 2026
a9e1586
fix(quality-gate): harden engine cache + exclude landing from duplica…
AlexandreCamillo May 29, 2026
f413acb
chore: remove GET /api/annotations/[id]/region endpoint
AlexandreCamillo May 28, 2026
1ea2a68
style: biome-format quality-gate.config.json
AlexandreCamillo May 29, 2026
f98d757
refactor(quality-gate): dedupe workflows into composite action; pin j…
AlexandreCamillo May 29, 2026
0b4737e
fix(quality-gate): drop runner.temp expression from action descriptio…
AlexandreCamillo May 29, 2026
cf7c672
fix(quality-gate): skip sticky PR comment when the gate produced no c…
AlexandreCamillo May 29, 2026
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
71 changes: 71 additions & 0 deletions .github/actions/quality-gate-prepare/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: Quality Gate prepare
description: >-
Set up the toolchain, run the test suite with coverage, build the pinned
quality-gate engine, and run the adapter. Shared by the PR and baseline
workflows so the prelude and engine build stay in one place. The adapter
writes its six metric files to the runner temp dir (runner.temp/qg).

inputs:
engine-sha:
description: Commit of alkg-cloud/quality-gate to build the engine from.
default: 192fcaf386cf5bbb464dca53a26949078240c100

runs:
using: composite
steps:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- run: pnpm install --frozen-lockfile
shell: bash
- run: pnpm prisma generate
shell: bash
- run: pnpm prisma migrate deploy
shell: bash
env:
DATABASE_URL: file:./prisma/test.db
- run: pnpm tsx tests/fixtures/build-fixtures.ts
shell: bash

- run: pnpm test --coverage
shell: bash
env:
DATABASE_URL: file:./prisma/test.db

- name: Restore quality-gate engine build
id: cache-qg
uses: actions/cache/restore@v4
with:
path: /tmp/qg-core
key: qg-core-${{ inputs.engine-sha }}

- name: Build quality-gate engine (upstream not on npm)
if: steps.cache-qg.outputs.cache-hit != 'true'
shell: bash
run: |
git clone --depth 1 https://github.com/alkg-cloud/quality-gate /tmp/qg-core
cd /tmp/qg-core
git fetch --depth 1 origin ${{ inputs.engine-sha }}
git checkout ${{ inputs.engine-sha }}
pnpm install --frozen-lockfile
pnpm run build

# Save only after a clean build, so a crashed install/build never caches a
# partial /tmp/qg-core (missing dist/cli.js) under the fixed-SHA key — which
# would make every later run hit the poisoned cache and skip the rebuild.
- name: Save quality-gate engine build
if: steps.cache-qg.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: /tmp/qg-core
key: qg-core-${{ inputs.engine-sha }}

- name: Run adapter
shell: bash
run: ./.quality-gate/adapter.sh
env:
QG_OUTPUT_DIR: ${{ runner.temp }}/qg
QG_CONFIG: ./quality-gate.config.json
29 changes: 29 additions & 0 deletions .github/workflows/quality-gate-main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: quality-gate-baseline
on:
push:
branches: [main]

permissions:
contents: write

concurrency:
group: quality-gate-main
cancel-in-progress: false

jobs:
update-baseline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: ./.github/actions/quality-gate-prepare

- name: Update baseline on orphan branch
run: |
node /tmp/qg-core/dist/cli.js update-baseline \
--config ./quality-gate.config.json \
--output-dir ${{ runner.temp }}/qg
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
55 changes: 55 additions & 0 deletions .github/workflows/quality-gate-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: quality-gate
on:
pull_request:
branches: [main]

permissions:
contents: read
pull-requests: write

concurrency:
group: quality-gate-pr-${{ github.ref }}
cancel-in-progress: true

jobs:
quality-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: ./.github/actions/quality-gate-prepare

- name: Run quality gate
run: |
node /tmp/qg-core/dist/cli.js pr \
--config ./quality-gate.config.json \
--output-dir ${{ runner.temp }}/qg

# The comment file only exists once the engine has run; on an earlier
# failure (e.g. the suite or engine build) it is absent, so skip the
# comment step rather than letting it fail and bury the real error.
- name: Check for gate comment
id: gate-comment
if: always()
run: |
if [ -f "${{ runner.temp }}/qg/pr-comment.md" ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi

- name: Post sticky PR comment
if: always() && steps.gate-comment.outputs.exists == 'true'
uses: marocchino/sticky-pull-request-comment@v2
with:
header: quality-gate-marker-v1
path: ${{ runner.temp }}/qg/pr-comment.md

- name: Upload artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: quality-gate-report
path: ${{ runner.temp }}/qg
29 changes: 0 additions & 29 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,6 @@ jobs:

test-coverage:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
Expand All @@ -68,32 +65,6 @@ jobs:
- run: pnpm test --coverage
env:
DATABASE_URL: file:./prisma/test.db
- id: ratchet
run: pnpm exec tsx scripts/coverage-ratchet.ts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- if: github.event_name == 'pull_request' && always()
uses: actions/github-script@v7
env:
# Route the step output through an env var so the markdown content
# (which can contain backticks, ${...}, or other JS syntax) is read
# via process.env rather than interpolated into the script source.
# See: https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
RATCHET_MARKDOWN: ${{ steps.ratchet.outputs.markdown }}
with:
script: |
const body = process.env.RATCHET_MARKDOWN ?? '';
if (!body.trim()) return;
const { owner, repo } = context.repo;
const issue_number = context.issue.number;
const marker = '<!-- coverage-ratchet -->';
const comments = await github.rest.issues.listComments({ owner, repo, issue_number });
const existing = comments.data.find((c) => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}

e2e:
runs-on: ubuntu-latest
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ public/_qa-mobile.html
# landing-export build artifacts
landing-export/.next/
landing-export/out/

# quality-gate adapter local output dir
/qg-output/
15 changes: 15 additions & 0 deletions .quality-gate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Quality Gate adapter

This directory holds the Markup-specific adapter for [`@quality-gate/core`](https://github.com/alkg-cloud/quality-gate).

`adapter.sh` is invoked by both `quality-gate-pr.yml` and `quality-gate-main.yml`. It reads two env vars (`QG_OUTPUT_DIR`, `QG_CONFIG`) and writes six canonical JSON files into `$QG_OUTPUT_DIR`. The schemas it must satisfy live at `src/schemas/*.schema.json` in the upstream repo.

To run locally:

```bash
mkdir -p /tmp/qg
QG_OUTPUT_DIR=/tmp/qg QG_CONFIG=./quality-gate.config.json ./.quality-gate/adapter.sh
ls /tmp/qg
```

For the engine contract (PR-mode, main-mode, baseline format), see `docs/quality-gate.md`.
137 changes: 137 additions & 0 deletions .quality-gate/adapter.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# Quality Gate adapter for the Markup project.
# Stack: vitest (coverage) + biome (lint) + jscpd (duplication) + pnpm audit (security).
#
# Contract (from upstream src/schemas/*.schema.json):
# Inputs: $QG_OUTPUT_DIR (must exist, writable), $QG_CONFIG (path to quality-gate.config.json)
# Outputs (exactly 6 files in $QG_OUTPUT_DIR):
# coverage.json — { lines_pct, files:[{path,lines_pct}] } OR { _skipped }
# lint.json — { total, by_file:[{path,count}] } OR { _skipped }
# duplication.json — { pct, clones? } OR { _skipped }
# file_size.json — { max_lines, violations:[{path,lines}] } OR { _skipped }
# security.json — { critical, high, moderate, low } OR { _skipped }
# _meta.json — { adapter, adapter_version, tools[] }
#
# All paths in metric files are repo-relative (no leading "./" or absolute).
set -euo pipefail
: "${QG_OUTPUT_DIR:?must be set}"
: "${QG_CONFIG:?must be set}"

mkdir -p "$QG_OUTPUT_DIR"
ROOT="$PWD"
MAX_FILE_LINES=$(jq -r '.thresholds.MAX_FILE_LINES' "$QG_CONFIG")

# Intermediate tool reports live outside the repo tree so they never pollute the
# working copy nor get picked up by jscpd's own scan of ".".
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT

# Each metric section must produce a strictly schema-compliant file even on tool
# failure — use the {"_skipped":"…"} fallback to keep the gate green for that metric.

# --- 1. Coverage (consumes coverage/coverage-summary.json produced by vitest) ---
if [ -f coverage/coverage-summary.json ]; then
jq --arg root "$ROOT/" '
.total.lines.pct as $total
| {
lines_pct: ($total // 0),
files: [
to_entries[]
| select(.key != "total")
| {
path: (.key | sub("^" + ($root | @text); "")),
lines_pct: (.value.lines.pct // 0)
}
]
| sort_by(.path)
}
' coverage/coverage-summary.json > "$QG_OUTPUT_DIR/coverage.json"
else
echo '{"_skipped":"coverage/coverage-summary.json missing — did the workflow run pnpm test --coverage?"}' \
> "$QG_OUTPUT_DIR/coverage.json"
fi

# --- 2. Lint (biome --reporter=json; biome exits 1 with violations, hence || true) ---
pnpm exec biome check . --reporter=json 2>/dev/null > "$WORK/biome-report.json" || true
if [ -s "$WORK/biome-report.json" ] && jq -e '.diagnostics' "$WORK/biome-report.json" > /dev/null 2>&1; then
jq --arg root "$ROOT/" '
# Aggregate diagnostics by file path; biome paths can be absolute or relative.
# Biome 2.x emits .location.path as a string; older shapes used {file: "..."}.
# Handle both defensively.
reduce .diagnostics[] as $d ({};
(($d.location.path | if type == "object" then .file else . end) // "unknown") as $raw
| ($raw | sub("^" + ($root | @text); "")) as $rel
| .[$rel] = ((.[$rel] // 0) + 1)
)
| {
total: ([.[]] | add // 0),
by_file: [
to_entries[] | { path: .key, count: .value }
] | sort_by(.path)
}
' "$WORK/biome-report.json" > "$QG_OUTPUT_DIR/lint.json"
else
echo '{"_skipped":"biome produced no parseable JSON report"}' > "$QG_OUTPUT_DIR/lint.json"
fi

# --- 3. Duplication (jscpd) ---
# Ignore paths beyond defaults: .next build cache, prisma generated client,
# vitest scratch dirs, the landing static export, and tests fixtures (binary zips).
# The landing source (src/app/landing, src/components/landing) is excluded to match
# the coverage + file_size metrics: it is a presentation-heavy marketing surface
# whose repeated section markup would inflate duplication without signalling real debt.
pnpm exec jscpd . --reporters json --output "$WORK/jscpd" \
--ignore "**/node_modules/**,**/dist/**,**/coverage/**,**/.jscpd/**,**/.next/**,**/landing-export/**,**/src/app/landing/**,**/src/components/landing/**,**/prisma/migrations/**,**/tests/fixtures/**,**/.git/**" \
--silent 2>/dev/null || true

if [ -f "$WORK/jscpd/jscpd-report.json" ]; then
jq '{
pct: (.statistics.total.percentage // 0),
clones: (.statistics.total.clones // 0)
}' "$WORK/jscpd/jscpd-report.json" > "$QG_OUTPUT_DIR/duplication.json"
else
echo '{"_skipped":"jscpd produced no report"}' > "$QG_OUTPUT_DIR/duplication.json"
fi

# --- 4. File size (walk src/ and tests/, count lines, emit violations) ---
{
find src tests \
-type f \
\( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.mjs" -o -name "*.cjs" \) \
-not -path "src/app/landing/*" \
-not -path "src/components/landing/*" \
-not -path "*/landing-export/*" \
-not -path "*/node_modules/*" \
2>/dev/null \
|| true
} | while read -r f; do
lines=$(awk 'END { print NR }' "$f")
if [ "$lines" -ge "$MAX_FILE_LINES" ]; then
printf '{"path":"%s","lines":%d}\n' "$f" "$lines"
fi
done | jq -s --argjson max "$MAX_FILE_LINES" '{
max_lines: $max,
violations: (sort_by(.path))
}' > "$QG_OUTPUT_DIR/file_size.json"

# --- 5. Security (pnpm audit) ---
pnpm audit --json > "$WORK/pnpm-audit.json" 2>/dev/null || true
if [ -s "$WORK/pnpm-audit.json" ] && jq -e '.metadata.vulnerabilities' "$WORK/pnpm-audit.json" > /dev/null 2>&1; then
jq '.metadata.vulnerabilities | {
critical: (.critical // 0),
high: (.high // 0),
moderate: (.moderate // 0),
low: (.low // 0)
}' "$WORK/pnpm-audit.json" > "$QG_OUTPUT_DIR/security.json"
else
echo '{"_skipped":"pnpm audit produced no metadata.vulnerabilities"}' > "$QG_OUTPUT_DIR/security.json"
fi

# --- 6. _meta (must list the tools the adapter actually invoked) ---
# adapter/version derive from quality-gate.config.json so the _meta the engine
# ingests cannot drift from the config_snapshot it stores in baseline.json.
jq '{
adapter: .adapter.name,
adapter_version: .adapter.version,
tools: ["vitest", "biome", "jscpd", "pnpm-audit"]
}' "$QG_CONFIG" > "$QG_OUTPUT_DIR/_meta.json"
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
<a href="https://nextjs.org/"><img alt="Built with Next.js 16" src="https://img.shields.io/badge/Next.js-16-000?logo=nextdotjs&logoColor=white"></a>
<a href="https://github.com/alkg-cloud/markup/pkgs/container/markup"><img alt="Release" src="https://img.shields.io/github/package-json/v/alkg-cloud/markup?label=release"></a>
<a href="https://github.com/alkg-cloud/markup/actions/workflows/test.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/alkg-cloud/markup/test.yml?branch=main&label=ci"></a>
<a href="https://github.com/alkg-cloud/markup/tree/coverage-data/report"><img alt="Coverage" src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/alkg-cloud/markup/coverage-data/badge.json"></a>
<img alt="coverage" src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/alkg-cloud/markup/quality-metrics/badges/coverage.json">
<img alt="quality" src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/alkg-cloud/markup/quality-metrics/badges/quality.json">
</p>

</div>
Expand Down
3 changes: 2 additions & 1 deletion docs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Start here to find which docs apply to your task. If multiple docs are relevant,

- [Task Rules](task-rules.md) — what to do before/after every task; the per-area checklist
- [CI and coding rules](ci.md) — what fails CI, the pre-push checklist, and the conventions the agent must follow to keep `main` green
- [Quality Gate](quality-gate.md) — multi-metric ratchet (coverage, lint, duplication, file size, security) against the `quality-metrics` orphan branch
- [Documentation Standards](doc-standards.md) — how to write and maintain docs (snapshot-only, declarative present tense)
- [Git Conventions](git/conventions.md) — commit messages and workflow

Expand Down Expand Up @@ -42,7 +43,7 @@ Start here to find which docs apply to your task. If multiple docs are relevant,

- [Agent-loop INDEX](agent-loop/INDEX.md) — overview + endpoint map
- [Overview](agent-loop/overview.md) — the user→agent→user cycle
- [Endpoints](agent-loop/endpoints.md) — `/intent`, `/context`, `/version-patch`, `/region`, `/diff`
- [Endpoints](agent-loop/endpoints.md) — `/intent`, `/context`, `/version-patch`, `/diff`
- [Intent payload](agent-loop/intent-payload.md) — what `/intent` returns, sidecar caching, invalidation
- [Patch format](agent-loop/patch-format.md) — unified-diff conventions for `/version-patch`
- [Chips](agent-loop/chips.md) — G1 intent vocabulary (`visual` / `copy` / `behavior` / `other`)
Expand Down
2 changes: 1 addition & 1 deletion docs/agent-loop/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Silent drift in any of these endpoints breaks consumers. See the [agent-loop rul
## Read first

- [Overview](overview.md) — the cycle end-to-end with byte costs
- [Endpoints](endpoints.md) — `/context`, `/version-patch`, `/region`, `/diff`
- [Endpoints](endpoints.md) — `/context`, `/version-patch`, `/diff`
- [Uploads](uploads.md) — `POST /api/mockups`, `POST /api/mockups/[id]/version` (raw HTML + zip, size cap)
- [Patch format](patch-format.md) — unified-diff conventions for `/version-patch`

Expand Down
Loading
Loading