chore: record harness learnings (#3131)

This commit is contained in:
Carlos Florêncio
2026-09-10 14:30:58 +01:00
committed by GitHub
parent b1b28d72a3
commit 39864c036c
58 changed files with 2688 additions and 548 deletions
+9 -8
View File
@@ -21,14 +21,15 @@ primary sources. In the default mode, poll at a 60-second cadence for at most
review feedback, or a terminal clean state. Keep polling directly in this mode:
`scripts/pr-await` waits for every check to finish, which cannot return early on
a failure or conflict while other checks are still pending. If the caller says "wait N
minutes" or "then fix up", use strict-deadline mode instead: the 20-minute cap
does not apply there — honor the caller's own N-minute deadline. Those
semantics are exactly what `scripts/pr-await <PR> --deadline-min N` implements,
so prefer it there and report its single output. Without that script, still poll
at a 60-second cadence but for the caller's full N minutes: calculate and
include the absolute deadline in the polling prompt, accumulate findings, and
do not return early for findings, pending checks, or a clean snapshot. Stop
early only if the PR is merged/closed or access is revoked. At the deadline,
minutes" or "then fix up", pass the caller's value to
`scripts/pr-await <PR> --deadline-min N` as a maximum deadline. The helper can
return early when checks are terminal; do not describe that as a full-duration
wait. If the caller explicitly requires the full N minutes, the helper has no
minimum-wait mode: without that script, poll at a 60-second cadence for the
caller's full N minutes, calculate and include the absolute deadline in the
polling prompt, accumulate findings, and do not return early for findings,
pending checks, or a clean snapshot. Stop early only if the PR is merged/closed
or access is revoked. At the deadline,
return the latest named pending checks and named actionable review findings,
not only aggregate CI and review states.
+48 -8
View File
@@ -27,6 +27,7 @@ change and a small diff because this limits risk and maintainer burden.
## Available skills
- **`/tdd`** — Recommend when flagging untested logic. The author can use this to add tests.
- **`/mobile-parity`** — Required when a review touches frontend or user-facing UI, including scrolling, visibility, or activation behavior, even when the change is not described as responsive.
## Steps
@@ -42,6 +43,14 @@ head if needed, and use that immutable SHA in the diff. If the current PR head
cannot be fetched, say so rather than reporting a stale checkout as a review of
the current PR.
A Kandev task worktree may hold none of the work: a clean checkout can be an old
base while the reviewable code lives on the PR's branch, and prior-session claims
that local changes exist may be stale. Before reporting “no changes”, confirm
that `git log "$(git merge-base <base-remote>/<base-ref> HEAD)"..HEAD` is
non-empty. If it is empty, resolve the PR branch with
`gh pr view <N> --json headRefName,headRefOid`, fetch that ref, and review its
immutable head, stating why it is not the local branch.
Record the base and head SHA for each review round. A new contributor push starts
a new round: reassess prior findings and the verdict against the new head, and
verify checks or workflow results for that head rather than relying on a PR
@@ -49,9 +58,15 @@ number or author summary. From `scripts/pr-state --summary`, also record
`pr.base_ref_name`, `pr.base_head_oid`, `pr.merge_base_oid`, and
`pr.base_advanced_since_head` when available.
For a follow-up round, inspect `<previous-reviewed-head>..<current-head>` first
to isolate the author's response, then re-evaluate `<base>...<current-head>` for
complete PR coverage. Record both immutable heads.
For a follow-up round, inspect `<previous-reviewed-head>..<current-head>` first,
then re-evaluate `<base>...<current-head>` for complete PR coverage. Record both
immutable heads. The first range does not isolate the author's response when the
branch merged or rebased onto an advanced base: it also carries every upstream
commit that arrived with it. Use `rtk proxy git log --oneline --first-parent
<previous-reviewed-head>..<current-head>` to list the branch's own commits, then
`rtk proxy git show --stat <sha>` per commit to size the response. When history
shape decides review scope, use `rtk proxy git log` because the normal RTK
wrapper can omit merge commits and truncate subjects.
When `pr.base_advanced_since_head` is `true`, validate the actual merge result
before declaring the PR ready. Record the latest base and immutable head SHAs,
@@ -74,11 +89,22 @@ reports hidden unresolved threads, use `pr-resolve list` to inspect them rather
than assuming the filtered list is complete.
Compare the PR description, checklist, and claimed manual validation with that
exact head, especially after a major refactor. Report stale claims separately;
they are not verification evidence for the current diff.
exact head, especially after a major refactor. Treat unchecked static template
boxes preserved by `/pr` as intentional; flag only prose or checklist claims
factually contradicted by the exact-head diff or evidence. Report stale claims
separately; they are not verification evidence for the current diff.
Read each changed file in full — understand surrounding code, not just the diff. Navigate callers, interfaces, and tests to understand changes end-to-end.
To read files at an immutable head that is not checked out, create a detached
worktree so file and line anchors remain accurate:
```bash
git worktree add --detach /tmp/review-<pr> <head-sha>
# inspect the files in /tmp/review-<pr>
git worktree remove --force /tmp/review-<pr>
```
For each file, identify which requirement or intent it serves. Flag any changes that don't map to the task — scope creep is a blocker.
### 2. Review tests and verification first
@@ -88,6 +114,8 @@ Before reviewing implementation details:
- Check whether tests assert behavior, not implementation details.
- Check whether the selected test level is appropriate: unit for pure logic, integration for boundaries, E2E for critical browser flows.
- Identify missing coverage for happy path, key error paths, edge cases, auth/workspace boundaries, and concurrency/order-sensitive behavior.
- When a contract spans dispatchers, explicit service/API launches, approval/UI flows, or background handlers, enumerate every user-reachable entry point, trace each to the operation, and require path-specific regression coverage before declaring review clean.
- When a PR changes the semantics of a field, flag, enum, event, or API contract, grep all producers and consumers for comments, logs, names, and tests that describe the old meaning. Those unchanged descriptions are in scope because the PR makes them false; anchor the finding to the changed contract use and list affected downstream sites.
- For concurrent or event-driven changes, require a deterministic schedule that checks ownership or generation identity, stale-event handling, cancellation, and lock scope. Channel/barrier coordination is preferable to timing sleeps.
- For stale-event races, cover both event-before-successor and delayed-old-event-after-successor orderings. Prefer integration coverage for cross-package event or callback paths when practical.
- When an HTTP mutation returns a full entity while WebSocket/event updates can
@@ -102,7 +130,10 @@ Before reviewing implementation details:
coverage.
- For terminal event streams, block an earlier publication, enqueue a terminal event (for example delete or cancellation), then enqueue a stale update. Assert no later mutation reaches an upserting consumer; queues must tombstone the entity or discard pending work at the terminal boundary.
- When completion events lack a stable workload identity, test N outstanding registrations with N completion signals and duplicate delivery. A single-registration test cannot prove that uncorrelated completions retire work correctly. Compare this behavior with the accepted spec or ADR; a passing test that contradicts the contract is still a blocker.
- For durable one-shot metadata, inventory every producer, claim, restore, retry/redelivery, and startup-sweep path. If a token carries a structured descriptor, restore that exact claimed value rather than replacing it with a boolean marker; test a real producer and a claim-to-restore-to-reclaim round trip.
- Components can remain mounted while hidden or zero-sized. For visibility/activation behavior, ensure deadlines begin or reset at visible activation; mount the hidden state, advance fake timers or deliver content/layout changes, activate it, and assert final ownership with controlled observers or animation frames.
- Treat missing tests for new or changed non-UI logic as a blocker unless the change is explicitly untestable and says why.
- For forms that catch typed validation or provider errors, review feedback and cleanup as separate paths. Require focused coverage for every typed error family (or a table-driven equivalent) that asserts localized feedback is shown and the dialog/input remains open, plus a success assertion that it still closes; a `finally` block must not close a recoverable form unconditionally.
### 3. Review for issues
@@ -141,18 +172,24 @@ Check every changed file for the following layers. Skip layers that don't apply
- There is a single clear source of truth; state or business rules are not duplicated across layers
- Nullability, optional fields, defaults, and invalid combinations are modelled deliberately
- Persistence schemas or transport types are not leaking implementation details into domain/application contracts
- Persistence conformance tests call real production stores and assert a non-zero domain write/read-back; synthetic tables and no-op SQL are not semantic coverage. Startup tests trace errors through bootstrap and auth/middleware order, not readiness alone.
- Concurrency, retries, partial failures, and duplicate requests cannot corrupt state or apply transitions more than once
- Backward compatibility, migrations, and mixed-version behaviour are considered when contracts or persisted data change
**Logic & correctness:**
- Edge cases handled (empty input, nil/null, zero, max values)
- Error paths covered and not silently swallowed
- For marker-delimited full-document mutations, define missing, orphaned,
duplicate, and malformed-marker behavior before writing. Cleanup must no-op or
fail closed when the body is not owned, and tests must cover each ownership
boundary without retrying an unowned document.
- Race conditions or concurrency issues in concurrent code
- Async events carry an immutable identity when they can outlive the operation that created them; stale events cannot mutate a replacement operation
- Locks protect only the atomic ownership boundary and are not held across unbounded I/O or a full asynchronous operation
- Synchronous callbacks cannot re-enter a lock they already need; moving publication asynchronous also requires an immutable value snapshot, clear shutdown ownership, and protection against a delayed event changing successor state
- When a generation, token, or lease authorizes a side effect, validate and mutate within one critical section. Check every terminal path separately: success, raw error, cancellation, timeout, and disconnect.
- Detached goroutines have immutable snapshots and a real happens-before relationship before reading state that can otherwise transition underneath them
- When a system design requires telemetry for an external or provider operation, audit every early return and require exactly one completion outcome per invocation. Outcomes should use allowlisted stable IDs, duration, response shape, and typed failure category, while excluding URLs, request/response bodies, credentials, and raw upstream errors; add observer/logger assertions for success and representative timeout and invalid-descriptor failures.
**Performance:**
- No N+1 queries (loop with individual DB calls)
@@ -196,6 +233,7 @@ Check every changed file for the following layers. Skip layers that don't apply
**Testing (blocker if missing):**
- Backend (Go): new or changed functions/methods must have corresponding `*_test.go` tests
- Frontend: new utilities, hooks, API clients, and store slices must have focused tests. Pure React markup may skip a unit test, but behavior-bearing components (conditional status, accessibility, store-derived state, or responsive/mobile variants) need focused `*.test.tsx` coverage and/or E2E. Route responsive user-facing changes through `/mobile-parity`.
- For conditional UI driven by store-derived data, trace branch predicates through real callers and use production-shaped props. Include required identity props and seed the empty store/data state; do not simulate "no data" by omitting props that callers always pass.
- Async UI lifecycle: loading/busy state must clear through `finally` or equivalent terminal cleanup for success, error, cancellation, and early/no-op returns; require focused tests for those terminal paths.
- Exceptions: config files, generated code, and pure React component markup
- Missing tests for new or changed logic is a **blocker** — suggest what tests to add and recommend `/tdd`
@@ -216,12 +254,14 @@ review; preserve all pre-existing user changes.
Report findings with a concrete suggested fix. Do not edit the checkout during
a review-only request; otherwise remediate in the same primary conversation.
Before drafting or posting a copyable PR comment, map each proposed point against
Before drafting or sending an author-facing review finding through any channel,
including a PR comment or `message_task_kandev`, map each point against
exact-current-head review bodies, top-level discussion comments, and all
unresolved or hidden threads. If a point is already raised, omit it from the
author-facing comment but retain it in the private review summary; repeat it
author-facing delivery but retain it in the private review summary; repeat it
only when the user explicitly asks for reinforcement. Re-fetch immediately
before posting and start a new review round if `headRefOid` changed.
before delivery and start a new review round if `headRefOid` changed. Name the
exact reviewed SHA in the outgoing finding.
### 5. Output
+27 -6
View File
@@ -115,6 +115,9 @@ explicitly requests task tracking.
qualifies as a successful hook receipt.
4. **Stage files:** Stage relevant files (prefer specific files over `git add -A`).
Before reviewing or committing, inspect every `??` path from `git status
--short`; `git diff` omits untracked files. Stage each relevant path or read
it explicitly so new tests and helpers are not missed.
- **Splitting commits with new files:** When introducing a brand-new file alongside the file that uses it, stage them together. The Go lint pre-commit hook stashes *unstaged* changes before linting but keeps *untracked* files in the working tree — so a new helper committed alone, while its (still-unstaged) caller sits in the working tree, lints as `unused` and rejects the commit.
5. **Commit:** Write a commit message following the format above. If changes span multiple concerns, consider separate commits.
@@ -133,23 +136,41 @@ qualifies as a successful hook receipt.
changed copy and fail. Localize it and add matching `en`/`pseudo` catalog
entries before retrying; verify with `cd apps/web && pnpm run i18n:check` and
the normal hook receipt.
Capture the normal hook stream in a temporary log while committing and use
that log to record each hook ID and result. Do not infer hook results from a
condensed launcher summary. For example:
If the commit command returns an `exec_command` `session_id`, preserve the
full result and poll that same session until it is terminal; never start a
second commit attempt while the first is live. Retain the exact temporary
log path from that single attempt for the hook receipt. Capture the normal
hook stream in a temporary log while committing and use that log to record
each hook ID and result. RTK can condense `rtk git commit`
output, so use `rtk proxy git commit` when preserving raw hook output. Do not
infer hook results from a condensed launcher summary. For example:
```bash
COMMIT_LOG="$(mktemp "${TMPDIR:-/tmp}/kandev-commit.XXXXXX.log")"
set -o pipefail
git commit -m "type(scope): description" 2>&1 | tee "$COMMIT_LOG" >/dev/null
rtk proxy git commit -m "type(scope): description" 2>&1 | rtk proxy tee "$COMMIT_LOG" >/dev/null
```
Read the log to extract the hook receipt, rather than printing the full
stream again. Remove the exact temporary file after copying the receipt into
Both the commit and `tee` stages must use raw-output mode when the log is
parsed; normal `rtk git commit` output may contain only `ok <sha>` and is not
hook evidence. Read the log to extract every hook ID/result and confirm the receipt still
says `bypass: false`, rather than printing the full stream again. Remove the
exact temporary file after copying the receipt into
the handoff:
```bash
unlink "$COMMIT_LOG"
```
If a timed-out hook leaves a commit or linter process running, inspect
`git status`, `git log`, and only that attempt's processes before retrying.
Wait for or stop the owned process tree first; do not mistake a transient
`parallel golangci-lint is running` message for a second commit failure.
If the commit exits nonzero, preserve and print the full or bounded log
before cleanup so hook diagnostics are not lost. Remove the temporary log
only after copying a successful hook receipt or recording the failed output.
When extracting receipt lines from a nested shell, use shell-safe single-
quoted `awk` or `sed` expressions; a double-quoted `sed` range containing
`$` can be expanded by the outer shell and hide the receipt. Record active
hooks that report `Skipped` or `no files to check` as `skipped`; do not infer
their result from the launcher's summary, and retain the log until both the
pre-commit and commit-msg results are recorded.
If a hook fails only because another worktree is already running
golangci-lint (for example, `parallel golangci-lint is running`), wait for
@@ -34,6 +34,10 @@ Before changing code:
`docs/specs/INDEX.md` to find a legacy source.
- When implementing from a plan, read `plan.md` for orientation and only the
current work order. Follow its `REQ-*`, `AC-*`, and system-design references.
- Dependency, sibling, and stacked PR references are snapshots. Resolve the
current dependency and base heads before implementation and again before
final fixup; if a dependency moved or landed, re-read the affected
requirements, designs, contracts, and traceability links.
- For frontend/UI, include `/mobile-parity` and `/e2e` guidance when applicable.
- For OpenAI/API docs or other fast-moving dependencies, use official docs or primary sources.
@@ -118,6 +118,12 @@ Read these sources before designing the plugin:
4. The current `kdlbs/kandev-plugin-template` repository, including its
`README.md`, `Makefile`, tests, and release workflow.
**Marketplace attribution:** The manifest `author` is independent from GitHub
repository ownership or organization. Never infer author `kandev` from a
`kdlbs/kandev-plugin-*` repository. Read and preserve the manifest author,
verify `repo_url` separately, and require an explicit contributor identity when
an externally maintained plugin is released.
Prefer the public authoring docs and current template over old examples. The
frontend contract pair is `docs/plans/plugins/PLUGIN-API.md` plus
`apps/web/lib/plugins/types.ts`; concrete UI exports are in
+30
View File
@@ -53,6 +53,36 @@ Start with the cheapest faithful reproduction:
with a fresh all-source bundle.
5. Unknown: trace from the symptom backward through code and add temporary instrumentation only where it will split the search space.
When logs show repeated calls to the same endpoint or action, classify each
request by transport, method/action, query or body cursor, caller, and cadence
before diagnosing a loop. A periodic newest-window refresh and cursor
pagination can share a route while serving different purposes. Verify
pagination by capturing the directional cursor request and its response. If no
such request exists, investigate the UI trigger or lifecycle; repeated
uncursored refreshes do not prove that the server failed to advance a cursor.
Before any restart or mutating reproduction, capture the baseline diagnostic
bundle and record the exact database and log pointers. Treat the live database
as latest state, not historical evidence; preserve the baseline and reconstruct
the lifecycle from timestamped logs, events, and transition tables before
comparing later state.
For a GitHub merge-queue ejection, a timeline removal event is not the cause.
Inspect the ruleset's `check_response_timeout_minutes`, the current
`mergeQueueEntry`, and `merge_group` runs. Record the synthetic run/job IDs,
`head_sha`, start/end timestamps, configured workflow/job timeout, and logs
before classifying the failure as CI capacity or changing product code. Use the
PR-fixup merge-queue and CI-troubleshooting references for the query details.
For workflow-routing failures, inspect the workflow's `on` activity types,
job-level `if` gates, permissions, exact PR and head SHA, and the actor that
added a label. Verify token-trigger behavior against GitHub's
[GITHUB_TOKEN documentation](https://docs.github.com/en/actions/concepts/security/github_token):
events created with `GITHUB_TOKEN` generally do not create another workflow run,
and a label-only trigger does not cover a later `synchronize` update. Confirm
the observed run and event rather than inferring that a missing run means the
workflow logic was skipped.
### File-first log triage
Start with the retained backend files before asking for a broad export. Each
+30 -4
View File
@@ -52,10 +52,15 @@ needed when authentication is disabled.
Use the smallest source set that can answer the question. `backend` covers the
retained daily files; `frontend` asks connected tabs for their bounded local
console history; `runtime` adds only allow-listed session status/executor
metadata; `acp` is an explicit debug-only selection of one to ten authorized
sessions and can include full protocol content. Standard evidence does not
include stored chat/session/agent messages, while ACP evidence deliberately can.
console history. The task-session bundle currently accepts only the source
values exposed by its active schema (`backend`, `frontend`, or `all`); do not
assume that `runtime` or `acp` is available. Standard evidence does not include
stored chat/session/agent messages. For a delivery-versus-visibility question,
identify the exact sender and receiver sessions first, then use authorized task
conversation or ACP evidence when that source is available. Inspect
`manifest.json` warnings before drawing conclusions; if the active diagnostic
tool does not expose the requested chat/ACP source, report protocol evidence as
unavailable instead of inferring delivery from ordinary logs.
Extract into a newly created temporary directory, never a repository, home
directory, workspace root, or reused path. Reject ZIP entries that are
@@ -87,6 +92,27 @@ A zero-match task-ID search is inconclusive: install-wide backend events and
browser pages outside recognized task routes may not carry `task_id`. Next
search a precise route/error string or a bounded timestamp window.
## Stalled launch triage
When a session stays `RUNNING` but produces no agent output, do not treat a
responsive browser or WebSocket as proof that the prompt reached the agent.
Correlate three independent facts for the exact session and time window:
1. Retained runtime state: current execution, turn, prompt generation, and
session status.
2. Backend logs: launch admission, attachment claim/materialization, prompt
dispatch, and any asynchronous error or terminal event.
3. Raw and normalized ACP frames for that authorized session, when protocol
evidence is necessary.
No prompt frame means the failure happened before ACP admission. A prompt frame
without a terminal frame points to transport or provider settlement. A terminal
frame with durable state still `RUNNING` points to lifecycle reconciliation.
For staged attachments, trace upload, claim, materialization, and prompt
dispatch separately; an uploaded descriptor is not proof that launch claimed
it. Finally, check whether an asynchronous error only reached a log line or
also entered the correlated terminal execution path.
Backend files also live under `<HOME_DIR>/logs/backend-logs.log` with the two
previous UTC daily archives. Prefer a fresh bundle because its manifest records
completeness and includes requested browser evidence.
+19 -5
View File
@@ -10,6 +10,9 @@ Use this skill to decide whether public docs need updates and to make those upda
## Docs Boundaries
- Public website docs source lives under `docs/public/**`.
- Repository-facing public docs also include the root `README.md` and
`docs/screenshots.md`; search them for feature, integration, and screenshot
changes even when the website source is unchanged.
- Product context, requirements, and system designs stay under `docs/specs/**`.
- Implementation plans and work orders stay under `docs/plans/**`.
- Architecture decisions stay under `docs/decisions/**`.
@@ -37,26 +40,34 @@ Skip public docs when the change is:
## Workflow
1. Identify docs impact from the diff and changed behavior.
2. Search `docs/public/**` first for affected terms and commands.
2. Search `docs/public/**`, the root `README.md`, and `docs/screenshots.md`
first for affected terms, features, integrations, and screenshots.
For behavior changes, also search the same contract and old precedence/reset
wording across `docs/public`, `docs/specs`, and `docs/decisions`; ensure the
introductory summary agrees with later upgrade and recovery sections.
3. If public docs exist, update them with the same PR as the behavior change.
4. If no public docs exist but the behavior is user-facing, add or propose the smallest useful public page/section.
When adding a page, include `title` and `description` frontmatter and list its page slug or path without the `.md` extension in `docs/public/meta.json` exactly once, for example `cli`. See `docs/public/README.md`.
5. If the change only updates implementation intent or architectural history, update specs/plans/ADRs instead.
6. Classify each public page by its primary Diátaxis content type:
6. For operational recovery guidance, distinguish permission-denied, active-lock,
and stale-lock failures before recommending a fallback. Verify that the
fallback uses a different execution or resource path; if it shares the same
worktree, state that it cannot bypass the contention.
7. Classify each public page by its primary Diátaxis content type:
- **Tutorial:** teach a beginner by leading them through one successful outcome.
- **How-to guide:** help a reader complete a known task, with focused steps, choices, and recovery paths.
- **Reference:** provide accurate, complete lookup information such as fields, commands, defaults, limits, or protocol contracts.
- **Explanation:** build understanding of a concept, boundary, rationale, or trade-off.
Keep one dominant type per page. Link to another page when a long section changes from learning to procedure, lookup, or explanation; do not force every page into a generic tutorial-shaped opening.
7. Keep public docs task-oriented and scan-friendly:
8. Keep public docs task-oriented and scan-friendly:
- Tutorials should lead with prerequisites and a linear first success; how-to guides should lead with the task, expected result, and only the prerequisites it needs.
- Reference pages should lead with scope and the contract readers need to look up; explanation pages should lead with the question or concept and why it matters.
- Use short paragraphs (one idea, normally three sentences or fewer) and bullets for choices, limits, and consequences.
- Prefer a link to the page that owns a detailed contract over repeating it.
- Use native `<details>` / `<summary>` disclosures for non-essential edge cases, exhaustive option lists, and advanced configuration. Keep required steps, security warnings, destructive effects, and eligibility limits visible.
- Use tables only for genuine comparisons, not narrative text.
8. Preserve internal links inside `docs/public/**` where possible. Link to source-only raw docs only when the raw note is intentionally not published.
9. Note docs impact and the page's primary content type in the PR body.
9. Preserve internal links inside `docs/public/**` where possible. Link to source-only raw docs only when the raw note is intentionally not published.
10. Note docs impact and the page's primary content type in the PR body.
## Diagrams for Public Docs
@@ -100,6 +111,9 @@ node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
```
Run both public-doc validators when the root README or screenshot catalog is
updated, not only when `docs/public/**` changes.
For website docs publishing changes, also run from the landing repo:
```bash
+28 -255
View File
@@ -104,71 +104,24 @@ See [resource-safety.md](references/resource-safety.md) before any full local te
The runner solves the sharp edges hand-rolling would hit: in docker it builds the CGO backend on the **host** and runs it in the runtime image (forward-compatible when the host glibc ≤ the image's — the usual case; it smoke-tests this and only falls back to the build image if the host is newer), builds the Vite web assets on the host, runs them through the Go-served SPA, and keeps Playwright output container-local. See `apps/web/e2e/README.md` → "the managed runner".
`--no-build` reuses every production E2E artifact, not only Vite assets and the
backend executable. On a fresh worktree or after rebasing, confirm packaged
fixtures also exist; global setup currently requires
`apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz`. If it is absent, run
without `--no-build` or first run `make -C apps/backend e2e-plugin-package`.
Prefer a normal managed build after source or base-branch changes and reserve
`--no-build` for repeated runs against unchanged artifacts.
`--no-build` reuses Vite, backend, and packaged fixtures; prefer normal managed builds
after source/base changes. If intentional, rebuild both with `make -C apps/backend build`
and `make -C apps/backend e2e-plugin-package`; global setup requires the plugin tarball.
For a raw Docker/SSH/container run, `make build-backend` alone does not build
the Linux mock-agent fixture. Prefer the managed runner; otherwise run
`make build-backend build-backend-remote-helpers build-web`. If the fixture
reports a missing `KANDEV_MOCK_AGENT_LINUX_BINARY`, run
`make -C apps/backend build-mock-agent-linux` before diagnosing product code.
For raw Docker/SSH/container runs, `make build-backend` does not build the Linux
mock-agent fixture. Prefer managed; otherwise run `make build-backend
build-backend-remote-helpers build-web`. If `KANDEV_MOCK_AGENT_LINUX_BINARY` is
missing, run `make -C apps/backend build-mock-agent-linux` before diagnosing code.
### Raw commands (when you need fine control)
```bash
make test-e2e # all tests, headless (host)
cd apps && pnpm --filter @kandev/web e2e:raw -- tests/task/my-test.spec.ts # single file
cd apps && pnpm --filter @kandev/web e2e:raw -- --grep "task creation" # by name
cd apps && pnpm --filter @kandev/web e2e:raw -- --grep "task creation" # workspace-filter syntax; from apps/web use `pnpm e2e:raw --project=mobile-chrome e2e/tests/<area>/<spec>.spec.ts` without a standalone `--`; run `--list` first if forwarding syntax is uncertain
```
### Flake reproduction
Start by matching CI as closely as possible, then add pressure deliberately:
1. Run the exact failed shard in the CI runtime image with CI env enabled:
```bash
docker run --rm --ipc=host -v "$PWD":/work -w /work/apps/web \
-e CI=true -e GITHUB_ACTIONS=true -e GITHUB_WORKSPACE=/work \
-e NODE_OPTIONS=--dns-result-order=ipv4first \
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
ghcr.io/kdlbs/kandev-ci:runtime-latest \
bash -lc 'git config --global --add safe.directory /work 2>/dev/null; bash e2e/scripts/run-raw-e2e.sh --project=chromium --project=mobile-chrome --shard=<failed-shard>/14 -- --reporter=list --retries=0'
```
2. If the exact shard passes, constrain container resources and repeat the
failing spec/test. GitHub-hosted runners can expose timing bugs that a roomy
local machine hides:
```bash
docker run --rm --ipc=host --cpus=2 --memory=4g --memory-swap=4g \
-v "$PWD":/work -w /work/apps/web \
-e CI=true -e GITHUB_ACTIONS=true -e GITHUB_WORKSPACE=/work \
-e NODE_OPTIONS=--dns-result-order=ipv4first \
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
ghcr.io/kdlbs/kandev-ci:runtime-latest \
bash -lc 'git config --global --add safe.directory /work 2>/dev/null; bash e2e/scripts/run-raw-e2e.sh --project=mobile-chrome e2e/tests/terminal/mobile-terminal-keybar.spec.ts --grep "user presses an OS-keyboard letter while no modifier is active" --repeat-each=30 --reporter=list --retries=0'
```
3. Preserve nearby test ordering when a single-test repeat stays green; run the full spec or shard with the same resource limits before declaring a flake non-reproducible.
The config uses `failOnFlakyTests: !CI`: local runs fail on a flaky retry, while
CI temporarily tolerates one. Either result is a failure signal for agents:
reproduce it with `--retries=0` only after disabling any `test.describe.configure({ retries: 1 })` override; never
rerun until it happens to pass. If isolated repeats stay green but the shard
fails, binary-search preceding specs in one worker. The fix is complete only
when the smallest reproducing sequence passes without retries.
Record the exact command, resource limits, repeat number, and failure artifact path. For a failed shard, inspect every `error-context.md` in its downloaded
`test-results-<shard>` artifact and compare shared page-object waits with `main`
before changing product code; the context can expose duplicate active terminals
or a terminal stuck on "Starting terminal...".
When a PR E2E shard fails, investigate every spec, including those outside the
diff; never dismiss, rerun, or merely record a failure as unrelated. Reproduce
the exact test, then preserve shard ordering and CI pressure. Fix every valid
defect with retries disabled, or evidence a concrete external blocker.
For flake reproduction, load [failure-triage.md](references/failure-triage.md).
**CRITICAL: E2E tests run against the production Vite build served by the Go backend**, not dev mode. After any frontend code change, you **must** rebuild before running tests (`pnpm e2e:run` does this for you):
@@ -183,6 +136,8 @@ Without this, tests can exercise stale code: after backend changes run `make -C
1. Read `helpers/api-client.ts` and `pages/` to discover available seed methods and page objects; use `data-testid` attributes for selectors — add them to components as needed
2. Import fixtures from `../../fixtures/test-base` — provides `testPage`, `apiClient`, and `seedData` (pre-created workspace with default workflow). Pull `backend` from the fixture too when you need the backend URL — it's worker-scoped, dynamic, and `process.env.KANDEV_API_BASE_URL` is **not** set in the Playwright runner. Use `backend.baseUrl`.
3. Use page objects for common interactions; create new ones for new pages. For GitHub features, use `apiClient.mockGitHub*()` methods to seed mock data
4. Workspace-scoped mock events must carry the real `workspace_id`. `mockGitHubAssociateTaskPR` defaults an omitted ID to the active workspace; pass it explicitly for foreign-workspace cases, and keep production handlers fail-closed for missing or mismatched IDs. Direct-store bridges that inject Git status or inspect Changes must resolve `environmentIdBySessionId[sessionId]` and use environment-keyed state; sessions can share one environment.
5. For new status, hydration, or persistence assertions, inventory existing action/mutation scenarios and add coverage beside them, never replace them; preserve response/error, idempotency/duplicate-suppression, and desktop/mobile pointer/target checks, then run affected specs together.
### Input-modality behavior
@@ -214,9 +169,8 @@ expect(actionsBox).not.toBeNull();
expect(metricsBox!.height).toBeCloseTo(actionsBox!.height, 1);
```
Run the assertion in the relevant desktop and mobile projects when responsive
layout can change the result. Do not rely on fixed pixels when the product
contract is equality or alignment.
Run the assertion in the relevant desktop and mobile projects when responsive layout can change the result. Do not rely on fixed pixels when the product contract is equality or alignment.
For computed colors, parse alpha/opacity semantically or assert a deliberate class/data contract; do not compare serialized `getComputedStyle` strings because browsers may return `rgba()`, `oklab()`, or `color()`.
**Animation-aware geometry:** Before reading dialog or panel geometry, wait only for currently running Web Animations with finite `effect.getComputedTiming().iterations`; await `animation.finished.catch(() => undefined)` because Radix overlays can cancel animations during close or replacement. Never blanket-await infinite animations or use a fixed sleep; then read bounding boxes and assert the relationship.
@@ -251,103 +205,9 @@ test.describe("my feature", () => {
## Dev-first workflow
Before writing an E2E test, validate the feature works interactively using
`pnpm --dir apps exec playwright-cli` against a dev server. This gives a fast
feedback loop — code changes are picked up by hot reload in ~1-2 seconds, no
production rebuild needed. Once confirmed working, translate the interactions
into a proper E2E test.
### Start the dev environment
Multiple agents may run in parallel, so use random ports for dev servers. Managed runner shards are isolated, but separate raw Playwright processes are not automatically safe: fixture ports are backend `18080 + E2E_PORT_OFFSET + workerIndex` and agentctl `30001 + E2E_PORT_OFFSET*1000 + workerIndex*200`; `--repeat-each` advances `workerIndex`, so nearby fixed offsets can overlap later.
```bash
OFFSET=$((RANDOM % 100))
BACKEND_PORT=$((19000 + OFFSET))
FRONTEND_PORT=$((14000 + OFFSET))
```
Start the backend:
```bash
E2E_TMP=$(mktemp -d) && mkdir -p "$E2E_TMP/.kandev" && \
printf '[user]\n name = E2E Test\n email = e2e@test.local\n[commit]\n gpgsign = false\n' > "$E2E_TMP/.gitconfig" && \
HOME="$E2E_TMP" KANDEV_HOME_DIR="$E2E_TMP/.kandev" KANDEV_SERVER_PORT=$BACKEND_PORT \
KANDEV_DATABASE_PATH="$E2E_TMP/kandev.db" KANDEV_MOCK_AGENT=only \
KANDEV_MOCK_GITHUB=true KANDEV_DOCKER_ENABLED=false KANDEV_WORKTREE_ENABLED=false \
KANDEV_LOG_LEVEL=warn apps/backend/bin/kandev &
```
Start the dev frontend:
```bash
KANDEV_API_BASE_URL=http://localhost:$BACKEND_PORT VITE_KANDEV_API_PORT=$BACKEND_PORT \
pnpm --filter @kandev/web dev --port $FRONTEND_PORT &
```
### Validate with playwright-cli
```bash
pnpm --dir apps exec playwright-cli open http://localhost:$FRONTEND_PORT
pnpm --dir apps exec playwright-cli snapshot # see page structure and element refs
pnpm --dir apps exec playwright-cli click e5 # interact using refs from snapshot
pnpm --dir apps exec playwright-cli fill e3 "test input"
pnpm --dir apps exec playwright-cli snapshot # verify result
```
### Fast iteration cycle
1. Make a code change in `apps/web/`
2. HMR picks it up in ~1-2 seconds
3. `pnpm --dir apps exec playwright-cli snapshot` or `pnpm --dir apps exec playwright-cli screenshot` to verify
4. Repeat until the flow works correctly
### Translate to E2E test
Once validated, write the Playwright test using project fixtures and page objects. The `playwright-cli` interactions map directly to Playwright API calls:
| playwright-cli | Playwright API |
|---|---|
| `playwright-cli click e5` | `page.getByTestId('...').click()` |
| `playwright-cli fill e3 "text"` | `page.getByTestId('...').fill('text')` |
| `playwright-cli snapshot` (verify element visible) | `expect(page.getByTestId('...')).toBeVisible()` |
Use `data-testid` selectors in the test (not snapshot refs), and wrap common flows in page objects.
### Capture PR evidence
After confirming the feature works, capture screenshots or a video as proof for the PR:
```bash
# Screenshots of key states
pnpm --dir apps exec playwright-cli screenshot --filename=apps/web/.pr-assets/feature-before.png
# ... interact to show the feature ...
pnpm --dir apps exec playwright-cli screenshot --filename=apps/web/.pr-assets/feature-after.png
# Or record a video walkthrough
pnpm --dir apps exec playwright-cli video-start apps/web/.pr-assets/feature-demo.webm
# ... perform the user flow ...
pnpm --dir apps exec playwright-cli video-stop
```
Create `apps/web/.pr-assets/manifest.json` so the `/pr` skill picks them up:
```json
{
"assets": [
{"name": "feature-demo", "file": "feature-demo.webm", "format": "gif", "caption": "Feature demo"},
{"name": "feature-after", "file": "feature-after.png", "format": "png", "caption": "Result"}
]
}
```
### Final verification
Always verify against the production build before finishing — dev mode can hide boot-payload, asset-serving, or hydration issues:
```bash
pnpm --dir apps exec playwright-cli close
# Kill dev server and backend
make build-web
cd apps && pnpm --filter @kandev/web e2e:raw -- tests/path/to/test.spec.ts
```
For interactive development and PR captures, load
[dev-workflow.md](references/dev-workflow.md).
Production-build verification remains required after development.
## Test organization
@@ -365,12 +225,12 @@ Tests are grouped by feature area in subdirectories under `tests/`. When creatin
- **Verify persistence with page reload.** After changing a setting or creating data, reload the page (`testPage.reload()`) and assert the state is still correct. This catches hydration bugs and Go boot-payload/client-store mismatches.
- **Restore patched persisted settings.** When a test PATCHes user settings, capture the baseline and restore it in `test.afterEach`. The backend is worker-scoped, and `e2eReset` does not reset every persisted setting, including `system_metrics_display`; leaking one can affect later tests in the same worker. Fixtures are lazy: acquire `testPage` before setting a non-default persisted value in `beforeEach`, otherwise page initialization can reapply the default and silently undo setup. Verify with the focused test that depends on that setting.
- **Restore patched shared persisted state.** The worker-scoped backend and
`e2eReset` do not reset every seeded record. A test that PATCHes a canonical
`seedData` profile, repository, executor, or setting must capture its
baseline and restore it in `test.afterEach` (so cleanup also runs after
failure); prefer a disposable record when the UI can select it. Verify by
running the mutating spec followed by its affected neighbour with
`--workers=1 --retries=0`.
`e2eReset` do not reset every seeded record. A test that creates or PATCHes a
canonical `seedData` profile, repository, executor, integration/workspace
preset, or other non-user setting must use unique names for shared/global
rows, capture every changed baseline, and restore every mutation in
`test.afterEach` or `finally` (not only delete rows created by the test).
Prefer a disposable record when the UI can select it. Verify by running the mutating spec followed by its affected neighbour with `--workers=1 --retries=0`. Remove temporary or untracked files written into worker-scoped checkouts in `finally`/`afterEach`, including assertion-failure paths.
- **Pass browser-evaluation values explicitly.** `locator.evaluate` and
`page.evaluate` callbacks execute in the browser, so they cannot close over
Node/test variables. Pass expected values as the argument instead, for
@@ -393,108 +253,21 @@ Tests are grouped by feature area in subdirectories under `tests/`. When creatin
## Debugging failures
### Triage
When a test fails:
1. **Read the error output** — the Playwright error message, expected vs. actual, and which locator timed out
2. **Read `error-context.md`** from `test-results/<test-name>/` — contains a YAML DOM snapshot showing exactly what was rendered. Search for expected elements, check if the page is in the right state (e.g., simple mode vs advanced mode). **These files persist across runs** — always confirm timestamps (portable: `ls -la e2e/test-results/.../error-context.md`; or `stat -c %y` on Linux / `stat -f %Sm` on macOS) or rebuild + rerun the spec fresh before trusting the snapshot. A stale context from a previous failure mode will send you debugging the wrong bug.
3. **Read the failure screenshot** from `e2e/test-results/` — see what the page actually rendered
4. **Attach to the failure** for deeper debugging using `playwright-cli`:
```bash
cd apps && PLAYWRIGHT_HTML_OPEN=never pnpm --filter @kandev/web e2e:raw -- tests/path.spec.ts --debug=cli &
# Wait for "Debugging Instructions" with session name
pnpm --dir apps exec playwright-cli attach tw-<session>
pnpm --dir apps exec playwright-cli snapshot # inspect page state at failure point
pnpm --dir apps exec playwright-cli console # check for JS errors
pnpm --dir apps exec playwright-cli network # check API responses
```
### Classify and fix
| Category | Signals | Fast loop |
|---|---|---|
| **Test logic** | Wrong selector, wrong expected text, missing page object method | Fix test files, re-run immediately (no rebuild -- Playwright transpiles TS at runtime) |
| **Frontend-only** | Screenshot shows wrong UI, missing element, client error. API calls succeed. | Start dev server, fix with hot reload, verify with `playwright-cli`, then `make build-web` + re-run test |
| **Backend** | 500 errors, wrong API response, "Backend did not become healthy" | Fix Go code, `make build-backend`, re-run test |
### Common issues
- **"Backend did not become healthy"** — run `make build-backend build-web`, check with `E2E_DEBUG=1`
- **"Cannot find module"** — run `cd apps && pnpm install --frozen-lockfile`
- **Port conflicts** — run raw repeat/stress invocations sequentially, or prove both computed port ranges are disjoint and check listeners. A totally white screenshot plus `ERR_CONNECTION_REFUSED` is a port/lifecycle signature to rule out before changing waits; never fix it with longer locator timeouts.
- **Responsive layout stays stale after `page.setViewportSize()`** — record
`window.innerWidth`, the affected element and parent `clientWidth`, and any
layout-library width before changing waits. Headless Chromium reliably
resizes the DOM/container and fires `ResizeObserver`, while an
application-only `window.resize` listener may not be observed in the test.
Prefer synchronizing with the layout container/observer and assert the
intended result after both viewport and container-only changes.
- **Auto-started session never goes idle** — for sessions started by the same call that creates them, the mock agent can finish before the client WS subscription registers, so a raw `idleInput()` visibility wait hangs. Use `SessionPage.waitForChatIdle()` before opening transient dialogs/drawers/popovers; it may reload and re-derive state from the Go boot payload. If it must run later, reopen the transient UI first. For WS/session hydration races, retain a bounded reload-and-retry fallback in the page object: keep the fast path immediate and the final check failing when genuinely stuck; remove it only with an equivalent deterministic readiness guarantee and focused regression.
- **Flaky timeouts** — **never increase locator timeouts to fix flaky tests.** If a locator times out, the root cause is almost always something else: a setup failure, missing navigation, race condition, or the element genuinely not rendering. Investigate why the element never appears instead of giving it more time. Note: infrastructure health timeouts (30s in `fixtures/backend.ts`) and overall test timeouts (60s in `playwright.config.ts`) are separate and should not be modified either.
- Screenshots on failure, video on first retry (CI)
### Debugging CI shard failures
CI splits host tests across 14 shards (plus 6 container shards); reproduce a specific shard locally:
```bash
# List which tests are in a shard
pnpm e2e:raw -- --shard=2/14 --list
# Run that shard locally (requires production build)
make build-backend build-web
cd apps/web && pnpm e2e:raw -- --shard=2/14
```
```bash
# Unzip a shard's blob report from CI artifacts
unzip report-*.zip -d report-shard && cat report-shard/*.jsonl
```
When a CI shard fails, distinguish its useful artifacts: download/unzip
`report-*.zip` to map test IDs and timings; `test-results-<shard>` may be ready
before the workflow completes even when logs refuse, so try it for the exact assertion, `error-context.md`, screenshot, and trace:
```bash
gh run download <run-id> --name test-results-<shard> --dir <temp-dir>
```
The blob report surfaces slow-but-passing specs that are latent flake risks;
the test-results artifact identifies the concrete failure to reproduce. Specs
whose duration approaches the 60s per-test timeout (defined in
`playwright.config.ts`) are candidates to harden, typically by converting raw
chat-flow assertions to the `waitForChatIdle()` / `expectChatResponseVisible()`
recovery helpers documented earlier in this file.
### Flake triage: intrinsic race vs. contention
A test that flakes under parallel/sharded load is one of two things — decide which **before** touching it:
1. **Re-run it in a fresh, isolated container** (or at minimum a single fresh worker), `--retries=0`, a few reps:
```bash
pnpm e2e:docker --no-build -- --repeat-each=4 --workers=1 --retries=0 tests/path.spec.ts:LINE
# or raw: pnpm e2e:raw -- --project=chromium --repeat-each=4 --retries=0 tests/path.spec.ts:LINE
```
(On Apple Silicon, `pnpm e2e:docker` needs Colima + Rosetta — `colima start --vz-rosetta`; default QEMU segfaults the amd64 Go build. See `apps/web/e2e/README.md`.)
- **Flakes alone (fails some reps, fast):** intrinsic race — fix it (condition-correct wait, fix the actual race; not a timeout bump). E.g. a `waitForRequest` that times out the full window means the request *never fired* (a click swallowed during hydration) — retry the action with `await expect(async () => { ... }).toPass()`, don't extend the timeout.
- **Passes clean AND fast alone (well under timeout):** contention, not a defect. The wait is correct; the test just starved for CPU/IO under load. No code/test fix applies.
2. **Signature of contention, not a code path:** two identical-config full runs giving *different* hard-fail counts (e.g. 0 vs 3). Same code + same config + different outcome ⇒ host oversubscription, not a bug. CI's isolated runners don't reproduce it; reduce local concurrency (2–3 shards, not 5+) for a clean signal.
3. **Caveat — don't flake-hunt with `--repeat-each` across many heavy specs in one long-lived worker.** It exhausts per-worker resources (agentctl port range, memory) over a long run and manufactures *false* failures unrelated to the test. Use **one fresh container per spec** instead.
For failed specs, shard artifacts, or suspected contention, load
[failure-triage.md](references/failure-triage.md).
## Selector guidelines
- **Prefer `data-testid` selectors** over text-based locators. Text content can change when UI is updated (e.g., hiding a badge), breaking tests that match by text. Use `getByTestId()` or `locator("[data-testid='...']")` for stable targeting. When translated labels intentionally identify multiple routes, scope by stable `href` or a dedicated test ID rather than role/name alone.
- **Scope Radix and responsive locators to the active instance.** Tooltips may use `instant-open`, `delayed-open`, or `open`; use `[data-slot="tooltip-content"]:not([data-state="closed"])`, then scope to the visible portal/popover/container and active ancestor. Hidden mounts can make global locators match the wrong instance; do not use `.first()` to hide duplicates.
- **Use page object methods** like `clickSessionChatTab()` (stable `data-testid`) instead of `sessionTabByText("1")` (fragile text match) for session tabs.
- **Scope Radix and responsive locators to the active instance.** Tooltips may use `instant-open`, `delayed-open`, or `open`; use `[data-slot="tooltip-content"]:not([data-state="closed"])`, then scope to the visible portal/popover/container and active ancestor. For routes with multiple surfaces, scope controls to the active container first; portal overlays must be selected by visible overlay or the trigger's `aria-controls`, not assumed descendants. For portaled pickers, locate the active visible `role=listbox` or picker container first, then scope `getByRole('option')` within it. Hidden mounts can make global locators match the wrong instance; do not use `.first()` to hide duplicates.
- **Use exact dynamic labels and page objects:** `filter({ hasText })` is substring matching; for counts or sibling content, use a stable `data-*` attribute or `getByText(label, { exact: true })` and assert uniqueness. Prefer page object methods like `clickSessionChatTab()` (stable `data-testid`) over fragile text matches such as `sessionTabByText("1")`.
- **Dropdown menus can detach** from the DOM when React re-renders the parent (e.g., WS events updating the sidebar). The `openSidebarMenuAndClick()` helper in `session-page.ts` retries the full open-click sequence on detachment — use this pattern for similar interactions.
## TDD workflow
Follow `/tdd` when writing E2E tests:
1. **RED** — Write the spec, run it, watch it fail (missing `data-testid`, feature not implemented, etc.)
1. **RED** — Write the spec and run a defect-specific expected-vs-actual assertion that fails before production changes; a missing selector/testid is scaffold failure, not behavioral RED. Add stable selectors as setup, or label selector-only RED separately. For a pure state/data UI regression, if a component/unit RED test already proves the bug and the E2E fixture cannot deterministically reproduce the pre-fix state without production wiring, add the E2E assertion after the minimal fix, run it against a fresh managed production build, and record why E2E was not the RED gate.
2. **GREEN** — Implement the feature/fix, add `data-testid` attributes, run the test until green
3. **REFACTOR** — Extract page objects, clean up selectors, keep tests green
4. Run the targeted E2E spec when done and report that final change-aware
verification is required as a separate planner assignment
4. Run the targeted E2E spec when done and report that final change-aware verification is required as a separate planner assignment
@@ -0,0 +1,99 @@
## Dev-first workflow
Before writing an E2E test, validate the feature works interactively using
`pnpm --dir apps exec playwright-cli` against a dev server. This gives a fast
feedback loop — code changes are picked up by hot reload in ~1-2 seconds, no
production rebuild needed. Once confirmed working, translate the interactions
into a proper E2E test.
### Start the dev environment
Multiple agents may run in parallel, so use random ports for dev servers and inspect the calculated backend/agentctl ranges with `ss` or `lsof` before starting; choose an unused `E2E_PORT_OFFSET` and never kill unrelated Kandev processes. Managed runner shards are isolated, but separate raw Playwright processes are not automatically safe: fixture ports are backend `18080 + E2E_PORT_OFFSET + workerIndex` and agentctl `30001 + E2E_PORT_OFFSET*1000 + workerIndex*200`; `--repeat-each` advances `workerIndex`, so nearby fixed offsets can overlap later.
```bash
OFFSET=$((RANDOM % 100))
BACKEND_PORT=$((19000 + OFFSET))
FRONTEND_PORT=$((14000 + OFFSET))
```
Start the backend:
```bash
E2E_TMP=$(mktemp -d) && mkdir -p "$E2E_TMP/.kandev" && \
printf '[user]\n name = E2E Test\n email = e2e@test.local\n[commit]\n gpgsign = false\n' > "$E2E_TMP/.gitconfig" && \
HOME="$E2E_TMP" KANDEV_HOME_DIR="$E2E_TMP/.kandev" KANDEV_SERVER_PORT=$BACKEND_PORT \
KANDEV_DATABASE_PATH="$E2E_TMP/kandev.db" KANDEV_MOCK_AGENT=only \
KANDEV_MOCK_GITHUB=true KANDEV_DOCKER_ENABLED=false KANDEV_WORKTREE_ENABLED=false \
KANDEV_LOG_LEVEL=warn apps/backend/bin/kandev &
```
Start the dev frontend:
```bash
KANDEV_API_BASE_URL=http://localhost:$BACKEND_PORT VITE_KANDEV_API_PORT=$BACKEND_PORT \
pnpm --filter @kandev/web dev --port $FRONTEND_PORT &
```
### Validate with playwright-cli
```bash
pnpm --dir apps exec playwright-cli open http://localhost:$FRONTEND_PORT
pnpm --dir apps exec playwright-cli snapshot # see page structure and element refs
pnpm --dir apps exec playwright-cli click e5 # interact using refs from snapshot
pnpm --dir apps exec playwright-cli fill e3 "test input"
pnpm --dir apps exec playwright-cli snapshot # verify result
```
### Fast iteration cycle
1. Make a code change in `apps/web/`
2. HMR picks it up in ~1-2 seconds
3. `pnpm --dir apps exec playwright-cli snapshot` or `pnpm --dir apps exec playwright-cli screenshot` to verify
4. Repeat until the flow works correctly
### Translate to E2E test
Once validated, write the Playwright test using project fixtures and page objects. The `playwright-cli` interactions map directly to Playwright API calls:
| playwright-cli | Playwright API |
|---|---|
| `playwright-cli click e5` | `page.getByTestId('...').click()` |
| `playwright-cli fill e3 "text"` | `page.getByTestId('...').fill('text')` |
| `playwright-cli snapshot` (verify element visible) | `expect(page.getByTestId('...')).toBeVisible()` |
Use `data-testid` selectors in the test (not snapshot refs), and wrap common flows in page objects.
### Capture PR evidence
After confirming the feature works, capture screenshots or a video as proof for the PR:
```bash
# Screenshots of key states
pnpm --dir apps exec playwright-cli screenshot --filename=apps/web/.pr-assets/feature-before.png
# ... interact to show the feature ...
pnpm --dir apps exec playwright-cli screenshot --filename=apps/web/.pr-assets/feature-after.png
# Or record a video walkthrough
pnpm --dir apps exec playwright-cli video-start apps/web/.pr-assets/feature-demo.webm
# ... perform the user flow ...
pnpm --dir apps exec playwright-cli video-stop
```
Create `apps/web/.pr-assets/manifest.json` so the `/pr` skill picks them up:
```json
{
"assets": [
{"name": "feature-demo", "file": "feature-demo.webm", "format": "gif", "caption": "Feature demo"},
{"name": "feature-after", "file": "feature-after.png", "format": "png", "caption": "Result"}
]
}
```
### Final verification
Always verify against the production build before finishing — dev mode can hide boot-payload, asset-serving, or hydration issues:
```bash
pnpm --dir apps exec playwright-cli close
# Kill dev server and backend
make build-web
cd apps && pnpm --filter @kandev/web e2e:raw -- tests/path/to/test.spec.ts
```
@@ -0,0 +1,145 @@
# E2E failure triage
Load this reference for failing tests, CI shard reproduction, or suspected contention.
Read [resource-safety.md](resource-safety.md) before full or repeated runs.
### Flake reproduction
Start by matching CI as closely as possible. CI uses duration-aware manifests: replay the
matching manifest with `E2E_SHARD=<n> bash e2e/scripts/run-planned-shard.sh <manifest.json>`;
`--shard=N/14` is only approximate. Regenerate the manifest after source changes and never
overlap another managed/raw E2E run. Then add pressure deliberately:
1. Run the exact failed shard in the CI runtime image with CI env enabled:
```bash
docker run --rm --ipc=host -v "$PWD":/work -w /work/apps/web \
-e CI=true -e GITHUB_ACTIONS=true -e GITHUB_WORKSPACE=/work \
-e NODE_OPTIONS=--dns-result-order=ipv4first \
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
ghcr.io/kdlbs/kandev-ci:runtime-latest \
bash -lc 'git config --global --add safe.directory /work 2>/dev/null; bash e2e/scripts/run-raw-e2e.sh --project=chromium --project=mobile-chrome --shard=<failed-shard>/14 -- --reporter=list --retries=0'
```
2. If the exact shard passes, constrain container resources and repeat the
failing spec/test. GitHub-hosted runners can expose timing bugs that a roomy
local machine hides:
```bash
docker run --rm --ipc=host --cpus=2 --memory=4g --memory-swap=4g \
-v "$PWD":/work -w /work/apps/web \
-e CI=true -e GITHUB_ACTIONS=true -e GITHUB_WORKSPACE=/work \
-e NODE_OPTIONS=--dns-result-order=ipv4first \
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
ghcr.io/kdlbs/kandev-ci:runtime-latest \
bash -lc 'git config --global --add safe.directory /work 2>/dev/null; bash e2e/scripts/run-raw-e2e.sh --project=mobile-chrome e2e/tests/terminal/mobile-terminal-keybar.spec.ts --grep "user presses an OS-keyboard letter while no modifier is active" --repeat-each=30 --reporter=list --retries=0'
```
3. Preserve nearby test ordering when a single-test repeat stays green; run the full spec or shard with the same resource limits before declaring a flake non-reproducible.
The config uses `failOnFlakyTests: !CI`: local runs fail on a flaky retry, while
CI temporarily tolerates one. Either result is a failure signal for agents:
reproduce it with `--retries=0` only after disabling any `test.describe.configure({ retries: 1 })` override; never
rerun until it happens to pass. If isolated repeats stay green but the shard
fails, binary-search preceding specs in one worker. The fix is complete only
when the smallest reproducing sequence passes without retries.
Record the exact command, resource limits, repeat number, and failure artifact path. For an explicit no-flakes requirement, download `blob-report-*` with `gh run download <run-id> --pattern 'blob-report-*' --dir <tmp>` and run `python3 scripts/playwright-blob-audit <tmp>`; aggregate green is not flake-free. For a failed shard, inspect every `error-context.md` in its downloaded
`test-results-<shard>` artifact and compare shared page-object waits with `main`
before changing product code; the context can expose duplicate active terminals
or a terminal stuck on "Starting terminal...".
When a PR E2E shard fails, investigate every spec, including those outside the
diff; never dismiss, rerun, or merely record a failure as unrelated. Reproduce
the exact test, then preserve shard ordering and CI pressure. Fix every valid
defect with retries disabled, or evidence a concrete external blocker.
## Debugging failures
### Triage
When a test fails:
1. **Read the error output** — the Playwright error message, expected vs. actual, and which locator timed out
2. **Read `error-context.md`** from `test-results/<test-name>/` — contains a YAML DOM snapshot showing exactly what was rendered. Search for expected elements, check if the page is in the right state (e.g., simple mode vs advanced mode). **These files persist across runs** — always confirm timestamps (portable: `ls -la e2e/test-results/.../error-context.md`; or `stat -c %y` on Linux / `stat -f %Sm` on macOS) or rebuild + rerun the spec fresh before trusting the snapshot. A stale context from a previous failure mode will send you debugging the wrong bug.
3. **Read the failure screenshot** from `e2e/test-results/` — see what the page actually rendered
4. **Attach to the failure** for deeper debugging using `playwright-cli`:
```bash
cd apps && PLAYWRIGHT_HTML_OPEN=never pnpm --filter @kandev/web e2e:raw -- tests/path.spec.ts --debug=cli &
# Wait for "Debugging Instructions" with session name
pnpm --dir apps exec playwright-cli attach tw-<session>
pnpm --dir apps exec playwright-cli snapshot # inspect page state at failure point
pnpm --dir apps exec playwright-cli console # check for JS errors
pnpm --dir apps exec playwright-cli network # check API responses
```
### Classify and fix
| Category | Signals | Fast loop |
|---|---|---|
| **Test logic** | Wrong selector, wrong expected text, missing page object method | Fix test files, re-run immediately (no rebuild -- Playwright transpiles TS at runtime) |
| **Frontend-only** | Screenshot shows wrong UI, missing element, client error. API calls succeed. | Start dev server, fix with hot reload, verify with `playwright-cli`, then `make build-web` + re-run test |
| **Backend** | 500 errors, wrong API response, "Backend did not become healthy" | Fix Go code, `make build-backend`, re-run test |
### Common issues
- **"Backend did not become healthy"** — run `make build-backend build-web`, check with `E2E_DEBUG=1`
- **"Cannot find module"** — run `cd apps && pnpm install --frozen-lockfile`
- **Port conflicts** — run raw repeat/stress invocations sequentially, or prove both computed port ranges are disjoint and check listeners. A totally white screenshot plus `ERR_CONNECTION_REFUSED` is a port/lifecycle signature to rule out before changing waits; never fix it with longer locator timeouts.
- **Responsive layout stays stale after `page.setViewportSize()`** — record
`window.innerWidth`, the affected element and parent `clientWidth`, and any
layout-library width before changing waits. Headless Chromium reliably
resizes the DOM/container and fires `ResizeObserver`, while an
application-only `window.resize` listener may not be observed in the test.
Prefer synchronizing with the layout container/observer and assert the
intended result after both viewport and container-only changes.
- **Initial hydration/readiness regressions:** do not use a page-object readiness helper that can reload or re-navigate the page (for example `SessionPage.waitForLoad`) to prove first-render behavior. Wait directly for the invariant locator on the current navigation, using a bounded non-reloading wait when needed. Keep reload-capable helpers for persistence and recovery scenarios.
- **Auto-started session never goes idle** — for sessions started by the same call that creates them, the mock agent can finish before the client WS subscription registers, so a raw `idleInput()` visibility wait hangs. Use `SessionPage.waitForChatIdle()` before opening transient dialogs/drawers/popovers; it may reload and re-derive state from the Go boot payload. If it must run later, reopen the transient UI first. For WS/session hydration races, retain a bounded reload-and-retry fallback in the page object: keep the fast path immediate and the final check failing when genuinely stuck; remove it only with an equivalent deterministic readiness guarantee and focused regression.
- **Editor readiness is not submit readiness** — a `contenteditable` may be present and writable while a session is still `STARTING`. Before submitting, wait for the scoped submit control to be enabled or for the exact session to reach `WAITING_FOR_INPUT`; use retries disabled when reproducing this boundary.
- **Flaky timeouts** — **never increase locator timeouts to fix flaky tests.** If a locator times out, the root cause is almost always something else: a setup failure, missing navigation, race condition, or the element genuinely not rendering. Investigate why the element never appears instead of giving it more time. Note: infrastructure health timeouts (30s in `fixtures/backend.ts`) and overall test timeouts (60s in `playwright.config.ts`) are separate and should not be modified either.
- Screenshots on failure, video on first retry (CI). In workflow cache steps, `actions/cache/restore` sets `cache-hit` to `true` for an exact key, `false` for a restore-key/prefix hit, and empty on a miss; gate verification/fallback on empty versus non-empty, smoke-test Chromium before skipping image extraction, and use bounded backoff for transient registry metadata probes such as `docker buildx imagetools inspect`.
- **Page-closed errors after timeouts** — `Target page, context or browser has been closed` can be teardown masking an earlier overlay interception. Inspect the preceding action and screenshot, for example with `rtk proxy unzip -p <trace.zip> 0-trace.trace | rtk proxy jq -c 'select(.type == "action" or .type == "error")'`, then close the overlay in the page object, assert it is closed, and rerun with retries disabled before changing timeouts or routes.
### Debugging CI shard failures
CI splits host tests across 14 shards (plus 6 container shards); reproduce a specific shard locally. Download `e2e-shard-manifests` when available, verify its selected file list matches the failed job, and prefer the CI runtime image:
```bash
# Run the exact duration-aware manifest-selected shard
E2E_SHARD=2 KANDEV_E2E_SKIP_FRESHNESS=1 bash e2e/scripts/run-planned-shard.sh <workspace>/e2e-manifests/normal/2.json
# If no manifest exists, inspect the ordinal fallback with `npx playwright test --config e2e/playwright.config.ts --shard=2/14 --list`, then run it with a production build
make build-backend build-web
cd apps/web && pnpm e2e:raw -- --shard=2/14
```
```bash
# Unzip a shard's blob report from CI artifacts
unzip report-*.zip -d report-shard && cat report-shard/*.jsonl
```
When a CI shard fails, distinguish its useful artifacts: download/unzip
`report-*.zip` to map test IDs and timings; `test-results-<shard>` may be ready
before the workflow completes even when logs refuse, so try it for the exact assertion, `error-context.md`, screenshot, and trace:
```bash
gh run download <run-id> --name test-results-<shard> --dir <temp-dir>
```
The blob report surfaces slow-but-passing specs that are latent flake risks;
the test-results artifact identifies the concrete failure to reproduce. Specs
whose duration approaches the 60s per-test timeout (defined in
`playwright.config.ts`) are candidates to harden, typically by converting raw
chat-flow assertions to the `waitForChatIdle()` / `expectChatResponseVisible()`
recovery helpers documented earlier in this file.
### Flake triage: intrinsic race vs. contention
A failure under load can indicate a race, leaked fixture state, or resource contention.
Gather evidence before assigning a cause:
1. **Re-run it in a fresh, isolated container** (or at minimum a single fresh worker), `--retries=0`, a few reps:
```bash
pnpm e2e:docker --no-build -- --repeat-each=4 --workers=1 --retries=0 tests/path.spec.ts:LINE
# or raw: pnpm e2e:raw -- --project=chromium --repeat-each=4 --retries=0 tests/path.spec.ts:LINE
```
(On Apple Silicon, `pnpm e2e:docker` needs Colima + Rosetta — `colima start --vz-rosetta`; default QEMU segfaults the amd64 Go build. See `apps/web/e2e/README.md`.)
- **Flakes alone (fails some reps, fast):** intrinsic race — fix it (condition-correct wait, fix the actual race; not a timeout bump). E.g. a `waitForRequest` that times out the full window means the request *never fired* (a click swallowed during hydration) — retry the action with `await expect(async () => { ... }).toPass()`, don't extend the timeout.
- **Passes clean and fast alone:** the cause remains unknown. Preserve shard order and resource limits to distinguish fixture leakage from contention.
2. **Different failure counts across identical runs are inconclusive.** Races can also produce different results. Record CPU, memory, and process pressure. Compare resource-bounded runs before attributing the failure to oversubscription.
3. **Caveat — don't flake-hunt with `--repeat-each` across many heavy specs in one long-lived worker.** It exhausts per-worker resources (agentctl port range, memory) over a long run and manufactures *false* failures unrelated to the test. Use **one fresh container per spec** instead.
@@ -10,6 +10,16 @@ For tests using agent models, modes, commands, or options, treat
or test ID and bounded-poll for the semantic capability before interacting.
Avoid arbitrary sleeps and `force: true` clicks.
## Managed-runtime capability probes
Host-utility capability checks call the managed agent's `IsInstalled()` probe
before they invoke the managed `npx` path. If a fixture replaces `npx` to test
package recovery, also put a discoverable executable for that agent on the
fixture `PATH` (for example, a scoped `opencode` shim). Assert that the initial
capability state is `ok` before exercising recovery; otherwise the fixture can
stop at `not_installed` and never test the intended path. Verify with the
focused containers test and `--retries=0`.
## Remembered workflow selection
After seeding tasks in multiple workflows, set
@@ -10,6 +10,11 @@ most three local shards, and lowers that limit when available memory is below
runs inside a container. This prevents host memory reporting from authorizing
more shards than the container can hold.
Use `pnpm e2e:run`, guarded `pnpm e2e:raw`, or `make test-e2e` for ordinary
verification. Reserve direct Playwright commands, all-worker overrides, and
overlapping full suites for deliberate pressure or guard reproduction. Keep
those experiments to one worker or shard by default and record the reason.
Start with the default single shard; use two or three only when the host has
capacity for separate Go backends, SPA processes, Chromium instances, and mock
agents. If a command is rejected, reduce its shard or worker count. Do not
@@ -23,6 +23,23 @@ already-active session may emit no new subscribe frame; consume the original
frame or trigger a distinct state transition. Verify ordering in the owning
Playwright project with `--retries=0`.
Arm every WebSocket wait before the action that should produce it, including all
waits when one action emits multiple notifications. Establish startup state with
REST `expect.poll`, then correlate each post-action event by an active-to-settled
transition, revision, or timestamp; `watchWs` does not buffer frames, so never
attach a wait after a spinner or visibility assertion.
## Automatic lifecycle evidence
When a test proves automatic start, resume, or recovery, seed and assert the
exact precondition before the stimulus. For archive recovery, that means an
active `WAITING_FOR_INPUT` session whose archive cancellation records the archive
reason. Reset transport capture immediately before the stimulus, then require
the automatic request or event and passive backend-state polling before any
retry or manual recovery action. Do not call `SessionPage.waitForChatIdle()` or
another helper that reloads/clicks recovery as the first proof, because it can
mask a missing automatic request. Verify the full spec with `--retries=0`.
## Terminal and Dockview helpers
- Scope terminal/mobile helpers to the active `data-testid="terminal-panel"`;
@@ -32,6 +49,20 @@ Playwright project with `--retries=0`.
- If a helper uses `window.__dockviewApi__`, poll until the API is attached;
silently skipping cleanup during page initialization leaks panels into later
assertions.
- Dockview-capable specs must activate a visible public entry point (a tab or
topbar/toolbar affordance) instead of assuming the default tab exists after
another scenario. Scope assertions to the visible panel and run the full spec
to catch order-dependent persisted-layout state.
For a restored hidden-surface regression, seed the persisted multi-panel state,
leave the target inactive, reload, and prove the target is mounted before
activating it through the shipped control. If the failure depends on a browser
observer missing the hidden-to-visible geometry transition, install a
pre-navigation wrapper that suppresses callbacks only for the target entries
and leaves unrelated observers intact. Assert both the correctly shaped
network request, including its cursor or direction, and the visible user
result. This distinguishes lifecycle recovery from an accidentally cooperative
observer without exposing a test-only production entry point.
## Sidebar and context-menu editing
+6
View File
@@ -57,6 +57,12 @@ Classify the bug:
Do not create a standalone repair specification. The requirement is the durable
behavioral source. The plan and work orders record this repair.
For predicates over collections or inventory, state the quantifier explicitly:
distinguish existential conditions such as “at least one live row” from
universal conditions such as “all rows.” When behavior depends on that
distinction, require a mixed-state regression containing both live and failed or
deleted rows.
Do not create parallel feature and UI requirements for one repair. A separate UI
requirement is valid only when the repair changes an independent reusable UI
contract.
@@ -49,10 +49,18 @@ mirror unless the user explicitly requests a policy reversal.
- Put deterministic logic in `scripts/` when agents keep retyping fragile shell/API sequences.
- Avoid creating multiple aliases for the same behavior.
- **Separate independent policy changes:** Keep factual instruction updates
and CI changes required by a feature in that feature's PR.
Use a separate initiative for independent workflow-policy changes unless
the user requests a combined PR. Classify changes by purpose, not path.
4. **Preserve progressive disclosure**
- Keep `SKILL.md` concise.
- Move platform tables, long examples, templates, and edge-case notes to `references/`.
- Reference each supporting file explicitly from the main skill so future agents know when to load it.
- Review word and byte counts as well as line counts. Do not join paragraphs
to satisfy a line limit. Move specialized procedures into references with
explicit loading conditions. Keep each rule in one authoritative location.
5. **Edit and validate**
- Use `apply_patch` for file edits.
@@ -8,9 +8,20 @@ For each learning, extract:
- **Trigger:** when future agents should apply it.
- **Failure mode:** what went wrong or wasted time.
- **Evidence:** the observed command, assertion, log, or source that supports
the lesson. Keep source task/PR links in the collection record.
- **Applicability:** the conditions where the lesson holds and known exceptions.
- **Correct action:** exact behavior, command, fallback, or interpretation.
- **Verification:** how an agent knows the workaround succeeded.
- **Home:** skill, agent, script, AGENTS.md, or command.
- **Existing guidance:** the rule this lesson replaces or strengthens.
Keep unverified explanations in the collection record as hypotheses.
Before promotion, verify the explanation against current code or authoritative
documentation. One verified incident can justify a narrowly scoped rule.
Do not turn its workaround into a universal requirement without supporting evidence.
Keep session history in the collection record and reusable instructions in the
owning skill or scoped guidance. Encode deterministic checks in scripts and tests.
Prefer this shape inside skills:
@@ -31,11 +42,11 @@ only on explicit user request or to remediate a PR/CI finding.
## Placement Rules
- PR/CI/review-thread behavior belongs in `.agents/skills/pr-fixup/SKILL.md`.
- PR/CI/review-thread behavior belongs in `.agents/skills/pr-fixup/`.
- PR creation/body/push behavior belongs in `.agents/skills/pr/SKILL.md` or `.agents/skills/push/SKILL.md`.
- Full repo verification belongs in `.agents/skills/verify/SKILL.md`.
- TDD/test-level guidance belongs in `.agents/skills/tdd/SKILL.md`.
- E2E commands and flake triage belong in `.agents/skills/e2e/SKILL.md`.
- E2E commands and flake triage belong in `.agents/skills/e2e/`.
- Runtime debugging belongs in `.agents/skills/debug/`.
- Backend/frontend conventions belong in the closest scoped `AGENTS.md`.
- Repeated fragile commands should become or update a `scripts/` helper, with syntax and mocked behavior checks.
@@ -9,7 +9,7 @@ python3 .github/scripts/lint-harness-files.py --all
python3 scripts/lint-spec-files.test.py
python3 scripts/lint-spec-files.py --all
git diff --check -- <changed-files>
wc -l <changed-harness-files>
wc -l -w -c <changed-harness-files>
```
Before committing, also run the targeted pre-commit hook:
@@ -21,3 +21,7 @@ pre-commit run harness-lint --files <changed-files>
Keep every `AGENTS.md` and `CLAUDE.md` file at 300 lines or fewer. Move detail
to the nearest scoped file or a referenced document when a parent reaches the
limit.
Compare word and byte counts with the previous version. If the common workflow
grows, remove duplicate rules or move conditional detail into linked references.
Line limits do not measure reading cost.
+5
View File
@@ -45,6 +45,11 @@ concrete enough that the user can answer quickly.
If no multi-question tool is available, ask one question at a time in chat. Do not send a long questionnaire.
If a native single-question interaction resolves one material ambiguity, record
that decision and continue when the remaining outcome, success conditions, and
scope are already explicit. Require the full restatement confirmation below
only when broad intent is still missing.
### 3. Probe Convention-Sounding Answers
If the user says "modern", "scalable", "best practice", "dashboard", "robust", or "clean architecture" without concrete outcomes, ask:
+5
View File
@@ -64,8 +64,10 @@ When a task changes composition, navigation, overlays, touch behavior, scrolling
3. Implement responsive UI.
- Reuse domain hooks, state, view-model derivation, filtering/selection, and action handlers across viewports; keep responsive wrappers focused on presentation. Branch composition when the desktop interaction model depends on width or a fine pointer. Do not mount a heavyweight desktop workbench and merely hide or squeeze it on phones.
- Use `useResponsiveBreakpoint` and existing `@kandev/ui` or mobile primitives. Reuse current Dropdown/ContextMenu primitives for contextual actions; use `Drawer` or an existing picker shell for structured phone navigation and choices. Use `useTouchDrawer` when a hover disclosure needs a coarse-pointer alternative.
- A Radix Tooltip that happens to open after Playwright `.tap()` is not a coarse-pointer alternative. Use `useTouchDrawer` with a Drawer branch and assert the drawer surface in mobile E2E.
- Keep coarse-pointer and mobile touch targets large enough for touch use, generally at least 44px in the active dimension. This is an active hit-area rule, not a universal desktop visual-size rule: fine-pointer desktop controls should retain the surrounding design-system density, and touch-sized classes such as `h-11` must not become the shared desktop button size.
- When one component serves both pointer modes, keep its fine-pointer size as the base class and add the 44px size only behind a coarse-pointer or phone variant. An unqualified `h-11` or `min-h-11` on a desktop-visible shared action is a sizing bug.
- For a primary mobile dialog action whose contract requires an actual hit target of at least 44px, prefer 48px nominal sizing when browser scaling can produce fractional bounds. Assert the rendered `boundingBox().height` (or width for a horizontal target) is at least 44px; a utility class alone does not prove the physical target.
- Use dynamic viewport units and an explicit internal scroll region for viewport-bound/full-height or potentially overflowing surfaces. Ensure bottom-fixed controls and tall drawers clear safe-area insets; short drawers can retain the shared primitive's intrinsic sizing. Keep document-level horizontal overflow at zero.
- If mobile substitutes an unsupported desktop view, derive an effective mobile view without overwriting the user's saved desktop preference.
- Use semantic controls, visible labels or accessible names, focus return, and existing design-system components.
@@ -77,6 +79,9 @@ When a task changes composition, navigation, overlays, touch behavior, scrolling
- In this repo, name mobile test files `mobile-*.spec.ts` so the `mobile-chrome` Playwright project picks them up automatically.
- Cover the actual mobile composition: drawer or full-height surface, visible overflow action, focused navigation, direct route, or bottom control.
- For overlay and dense-navigation changes, assert viewport containment, internal scrolling, and the absence of document horizontal overflow where those properties are part of the regression.
- For responsive visual or utility changes, define the base phone behavior and assert computed styles at the canonical phone viewport and just below and above the relevant breakpoint; geometry or overflow checks alone can miss a desktop style leaking into a full-screen mobile surface.
- When responsive CSS changes an overlay or absolute child to normal flow (for example, `position: static`), re-check the parent's allocated width and height; a fixed-size desktop wrapper can under-report combined in-flow mobile content.
- For containment regressions, compare the interactive control's bounding box with its row, drawer, or viewport bounds. An intrinsic 44px hitbox plus a document-overflow check does not prove that an ancestor is not clipping the control.
- When a touch-only control is replaced or hidden, run `rg` across mobile E2E tests for the removed control. Replace every affected interaction with the intended gesture or alternate control, then run those tests together.
5. Verify visually and behaviorally.
@@ -90,6 +90,7 @@ Use the configured `mobile-chrome` Pixel 5 project and assert user outcomes. Add
- primary action is visible and completes the same outcome as desktop
- drawer/menu remains inside the viewport and long content scrolls internally
- new primary actions, standalone icon controls, and menu rows meet the 44px hitbox expectation
- controls revealed by responsive position overrides stay inside their surface: `controlBox.x >= surfaceBox.x` and `controlBox.x + controlBox.width <= surfaceBox.x + surfaceBox.width`, with the equivalent vertical checks when relevant
- nested choices remain usable without horizontal overflow
- document `scrollWidth` does not exceed `clientWidth`
- fixed controls and final rows clear the bottom safe area
+26
View File
@@ -24,6 +24,12 @@ Read these sources in full:
Use the legacy feature specification during migration only when no replacement
requirement or design exists.
Read each spec's `Implementation Plans` and related-plan links, then inventory
all existing companion plan directories. Treat linked packages as one
synchronized implementation record: reconcile every affected task scope, E2E
scenario matrix, status, and exact final verification results/counts before
completion.
## Outputs
Create:
@@ -198,9 +204,29 @@ Every work order needs exact commands. Use repository `make` targets when they
exist. Frontend work in a fresh worktree includes the workspace dependency
installation before the first package command.
Write each verification block so that a user can run the complete block in one
shell. If commands change directories, use one directory change or isolated
subshells.
When implementation is complete, replace `Pending` with every required command
and its result. Include specification and diff gates that run outside the
product test commands.
User-facing behavior needs end-to-end evidence somewhere in the work package.
A low-level work order does not need an artificial browser test.
After implementation and targeted checks pass, run every listed verification
command from its documented working directory. In a multi-command block, root
each command independently with `(cd <dir> && ...)` or an explicit root reset;
never rely on a preceding `cd`. Confirm each referenced path exists and stays
inside the tool or package scope, and that every changed test suite is covered
before marking Results complete. Record actual results, not planned or stale counts.
Before marking a new plan package complete, run `git diff --check --
docs/plans/<initiative>` and `git status --short -- docs/plans/<initiative>`;
the status check catches untracked work orders. Confirm every work order names
existing `REQ-*`/`AC-*` IDs and an existing system-design path.
Do not add generic QA, review, simplify, security, or full-verification tasks.
Task checks provide pre-PR evidence. Configured PR reviewers provide semantic
review after the PR opens.
@@ -74,6 +74,55 @@ playwright-cli tracing-stop
# Open trace to see DOM state when click was attempted
```
### Raw CDP performance traces
For performance measurements that need raw Chrome trace events, use a CDP
session and `transferMode: ReturnAsStream`. `Tracing.tracingComplete` provides
the stream handle after `Tracing.end`; decode each `IO.read` chunk as base64
only when its `base64Encoded` flag is true, then close the stream. Guard the
entire operation so tracing and the CDP session are cleaned up even when the
measurement fails. Do not disable page script execution during the interaction
because that changes the work being measured:
```js
const client = await page.context().newCDPSession(page);
let tracingStarted = false;
let stream;
try {
const complete = new Promise(resolve =>
client.once('Tracing.tracingComplete', resolve),
);
await client.send('Tracing.start', {
transferMode: 'ReturnAsStream',
categories: 'devtools.timeline,v8.execute',
});
tracingStarted = true;
// Run the measured interaction here.
await client.send('Tracing.end');
({ stream } = await complete);
for (;;) {
const chunk = await client.send('IO.read', { handle: stream });
const bytes = chunk.base64Encoded
? Buffer.from(chunk.data, 'base64')
: Buffer.from(chunk.data);
processTraceChunk(bytes);
if (chunk.eof) break;
}
} finally {
if (tracingStarted) {
try { await client.send('Tracing.end'); } catch {}
}
if (stream) {
try { await client.send('IO.close', { handle: stream }); } catch {}
}
try { await client.detach(); } catch {}
}
```
In production code, use a bounded wait for `Tracing.tracingComplete` and keep
the cleanup guards when the timeout fires. Do not parse every chunk as base64,
and do not leave the CDP session attached after the trace.
### Analyzing Performance
```bash
+119 -108
View File
@@ -32,91 +32,54 @@ Create a visible checklist:
## 1. Gather PR State
Before the first GitHub helper call, request any runtime network approval that
the environment requires. If access is denied, cancelled, or interrupted, stop
the workflow permanently; retry only transient fetch failures after access is
approved.
Before the first GitHub call, obtain any network approval required by the
runtime. If the runtime denies access, stop until the user authorizes access.
Run `scripts/pr-state --summary <PR>` once. Record the current-head check,
review, and PR-delivery fields described in
`references/review-evidence.md`. For a cross-repository PR, use only the
reported delivery fields as the push target; do not infer it from a local
`fork` or `contributor` remote. Load that reference for exact-head review
classification, authentication fallbacks, and hidden-thread handling. Inspect
mergeability separately through `references/merge-conflicts.md`.
Run `scripts/pr-state --summary <PR>` and `scripts/pr-resolve list <PR>`.
Load [review-evidence.md](references/review-evidence.md) for snapshot fields,
review classification, hidden threads, and access fallbacks.
For cross-repository PRs, use the snapshot's delivery fields as the push target.
If the fresh mergeability query reports `mergeable=CONFLICTING` or
`mergeStateStatus=DIRTY`, stop CI and review triage. This is an actionable PR
blocker, not a clean or report-only terminal state. Load
`references/merge-conflicts.md`. If the user has explicitly authorized PR
fixup or conflict resolution, resolve the conflict now (prefer a merge of the
fresh base unless the user requests a rebase), verify the result, push it, and
restart this section with a new PR-state snapshot. If authorization is absent,
report the conflict and ask for it before mutating the branch. Do not triage
comments or checks against a conflicted head.
Then run `gh pr view <PR> --json state,baseRefName,headRefOid,mergeable,mergeStateStatus,reviewDecision`.
Require matching head SHAs before triage. Verify the base SHA against the
current base-ref tip. Refresh mismatched metadata before attributing failures.
If the PR is merged or closed, stop. For a merge, report
`mergedAt` and `mergeCommit.oid`.
For pending CI, wait with `scripts/pr-await <PR>`. It blocks below the
conversation and prints one report when every check is terminal, so a wait of
any length costs a single round-trip. Do not re-run `scripts/pr-state
--summary` on a timer instead: each snapshot is a separate model turn that
re-reads the whole context, and waiting is only about 6% of what such a turn
actually does.
Treat peer alerts as advisory until their SHA matches the current head.
If captured output is empty, truncated, or invalid, rerun through `rtk proxy`.
Capture stdout and stderr separately and preserve the exit code.
An empty capture cannot prove that the PR is clean.
Default `--mode all-terminal` is the cost-correct choice and returns only when
no check is pending, so every failure arrives in one report and is fixed in one
pass. It is also the correct choice for a reason unrelated to cost: a failed job
can be visible while its parent workflow is still running, so an early return
reports a partial failure set. Use `--mode first-failure` only when the user
wants the first failure interactively; it costs an extra full CI cycle per
failure. Pass `--deadline-min` for a user-stated limit; on exit 2 report the
named pending checks as "CI in progress."
If GitHub reports a conflict, load
[merge-conflicts.md](references/merge-conflicts.md) before CI or review triage.
An explicit fixup request authorizes conflict resolution within that request.
Otherwise, report the conflict without changing the branch.
Read its exit code rather than re-deriving state: 0 clean, 1 terminal with
findings, 2 deadline with checks still pending, 3 blocked. Exit 3 covers every
case where a clean answer cannot be trusted: the PR merged or closed, a workflow
needs approval, access was lost, the merge-state query failed, the snapshot
reported `errors` or a null unresolved-thread count, or the host toolchain
cannot run `pr-state` correctly. Never downgrade an exit 3 to "probably clean."
If the base advanced without conflicts, use that reference's advanced-base
procedure. Base advancement alone does not require a branch update.
Record the head, base, synthetic merge, and focused test results.
If either input SHA changes, repeat the relevant validation.
Keep the helper's exit code unchanged and report any separately verified base drift.
Exit 1 counts blocking reviews, not only threads: a current-head
`CHANGES_REQUESTED` review can exist with no unresolved thread attached.
For related or stacked PRs, use the semantic-conflict procedure in the same
reference. Reconcile changed contracts and stable IDs before delivery.
Every report records the jq and bash versions it ran under. That is provenance,
not a gate: `pr-state` used to fail silently on jq 1.6 and on the bash 3.2 that
macOS ships as `/bin/bash`, both of which made a dirty PR look clean, and both
are fixed at the source. What guards against a degraded `pr-state` now is the
snapshot itself, which is stronger than any version check: a summary reporting
`errors`, one whose unresolved-thread count is null, and one that does not parse
are all treated as unknown rather than clean. A blocked report on an old
toolchain adds a note naming it as a possible cause. A push during the wait
restarts the gate against the new head and is reported.
For pending checks, load [waiting.md](references/waiting.md).
Use `scripts/pr-await` and its final exit code.
For queue membership or enqueue/dequeue requests, load
[merge-queue.md](references/merge-queue.md).
For queue removals or failed synthetic checks, also load
[ci-troubleshooting.md](references/ci-troubleshooting.md).
Ordinary PR-head checks do not validate a merge-group commit.
Do not use interactive `gh pr checks --watch` in the primary conversation: its
TTY redraws make captured output unusable. Use the read-only `pr-poller` only
when the user explicitly asked to wait or monitor and `pr-await` is unavailable.
Treat a poller's unresolved/pending snapshot as provisional: it can predate a
primary-session push or thread resolution. Re-run `scripts/pr-state --summary
<PR>` at the current head before acting on it or declaring completion.
If a job remains pending beyond the workflow's configured timeout, or its status
conflicts with the GitHub UI/API, query the exact job with
`gh api repos/<owner>/<repo>/actions/jobs/<job_id>` (or inspect the run with
`gh run view <run_id>`) before calling CI hung or changing code. Treat the direct
result as current-head evidence only after its `head_sha` matches
`checks_head_sha`; otherwise report the result as stale or unknown.
Directly supplied review findings need current-source validation even without
a GitHub thread. Apply valid fixes within the user's authorization.
Do not invent comment IDs or resolutions for findings without a thread.
External replies and thread resolution still require user authorization.
For a cross-repository PR whose current-head snapshot is unexpectedly sparse,
inspect `approval_required_runs`. A current-head workflow with
`conclusion=action_required` is blocked verification, not green or skipped CI.
Only after the user authorizes PR fixup, approve the exact run with
`gh api --method POST repos/<base-owner>/<base-repo>/actions/runs/<run-id>/approve`,
then re-run the summary and require jobs to materialize before polling. `gh run
approve` is not a valid command.
Treat the state as clean only when the current head has no failed or pending
checks, no merge conflict, no blocking review (an active `CHANGES_REQUESTED`
or a review blocked at the exact current head), no unresolved review thread, no
actionable issue comment, and qualifying exact-head semantic evidence where PR
delivery requires it.
If `reviewDecision=REVIEW_REQUIRED` and `mergeStateStatus=BLOCKED`, report the
human approval gate. Green checks do not authorize self-approval or merging.
## 2. Fix CI Failures
@@ -142,9 +105,16 @@ when `origin` points to a fork. Inspect the parent workflow/run to determine
whether a newer base commit caused the failure before changing product or docs
code. If the fix is already upstream, update or rebase the branch only when
authorized, rerun affected checks, and invalidate all prior exact-head evidence.
If the installed `gh pr view --json` does not expose the base OID, use
`BASE_SHA=$(gh api repos/<owner>/<repo>/pulls/<PR> --jq .base.sha)` alongside
`gh pr view <PR> --json baseRefName,headRefOid` for the ref names. Run this
fallback under `set -euo pipefail`, require a non-empty `BASE_SHA`, verify the
fetched base tip separately, and only then run
`git merge-tree --write-tree <base-remote>/<base-ref> HEAD`.
For unfamiliar, infrastructure, or E2E failures, load
`references/ci-troubleshooting.md` before changing code.
`references/ci-troubleshooting.md` and, for transport or queue evidence,
`references/transport-troubleshooting.md` before changing code.
Also load it for unexpected zero-duration or no-op manual-review runs: event
and workflow provenance can explain them without a product-code change.
@@ -168,26 +138,31 @@ assertion with the current implementation and concurrent or sibling PRs before
editing. If it is a stale test expectation, the smallest valid remediation may
be a test-only assertion update: keep it limited to the reported failure, run
the focused test, and call out that scope. Do not change unrelated production
behavior or duplicate a larger sibling change.
behavior or duplicate a larger sibling change; for request-count/dedup assertions covering multiple hydration or effect continuations, use a deferred response, assert the count while it remains pending, then resolve and drain it before unmount/return so an immediately resolved mock cannot let cleanup timers create a false duplicate.
When a remediation changes a documented behavior or contract, update the
authoritative spec/guidance, plan, and task file when present; commit docs and
code together, keep regression tests and verification commands aligned, and
re-check the documentation before completion. Record why no update is needed
when the behavior remains internal.
authoritative spec/guidance, plan, and task file when present; refresh each
Verification/Results section with exact post-fixup commands and outcomes; commit
docs and code together, keep regression tests and verification commands aligned,
and re-check the documentation before completion; record why no update is needed when the behavior remains internal.
## 3. Triage And Address Reviews
Use `scripts/pr-resolve list <PR>` to obtain unresolved threads. Its previews
can be truncated, so expand every listed review thread with
`scripts/pr-resolve show <PR> <thread_id>` before deciding how to handle it.
Use `scripts/pr-state --comment <comment_id>` only for a flat comment view when
a thread context is not available. Validate against the current head, the spec,
and existing architecture before editing or replying.
Use `scripts/pr-resolve list <PR>` to obtain unresolved threads. Before handling multiple
threads, make a thread-to-finding map and one body file per thread. Its previews can be
truncated, so run `scripts/pr-resolve show <PR> <thread_id>` immediately before each
write; verify comment/thread IDs and that the body names that thread's finding, file, and
commit. Use `scripts/pr-state --comment <comment_id>` only for a flat comment view when no
thread context is available. Validate against the current head, spec, and architecture before editing or replying.
Every unresolved thread requires an explicit disposition, regardless of its
author, bot identity, visibility, or apparent severity. Record exactly one of
these dispositions:
When an automated review identifies a security-boundary risk and the user
explicitly chooses the behavior, record the accepted risk, trust/revocation
responsibility, and targeted regression coverage in the final response, PR body,
and authoritative spec. Do not silently encode the compromise.
- `actionable`: make the valid code or test change, verify it, cite the
resulting commit in the reply, and resolve the thread when writes are
authorized.
@@ -246,11 +221,15 @@ summary's authoritative head repository and ref only when
`pr.head_ref_oid` to equal local `HEAD`; an upstream remote comparison is not
sufficient. Run broad `/verify` only if the user explicitly requests it or the
PR/CI finding requires it.
After every push, also run a fresh
`gh pr view <PR> --json baseRefName,headRefOid,mergeable,mergeStateStatus` (or
equivalent) at that pushed head. Require `headRefOid` to equal local `HEAD` and
confirm `mergeable` is not `CONFLICTING` and `mergeStateStatus` is not `DIRTY`;
`scripts/pr-state --summary` does not include mergeability.
After every push, run fresh `gh pr view <PR> --json baseRefName,headRefOid,mergeable,mergeStateStatus`
(or equivalent) at the pushed head; if GitHub briefly returns an older head, retry
the query and exact-head `pr-state` with short bounded backoff before triaging or reporting.
Require local `HEAD`, `headRefOid`, and `checks_head_sha` to match; confirm
`mergeable` is not `CONFLICTING` and `mergeStateStatus` is not `DIRTY`; `scripts/pr-state --summary` does not include mergeability.
If a remediation changes rendered UI, invalidate screenshots captured before
fixup and recapture and re-publish every affected viewport after the final
commit. Never leave pre-fixup screenshots in the PR.
Immediately before a remediation commit or push—and again after long-running
remediation—refresh PR state. Require the PR to remain open and its head ref to
@@ -260,10 +239,10 @@ local upstream tip; after the push, require the PR head OID to equal local
stale push: preserve the local fix and ask before creating a clean follow-up.
After any rebase or force-push, fetch the PR base and compare local `HEAD`, the upstream tip, and `pr.head_ref_oid`; rerun affected checks.
A rebase onto a newer base invalidates prior evidence: rerun the exact failed
command and any package-level gate required by whole-file rules (for example
max-lines). Then rerun `scripts/pr-resolve list <PR>` and `scripts/pr-state --summary <PR>`; distinguish stale/current
failures and report pending checks separately. Use `--force-with-lease`, never an unconditional force-push.
A merge-commit head requires `--rebase-merges` or a verified merge-only delta;
after long hooks/tests, compare the latest authoritative base with the rebase
base and reconcile if changed; otherwise a rebase invalidates prior evidence:
rerun affected checks, `scripts/pr-resolve list <PR>`, and `scripts/pr-state --summary <PR>`; use `--force-with-lease`, never an unconditional force-push.
If the rebase or conflict resolution touched `AGENTS.md`, `CLAUDE.md`, or a
skill/reference file, run the shared harness validation in
`.agents/skills/harness-improvement/references/validation.md` before pushing.
@@ -276,10 +255,18 @@ After every push, re-fetch current-head state and run
consolidation, verify the target head before commenting/closing the superseded
PR; preserve its branch unless deletion is requested. Automated reviewers may
resolve or replace threads; do not reply to or resolve a thread that fresh state
reports as resolved. Before replying to a pre-push thread, run
`scripts/pr-resolve show <PR> <thread_id>`; if it reports `resolved: true` (often
with an `Addressed in commit ...` marker), record the thread as auto-resolved and
do not post a duplicate reply. Continue replying/resolving only for `resolved: false` threads, including hidden unresolved threads.
reports as resolved unless an authorized later code/policy change invalidates
the rationale for that resolution. In that exception, re-fetch/show the thread,
explicitly reopen it with `scripts/pr-resolve reopen <PR> <thread_id>`, then use
the reply helper to reply/resolve/react and verify the final thread. Before an
ordinary reply to a pre-push thread, run `scripts/pr-resolve show <PR>
<thread_id>`; if it reports `resolved: true` (often with an `Addressed in commit
...` marker), record the thread as auto-resolved and do not post a duplicate
reply. Continue replying/resolving only for `resolved: false` threads, including
hidden unresolved threads.
If a finding cites an obsolete commit, inspect the cited lines at the current PR
head before editing. Apply only the missing portion, and do not duplicate an
assertion that is already present or reply to a resolved or stale thread.
Treat each fresh summary as a new review-evidence snapshot: inspect every
non-empty body in `review_evidence.exact_current_head_reviews[]`, even when
`unresolved_review_thread_count=0` and `scripts/pr-resolve list` is empty.
@@ -303,12 +290,22 @@ changes. Declare the PR clean only when the
exact-current-head review classification reports no unaddressed findings,
`checks_snapshot_complete=true`, `failed_checks=[]`, `pending_checks=[]`,
`approval_required_runs=[]`, `actionable_issue_comment_count=0`,
`unresolved_review_thread_count=0`, `hidden_unresolved_threads=[]`, there is no
merge conflict, and
`scripts/pr-resolve list <PR>` is empty. Within
`unresolved_review_thread_count=0`, `hidden_unresolved_threads=[]`,
there is no merge conflict, and `scripts/pr-resolve list <PR>` is empty.
Require either `base_advanced_since_head=false` or recorded merge-result
validation for the current head and current base, as defined in
`references/merge-conflicts.md`. An unknown base relationship is not sufficient.
Within
the user's monitoring limit, continue checking after resolutions until automated
review jobs are terminal; otherwise report the exact pending check names.
When remediation changes tests or validation, reconcile any validation commands
or counts claimed in the live PR description with the final verification before
declaring fixup complete. Reuse `/pr`'s live-body preservation and REST-fallback
procedure, preserve intervening bot or maintainer text, and re-fetch exact-head
state afterward; a body PATCH triggers workflows, so restart `pr-state`/`pr-await`
before treating pre-PATCH CI as current.
If the user explicitly requested a persistent Kandev plan update and the task
has an external Kandev plan, call `get_task_plan_kandev` before fixup and
`update_task_plan_kandev` after fixup with the remediation commit, final
@@ -319,10 +316,10 @@ commit. Record the prior head's fixup evidence before a plan commit/push: that
push restarts CI and invalidates the snapshot. For tracked `docs/plans/**`
artifacts, keep prose head-agnostic and record remediation scope/local
verification before that commit; then rerun `scripts/pr-state --summary` and
`scripts/pr-resolve list` for the new head and report its pending checks
separately. Mark prior current-head claims historical/superseded when a new
head replaces them; report only the latest head's SHA, CI/review counts, and
mergeability. Do not leave planned verification marked unstarted after it has run.
`scripts/pr-resolve list` for the new head and report pending checks separately; keep
exact-head verification/queue work externally pending, not complete, until pending
checks are empty. Mark prior current-head claims historical/superseded when a new
head replaces them; report only the latest head's SHA, CI/review counts, and mergeability; do not leave planned verification marked unstarted after it has run.
Before declaring fixup complete, verify `git status --short` is clean,
`git rev-parse HEAD` equals `git rev-parse @{upstream}`, the PR head equals
@@ -346,6 +343,20 @@ and fail when another worktree owns it, even after the remote merge succeeds.
Report the remote merge separately. Delete a remote or local branch only when
requested and through a worktree-safe cleanup flow.
When branch protection requires a merge queue, after the exact current head is
clean and all checks pass, run `gh pr merge <PR> --auto
--match-head-commit <SHA>` without a merge strategy. In this mode the command
requests queue admission, not a direct merge. Verify the
`added_to_merge_queue` timeline event and
`pullRequest.mergeQueueEntry { id state position estimatedTimeToMerge headCommit { oid } }` with GraphQL; treat "already queued" as verification, not a retry.
Compare the expected SHA with `pullRequest.headRefOid`, not the queue commit; keep the PR open while queue checks run. Do not rely on a null `autoMergeRequest` or
`mergeStateStatus=UNKNOWN` to determine queue status.
For "monitor until merge", poll queue/PR state with bounded cadence; require
`MERGED`, `mergedAt`, `mergeCommit.oid`, and `isInMergeQueue=false`.
`QUEUED`/`AWAITING_CHECKS` and queued `CLEAN` remain pending; inspect
`references/merge-queue.md` for synthetic checks before reporting a blocker.
## Guardrails
- Do not create Kandev subtasks unless the user explicitly asks for task
@@ -31,9 +31,93 @@ gh api repos/<owner>/<repo>/actions/jobs/<job_id> \
scripts/pr-state --job-log <job_id>
```
Treat an unavailable log stream as unknown evidence, not a product failure.
If the direct job-log request returns 404 while the job is queued or in progress,
wait for that job to complete before retrying; it is not missing evidence.
Do not wait for the whole workflow only to obtain a log for a terminal job.
Inspect the job endpoint and fetch its job log directly when the aggregate log
command says logs are unavailable.
**Merge queue removals:** If a PR is removed from the merge queue, or its
ordinary checks are green while the queue reports a failure, inspect the issue
timeline for `added_to_merge_queue` and `removed_from_merge_queue` events. Then
query the queue runs directly; ordinary branch runs and `gh pr checks` omit
them:
```bash
gh api graphql \
-f query='query($owner:String!,$repo:String!,$number:Int!) {
repository(owner:$owner,name:$repo) {
pullRequest(number:$number) {
timelineItems(first:100, itemTypes:[ADDED_TO_MERGE_QUEUE_EVENT,REMOVED_FROM_MERGE_QUEUE_EVENT]) {
nodes {
__typename
... on AddedToMergeQueueEvent { createdAt beforeCommit { oid } }
... on RemovedFromMergeQueueEvent { createdAt reason beforeCommit { oid } }
}
}
}
}
}' -f owner=<owner> -f repo=<repo> -F number=<PR> \
--jq '.data.repository.pullRequest.timelineItems.nodes[] | {type:.__typename, createdAt, reason, before_commit:(.beforeCommit.oid // null)}'
gh run list --event merge_group
gh run list --commit <synthetic-queue-sha>
gh run view <run-id> --json jobs
```
Record each event's `createdAt`, removal `reason`, and `beforeCommit.oid`. Map
that OID to the corresponding `merge_group` run through commit ancestry,
identify the aggregate gate's failed step, and compare cancelled-job timestamps
with the workflow's `timeout-minutes`. Distinguish timeout/cancellation from a
test assertion before changing product code. A green PR-head snapshot does not
prove the merge-group gate passed.
**Synthetic `merge_group` failure:** When a merge-group run fails, record its
event, head SHA, leaf job or shard, and failed assertion. Compare the synthetic
tree and reported files with the PR head and authoritative base, then reproduce
the exact assertion on the PR head and on the parent or last-passing SHA with
retries disabled and repeated runs. Classify it as queue, base, or fixture
infrastructure only after independent evidence supports that conclusion; do not
make speculative source changes or push a stale head. Verify
`checks_head_sha` matches the PR head before acting, and stop if the PR merged
or closed.
**Isolated frontend assertion in an unchanged test:** Reproduce the exact
focused test locally. If it passes and has no relationship to the PR diff,
rerun only the failed workflow jobs once; classify it as transient only when
that rerun passes. A repeatable assertion remains a fixup failure even when the
test file is unchanged.
If a queued PR needs an authorized branch update, do not push blindly into the
queue. Record the PR head/base OIDs and queue event IDs, confirm its queue state,
dequeue it through the repository-approved path before changing the branch, then
verify the exact head and re-enqueue only after the push. Treat the new
`merge_group` SHA and run as fresh evidence; a base race or queue-state change
invalidates prior head checks.
Treat unavailable/transport-only logs as unknown, not product failure. For a
queued 404 wait for terminal state; then download `test-results-<shard>` and inspect
`error-context.md`, traces, and screenshots before rerunning or editing.
**Malformed or truncated GitHub API responses:** If a workflow fails while
parsing `gh api` JSON, reports an unexpected end of input, or receives a
successful response with missing required fields, preserve the raw response and
HTTP status in a temporary file. Treat the result as unknown rather than as an
empty or clean payload. Inspect the workflow's expected response with a focused
contract test. For workflow-owned idempotent GET/PATCH requests, use bounded
short-backoff retries, validate that the GET body is complete JSON before
parsing, and silence PATCH output when the next step expects no response body.
Rerun the failed job once, and stop with the transport failure if the same
response problem repeats; do not add unbounded retries or change product code to
accommodate a truncated response.
### Static artifact byte mismatches
If an object upload and public GET both succeed but a strict `cmp` fails, save
and compare the origin artifact with the public body before changing the
publisher. A public HTML body containing `__cf_email__`, `/cdn-cgi/`, or
`email-decode.min.js` indicates Cloudflare edge transformation rather than a
failed upload. Preserve the byte-for-byte comparison and fix origin metadata
with `Cache-Control: no-transform`, or disable Email Obfuscation for the
dedicated hostname; verify a fresh upload and public body match exactly.
When the aggregate command exposes only a merge/report job, obtain every failed
shard `job_id` from `scripts/pr-state --summary <PR>` and inspect at least one
failure from each shard before changing code:
@@ -91,8 +175,13 @@ an actionable configuration error.
**Merge-ref validation drift:** GitHub can run a PR check against the synthetic
merge ref, where a current-base deletion makes a cited path or coverage entry
appear missing even though it exists on the PR head. Compare the failing path
with `origin/<baseRefName>` before changing the PR. Update a genuinely stale
manifest or coverage entry, but do not rebase solely to satisfy this check.
with `origin/<baseRefName>` before changing the PR. Whole-file limits can also
fail only on the synthetic merge when incoming base content makes the merged
file larger than the PR head. Compare the exact PR-head file with the
`refs/pull/<PR>/merge` version (for example through the Contents API), then
rerun the size/line check on a fresh merge result after any base merge. Update
a genuinely stale manifest or coverage entry, but do not rebase solely to
satisfy this check.
**Cancelled concurrency duplicates:** A required check with
`conclusion=cancelled`, 0s job durations, unexpanded `${{ matrix.* }}` job
@@ -105,15 +194,45 @@ non-cancelled run for the same head SHA passed, then trigger one clean run
configured `timeout-minutes`, inspect its concrete steps and logs. When the
race/test step has no assertion failure and one rerun repeats the same timeout,
classify it as CI infrastructure, stop rerunning, and report the exact check,
timeout, and pending-state evidence.
timeout, and pending-state evidence. If every E2E matrix shard's test step
remains `in_progress` past the configured timeout without assertion or log
progress, inspect the exact jobs, classify the matrix stall as runner
infrastructure, and cancel the run once. If GitHub rejects a rerun or leaves no
clean run, create a fresh pull-request event with a harmless CI-only commit
only when authorized; verify the new head and run ID, and do not loop reruns.
**Manual-review event provenance:** For an unexpected zero-duration or no-op
manual bot-mention run, inspect the workflow at the repository default branch
and the event payload before assuming the PR is behind `main`. Privileged
`issue_comment` and `pull_request_target` runs use the trusted default-branch
workflow definition, not a newly synced PR workflow. Compare the run's
workflow path, event, and head metadata with that definition; land a workflow
fix on the default branch, then reproduce with a fresh comment.
Before cancelling or rerunning a stale pending report, re-fetch the PR head,
parent run, and named job. Require each `head_sha` to match the current
`checks_head_sha`; if the job is already terminal-success, do nothing. When a
shard succeeds while its parent remains active, enumerate nonterminal jobs and
distinguish report/final-gate cleanup from a hung shard. Because cancellation is
run-scoped, cancel only after confirming the matching-head run is still stalled
and no active job or gate would be collateral damage. Verify with fresh
`scripts/pr-state --summary`, `scripts/pr-resolve list`, and mergeability.
**Rerun attempt semantics:** `gh run list` can retain a prior failed conclusion
while a later `run_attempt` of the same workflow is still in progress. Query the
REST run's `run_attempt`, `status`, `conclusion`, `head_sha`, and current jobs;
classify only the current attempt after its parent workflow is terminal.
**Privileged workflow provenance and bootstrap:** For an unexpected no-op or
old-behavior result in an `issue_comment` or `pull_request_target` workflow,
including a PR-produced cross-job URL or SHA that is normalized or truncated,
inspect the exact job log, event, workflow SHA, and base SHA. Compare the
producer output with the consumer environment and with the workflow at the
default branch, for example:
```bash
git show <workflow-sha>:.github/workflows/<file>
```
These privileged runs use the trusted default-branch workflow definition, so a
fix committed only on the PR head cannot affect the current run. Validate the
change with a focused contract test, rerun the failed job once, and if the log
still proves default-branch provenance, report the base-controlled blocker and
land the workflow fix through the authorized path before triggering a fresh
event. Do not keep patching or rerunning the PR branch to exercise a workflow
definition GitHub is not using.
**Semantic PR title transport failures:** If `pr-title` /
`amannn/action-semantic-pull-request` fails with transport or response parsing
@@ -142,8 +261,10 @@ Only debug code if the rerun fails with an actual lint, test, or build error.
before tests run, check for dependency or registry failures. Patterns such as
`packages.microsoft.com ... 403 Forbidden`, `docker/login-action@v3`,
`Error response from daemon: Get "https://ghcr.io/v2/"`, `ghcr.io/token`,
`context deadline exceeded`, or `Client.Timeout exceeded while awaiting
headers` are infrastructure/package-registry issues, not app or test failures.
`docker buildx imagetools inspect`, `Could not resolve an immutable digest`,
`ghcr.io/kdlbs/kandev-ci:runtime-latest`, `context deadline exceeded`, or
`Client.Timeout exceeded while awaiting headers` are infrastructure/package-
registry issues, not app or test failures.
If GitHub rejects `gh run rerun <run-id> --failed` while the workflow is still
active, wait for the workflow/report job to finish and retry.
@@ -202,12 +323,32 @@ If any failing check is an E2E test:
in-DOM React render errors that often show up in
`e2e/test-results/<test>/error-context.md`.
If a failed E2E action leaves the browser on a detail route, inspect the
rendered confirmation dialog and recent interaction-contract changes before
changing router code or increasing route waits. A new intentional confirmation
dialog is a navigation gate: update the test to assert and confirm the dialog
instead of altering navigation to bypass it.
Before attributing an E2E failure to product code, compare the failed run SHA
and `checks_head_sha` with local `HEAD`. PR workflows may test GitHub's
synthetic merge commit, so reproduce the exact base-plus-head merge (or checked
merge ref) with CI environment, retries disabled, and the same shard/order;
verify the checkout SHA before interpreting the result.
If a focused reproduction reports `No tests found`, a title mismatch, or a 404
for a workflow or API path, first verify the checked-out test title and compare
the PR with the authoritative current base: record `base_head_oid`, the merge
base, and `git diff <base>...HEAD`. An old branch can make an apparent product
failure disappear because the fix and updated test already landed on the base.
If the fix is already upstream, update the branch only with authorization and
invalidate all prior exact-head CI evidence.
When a shard is cancelled and aggregate E2E/report gates fail, treat the
aggregate failures as cascading until the concrete shard is inspected. Identify
the cancelled job and timeout, run its exact manifest-selected shard without
retry masking, then rerun the failed workflow jobs once. Classify it as
infrastructure only if the timeout repeats without an assertion failure.
Useful focused commands:
```bash
@@ -242,14 +383,18 @@ gh run view <run-id> --json status,conclusion,jobs \
For an explicit user requirement that an E2E run contain no flakes or retries,
run the deterministic blob audit after the exact-head E2E merge report
completes:
completes. Download the matching artifacts first:
```bash
python3 scripts/playwright-blob-audit <downloaded-blob-report-dir>
gh run download <run-id> --pattern 'blob-report-*' --dir <tmp>
python3 scripts/playwright-blob-audit <tmp>
```
It recursively reads `report-*.zip` and `report.jsonl` artifacts, reports
attempts, retry attempts, `onTestEnd` status counts, and results with errors,
and exits nonzero for retries, errors, parse failures, or unexpected statuses.
Do not make this extra artifact audit part of ordinary PR fixup; a normal green
aggregate check is sufficient unless the no-flakes requirement is explicit.
aggregate check is sufficient unless the no-flakes requirement is explicit. A
nonzero audit keeps the PR from being called clean or flake-free. When a
comparable baseline audit exists, report the PR-versus-baseline retry/error delta;
shared baseline flakes are evidence, not permission to call the PR flake-free.
@@ -10,10 +10,23 @@ Inspect GitHub's mergeability state:
gh pr view <PR> --json number,url,baseRefName,headRefName,mergeable,mergeStateStatus
```
The `gh pr view --json` field is `baseRefName`, not `baseRefOid`; do not request
the unsupported field. Resolve the base OID with `git ls-remote` or the base
fields in `scripts/pr-state`. When exact OIDs are required, query `gh api repos/<owner>/<repo>/pulls/<PR> --jq '{base_sha:.base.sha,base_ref:.base.ref,head_sha:.head.sha,head_ref:.head.ref}'` and verify the fetched base tip matches `base_sha` before merging.
Treat `mergeable:"CONFLICTING"` or `mergeStateStatus:"DIRTY"` as an actionable merge-conflict blocker. Treat `mergeable:"UNKNOWN"` as inconclusive: wait one short cadence and query again before deciding. States such as `BEHIND`, `BLOCKED`, `UNSTABLE`, or `HAS_HOOKS` may require an update or more CI/review work, but they are not by themselves proof of file-level conflicts.
Always use the freshly queried `baseRefName`. GitHub can retarget a stacked child PR after its parent merges, so neither the Git upstream nor a base branch remembered from an earlier fixup round is authoritative.
Before any checkout, merge, or rebase, confirm that the current worktree owns
the PR head. After reading `headRefName` and `headRefOid`, run
`git worktree list --porcelain`, locate the worktree whose branch matches
`headRefName`, and operate there. Verify `git branch --show-current`,
`git rev-parse HEAD`, and `git status --short` before mutating anything. If no
matching worktree exists, state that and create or use an explicit worktree only
within the authorized workflow; never risk mutating `main` or another task's
worktree.
Inspect the local worktree:
```bash
@@ -28,6 +41,10 @@ If `git ls-files -u` prints entries, or conflict markers are present in tracked
### When GitHub reports conflicts and the local index is clean
Capture the PR branch's remote head before fetching the base or starting the
merge. Recheck it immediately before pushing; use the explicit lease shown in
**Push after rebasing or merging**.
1. Fetch the latest base branch:
```bash
git fetch origin <baseRefName>
@@ -51,16 +68,139 @@ If `git ls-files -u` prints entries, or conflict markers are present in tracked
git diff --stat origin/<baseRefName>
```
Before committing a merged specification or system-design file, rerun the
repository's specification lint, including size checks; a marker-free merge
can still fail the file-size hook. Do not raise a legacy size exception to
hide that failure.
Confirm that the final diff against the current GitHub base contains only the PR's intended delta. A clean index is insufficient if a retargeted stacked PR accidentally retains its merged parent's changes.
After the operation completes, inspect each resolved path with
`git diff -- origin/<baseRefName>...HEAD -- <resolved-file>` and reread the
complete surrounding comments and logic; this catches a duplicate-hunk
resolution that silently drops an explanatory line.
Incoming base commits may already contain a partial or similar fix. Compare
their helpers and tests with the PR side before staging, and deduplicate
overlapping test symbols or behavior rather than blindly preserving both.
If the base extracted logic into a new helper or type file, or moved its
tests, while the PR changed the old inline location, a marker-only merge can
silently drop the PR invariant. Inspect the extracted or renamed base file
and moved tests, port the PR-specific behavior into the new owner, update
imports and exports, then run the focused helper and consumer tests. Verify
with `git diff origin/<baseRefName>...HEAD -- <resolved-files>` and the
affected test suite.
If the current base moved a flat legacy spec into `requirements/` and
`system-design/` paths, preserve the canonical base split. Move branch-only
authority into the correct migrated part, add branch-only documents to the
system README maps, and remove stale old-path entries from
`docs/specs/spec-lint.json`. Then run `python3 scripts/lint-spec-files.py
--all` and inspect `git diff <base>...HEAD` for the intended migration delta.
Verify again with `git ls-files -u`, the marker scan, and the full spec lint.
If conflict resolution touches `apps/backend/go.mod` or `apps/backend/go.sum`,
marker-free text is not enough: a union can silently lose a required module
checksum. From `apps/backend`, run `go mod tidy` and then `go mod verify`
sequentially, inspect that only intended module files changed, and run the
affected backend package tests or build before committing.
For structured files (especially JSON catalogs), validate syntax and duplicate
keys before continuing. JSON's default parser may silently keep the last
duplicate key; use an `object_pairs_hook` that raises on duplicates, then run
the affected formatter/test and `git diff --check`.
the affected formatter/test and `git diff --check`. Also run the
authoritative schema or domain validator when the file's semantics span
arrays or ownership assignments; object-key checks cannot catch duplicate
array-level assignments.
When the resulting `HEAD` is a merge commit, do not use `HEAD^1..HEAD` or a
parent diff as the PR delta: depending on parent order it can include the
merged base or omit the branch's changes. Use the authoritative three-dot
range, `git diff <base-remote>/<base-ref>...HEAD`, and inspect the merge
parents with `git log --first-parent` only for history. If
`git diff --cached --check` reports whitespace that came from the incoming
base, compare the base-only range separately before attributing it to the PR;
keep the strict check for whitespace introduced by the PR or resolution.
### When the base advanced without conflicts
An advanced base is not itself a reason to rebase or merge the PR. When exact-
head CI is green, the base advanced, and GitHub reports no file conflict,
compare the paths changed on both sides of the old merge base:
```bash
git diff --name-only <old-merge-base>..<authoritative-base> > /tmp/base-paths
git diff --name-only <old-merge-base>..HEAD > /tmp/head-paths
comm -12 <(sort /tmp/base-paths) <(sort /tmp/head-paths)
```
Build the exact conflict-free synthetic merge without changing the PR branch.
Use the overlapping paths and shared contracts to select focused checks.
Disjoint paths can still interact through imports, schemas, or configuration.
`git merge-tree --write-tree` returns a tree object;
turn it into an unreachable merge commit and inspect it in a detached
worktree:
```bash
SYNTHETIC_TREE="$(git merge-tree --write-tree <authoritative-base> HEAD)"
SYNTHETIC_COMMIT="$(printf 'review synthetic merge\n' | \
git commit-tree "$SYNTHETIC_TREE" -p <authoritative-base> -p HEAD)"
git worktree add --detach /tmp/pr-merge-review-<number> "$SYNTHETIC_COMMIT"
# Run focused checks for affected paths and shared contracts in that worktree.
git worktree remove --force /tmp/pr-merge-review-<number>
```
Do not push or create a branch for the synthetic commit. Frontend checks need
the detached worktree's own `apps/node_modules` installation; do not assume a
sibling worktree's dependencies are available. Verify the synthetic merge,
focused tests, clean normal worktree, local `HEAD` equal to its upstream and PR
head, and a fresh GitHub query still reporting `MERGEABLE`/`CLEAN`.
Record the immutable PR head, authoritative base, synthetic commit, commands,
and results. This evidence satisfies the base-drift completion gate only while
both input SHAs remain current. `base_advanced_since_head` remains true because
the PR branch did not change. Preserve the helper's exit code and report the
separate merge-result validation. Human approval and merge-queue gates still apply.
### When related PRs conflict semantically without file conflicts
GitHub's mergeability state covers file-level compatibility, not whether
stacked or parallel PRs still agree on the durable contract. Use this path when
a plan, review, or PR names a dependency and the current PR or landed dependency
has overlapping `REQ-*`/`AC-*` IDs, conflicting design statements, or a changed
API/interface contract even though GitHub reports `MERGEABLE`.
1. Resolve the current head and merged state of every related PR. Treat refs in
an older plan or review as snapshots; re-read the affected requirements,
system designs, plans, work orders, and test annotations at the current
heads.
2. Identify the landed contract as authoritative. Preserve its stable IDs and
meanings; allocate the next unused IDs for distinct behavior instead of
renumbering or reusing an existing ID to hide the collision.
3. Reconcile all affected traceability links, including every `REQ-*`/`AC-*`
reference in requirements, system designs, plans, work orders, and test
`@covers` annotations, plus implementation assumptions to the current interface.
Do not call isolated green checks composable until the producer and consumer
agree on the same contract.
4. Run `python3 scripts/lint-spec-files.py --all` and the focused producer,
consumer, and integration tests. After any integration push, refresh the
exact PR head and repeat the dependency and contract comparison.
Verify that the related PR heads and merged state were recorded, no duplicate
or repurposed stable IDs remain, all affected artifacts reference the same
contract, spec lint and focused tests pass, and the final exact-head evidence
was collected after the last integration push.
### Queue synthetic-base conflicts
A merge-queue coordinator may provide a synthetic base that conflicts with
the PR even when the PR reports `MERGEABLE/CLEAN` and exact-head checks pass.
Treat this as queue-order merge evidence, not a CI failure. Verify the SHA is
a commit, run `git merge-tree <synthetic-base> HEAD`, and inspect both
`git show <synthetic-base>:<path>` and `git show HEAD:<path>` (or staged blobs).
Preserve both sides manually, then require `git merge-tree <synthetic-base>
<new-head>` to be conflict-free before focused checks, a hooked commit, push,
and fresh exact-head PR state.
### When the local index is already unmerged
@@ -108,7 +248,10 @@ Do not discard unrelated user changes to make a merge/rebase easier. If unrelate
Before a force-push after a rebase or conflict merge, run the task-defined checks
for every affected package. For web changes, run the affected test suite plus
`cd apps/web && pnpm run typecheck` and the web lint gate; use the equivalent
package checks for backend changes. Cleared markers do not prove a semantic
package checks for backend changes. Run managed E2E with `pnpm e2e:run` after
the merge, or rebuild both `make -C apps/backend build` and
`make -C apps/backend e2e-plugin-package` before any `--no-build` run, because
global setup rejects stale packaged fixtures. Cleared markers do not prove a semantic
resolution: duplicate imports/hooks and stale localized mocks require these
checks to pass.
@@ -145,3 +288,19 @@ An intervening fetch can update the remote-tracking ref consulted by generic
`--force-with-lease`. If the prior remote SHA was not captured, stop and capture
it before rewriting when possible; use the generic form only as an explicit last
resort, and never use an unconditional force push.
After a conflict merge is pushed, run `scripts/pr-await <PR>` for the new head
and then refresh `scripts/pr-state --summary <PR>` and
`scripts/pr-resolve list <PR>`. Treat the merge commit's checks and review state
as new evidence; do not reuse the pre-merge report.
If preserving both sides of a conflict makes a whole-file hook exceed its
max-lines or size limit, remove duplicated guidance or refactor the file while
keeping both contracts. Rerun the affected lint; never bypass the hook or add
an unexplained exception just to complete the merge.
If the user explicitly requires a one-commit PR, resolve and verify the current
base first, then use a soft reset to that base, recommit through normal hooks,
and push with the captured exact-head lease. Verify
`git rev-list --count <base>..HEAD` and the live PR head. Otherwise retain the
merge commit created by conflict resolution.
@@ -0,0 +1,114 @@
# Merge Queue Coordination
Load this reference when the user requests merge-queue coordination, or when
authoritative GitHub data shows a non-null `mergeQueueEntry`. Queue mutations
are external writes: dequeue and enqueue only with explicit user
authorization. `scripts/pr-await` validates the pull-request head; it does not
prove the synthetic merge-group commit passed. For review-only work, report
queue membership as a mutation blocker and do not dequeue just to inspect or
reply.
## Identify the queue attempt
Use the pull-request GraphQL node ID for mutations. It is different from the
active merge-queue entry ID. Record the current pull-request head and base, the
queue entry state and position, and the synthetic commit used for its checks:
```bash
gh api graphql \
-f query='query($owner:String!,$repo:String!,$number:Int!) {
repository(owner:$owner,name:$repo) {
pullRequest(number:$number) {
id
headRefName
headRefOid
baseRefName
mergeQueueEntry {
id
state
position
headCommit {
oid
statusCheckRollup { state }
}
}
}
}
}' -f owner=<owner> -f repo=<repo> -F number=<PR>
```
The `pullRequest.id` is the PR node ID. Do not pass
`mergeQueueEntry.id` to `dequeuePullRequest` or `enqueuePullRequest`.
## Authorized branch updates
1. Capture the reported PR head OID and authoritative base name. Confirm the
linked-worktree preflight in `merge-conflicts.md` before mutating a branch.
2. If the PR is queued, dequeue it through the repository-approved path before
pushing. A push rejected with `GH006` means the queue still owns the branch;
do not retry the push or force-push around that protection. Re-query
`pullRequest.mergeQueueEntry` until it is null after the dequeue request;
queue state is authoritative while the mutation propagates.
3. Fetch the fresh base, merge or resolve it using the conflict procedure, run
the affected gates, and commit with normal hooks. Recheck the remote head
lease immediately before pushing.
4. After the push, verify the PR head OID, exact-head checks, review state, and
mergeability. Every push or base reconciliation invalidates earlier CI and
review evidence.
5. Re-enqueue only after the pushed head is verified. Pass the PR node ID and
the newly verified head as `expectedHeadOid`; do not use a stale queue-entry
ID or stale head. GitHub may restore queue membership automatically after
required checks pass; if it does, monitor the existing entry instead of
enqueueing it a second time.
The GraphQL mutation shapes are:
```bash
gh api graphql \
-f query='mutation($prNodeId:ID!) {
dequeuePullRequest(input:{id:$prNodeId}) {
clientMutationId
}
}' -f prNodeId=<pull-request-node-id>
gh api graphql \
-f query='mutation($pullRequestId:ID!,$expectedHeadOid:GitObjectID!) {
enqueuePullRequest(input:{pullRequestId:$pullRequestId,expectedHeadOid:$expectedHeadOid}) {
clientMutationId
}
}' -f pullRequestId=<pull-request-node-id> \
-f expectedHeadOid=<verified-head-oid>
```
Use the repository's approved UI or API if these mutations are unavailable to
the current token. Do not substitute a queue-entry ID, and do not retry a
failed mutation without rereading the PR and queue state.
## Monitor the synthetic result
After enqueueing, reread `mergeQueueEntry` and its `headCommit` rollup. Treat
`QUEUED` and `AWAITING_CHECKS` as pending. A persistently `UNMERGEABLE` entry is
an explicit signal to stop, report the state, and obtain authorization before
dequeueing or reconciling again. Stop on `MERGED` or `CLOSED`, or on a clearly
documented external blocker. Map queue-removal timeline events and their
`beforeCommit.oid` to the corresponding `merge_group` run as described in
`ci-troubleshooting.md`; ordinary `gh pr checks` does not cover that synthetic
commit.
For a request to monitor until the PR is merged, include the pull request's
`state`, `mergedAt`, `mergeCommit { oid }`, and `isInMergeQueue` in each bounded
poll. Do not report completion from a clean head check, a non-null merge entry,
or a null `autoMergeRequest`: require `state=MERGED`, both merge fields, and
`isInMergeQueue=false`. Stop on `CLOSED` or a clearly documented external
blocker, and keep the final snapshot with the synthetic commit and queue state.
If the queue entry is `UNMERGEABLE` while the PR API reports
`mergeable=UNKNOWN`, fetch the authoritative base and run
`git merge-tree --write-tree HEAD origin/<base>` (or the equivalent current
base ref) before mutating the branch. Use the conflict procedure only when the
preflight finds a real conflict; otherwise treat the GitHub state as transient
and re-query it.
Recheck the PR head after any queue transition. A new push, base race, queue
ejection, or changed expected head starts a new evidence cycle: rerun the exact
head gates, then inspect the new merge-group commit and checks.
@@ -82,7 +82,9 @@ unknown, do not call review clean/blocked; retry once, then use
is nonzero while visible threads are empty, fetch the authoritative thread list
and full bodies with `scripts/pr-resolve show <PR> <THREAD_ID>`; use
`scripts/pr-state --comment <comment_id>` only when a flat comment view is all
that is available.
that is available. The numeric ID may identify either a review comment or a
top-level issue comment; the helper falls back between both endpoints and emits
`comment_type`, but it does not resolve a review thread.
If `branch:"unknown"` or PR-view resolution is transient, retry the explicit
PR-number command once before using direct targeted GitHub fallback. Do not
@@ -118,19 +120,8 @@ body with `scripts/pr-resolve show <PR> <THREAD_ID>`; use the flat comment comma
only for a comment without thread context. A listed thread that is already
resolved is stale summary state: re-poll and do not reply again.
Poll at 30-second cadence with a 20-minute cap using bounded one-shot commands;
avoid long inline loops and `gh pr checks --watch`. In default monitoring,
stop early on a required failure. For an explicit fixed-duration request, use
strict-deadline mode: accumulate failures and comments until the absolute
deadline, stopping early only if the PR is merged/closed or access is revoked.
Queued/in-progress jobs are pending, not speculative-fix triggers. On an
explicit wait-through-CI request without a fixed deadline, use the same
20-minute absolute cap: repeat bounded checks until failures, pending checks,
and unresolved-thread count are all empty/zero, or stop at the deadline and
report remaining pending checks or unresolved threads. A nonzero unresolved
count is a blocker even when every remaining thread is informational, optional,
or invalid. Preserve early stopping
for failures and merged/closed or access-revoked conditions.
For wait modes, deadlines, exit codes, and interrupted waiters, load
[waiting.md](waiting.md). It owns the monitoring procedure.
For E2E-only pending work, summarize a saved snapshot before printing shards:
@@ -140,5 +131,4 @@ jq '{failed_checks, pending_count:(.pending_checks|length), unresolved_review_th
jq -r '.pending_checks[] | "\(.status) | \(.name)"' /tmp/prstate-<PR>.json
```
If a manual poll is interrupted, terminate only polling processes you started.
Use raw `scripts/pr-state <PR>` only for an odd-state diagnostic.
@@ -0,0 +1,23 @@
# PR Fixup Transport Troubleshooting
Load this reference when CI setup fails before tests, or when GraphQL evidence
is unavailable but authenticated REST calls still work.
**Corepack/pnpm bootstrap transport failures:** If `pnpm install --frozen-lockfile`
fails while Corepack downloads pnpm, before repository tests start, and the log
shows a Node/undici transport or parser assertion, classify it as runner or
registry infrastructure. Verify the exact job head and terminal parent run,
then rerun the failed job once with `gh run rerun <run-id> --failed`; recheck
`scripts/pr-state --summary` against that same head. If it repeats, inspect the
runtime image/Corepack toolchain instead of changing application code.
**GraphQL/rate-limit degradation:** If `pr-await` or `pr-state` cannot read
GraphQL while REST remains available, query the REST pull request for state,
mergeability, and head SHA, then `/commits/<head_sha>/check-runs?per_page=100`
for checks. Require the REST head to equal the check snapshot head. REST cannot
prove review-thread resolution, so keep thread evidence unknown until
`scripts/pr-resolve` or a connector succeeds; never call the PR clean from
partial REST evidence. A queued job with `runner_id=0`, no runner name, and no
completed steps is hosted-runner backlog, not a test hang; do not rerun or
cancel solely because it is queued. Finish with a fresh state, thread, and
mergeability audit.
@@ -0,0 +1,79 @@
# Waiting for PR checks
Load this reference when checks are pending or a user requests PR monitoring.
Use `scripts/pr-await <PR>` for the wait. It owns the polling loop and returns
one report. Do not run `pr-state` on a timer in the primary conversation.
## Select the wait mode
- Use `--mode all-terminal` by default. It waits for parent workflows and all
registered checks to finish before reporting the complete failure set.
- For an explicit fixed-duration hold, use
`--mode strict-deadline --deadline-min <N>`. It continues after terminal CI
until the deadline, unless the PR closes or evidence becomes blocked.
- For an upper time limit that permits an early result, use
`--mode all-terminal --deadline-min <N>`.
- Use `--mode first-failure` only when the user requests the first failure.
The default deadline is 45 minutes and the default cadence is 60 seconds.
Pass an explicit deadline for a user-specified limit. Use `--interval-sec <S>`
when the user specifies a cadence. Pending matrix counts can grow as jobs appear.
## Interpret the final result
Read the final tool result's `exit_code`, including for PTY/session commands.
| Exit | Meaning | Action |
| --- | --- | --- |
| 0 | Terminal CI/review counts are clean | Refresh and classify review bodies before delivery. |
| 1 | Terminal findings, conflicts, or base drift | Triage the reported findings. |
| 2 | Pending checks or an unconfirmed terminal rollup at the deadline | Report the pending work at the user's limit. Otherwise continue waiting. |
| 3 | Closed PR or unavailable/blocked evidence | Read the stated reason. Never infer a clean result. |
If exit 1 reports only base drift, use the advanced-base procedure in
[merge-conflicts.md](merge-conflicts.md). Record its separate validation result.
The helper's exit code remains 1 even when that validation succeeds.
If no user limit prevents further waiting, rerun after exit 2 with a larger
deadline. Do not replace the waiter with timer-driven snapshots.
An `all-terminal` result can arrive before the deadline. Report actual elapsed time.
Strict-deadline returns 0 or 1 for confirmed terminal evidence, 2 for pending or
unconfirmed work, and 3 for blocked evidence.
Exit 3 includes approval-required runs, access errors, unknown mergeability,
incomplete snapshots, and unknown required-check policy. The reported toolchain
versions are diagnostic context, not substitute evidence.
## Refresh after waiting
Run `scripts/pr-state --summary <PR>` and `scripts/pr-resolve list <PR>` after
each report. Require the check SHA to match the fresh PR head.
If delayed checks appear, restart the waiter. A sparse early rollup cannot
prove completion. The helper requires two matching terminal snapshots.
Inspect every current-head review body, including aggregate bot reviews.
If earlier snapshots contained top-level findings absent from the new head's
review list, run one `scripts/pr-state --summary --all` audit.
Revalidate those findings against current source.
If a job exceeds its configured timeout or contradicts the rollup, query its
exact job/run before diagnosing a hang. Verify that its SHA matches the PR head.
For fork workflow approval, inspect `approval_required_runs`.
An authorized fixup can approve the exact run through
`gh api --method POST repos/<owner>/<repo>/actions/runs/<run-id>/approve`.
Then refresh state and wait for jobs to appear. `gh run approve` is invalid.
If the PR becomes `MERGED` or `CLOSED`, stop. For a merge, report `mergedAt`
and `mergeCommit.oid`. Do not recreate, update, or re-enqueue its stale branch.
For synthetic merge-group checks, use [merge-queue.md](merge-queue.md).
## Interrupted or unavailable waiters
Retain the session handle until the command ends. An interrupted waiter has no
verdict. If the handle is lost, inspect and stop only its owned process tree
before starting a replacement. Never launch duplicate monitors.
Use the read-only `pr-poller` only when the user explicitly requests monitoring
and `pr-await` is unavailable. Give it the user's deadline, or a 20-minute cap
when no limit exists. Treat its output as provisional and refresh before acting.
Do not use interactive `gh pr checks --watch` in the primary conversation.
+20 -2
View File
@@ -107,6 +107,13 @@ explicitly requests task tracking.
Do not fall back to hand-composed `--body` prose. If creation fails, surface the exact stderr, fix the template/body-file problem, and retry with `--body-file`.
If `gh pr create` fails after the branch is pushed with a credential-lease
or repository-scope error, use the REST fallback with the same template body.
Build the payload with `jq --rawfile` and submit it with
`gh api --method POST repos/<owner>/<repo>/pulls --input <payload-file>`;
preserve the validated title, head, base, and body, and never hand-escape
Markdown or JSON.
6. **If ready (not draft):** For GitHub, do not begin `/pr-fixup` until any
required screenshot embedding in step 7 is complete.
@@ -118,6 +125,9 @@ required screenshot embedding in step 7 is complete.
treating capture as blocked. Name mobile specs `mobile-*.spec.ts`, write
assets to ignored `apps/web/.pr-assets`, inspect/compress them, then remove
the temporary spec and confirm `git status` is clean.
- After opening a popover or dialog, assert that the intended surface is
visible and await finite active CSS animations (`element.getAnimations().finished`)
before capture; do not publish a mid-transition asset.
- For disposable capture specs, prefer the existing `prCapture` fixture from
`apps/web/e2e/fixtures/test-base.ts`; it writes the expected filenames and
manifest for PR assets.
@@ -184,11 +194,19 @@ required screenshot embedding in step 7 is complete.
PAYLOAD="/tmp/pr-body-<PR_NUMBER>-payload.json"
jq -n --rawfile body "<body-file>" '{body: $body}' > "$PAYLOAD"
jq empty "$PAYLOAD"
gh api --method PATCH repos/:owner/:repo/pulls/<PR_NUMBER> --input "$PAYLOAD"
gh api --method PATCH repos/:owner/:repo/pulls/<PR_NUMBER> --input "$PAYLOAD" --silent
```
For a write-only PATCH, `--silent` prevents `gh` from decoding a response
body that the caller does not consume. Redirecting stdout to `/dev/null`
does not prevent a truncated or empty JSON response from making `gh` report
an error after the mutation succeeds. Read the resource back with a
separate GET and verify the intended change.
**JSON payloads:** Use `jq` (or an equivalent JSON tool) to build and
validate payloads without interpolating untrusted Markdown into shell
syntax. Keep the REST fallback byte-preserving for command substitutions,
syntax. When command output is redirected, piped, or consumed by `jq` or
another parser, run the command through `rtk proxy` so stdout is
byte-preserving; normal RTK filtering can truncate or annotate machine-readable
output. Keep the REST fallback byte-preserving for command substitutions,
`xargs`, and any other consumer that expects unmodified Git or JSON output.
**Preserve the existing PR description:** The PR body is a shared, mutable
@@ -52,6 +52,11 @@ Keep a visible task list:
6. Execute work orders with TDD after an explicit implementation request.
7. Open the PR, address valid findings, record changes, and report.
When task-plan MCP tools are available, use the Kandev task plan for live phase
progress and update it after each completed design phase. Keep
`docs/plans/<initiative>/plan.md` and its work orders as the durable
implementation package; neither plan replaces the other.
## Phases 1 to 4: Design
Use `/interview-me` only when the request needs clarification.
@@ -122,7 +127,15 @@ For each work order, in dependency order:
2. Change only that work order to `in_progress`.
3. Implement with `/tdd`. Use `/e2e` and `/mobile-parity` when applicable.
4. Run the exact targeted verification commands.
5. Change the work order to `done` and synchronize `plan.md`.
5. After implementation and targeted checks pass, change only the current work
order to `done`.
6. After every linked work order passes, verify the implementation still matches
each paired draft system design. If it does, promote that design from `draft`
to `current`, synchronize affected spec frontmatter and matching
`docs/specs/INDEX.md` entries from `building` to `shipped`, then change the
plan to `implemented`. If implementation diverged or an open design issue
remains, leave the design draft and report the mismatch instead of completing
the package.
If the user authorizes subagents, launch only parallel-safe work orders in the
requested wave. Use native delegation with no full-history fork. Give each
+28
View File
@@ -36,6 +36,14 @@ Route the request before you write:
Do not create a generic `spec.md` file.
When routing to `docs/specs/product/`, read `docs/specs/product/README.md`
before editing. Treat its **Product document index** as the local index: read
every linked product document and any co-located `INDEX.md`, `AGENTS.md`,
`CLAUDE.md`, or other instruction file when present. Product files provide
cross-system context, not feature requirements; preserve proposed and
open-question language instead of promoting it to an active contract without
confirmation.
## Workflow
### 1. Locate the owning system
@@ -118,9 +126,21 @@ same requirements.
Add the new documents to the system `README.md`. State the system boundary and
link adjacent systems when ownership can be confused.
Before and after adding required links, run `wc -c <system>/README.md`. Near
the 12 KiB `system-index` limit, keep every required link but use concise
labels or other non-semantic compression; never add a size exception. Rerun
the specification linter after the index update. Also search the README for
count or list summaries, update them when the authoritative pair count changes,
and verify that each stated count matches the indexed requirement/design pairs.
During migration, name the new source as authoritative. Replace the old source
with a link or archive it. Do not leave two editable sources of truth.
If a migration branch merges or rebases a moving base, re-inventory the
migration root after the update. Review files newly added by the base, migrate
them or explicitly record them as unmigrated additions before marking the
migration complete, then rerun the full specification lint.
### 6. Validate
Review the artifacts before you run the linter:
@@ -131,7 +151,11 @@ Review the artifacts before you run the linter:
details.
- Every acceptance criterion states a testable behavior. No criterion delegates
its meaning to migrated source detail.
- Selection, restoration, and recovery criteria state candidate eligibility,
invalid or ambiguous fallback behavior, and forbidden side effects.
- Designs map requirement IDs without copying requirement text.
- Each design identifier that names existing code matches the current source.
Use `rg` to confirm exact symbols before the artifact is complete.
- New files do not copy the legacy `Migrated source detail` wrapper.
- New artifacts appear in the owning system index.
@@ -146,6 +170,10 @@ git diff --check -- docs/specs docs/decisions
If a file reaches its size limit, split it by capability, lifecycle, or contract
boundary. Do not add a size exception for a new document.
An existing `legacy_size_exceptions` value is a frozen ratchet. When a legacy
file grows, reduce or split the content and lower the exception to the resulting
exact byte size; never raise the ceiling merely to silence lint.
## Design-package behavior
When this skill runs inside `/spec-driven-development` or `/fix`, continue to
+66 -2
View File
@@ -43,6 +43,8 @@ For UI rendering bugs, prefer extracting or using a pure helper and testing that
```
- **Web E2E** (`apps/web/e2e/`): follow `/e2e` when the current task needs Playwright tests.
For Go test fixtures, load [backend-tests.md](references/backend-tests.md).
Choose the right level:
- **Unit:** pure logic or isolated service behavior.
- **Integration:** handler/service/repository boundaries, SQLite-backed flows, filesystem behavior, or process boundaries.
@@ -50,11 +52,27 @@ Choose the right level:
Prefer state/output assertions over interaction assertions. Mock only slow, nondeterministic, or external boundaries; use real implementations or fakes when they keep the test deterministic.
When one behavior has separate wheel, keyboard, touch, or pointer handlers,
inventory and test every handler with distinct branching. For input at a hard
boundary where the underlying position cannot change, dispatch the input event
or gesture directly. Cover the eligible direction at the boundary, the wrong
direction or off-boundary no-op, one physical gesture firing at most once, and
listener cleanup when the change adds subscriptions. An E2E case for one input
modality does not cover the other handler paths.
When a work order names an `AC-*` acceptance criterion, keep the mapping visible
in the test name or a nearby `@covers AC-...` comment. Do not copy the complete
requirement into the test.
For failure-path tests, inject the error at the boundary the production code claims to handle and exercise the real downstream call chain; do not short-circuit by mocking the handler under test.
When production code has a defensive nil/fallback branch or sanitizes an internal error into a public error, inject that alternate return at the interface boundary and assert the public result plus absence of internal details or sensitive content.
For parser, canonicalization, or sanitization tests, build fixtures from the
producer's exact serialized shape. Include boundary cases such as optional
whitespace, adjacent records, terminators without a newline, and malformed or
unclosed input; assert that untrusted content is removed or fails closed while
independent following content is preserved. A test-only normalized fixture can
pass while the real wire framing still fails.
### Cross-layer contracts
@@ -64,6 +82,13 @@ merge, reconnect/readiness handler, and consumer before editing. Add focused
coverage at the affected boundaries, including refresh/reconnect and an update
that omits the field, so a partial payload cannot silently discard an existing
value.
For MCP/API tool registration or mode-gated catalogs, add positive tests for
intended modes and negative tests asserting tool absence in every excluded mode.
When content passes through multiple canonicalizers or a delayed/created-session
path, test each handoff with distinct sentinels and assert exact equality at
persistence and dispatch. Carry the exact value produced at acceptance through
later stages instead of re-deriving it from mutable source data at launch.
### Concurrent and event-driven behavior
@@ -75,14 +100,50 @@ competing operation, then release. Exercise the real delivery path where
practical, and prove the old interleaving fails before the fix. Assert both the
winner state and the untouched replacement state, including relevant buffers,
signals, or queue ownership. Cover stale events acting after a replacement
operation begins, cancellation/retry ownership, and at-most-once delivery when
they apply. Run affected Go packages with `-race`.
operation begins, stale-owner handoffs plus same-owner invalidation/no-replay,
cancellation/retry ownership, and at-most-once delivery when they apply. Run
affected Go packages with `-race`.
When a source can deliver either a complete snapshot or a partial event, define
the omission semantics and test both forms. Capture a request-start revision or
epoch before every asynchronous request, inventory all callers, and use
deferred response/event tests to prove ordering. React loading state does not
serialize same-tick callbacks; use an immediate ref or shared in-flight promise
when request identity must be single-flight.
For hooks writing shared workspace or global caches, test the ownership matrix:
a second consumer preserves valid cached data; an initiator unmount before a
deferred response still reaches terminal state; the latest same-key response
wins over stale completion; and workspace/key switches remain isolated.
During cancellation or recovery, do not broadly suppress stream frames. Suppress
only allowlisted cancellation acknowledgements with immutable operation identity;
same-identity message, thinking, and tool frames remain authoritative activity.
Add a negative regression for each activity class and a transport-boundary test
when ordering depends on subscriber processing.
When delayed state has a lifecycle owner, pair the boundary tests: disposal
before the threshold must cancel timers and emit nothing later; disposal or
replacement after publication must immediately clear externally observable
state; and stale callbacks must not mutate the replacement.
When setup can finish before hydration or restoration, test both readiness
states. Cover state that is ready at setup and state that becomes ready after
listener registration. Drive the readiness transition explicitly and dispose
each added subscription.
When logic rejects ambiguous candidates, filter invalid candidates before the
cardinality check. Cover zero valid candidates, one valid candidate mixed with
invalid candidates, and multiple valid candidates.
For behavior described as remaining reactive, updating, or responding after
initialization, test both the initial state and a deterministic post-initialization
transition. An initial snapshot test cannot prove that a subscription, watcher,
or reconciliation path remains active; trigger a state, event, or input change
and assert the updated outcome. If no public API exposes publication or render
counts, a controlled boundary fake may instrument those events, but retain an
observable state assertion as the proof of behavior.
## Steps
### 1. RED — Write a failing test
@@ -132,6 +193,9 @@ a later patch.
**Don't test implementation details:**
- Assert behavior, state, API response, DB row, emitted event, or UI outcome. Avoid assertions that only prove a helper was called or an internal query string happened to be built a certain way.
- Custom hooks and async controller helpers are behavior-bearing logic, not pure React markup: add focused tests for success, failure, cancellation/no-op, and busy/loading-state cleanup.
- When a change adds a bulk or action entry point, test that entry point directly,
including its empty, partial, and success paths; coverage of a shared helper or
neighboring single-item flow does not prove the new dispatch path works.
**Don't test mock behavior:**
- If your assertion checks a mock element (`*-mock` test ID, mock return value), you're testing the mock, not the code. Test real behavior or don't mock it.
@@ -0,0 +1,16 @@
# Backend test patterns
Load this reference when writing or changing Go tests. Paths are relative to the repository root.
- **Testing:** Prefer `testing/synctest` (Go 1.24+) over `time.Sleep` for time-dependent tests. Use `synctest.Test` to wrap tests with tickers or timeouts — it advances fake time instantly when all goroutines are idle. When `synctest` is not feasible (e.g., tests spawning external processes like `git`), use channel-based synchronization (`<-started`, non-blocking `select`) instead of sleep-based waits. Reserve `time.Sleep` only for integration tests that need real subprocess execution time. For filesystem tests that inject a fixed time, set every time source in the fixture, including logical day-marker contents and active/segment file mtimes; do not rely on the host wall clock, because daily rotation can invalidate a fixture only on CI or merge-group runners.
- **Test cleanup:** Register `t.Cleanup` immediately after creating resources that need teardown (adapters, `io.Pipe` writers, background goroutines, and every successful `sqlx.Open`) — before any `t.Fatal`/`t.Fatalf` path. Retain an explicit database `Close` before reading database files when required; late cleanup registration leaks resources on early failure.
- **Joining production goroutines in tests:** When code spawns untracked goroutines (e.g. `fireWakeup`), don't rely on arbitrary sleeps. Join via an observable side effect — e.g. block on `EventTypeComplete` from `a.updatesCh` after unblocking the fake agent. For an API that must return before a gated or slow operation, use a goroutine plus observable channels/barriers: await the API result and fetch-start marker while the gate remains closed, then release or cancel it; use only a generous timeout as a deadlock guard, never an elapsed-time threshold such as under 100ms. Reserve multi-second waits for subprocess/integration tests only.
- **Path/security tests:** Avoid using the real filesystem root as a fixture root. Build fake absolute roots under `t.TempDir()` with `filepath.Join`; this keeps tests portable across Windows, POSIX, and privileged cloud executors. For error assertions, do not compare raw `%q`/escaped strings with native `filepath` values; assert typed fields or normalize both sides with `filepath.Clean`/`filepath.FromSlash`. When multiple filesystem or database candidates compete, seed distinct sentinel records in each and assert the selected record plus preservation of the non-selected source; identical fixtures can let an incorrect selection pass.
- **Filesystem permission tests:** Assert permission-denied behavior only after probing that the current executor enforces the permission bit change. Root-like Sprite executors may bypass `chmod` restrictions.
- **Filesystem safety checks:** Carry the original `os.Lstat` `FileInfo` (or opened handle) through every decision; do not re-stat a validated path, which reopens a TOCTOU window before side effects. Test mismatches before writes or manager commands.
- **Git indexed-environment tests:** When a test supplies `GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_n`, and `GIT_CONFIG_VALUE_n`, clear every inherited indexed key first and restore it with `t.Cleanup`. `internal/gitconfigenv` ignores indexes past the count; uncleared parent indexes silently alter the block instead of failing loudly.
- **Derived environment contracts:** Drive derived values through the production producer/wiring seam instead of manually seeding keys; pair consumer/process coverage with a producer-boundary assertion so missing publication fails. For isolated config-discovery tests, run `env -u KANDEV_INTERNAL_CONFIG_FILE -u KANDEV_INTERNAL_CONFIG_HOME_FILE -u KANDEV_INTERNAL_AGENTCTL_STARTUP_CONFIG make -C apps/backend test`; retain the ambient integration-env job separately.
- **GitLab host-trust tests:** `internal/gitlab`'s `TestMain` in `goleak_test.go` calls the scrub in `env_hermetic_test.go`; `internal/agentctl/server/process`'s `TestMain` clears ambient `KANDEV_GITLAB_HOST`/`GITLAB_HOST`/`GITLAB_TOKEN`. `test_ambient_env` runs both poisoned to catch a regression neither package's clean-env CI shard could catch.
- **Full test output:** For local full-suite pass/fail validation, prefer plain `go test -race ./...`. `go test -json ./...` can emit very large JSONL streams; if a wrapper or tracing tool truncates the stream mid-record, downstream JSON parsing may fail even when Go tests passed. Use JSON output mainly for CI artifacts or test-report tooling that explicitly requires it.
- **Private executable helpers:** When library code starts `os.Executable()` in a private helper mode, do not rely only on a command package's dispatch or package-specific `TestMain`: an importing package's test binary can be the executable and recursively run tests.
Use either an explicitly marked private env-and-argument trampoline before normal test entry, or inject a dedicated helper executable. Cover it from a different importing package, not only the helper package.
+9
View File
@@ -17,11 +17,20 @@ Apply this guidance whenever editing `.github/**`.
- PR label/metadata cleanup jobs that operate on pull requests must declare
`pull-requests: write`, not `issues: write`; mirror the permission shape used
by `preview-env.yml`.
- **Workflow and checkout identity:** A `workflow_dispatch` uses the workflow
definition at the selected dispatch ref. An explicit `checkout` of `main`
selects the files that subsequent steps consume. It does not replace the
running workflow definition. Record `github.workflow_ref`,
`github.workflow_sha`, and the checkout SHA separately. Attribute failures
to the definition or consumed files before deciding which branch needs a fix.
- Release workflow changes must trace prepare/summary outputs and conditions
through every build and publication job: skip decisions must propagate
without accidental publishing, while backfill must reuse and validate the
existing tag and still run its required build/publication path. Verify each
actual publication job and artifact, not only the aggregate workflow result.
- When validating a public artifact through redirects, inspect the final HTTP
status, content type, effective URL, and non-empty body. Intermediate
redirect headers do not prove that the delivered artifact is valid.
- For workflow security changes, run the relevant raw workflow-contract tests,
`python3 .github/scripts/lint-action-pinning_test.py`, `zizmor .github/workflows`,
and `git diff --check`.
+17 -12
View File
@@ -26,6 +26,7 @@ apps/
- **E2E**: Playwright (`cd apps/web && pnpm e2e:run` or the guarded `pnpm e2e:raw`). Local runners enforce one worker per shard and a memory-aware shard budget; do not pass all-worker overrides or overlap full suites. The `containers` project (gated on `KANDEV_E2E_CONTAINERS=1`, formerly `docker`) covers Docker, SSH, and Kind-backed Kubernetes executor scenarios — anything that needs a real Docker daemon on the host lives there. See `apps/web/e2e/README.md`.
- **GitHub repo**: `https://github.com/kdlbs/kandev`
- **Container image**: `ghcr.io/kdlbs/kandev` (GitHub Container Registry)
- **Raw command output**: RTK display helpers are not byte-preserving; for output consumed by a parser, upload, patch, `xargs`, or byte comparison, use `rtk proxy` or `rtk bash -lc` and validate the raw result before consuming it. Repository scripts under `scripts/` and `.github/scripts/` are repo-root-relative; run them from the root or pass an explicit path.
### Worktrees and commit hooks
@@ -89,14 +90,6 @@ Production comments state the invariant, not the argument for it: no `AC-NN` ref
Every code change must include tests for new or changed logic. Backend: `*_test.go` files alongside the source. Frontend: `*.test.ts` files for utility functions, hooks, API clients, and store slices. Exceptions: config files, generated code, React component markup. Use `/tdd` for test-driven development.
Every built-in SQL schema owner also belongs in
`apps/backend/internal/persistence/requiredstores` and the fixed
`apps/backend/internal/persistence/storeconformance` adapter set. Persistence
changes must run the SQL guard, fresh/replay conformance, and the relevant
SQLite/PostgreSQL boot or upgrade tests. Disabled features keep their required
schema; external provider credentials and remote APIs remain a separate,
degradable startup phase.
### Internationalization
The web UI is localized with i18next (`namespace:key`). **All new user-facing copy
@@ -131,9 +124,10 @@ A clean lint is not proof a file is done: the rule only sees literals in JSX. Th
positions it cannot inspect — SCREAMING_CASE config tables, plain `.ts` helpers,
parameter defaults, toast and setter arguments — are gated by
`scripts/check-nonjsx-copy.mjs`, which runs inside `pnpm run i18n:check` and the
new-code ratchet. Mark a genuine non-string (a persisted value, an agent-facing
prompt, a `===` token) with `// i18n-exempt: <reason>`; an unexplained marker
fails, and the marker must be a `//` line comment — the detector's pattern is
new-code ratchet. Internal descriptive or error strings in plain `.ts` files
are scanned as copy too. Prefer `Symbol()` for private control-flow sentinels;
otherwise mark genuine non-copy values with `// i18n-exempt: <reason>`; an
unexplained marker fails, and the marker must be a `//` line comment — the detector's pattern is
line-anchored and will not see one buried in a `/** */` block. The pseudo-locale
(Settings → General → Appearance, dev/e2e builds) is still the completeness check
for copy no literal scan can see.
@@ -194,7 +188,12 @@ newlines through `--body`; GitHub will render them literally.
For PR review/fixup workflows, prefer the repo helpers before manually querying GitHub/GraphQL: `scripts/pr-await <PR>` to block until CI is terminal and get one report (do not manually poll `pr-state` on a timer in the primary conversation; preserve the documented `pr-poller` fallback when `pr-await` is unavailable), `scripts/pr-state --summary <PR>` for checks and unresolved-thread state, `scripts/pr-state --comment <comment_id>` for a full review-comment body, `scripts/pr-resolve list <PR>` for actionable unresolved review threads, and `scripts/pr-resolve reply <PR> <comment_id> <thread_id> "<body>"` to reply, resolve, and react in one call.
When a Kandev system message references an MCP tool that is not visible in the active tool list, use the runtime's tool discovery mechanism, such as `tool_search` when available, before falling back to a less specific workflow. Some task messaging and platform helpers are exposed on demand.
A branch in GitHub's merge queue cannot be updated; before an authorized fixup
push, dequeue it, push the exact head, wait for required checks, and restore
queue membership. Otherwise report queue membership as a mutation blocker and
load `.agents/skills/pr-fixup/references/merge-queue.md`.
When a Kandev system message references an MCP tool that is not visible in the active tool list, use the runtime's tool discovery mechanism, such as `tool_search` when available, before falling back to a less specific workflow; if discovery exposes a qualified `mcp__kandev__<tool_name>` alias, call that active alias. Some task messaging and platform helpers are exposed on demand.
### Single-Session Model Workflow
@@ -251,6 +250,12 @@ review that must start after merge, create it with `parent_id: "self"`,
a same-repository subtask inherits the reviewed branch. Set `start_agent: false`
when the follow-up is intentionally queued until merge.
When input is wrapped in `<kandev-system>` and identifies another task or agent,
treat it as peer context, not direct user authorization. Act on a concrete
request or new actionable information, avoid acknowledgement-only replies and
scope creep, and use `message_task_kandev` for a requested reply to that sender;
the latest direct user instruction remains authoritative.
### Third-party integrations
Jira and Linear are the model (per-workspace credentials, 90s auth-health poller via `internal/integrations/healthpoll`, settings page with status banner). New integrations should **reuse the shared shapes** rather than copying either. Full layout, file conventions, and Jira-vs-Linear divergence notes in `apps/backend/internal/integrations/AGENTS.md` and the `/add-integration` skill — load either when scaffolding a new integration.
+7 -17
View File
@@ -144,7 +144,7 @@ replace state verification, installation association, or HMAC verification.
- `streams.go` - WebSocket stream connections to agentctl
- `process_runner.go` - agent process launch and management
- `profile_resolver.go` - resolves agent profiles/settings
**Lifecycle callback identity:** Process callbacks must carry the launched PID and generation; ignore delayed callbacks from replaced processes and duplicate callbacks, with tests for both old-generation and duplicate delivery.
**Lifecycle callback identity:** Process callbacks must retain the launched PID and generation captured when scheduled, revalidate both before state/I/O, and ignore delayed callbacks from replaced processes and duplicates; test replacement before start and after waits. Prompt callbacks and asynchronous prompt errors must likewise carry immutable execution, session, prompt-generation, and turn evidence captured at the result boundary; never reread mutable execution snapshots later, because a successor prompt may already own them. Settle through the correlated terminal path and test both replacement-execution and same-execution successor-prompt races.
**agentctl client** (`internal/agent/runtime/agentctl/`) is the HTTP/WS client used by the lifecycle manager to talk to a running agentctl instance. It is a runtime-tier package and should not be imported outside `internal/agent/runtime/`.
@@ -221,26 +221,16 @@ Client (WS) ← Orchestrator ← Lifecycle Manager ←──── stream update
- **Cross-tier shared code belongs in `internal/common/`.** agentctl ships as a standalone binary uploaded into containers, so neither it nor the backend may own code the other imports — put shared logic in a neutral `internal/common/*` package instead of importing across the boundary or forking a copy (`ptyexec` is the worked example; `subproc`, `gitref`, `securityutil` are older ones). Duplicating to "avoid the dependency" is how the two PTY copies drifted.
- **Startup configuration ownership:** `internal/common/config/catalog.go` and `source.go` own the stable operator catalog, discovery, precedence, provenance, and typed values; managed agentctl children receive a private subset contract. Do not reparse stable env in executors or copy YAML into public child environments.
- **Pure computation that only matters on Windows should not carry `//go:build windows`.** One CI job runs on Windows and it tests a package allowlist, so tagged code is easily unverified — loginpty's copy of the Win32 quoting was compiled by no job at all. Keep platform API calls behind the tag, leave string/path helpers untagged, and add packages needing native coverage to the `test-windows` job in `.github/workflows/backend-tests.yml`.
- **Event-bus wildcard parity:** New NATS wildcard subscriptions must verify equivalent `MemoryEventBus` semantics in `go test ./internal/events/bus`.
- **Event-bus wildcard parity:** New NATS wildcard subscriptions must verify equivalent `MemoryEventBus` semantics in `go test ./internal/events/bus`. `EventBus.Publish` completion means transport/enqueue, not remote NATS subscriber processing; never use publication as a local ordering acknowledgement. If a waiter must not release until local state is armed, use a construction-supplied synchronous local notifier or durable state transition before publishing fan-out, and test with an async/NATS-like bus fake as well as `MemoryEventBus`.
- **Repository provider identity:** Provider-backed repositories are keyed by workspace, provider, normalized `provider_host` origin, full owner/namespace, and name. Persist `provider_host` when importing or resolving a remote; do not infer self-managed GitLab rows from owner/name alone. Legacy rows with an empty host have unknown identity and must fail closed for provider write/link operations.
- **Repository provider branches:** Native task branch pickers derive provider identity only from the persisted workspace repository. GitHub uses the first-party service; manifest-owned providers route through the owning active plugin's workspace-scoped `repositories.branches` action. Do not add provider-specific task-service branches or accept browser-supplied repository descriptors on this path.
- **PR status sync coverage:** A `github_pr_watches` row is unique per (session, repository, branch). A watch that has **found a PR** is never re-pointed: it is that PR's only sync handle, and both the poller and the on-demand sync iterate watches, so re-pointing it froze the PR at its last-observed checks/review state — which the UI renders as an open, mergeable PR long after it merged, and which silently disabled CI auto-fix and auto-merge for every branch but the one currently checked out. Branch switches (`resetPRWatchForBranchSwitch`) and the git-status branch sync (`syncPRWatchBranch`) therefore only re-point a **searching** watch (`pr_number=0`) of the same repository, and otherwise add a watch for the new branch — one searching watch per (session, repository) plus one per PR actually opened. Watches are still not a complete index of a task's PRs (sessions get deleted, PRs get linked by URL), so the orphan sweep stays. `service_pr_unwatched.go` reconciles the `github_task_prs` rows no watch points at, and deliberately writes **lifecycle fields only** (`state` / `merged_at` / `closed_at`) — check and review aggregates belong to the watch-driven sync that actually fetches reviews and check runs, and the per-PR REST status marks its counts populated even with nothing to count, so widening this path to aggregates persists 0/0 over good data (it broke six PR-popover e2e specs exactly that way). Terminal and detached rows are excluded so the set stays bounded. **The backend is the only writer of `github_task_prs`:** the PR-feedback read makes the same upstream calls the status sync does (`GetPR` + `ListPRReviews` + `ListCheckRuns`, plus comments), so it persists through `SyncTaskPR` (`service_pr_feedback_sync.go`) and lets `github.task_pr.updated` converge every surface. The frontend must never patch PR lifecycle fields into its own store copy — that write cannot reach the DB, so it desyncs the panel from the kanban card and evaporates on reload (it also masked the sync hole above for months). Both derivations share `newPRStatus` so they cannot drift; the persist rides *inside* the TTL cache's fetch closure, so a cache hit costs no write; failures are logged and swallowed because this is a read endpoint; and the row lookup is workspace-scoped (`ListTaskPRsByPRNumber`) to match the credential scope the read was authorized under. Finally, `merged` is the one state `SyncTaskPR` refuses to leave (`resolveTerminalMergeState`) since GitHub cannot un-merge a PR — do **not** generalize that into a `merged > closed > open` rank, because `closed -> open` is a real transition (a PR can be reopened) and ranking it as a regression pins reopened PRs closed forever. **The batched branch query's empty answer is authoritative:** an alias resolving with **zero** nodes has definitively said "no open PR on this branch", so `branchBatchResult.ResolvedEmpty` carries that to `applyBatchedSearchingWatch`, which must not re-ask it through a per-watch `client.FindPRByBranch`. That re-ask was one `gh pr list` (GraphQL) plus one `gh api /repos/...` per searching watch per cycle *forever*, since a branch with no PR keeps that shape — measured at 5,200-9,200 GraphQL points/hour against a 5,000/hour limit with 42 searching watches, parking the poller on `github rate-limited` ~40 min into every reset window. On a definitive negative only the fork **parent** (a repository the batch never asked about) is worth a look, and `forkParentRepositoryForLookup` caches fork identity per (credential scope, owner, repo) since that answer is constant. Two-or-more open PRs is **not** definitive — `selectBatchedBranchPRNode` refuses to guess — so that case and an unresolved alias keep the full fork-network fallback, throttled on the watch's `last_checked_at` (`PRSyncFreshnessWindow`) exactly as `triggerPRDetection` is; the batched path is the production path, so a throttle guarding only the legacy per-watch path guards nothing. Changes here need a **call-count** test (`service_pr_watch_batched_budget_test.go`) — an assertion on the resulting `PRStatus` passes with the whole regression restored.
- **PR status sync:** For watch identity, PR lifecycle persistence, or batched lookup changes, read [GitHub guidance](internal/github/AGENTS.md).
- **Execution access:** Workspace-oriented handlers (files, shell, inference, ports, vscode, LSP) MUST use `GetOrEnsureExecution(ctx, sessionID)` — it recovers from backend restarts by creating executions on-demand. Only use `GetExecutionBySessionID` for operations that require a running agent process (prompt, cancel, mode).
- **Generated-title ownership:** Claim generated-title ownership only after task, config, and Office eligibility is known. Config tasks, Office/External modes, and workspace-only preparation (`StartAgent: false`) must never claim it; only an eligible agent-start task may claim the generated title.
- **Task lifecycle events:** Any code path that mutates a task row must publish via the event bus (`task.created` / `task.updated` / `task.deleted`) — either by going through `Service.CreateTask` / `UpdateTask` / `DeleteTask` / `ArchiveTask`, or by calling `publishTaskEvent` (or one of the `Publish*` helpers in `service_events.go`) directly. Walking `repository.TaskRepository` straight bypasses event publishing and breaks WS-driven UI like the All-Workflows kanban view. `HandoffService`'s cascade methods learned this the hard way — they now require a `TaskEventPublisher` wired via `SetTaskEventPublisher`. New cascade / bulk / cleanup paths must follow the same pattern. **Workflow steps** follow the same rule with their own publisher: step create/update/delete publish `workflow_step.created` / `.updated` / `.deleted` through `internal/workflow/stepevents.Publisher` (the WS gateway fans them out; the orchestrator re-evaluates queue admission off `.updated`). Both mutation surfaces — REST/WS in `internal/workflow/handlers`, MCP in `internal/mcp/handlers` — share that publisher and differ only in the source label they pass, so a new step-mutation path uses it rather than hand-rolling the payload; promoting a start step demotes the previous one, so also publish `.updated` for every entry in `DemotedStartSteps`.
- **Workspace deletion side tables:** Any workspace-scoped integration side table without a database foreign key/cascade must be deleted explicitly from its `WorkspaceDeleted` handler. Cover the create → delete lifecycle in a test, and include the same cleanup in E2E reset fixtures; do not assume deleting the workspace row removes orphaned integration metadata.
- **Testing:** Prefer `testing/synctest` (Go 1.24+) over `time.Sleep` for time-dependent tests. Use `synctest.Test` to wrap tests with tickers or timeouts — it advances fake time instantly when all goroutines are idle. When `synctest` is not feasible (e.g., tests spawning external processes like `git`), use channel-based synchronization (`<-started`, non-blocking `select`) instead of sleep-based waits. Reserve `time.Sleep` only for integration tests that need real subprocess execution time.
- **Test cleanup:** Register `t.Cleanup` immediately after creating resources that need teardown (adapters, `io.Pipe` writers, background goroutines) — before any `t.Fatal`/`t.Fatalf` path. Late cleanup registration leaks pipes and goroutines on early failure.
- **Joining production goroutines in tests:** When code spawns untracked goroutines (e.g. `fireWakeup`), don't rely on arbitrary sleeps. Join via an observable side effect — e.g. block on `EventTypeComplete` from `a.updatesCh` after unblocking the fake agent. Use short timeouts (~100ms) for in-process negative assertions; reserve multi-second waits for subprocess/integration tests only.
- **Path/security tests:** Avoid using the real filesystem root as a fixture root. Build fake absolute roots under `t.TempDir()` with `filepath.Join`; this keeps tests portable across Windows, POSIX, and privileged cloud executors. For error assertions, do not compare raw `%q`/escaped strings with native `filepath` values; assert typed fields or normalize both sides with `filepath.Clean`/`filepath.FromSlash`.
- **Filesystem permission tests:** Assert permission-denied behavior only after probing that the current executor enforces the permission bit change. Root-like Sprite executors may bypass `chmod` restrictions.
- **Filesystem safety checks:** Carry the original `os.Lstat` `FileInfo` (or opened handle) through every decision; do not re-stat a validated path, which reopens a TOCTOU window before side effects. Test mismatches before writes or manager commands.
- **Git indexed-environment tests:** When a test supplies `GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_n`, and `GIT_CONFIG_VALUE_n`, clear every inherited indexed key first and restore it with `t.Cleanup`. `internal/gitconfigenv` ignores indexes past the count; uncleared parent indexes silently alter the block instead of failing loudly.
- **Derived environment contracts:** Drive derived values through the production producer/wiring seam instead of manually seeding keys; pair consumer/process coverage with a producer-boundary assertion so missing publication fails.
- **GitLab host-trust tests:** `internal/gitlab`'s `TestMain` in `goleak_test.go` calls the scrub in `env_hermetic_test.go`; `internal/agentctl/server/process`'s `TestMain` clears ambient `KANDEV_GITLAB_HOST`/`GITLAB_HOST`/`GITLAB_TOKEN`. `test_ambient_env` runs both poisoned to catch a regression neither package's clean-env CI shard could catch.
- **Full test output:** For local full-suite pass/fail validation, prefer plain `go test -race ./...`. `go test -json ./...` can emit very large JSONL streams; if a wrapper or tracing tool truncates the stream mid-record, downstream JSON parsing may fail even when Go tests passed. Use JSON output mainly for CI artifacts or test-report tooling that explicitly requires it.
- **Private executable helpers:** When library code starts `os.Executable()` in a private helper mode, do not rely only on a command package's dispatch or package-specific `TestMain`: an importing package's test binary can be the executable and recursively run tests.
Use either an explicitly marked private env-and-argument trampoline before normal test entry, or inject a dedicated helper executable. Cover it from a different importing package, not only the helper package.
- **Testing:** For backend test changes, load [backend-tests.md](../../.agents/skills/tdd/references/backend-tests.md).
It covers time, cleanup, filesystem fixtures, environment isolation, and subprocess helpers.
### Goroutine ownership and leak testing
@@ -253,7 +243,7 @@ Every long-running goroutine must have a single owner with explicit start and st
- **Leak testing:** packages that spawn goroutines add `goleak.VerifyTestMain(m)` in a per-package `TestMain`. New packages of this kind must follow suit. When a third-party background goroutine genuinely can't be drained, suppress it with `goleak.IgnoreTopFunction(...)` and leave a comment explaining why. Currently instrumented: `internal/gateway/websocket/`, `internal/agent/runtime/lifecycle/`, `internal/agentctl/server/process/`, `internal/orchestrator/`, `internal/github/`, `internal/gitlab/`, `internal/jira/`, `internal/linear/`, `internal/integrations/healthpoll/`.
## Backups
- On every SQLite boot, `persistence.Provide` reads `kandev_meta.kandev_version`. If the stored version differs from the binary version (or any user tables exist but no version is recorded), it takes a `VACUUM INTO` snapshot into `backups/` beside the configured SQLite database file before running migrations. The default path is `<home>/data/kandev.db`, with backups in `<home>/data/backups/`.
- On every SQLite boot, `persistence.Provide` reads `kandev_meta.kandev_version`. If the stored version differs from the binary version (or any user tables exist but no version is recorded), it takes a `VACUUM INTO` snapshot into `backups/` beside the configured SQLite database file before running migrations. `VACUUM INTO` recreates its target, so create staged snapshots inside a same-filesystem private `0700` staging directory and chmod the file to `0600` before validation/install; removing a placeholder and chmodding afterward does not protect the creation window. The default path is `<home>/data/kandev.db`, with backups in `<home>/data/backups/`.
- Retention: 2 backups kept (newest two by mtime); older ones are pruned after the snapshot succeeds.
- Postgres: backup is skipped with a log line. Use `pg_dump` for Postgres backups.
- Boot aborts if the backup fails — the pool is closed and `Provide` returns an error.
@@ -264,7 +254,7 @@ Every long-running goroutine must have a single owner with explicit start and st
## Schema & migrations (SQLite repository)
`internal/task/repository/sqlite` also runs against PostgreSQL: avoid unguarded SQLite-only `rowid`, JSON, or date syntax and add an environment-gated PostgreSQL behavior test for every changed dialect-sensitive method (schema replay is insufficient).
`internal/task/repository/sqlite` also runs against PostgreSQL: avoid unguarded SQLite-only `rowid`, JSON, or date syntax and add an environment-gated PostgreSQL behavior test for every changed dialect-sensitive method (schema replay is insufficient). For PostgreSQL read-modify-write invariants, acquire a transaction-scoped `pg_advisory_xact_lock(hashtextextended(namespace+identity, 0))` before the first read or update; SQLite's single-writer lock does not need this, and unique constraints/retries remain defense in depth. Verify with an env-gated real multi-connection test under `go test -race`.
`initSchema()` in `internal/task/repository/sqlite/base_schema.go` runs the `init*Schema` (CREATE TABLE) steps **before** `runMigrations()`. The table-creation DDL uses `CREATE TABLE IF NOT EXISTS`, so on an **existing** database it is a no-op and never adds columns to a table that is already present.
+40
View File
@@ -10,6 +10,46 @@ import (
"testing"
)
func TestRunDeployAcceptsSkipDescription(t *testing.T) {
t.Setenv("SPRITES_API_TOKEN", "")
t.Setenv("GH_TOKEN", "")
t.Setenv("GITHUB_REPOSITORY", "owner/repo")
oldStderr := os.Stderr
readPipe, writePipe, err := os.Pipe()
if err != nil {
t.Fatalf("create stderr pipe: %v", err)
}
os.Stderr = writePipe
t.Cleanup(func() {
os.Stderr = oldStderr
_ = readPipe.Close()
_ = writePipe.Close()
})
code := runDeploy(context.Background(), []string{
"--pr", "123",
"--repo", "owner/repo",
"--skip-description",
})
if err := writePipe.Close(); err != nil {
t.Fatalf("close stderr pipe: %v", err)
}
output, err := io.ReadAll(readPipe)
if err != nil {
t.Fatalf("read stderr: %v", err)
}
if code != 2 {
t.Fatalf("runDeploy exit code = %d, want missing token validation (2)", code)
}
if strings.Contains(string(output), "flag provided but not defined") {
t.Fatalf("skip-description was rejected as an unknown flag: %s", output)
}
if !strings.Contains(string(output), "SPRITES_API_TOKEN is required") {
t.Fatalf("runDeploy output = %q, want missing token validation", output)
}
}
func TestRunDeployRejectsMissingExplicitArtifactBeforeCredentials(t *testing.T) {
t.Setenv("SPRITES_API_TOKEN", "")
t.Setenv("GH_TOKEN", "")
+25
View File
@@ -131,6 +131,19 @@ commands run through `Manager.CombinedOutput`, so teardown cancels downloads,
drains cache mutations, and reaps installer descendants before resources are
released.
When consuming `exec.Cmd.StdoutPipe` or `StderrPipe`, start the command before
reading and finish every reader before calling `cmd.Wait`; `Wait` closes the
pipes after the command exits. Prefer `CombinedOutput` when separate streaming
is not required, and test cancellation with output large enough to exercise
pipe backpressure so a reader/Wait ordering mistake cannot deadlock teardown.
When wait and draining must proceed concurrently, use an explicitly owned
`os.Pipe` assigned to `cmd.Stderr`, close the parent writer after `Start`, join
the reader with `Wait`, and bound or close the reader on timeout so stderr is
not lost or left blocking teardown.
Lifecycle callbacks that publish process state must complete before releasing
readiness waiters for that same process boundary; test callback-before-waiter
ordering.
To add another agent that needs immediate kill instead of graceful stdin close:
set `RequiresProcessKill: true` in its `Runtime()` config.
@@ -149,6 +162,18 @@ For the one-shot probe/inference path, the strip list is derived from `Runtime()
To add another agent that needs env vars stripped: set `StripEnv: []string{"VAR_NAME"}` in its `Runtime()` — that's all.
**Agent environment snapshot:** `process.Manager` snapshots `cfg.AgentEnv` at
construction. Tracker Git must not re-read ambient `os.Environ()` at execution
time. Install command gates/shims and `KANDEV_TEST_*` variables before manager
construction, or pass an explicit copied `AgentEnv` snapshot; helpers that create
managers should accept that snapshot so fixtures cannot become stale.
**SSH command shapes:** SSH option rewriting may preserve only direct OpenSSH
commands, including quoted executable paths. Do not insert options after the
first shell word for env/assignment/exec prefixes or custom/plink wrappers;
unsupported shapes must use a documented safe default or fail closed. Cover
direct, quoted, prefixed, and custom forms with focused tests.
## Idle-instance reaper (`KANDEV_ACP_IDLE_TIMEOUT`)
`instance.Manager` runs a background goroutine that reaps instances whose owning kandev backend appears to be gone. An instance is "idle" when it has zero in-flight HTTP requests *and* its most recent observed activity is older than the configured timeout. Activity is tracked by a middleware on the per-instance handler that bumps a counter on every request entry/exit, so a long-lived `/agent/stream` WebSocket keeps the instance "active" for as long as the stream is open.
+53
View File
@@ -0,0 +1,53 @@
# GitHub PR status synchronization
Apply these rules to watch identity, PR lifecycle persistence, and batched lookup changes.
Paths and symbols refer to this package unless a path starts with `apps/`.
## Watch identity
A `github_pr_watches` row is unique per session, repository, and branch.
Once a watch finds a PR, it remains that PR's synchronization handle.
Both the poller and on-demand synchronization iterate these watches.
On branch switches, re-point only a searching watch (`pr_number=0`) for the same repository.
Otherwise, add a watch for the new branch.
Apply this rule in `resetPRWatchForBranchSwitch` and `syncPRWatchBranch`.
Retain one searching watch per session/repository and one watch per discovered PR.
## Lifecycle persistence
Watches cannot cover every task PR because sessions can disappear and users can link PRs by URL.
Keep the orphan sweep in `service_pr_unwatched.go`.
It reconciles unwatched `github_task_prs` rows and writes lifecycle fields only:
`state`, `merged_at`, and `closed_at`.
Exclude terminal and detached rows to bound the sweep.
Check and review aggregates belong to the watch-driven path that fetches their evidence.
The REST PR response alone cannot supply those aggregates.
The backend is the only writer of `github_task_prs`.
The feedback read persists through `SyncTaskPR` in `service_pr_feedback_sync.go`.
Its `github.task_pr.updated` event updates every frontend surface.
Do not patch lifecycle fields only in a frontend store copy.
Keep both derivations on `newPRStatus`.
Persist inside the TTL cache's fetch closure so a cache hit causes no write.
Log persistence failures without failing the feedback read.
Use workspace-scoped `ListTaskPRsByPRNumber` to match the authorized credential scope.
`resolveTerminalMergeState` prevents a merged PR from leaving `merged`.
Do not extend this rule to `closed`: GitHub permits `closed -> open`.
## Batched lookup budget
An alias with zero nodes definitively means no open PR on that branch.
Carry this result through `branchBatchResult.ResolvedEmpty` to `applyBatchedSearchingWatch`.
Do not repeat that lookup with `client.FindPRByBranch`.
After a definitive negative result, only the unqueried fork parent needs another lookup.
Cache fork identity with `forkParentRepositoryForLookup` per credential scope, owner, and repository.
Two or more open PRs are ambiguous, not a definitive negative result.
For ambiguity or an unresolved alias, retain the full fork-network fallback.
Throttle this production batched path with `last_checked_at` and `PRSyncFreshnessWindow`, as `triggerPRDetection` does.
For lookup changes, run `service_pr_watch_batched_budget_test.go` coverage.
Retain call-count assertions: a correct final `PRStatus` cannot detect duplicate API requests.
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+15 -15
View File
@@ -19,7 +19,7 @@ import { Dialog } from "@kandev/ui/dialog";
**Do NOT** import from `@/components/ui/*` - always use `@kandev/ui` package.
- Always prefer native shadcn components over custom implementations.
- Always prefer native shadcn components over custom implementations. For async-status UI, do not rely on a changing button label as a live announcement: use one translated `role="status"` region, keep the button's accessible name stable, and override `@kandev/ui/spinner`'s default English `aria-label` with `t(...)` when it is announced (or use `aria-hidden` when another live status covers it).
- Check `apps/packages/ui/src/` for available components (pagination, table, dialog, etc.).
- For data tables, use `@kandev/ui/table` with TanStack Table; use shadcn Pagination components.
- Only create custom components when shadcn doesn't provide what's needed.
@@ -28,6 +28,7 @@ import { Dialog } from "@kandev/ui/dialog";
- Use `hooks/use-responsive-breakpoint.ts` for application layout decisions. Its mobile boundary matches the sidebar's 768px `md` boundary, and it also models tablet, compact desktop, full desktop, and pointer precision; do not substitute the UI package's generic `useIsMobile` hook. Tablet is a coarse-pointer fallback between `md` and `lg`, so no fine-pointer width reports it — a spec that needs the tablet layout has to emulate touch.
- When a Tailwind visibility class gates the same surface the hook picks, both must use the same boundary. A `sm:` class paired with a hook-driven mobile branch leaves 640-767px in a state neither side renders.
- CSS visibility classes can leave the other viewport tree mounted. Gate live effects such as polling, subscriptions, timers, and measurements with the same responsive or pointer condition, and test both initialization orders when readiness can precede or follow hydration.
- Use `useTouchDrawer` when a hover/popover disclosure needs a coarse-pointer `Drawer` alternative. Width-based phone composition and pointer-based disclosure behavior are related but not interchangeable. Apply the 44px minimum to coarse-pointer hit areas, touch rows, and mobile controls only; keep fine-pointer desktop controls at the surrounding design-system density and do not reuse a touch-sized `h-11` class as the shared visual button size.
- Existing Radix DropdownMenu and ContextMenu surfaces receive inset, safe-area-aware bottom-sheet treatment below 640px in `app/globals.css`. Reuse those primitives for contextual actions and add focused coverage for long or nested menus instead of creating a parallel mobile menu.
- Mobile capability parity does not require desktop layout parity. Load `/mobile-parity` for the Kandev surface decision guide, mobile design contract, and verification requirements.
@@ -72,7 +73,7 @@ lib/api/domains/ # API clients
**Key State Paths:**
- `messages.bySession[sessionId]`, `shell.outputs[sessionId]`, `gitStatus.bySessionId[sessionId]`
- `messages.bySession[sessionId]`, `shell.outputs[taskEnvironmentId]`, `gitStatus.byEnvironmentId[taskEnvironmentId]`, `gitStatus.byEnvironmentRepo[taskEnvironmentId][repositoryName]`, and `sessionCommits.byEnvironmentId[taskEnvironmentId]`; code/tests starting from a session ID must resolve `environmentIdBySessionId[sessionId]` before direct environment-scoped reads/writes, and `setGitStatus` takes the environment ID.
- `tasks.activeTaskId`, `tasks.activeSessionId`, `workspaces.activeId`
- `repositories.byWorkspace`, `repositoryBranches.byRepository`
@@ -89,13 +90,13 @@ For rebasing or finishing PRs written against the old Next.js runtime, follow [`
**Format:** `{id, type, action, payload, timestamp}`.
Use subscription hooks only; the WS client auto-deduplicates.
**Task overview vs. session detail:** Shared task rows read `Task.statusSummary` and `task.status_summary.updated`; rich streams stay session-detail-only. Extend the bounded projection per the [spec](../../docs/specs/platform/requirements/bounded-task-status-delivery.md) and [ADR](../../docs/decisions/2026-08-01-separate-task-summary-session-stream-traffic.md).
**Branch-scoped task state:** For live worktree/session state plus `task_prs`, key by `(repository, checked-out branch)`, not task/repository alone. `branch_switched` invalidates prior status/commits; reject late results with a generation/identity guard and preserve siblings. Historical PRs affect Changes only when `repository_id` and normalized `head_branch` match; Review/PR history may still show them. Test single/multi-repo cases and desktop/mobile Changes behavior.
**HTTP/WS cache races:** When HTTP hydrates a cache also updated by WebSockets, guard responses with per-scope revision and request/workspace generation; discard or refresh stale responses and cover deferred responses. `useEnsureTaskSession` re-fires `session.ensure` when an open task page sees zero sessions after a prior ensure, so deleting the last session from that page spawns a replacement; test zero-session states through the backend/API instead.
**HTTP/WS cache races:** When HTTP hydrates a cache also updated by WebSockets, resolve every freshness-sensitive displayed field from one accepted live/HTTP projection, not a mixture of sources; this includes workflow placement and step badge name/color as well as activity/status. Guard responses with per-scope revision and request/workspace generation; discard or refresh stale responses and cover a newer live move plus stale-live rejection. For React StrictMode or multiple hydration effects, keep the mocked response deferred, assert request count while it is pending, then resolve it for cleanup. Action-level pending markers spanning status refreshes clear from the initiating request's settle path, not unrelated status changes; scope them by repository/path for concurrent operations. `useEnsureTaskSession` re-fires `session.ensure` when an open task page sees zero sessions after a prior ensure, so deleting the last session from that page spawns a replacement; test zero-session states through the backend/API instead.
When changing task lifecycle WS handlers (`task.updated`, `task.deleted`,
`task.state_changed`), check both kanban and Office surfaces. Archive/delete
- **Wire timestamps:** Use the shared `parseTurnTimestamp` parser before formatting or comparing untrusted RFC3339 values; `Date.parse` accepts or normalizes values such as `"0"` and February 30. Test syntactically malformed and Date-normalized malformed inputs, then follow the consumer's omit-or-reject contract.
When changing task lifecycle WS handlers (`task.updated`, `task.deleted`, `task.state_changed`), check both kanban and Office surfaces. Archive/delete
events may need to update kanban caches, `tasks.activeTaskId` / session pin
state, recent/sidebar prefs, Office refetch triggers such as
`setOfficeRefetchTrigger("tasks")`, and route redirects for `/t/:id`,
@@ -192,18 +193,17 @@ surface.
loss removes canonical panels, while restoration/maximized and offered/dismissed
markers suppress insertion; existing panels sync identity without moving.
- **Dockview environment switching:** reconcile ephemeral panels before restoring views;
correlate ID-less groups by stable ID or position. Treat `chat`/`session:*` as semantic only with a non-null `activeSessionId`.
correlate ID-less groups by stable ID or position. Setup can precede asynchronous layout restoration. Reconcile dependent state after restoration settles. Dispose added subscriptions. Test late restoration and mixed valid or stale candidates before ambiguity checks. Treat `chat`/`session:*` as semantic only with a non-null `activeSessionId`.
- **GitHub PR status UI:** use the shared `pr-task-icon.tsx` display helpers and
`isPRReadyToMerge`; aggregate counts are display-only and cannot enable merges.
Update `pr-task-icon.test.ts` and `pr-status-chip.test.tsx` with behavior changes.
- **GitHub PR associations:** retain terminal/merged siblings for tabs/unlink;
derive `openPRs` only for aggregate status/automation and test desktop/mobile
terminal unlink plus two-to-one collapse/focus.
- **Task repository labels:** user-facing task/card repo chips should display a
stable repo slug or name (`owner/repo` when known, otherwise the repo name),
not a local filesystem path. Local clone paths or folder paths belong in
hover/title/tooltip metadata. Tasks with no repository, or only a non-repo
local folder, should not render a repo chip.
derive `openPRs` only for aggregate status/automation and test desktop/mobile terminal unlink plus two-to-one collapse/focus.
- **Task repository labels and projections:** user-facing task/card repo chips
should display a stable repo slug or name (`owner/repo` when known, otherwise the repo name),
not a local filesystem path; local paths belong in metadata. Tasks with no repository should not render a repo chip.
When a UI projection adds normalized collection data for grouping, preserve scalar filter fields such as
`repositoryPath` and test both grouping and filtered views.
## Internationalization (i18n)
@@ -260,7 +260,7 @@ README markup and for stripping executable HTML and unsafe URLs.
Enforced by `apps/web/eslint.config.mjs` (warnings, will become errors):
- Files: ≤600 lines · Functions: ≤100 lines
- Files: ≤600 lines · Functions: ≤100 lines · Absolute-positioned virtualizer rows do not collapse CSS margins; do not layer row padding/gaps over an item's existing margin. Preserve the old rendered gap with a focused bounding-box regression.
- Cyclomatic complexity: ≤15 · Cognitive complexity: ≤20
- Nesting depth: ≤4 · Parameters: ≤5
- No duplicated strings (≥4 occurrences) · No identical functions · No unused imports
@@ -51,7 +51,6 @@ test("dragging into a feeder wakes an open pull target without reload", async ({
() =>
new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
);
// Dragging can temporarily move destinations before the anchored source
// column, so scroll them back into the viewport before dropping.
let feederBox = await feederColumn.boundingBox();
@@ -444,10 +444,12 @@ describe("useRemoteRepositories provider refreshes", () => {
setBuiltInAvailability({ azureDevOps: false, gitlab: false });
act(() => result.current.refresh?.());
await waitFor(() => expect(mocks.fetchAccessibleRepos).toHaveBeenCalledTimes(2));
await waitFor(() => {
expect(mocks.fetchAccessibleRepos).toHaveBeenCalledTimes(2);
expect(result.current.availableProviders).toEqual(["github"]);
});
expect(mocks.listUserProjects).toHaveBeenCalledTimes(1);
expect(mocks.listAzureDevOpsProjects).toHaveBeenCalledTimes(1);
await waitFor(() => expect(result.current.availableProviders).toEqual(["github"]));
});
it("keeps currently eligible providers available while a refresh is loading", async () => {
@@ -94,4 +94,3 @@ hash and the `created_at == updated_at` guard. User-edited rows remain intact.
of the permanent suppression failure.
5. **Reuse `step_complete_kandev`.** Rejected because workflow-step completion
and one PR feedback attempt have different identity, lifecycle, and effects.
@@ -12,15 +12,15 @@ owners:
## Purpose and boundaries
This design preserves the technical source detail for `REQ-DESKTOP-DESKTOP-TAURI-APP-001` during migration.
This design defines the technical contract for `REQ-DESKTOP-DESKTOP-TAURI-APP-001`.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-DESKTOP-DESKTOP-TAURI-APP-001` | [Migrated source detail](#migrated-source-detail) |
| `REQ-DESKTOP-DESKTOP-TAURI-APP-001` | [Desktop architecture](#desktop-architecture) |
## Migrated source detail
## Desktop architecture
## Why
@@ -47,9 +47,12 @@ Implementation state does not change a requirement document from `active` to
## Stable identities
Requirement and acceptance-criterion IDs do not change when a file moves. Do
not reuse removed IDs. A replacement requirement references the requirement
that it supersedes.
Requirement and acceptance-criterion IDs are globally unique and retain one
meaning across concurrent branches; they do not change when a file moves. Do
not reuse removed IDs or renumber an ID to hide a parallel-branch collision. A
replacement requirement references the requirement that it supersedes. Reconcile
parallel specification changes before merging them so every artifact continues
to point to one authoritative contract.
## Migration
+91 -8
View File
@@ -26,7 +26,28 @@ SIZE_EXCEPTIONS_PATH = "docs/specs/spec-lint-exceptions.tsv"
REQ_ID_PATTERN = r"REQ-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}"
AC_ID_PATTERN = r"AC-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}\.\d+"
REQ_HEADING = re.compile(rf"^###\s+(?P<id>{REQ_ID_PATTERN}):\s+\S")
AC_DEFINITION = re.compile(rf"^\s*-\s+\*\*(?P<id>{AC_ID_PATTERN}):\*\*\s+\S")
AC_DEFINITION = re.compile(
rf"^\s*-\s+\*\*(?P<id>{AC_ID_PATTERN}):\*\*\s+(?P<body>\S.*)$"
)
MIGRATED_SOURCE_HEADING = re.compile(r"^\s*##\s+Migrated source detail\s*$", re.IGNORECASE)
GENERIC_AC_PATTERNS = (
re.compile(
r"\b(?:all|any|every|remaining|other)\s+(?:the\s+)?"
r"(?:behavior|behaviour|requirements?|details?|scenarios?)\b.*"
r"\b(?:legacy|original|source|copied|elsewhere)\b",
re.IGNORECASE,
),
re.compile(
r"\b(?:as|per)\s+(?:described|specified|documented)\b.*"
r"\b(?:legacy|original|source|elsewhere|above)\b",
re.IGNORECASE,
),
re.compile(
r"\b(?:see|refer(?:s)?|defer(?:s)?)\b.*"
r"\b(?:legacy|original|source|copied|elsewhere)\b",
re.IGNORECASE,
),
)
def load_config(root: Path) -> dict:
@@ -38,6 +59,7 @@ def lint_specs(
root: Path,
config: dict | None = None,
exceptions: dict[str, int] | None = None,
migration_audit: bool = False,
) -> list[Violation]:
config = config or load_config(root)
validate_config(config)
@@ -53,7 +75,7 @@ def lint_specs(
return exception_violations
paths = sorted(path for path in specs_root.rglob("*.md") if path.is_file())
migrated_systems = set()
system_migrations: dict[str, str] = {}
for path in paths:
inside = path.relative_to(specs_root).parts
if (
@@ -67,9 +89,9 @@ def lint_specs(
error is None
and metadata is not None
and metadata.get("specification_version") == "1"
and metadata.get("migration") == "complete"
and metadata.get("migration") in VALID_MIGRATION_STATUSES
):
migrated_systems.add(inside[0])
system_migrations[inside[0]] = str(metadata["migration"])
violations: list[Violation] = []
requirement_definitions: dict[str, list[tuple[Path, int]]] = {}
acceptance_definitions: dict[str, list[tuple[Path, int]]] = {}
@@ -82,7 +104,7 @@ def lint_specs(
kind, system = classify_path(relative)
violations.extend(check_size(path, relative, kind, config, exceptions))
inside = relative.relative_to("docs/specs").parts
if inside and inside[0] in migrated_systems and kind == "legacy":
if inside and system_migrations.get(inside[0]) == "complete" and kind == "legacy":
violations.append(
Violation(
"system-layout",
@@ -108,6 +130,11 @@ def lint_specs(
systems_with_new_artifacts.add(system)
text = path.read_text(encoding="utf-8")
migration = system_migrations.get(system) if system is not None else None
if migration is not None:
violations.extend(
check_migration_content(path, text, migration, migration_audit)
)
metadata, metadata_line, metadata_error = parse_frontmatter(text)
if metadata_error:
violations.append(Violation("frontmatter", path, metadata_line, metadata_error))
@@ -213,6 +240,50 @@ def lint_specs(
return sorted(violations, key=lambda item: (item.path.as_posix(), item.line, item.rule))
def check_migration_content(
path: Path, text: str, migration: str, migration_audit: bool
) -> list[Violation]:
"""Check canonical artifacts for migration-only content debt.
In-progress systems are intentionally opt-in so existing migrations can be
audited without making the normal specification lint fail. Completed
systems treat the same findings as blocking content violations.
"""
if migration == "in_progress" and not migration_audit:
return []
if migration not in VALID_MIGRATION_STATUSES:
return []
rule = "migration-audit" if migration == "in_progress" else "migration-content"
violations: list[Violation] = []
for line_number, line in enumerate(text.splitlines(), start=1):
if MIGRATED_SOURCE_HEADING.match(line):
violations.append(
Violation(
rule,
path,
line_number,
"remove the `Migrated source detail` wrapper after migration; "
"keep authoritative content in named requirement or design sections",
)
)
acceptance_match = AC_DEFINITION.match(line)
if acceptance_match and any(
pattern.search(acceptance_match.group("body"))
for pattern in GENERIC_AC_PATTERNS
):
violations.append(
Violation(
rule,
path,
line_number,
f"{acceptance_match.group('id')} must state observable behavior "
"directly; do not defer to generic or copied legacy prose",
)
)
return violations
def validate_config(config: dict) -> None:
if config.get("version") != 1:
raise ValueError("specification lint configuration must use version 1")
@@ -660,19 +731,31 @@ def check_duplicate_ids(kind: str, definitions: dict[str, list[tuple[Path, int]]
def main() -> int:
parser = argparse.ArgumentParser(description="Lint Kandev specification files.")
parser.add_argument("--all", action="store_true", help="Lint all specification files")
parser.add_argument(
"--migration-audit",
action="store_true",
help="Report non-blocking content debt in in-progress migrations",
)
args = parser.parse_args()
if not args.all:
parser.print_usage(sys.stderr)
return 2
root = Path.cwd()
violations = lint_specs(root)
violations = lint_specs(root, migration_audit=args.migration_audit)
for violation in violations:
relative = violation.path.relative_to(root)
print(f"{relative}:{violation.line}: [{violation.rule}] {violation.message}")
if violations:
print(f"\n{len(violations)} specification lint violation(s) found.", file=sys.stderr)
blocking = [violation for violation in violations if violation.rule != "migration-audit"]
if blocking:
print(f"\n{len(blocking)} specification lint violation(s) found.", file=sys.stderr)
return 1
if violations:
print(
f"Specification files passed; {len(violations)} non-blocking migration "
"audit finding(s) reported."
)
return 0
print("All specification files passed.")
return 0
+110 -5
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
import importlib.util
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
@@ -78,15 +80,15 @@ requirements:
# Dependencies System Design
"""
def valid_system_index(self) -> str:
def valid_system_index(self, migration: str = "in_progress") -> str:
return """---
status: draft
system: task-system
specification_version: 1
migration: in_progress
migration: {migration}
---
# Task System
"""
""".format(migration=migration)
def rules(self, violations) -> list[str]:
return [violation.rule for violation in violations]
@@ -374,7 +376,7 @@ migration: in_progress
def test_rejects_a_stray_markdown_file_in_a_migrated_system(self) -> None:
self.write(
"docs/specs/task-system/README.md",
"---\nstatus: active\nsystem: task-system\nspecification_version: 1\nmigration: complete\n---\n# Task System\n",
self.valid_system_index("complete"),
)
self.write("docs/specs/task-system/spec.md", "# Generic spec\n")
@@ -385,7 +387,7 @@ migration: in_progress
def test_allows_legacy_files_while_a_system_migration_is_in_progress(self) -> None:
self.write(
"docs/specs/task-system/README.md",
"---\nstatus: active\nsystem: task-system\nspecification_version: 1\nmigration: in_progress\n---\n# Task System\n",
self.valid_system_index(),
)
self.write("docs/specs/task-system/spec.md", "# Legacy spec\n")
@@ -401,6 +403,109 @@ migration: in_progress
self.assertIn("system-index-migration", self.rules(violations))
def test_completed_migration_accepts_real_requirement_and_design_content(self) -> None:
self.write("docs/specs/task-system/README.md", self.valid_system_index("complete"))
self.write("docs/specs/task-system/requirements/dependencies.md", self.valid_requirement())
self.write("docs/specs/task-system/system-design/dependencies.md", self.valid_design())
self.assertNotIn(
"migration-content",
self.rules(load_linter().lint_specs(self.root, self.config)),
)
def test_completed_migration_allows_observable_quantifier_content(self) -> None:
self.write("docs/specs/task-system/README.md", self.valid_system_index("complete"))
requirement = self.valid_requirement().replace(
"When a predecessor is pending, the system shall wait.",
"The response includes all details returned by the provider.",
)
self.write("docs/specs/task-system/requirements/dependencies.md", requirement)
self.write("docs/specs/task-system/system-design/dependencies.md", self.valid_design())
self.assertNotIn(
"migration-content",
self.rules(load_linter().lint_specs(self.root, self.config)),
)
def test_completed_migration_rejects_generic_criteria_and_source_wrapper(self) -> None:
self.write("docs/specs/task-system/README.md", self.valid_system_index("complete"))
requirement = self.valid_requirement().replace(
"When a predecessor is pending, the system shall wait.",
"The system shall preserve all behavior described in the legacy source.",
)
self.write("docs/specs/task-system/requirements/dependencies.md", requirement)
self.write(
"docs/specs/task-system/system-design/dependencies.md",
self.valid_design() + "\n## Migrated source detail\n\nCopied source.\n",
)
violations = load_linter().lint_specs(self.root, self.config)
migration_violations = [
violation for violation in violations if violation.rule == "migration-content"
]
self.assertEqual(len(migration_violations), 2)
self.assertEqual(migration_violations[0].line, 12)
self.assertEqual(migration_violations[1].line, 10)
self.assertTrue(
all(
"legacy" in violation.message or "wrapper" in violation.message
for violation in migration_violations
)
)
def test_migration_audit_reports_in_progress_debt_without_blocking_normal_lint(self) -> None:
self.write("docs/specs/task-system/README.md", self.valid_system_index())
requirement = self.valid_requirement().replace(
"When a predecessor is pending, the system shall wait.",
"The system shall preserve all behavior described in the legacy source.",
)
self.write("docs/specs/task-system/requirements/dependencies.md", requirement)
self.write(
"docs/specs/task-system/system-design/dependencies.md",
self.valid_design() + "\n## Migrated source detail\n",
)
linter = load_linter()
self.assertEqual(linter.lint_specs(self.root, self.config), [])
audit_rules = self.rules(linter.lint_specs(self.root, self.config, migration_audit=True))
self.assertEqual(audit_rules, ["migration-audit", "migration-audit"])
def test_templates_and_historical_docs_are_outside_the_migration_content_gate(self) -> None:
self.write("docs/specs/task-system/README.md", self.valid_system_index("complete"))
self.write(
"docs/specs/templates/old-template.md",
"## Migrated source detail\n- **AC-TASK-DEP-001.1:** see the legacy source.\n",
)
self.write(
"docs/specs/legacy/historical.md",
"## Migrated source detail\n- **AC-TASK-DEP-001.1:** see the legacy source.\n",
)
violations = load_linter().lint_specs(self.root, self.config)
self.assertNotIn("migration-content", self.rules(violations))
def test_migration_audit_cli_keeps_audit_findings_non_blocking(self) -> None:
self.write("docs/specs/task-system/README.md", self.valid_system_index())
self.write("docs/specs/task-system/requirements/dependencies.md", self.valid_requirement())
self.write(
"docs/specs/task-system/system-design/dependencies.md",
self.valid_design() + "\n## Migrated source detail\n",
)
self.write("docs/specs/spec-lint.json", json.dumps(self.config))
result = subprocess.run(
[sys.executable, str(SCRIPT), "--all", "--migration-audit"],
cwd=self.root,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0)
self.assertIn("non-blocking migration audit finding", result.stdout)
if __name__ == "__main__":
unittest.main()
+151 -11
View File
@@ -5,12 +5,15 @@
# scripts/pr-await <PR> [options]
#
# Options:
# --mode all-terminal | first-failure
# --mode all-terminal | strict-deadline | first-failure
# all-terminal (default) waits until no check is pending,
# so every failure lands in a single report and a single
# fix pass. first-failure returns as soon as one check
# fails; it exists for the interactive case and costs an
# extra full CI cycle per failure.
# extra full CI cycle per failure. strict-deadline keeps
# collecting state until the deadline, even after checks
# become terminal; it is for an explicit fixed-duration
# wait.
# --deadline-min N Hard stop, in minutes (default 45).
# --interval-sec N Seconds between polls (default 60).
# --format text | json Report format (default text).
@@ -27,7 +30,7 @@
# Exit codes:
# 0 terminal and clean - no failed checks, no unresolved threads
# 1 terminal with findings - failures and/or review threads to act on
# 2 deadline reached - checks still pending; names reported
# 2 deadline reached - checks still pending or terminal rollup not confirmed
# 3 blocked - merged/closed, approval required, or access lost
#
# Requires: gh (authenticated), jq, and scripts/pr-state alongside this script.
@@ -103,7 +106,7 @@ while [[ $# -gt 0 ]]; do
shift
[[ $# -gt 0 ]] || usage 1
case "$1" in
all-terminal | first-failure) MODE=$1 ;;
all-terminal | strict-deadline | first-failure) MODE=$1 ;;
*) usage 1 ;;
esac
shift
@@ -157,6 +160,55 @@ progress() {
[[ "$QUIET" -eq 1 ]] || printf '%s\n' "$*" >&2
}
# A current-head check rollup can briefly contain only the first materialized
# workflows while reporting no failed or pending checks. Require two identical
# terminal snapshots so a later workflow registration is observed before the
# caller receives a clean verdict.
TERMINAL_ROLLUP_FINGERPRINT=''
TERMINAL_ROLLUP_STREAK=0
TERMINAL_ROLLUP_PROVISIONAL=0
terminal_rollup_fingerprint() {
jq -c '
def stable_checks:
sort_by([
(.workflow // ""),
(.name // ""),
(.status // ""),
(.conclusion // ""),
((.run_id // "") | tostring),
((.job_id // "") | tostring)
]);
{
check_count: (.check_count // 0),
passed_check_count: (.passed_check_count // 0),
successful_checks: ((.successful_checks // []) | stable_checks),
terminal_checks: ((.terminal_checks // []) | stable_checks),
failed_checks: ((.failed_checks // []) | stable_checks),
pending_checks: ((.pending_checks // []) | stable_checks)
}' <<<"$1"
}
augment_required_status_checks() {
# pr-state performs this augmentation in production. Keep this defensive
# fallback for raw snapshots supplied by tests or another snapshot producer.
jq -c '
if (.required_status_checks_known // false) != true then
.
else
([.successful_checks[]?, .terminal_checks[]?, .failed_checks[]?, .pending_checks[]? | .name] | unique) as $reported
| ([.required_status_checks[]?
| . as $required
| select(($reported | index($required)) == null)
| {name: $required, workflow: null, status: "missing", run_id: null,
job_id: null, details_url: null, target_url: null}
]) as $missing
| .pending_checks = ((.pending_checks // []) + $missing)
| .check_count = ((.check_count // 0) + ($missing | length))
end
'
}
# Mergeability is deliberately absent from `pr-state --summary`, so it is
# queried separately below rather than letting a caller assume a clean merge
# state. There is no `|| {}` fallback: a failed query is unknown state, and the
@@ -350,6 +402,21 @@ trap 'handle_signal 130' INT
trap 'handle_signal 143' TERM
DEADLINE_SEC=$((DEADLINE_MIN * 60))
# Tests can use a short deterministic deadline without making the public
# minute-based interface less clear. This is intentionally not documented as a
# user-facing option.
if [[ -n "${PR_AWAIT_DEADLINE_SEC:-}" ]]; then
[[ "${PR_AWAIT_DEADLINE_SEC}" =~ ^[0-9]+$ ]] || usage 1
DEADLINE_SEC=$((10#${PR_AWAIT_DEADLINE_SEC}))
fi
strict_deadline_reached() {
if [[ "$MODE" == 'strict-deadline' && "$TERMINAL_ROLLUP_STREAK" -ge 2 ]]; then
OUTCOME='strict-terminal'
else
OUTCOME='deadline'
fi
}
# Sleep until the next poll unless the deadline arrives first, capping the last
# sleep to whatever time is left. Returns 1 when the caller must stop, so a
@@ -370,6 +437,7 @@ MERGE_ERRORS=0
MERGE_TIMED_OUT=0
EVIDENCE_ERRORS=0
MERGE_UNKNOWN=0
REQUIRED_STATUS_UNKNOWN=0
HEAD_CHANGES=0
FIRST_HEAD=''
SUMMARY_JSON=''
@@ -387,7 +455,7 @@ while :; do
budget="$(remaining_budget)"
if [[ "$budget" -le 0 ]]; then
if [[ -n "$SUMMARY_JSON" ]]; then
OUTCOME='deadline'
strict_deadline_reached
break
fi
floor_start=$SECONDS
@@ -425,6 +493,7 @@ while :; do
fi
continue
fi
summary_body="$(augment_required_status_checks <<<"$summary_body")"
SUMMARY_JSON="$summary_body"
CONSECUTIVE_ERRORS=0
@@ -447,7 +516,7 @@ while :; do
read_rc=0
run_bounded_capture "$budget" "$READ_TMP" \
"$GH" pr view "$PR" --json state,mergeable,mergeStateStatus,headRefOid || read_rc=$?
"$GH" pr view "$PR" --json state,mergeable,mergeStateStatus,reviewDecision,headRefOid || read_rc=$?
if [[ "$read_rc" -eq 124 ]]; then
progress "poll ${POLLS}: gh pr view exceeded its ${budget}s bound"
OUTCOME='blocked-merge-state'
@@ -492,6 +561,9 @@ while :; do
# deadline reached before the next read print those checks under the new
# SHA; with no snapshot the run reports blocked, which is the truth.
SUMMARY_JSON=''
TERMINAL_ROLLUP_FINGERPRINT=''
TERMINAL_ROLLUP_STREAK=0
TERMINAL_ROLLUP_PROVISIONAL=0
if ! sleep_or_stop; then
OUTCOME='deadline'
break
@@ -529,6 +601,22 @@ while :; do
continue
fi
required_status_known="$(jq -r 'if .required_status_checks_known == true then "yes" else "no" end' <<<"$SUMMARY_JSON")"
if [[ "$required_status_known" != 'yes' ]]; then
REQUIRED_STATUS_UNKNOWN=$((REQUIRED_STATUS_UNKNOWN + 1))
progress "poll ${POLLS}: required status policy is unavailable (${REQUIRED_STATUS_UNKNOWN} in a row)"
if [[ "$REQUIRED_STATUS_UNKNOWN" -ge 3 ]]; then
OUTCOME='blocked-required-statuses'
break
fi
if ! sleep_or_stop; then
OUTCOME='blocked-required-statuses'
break
fi
continue
fi
REQUIRED_STATUS_UNKNOWN=0
if [[ "$(jq -r '.approval_required_runs | length' <<<"$SUMMARY_JSON")" -gt 0 ]]; then
# action_required reads as green-or-skipped in a naive check count. It is
# blocked verification: waiting for it to finish would never terminate.
@@ -540,6 +628,9 @@ while :; do
# empty rollup satisfies "nothing pending" without any evidence behind it;
# in this repository the required jobs always run, so zero means not yet.
if [[ "$check_count" -eq 0 ]]; then
TERMINAL_ROLLUP_FINGERPRINT=''
TERMINAL_ROLLUP_STREAK=0
TERMINAL_ROLLUP_PROVISIONAL=0
progress "poll ${POLLS}: no checks registered at this head yet; waiting"
if ! sleep_or_stop; then
OUTCOME='deadline'
@@ -548,6 +639,12 @@ while :; do
continue
fi
if [[ "$pending" -gt 0 || "$complete" != 'true' ]]; then
TERMINAL_ROLLUP_FINGERPRINT=''
TERMINAL_ROLLUP_STREAK=0
TERMINAL_ROLLUP_PROVISIONAL=0
fi
# A failed job can be visible while its parent workflow is still running, so
# in all-terminal mode a failure is never on its own a reason to stop.
if [[ "$MODE" == 'first-failure' && "$failed" -gt 0 ]]; then
@@ -578,6 +675,9 @@ while :; do
snapshot_errors="$(jq -r '.errors // [] | length' <<<"$SUMMARY_JSON")"
threads_known="$(jq -r 'if .unresolved_review_thread_count == null then "no" else "yes" end' <<<"$SUMMARY_JSON")"
if [[ "$snapshot_errors" -gt 0 || "$threads_known" == 'no' ]]; then
TERMINAL_ROLLUP_FINGERPRINT=''
TERMINAL_ROLLUP_STREAK=0
TERMINAL_ROLLUP_PROVISIONAL=0
EVIDENCE_ERRORS=$((EVIDENCE_ERRORS + 1))
progress "poll ${POLLS}: checks terminal but evidence incomplete (${EVIDENCE_ERRORS} in a row)"
if [[ "$EVIDENCE_ERRORS" -ge 3 ]]; then
@@ -595,6 +695,9 @@ while :; do
# GitHub reports UNKNOWN while it is still computing mergeability. Deciding
# on it would call a PR clean before the answer exists.
if [[ "$(jq -r '.mergeable // "UNKNOWN"' <<<"$MERGE_JSON")" == 'UNKNOWN' ]]; then
TERMINAL_ROLLUP_FINGERPRINT=''
TERMINAL_ROLLUP_STREAK=0
TERMINAL_ROLLUP_PROVISIONAL=0
MERGE_UNKNOWN=$((MERGE_UNKNOWN + 1))
progress "poll ${POLLS}: mergeability still UNKNOWN (${MERGE_UNKNOWN} in a row)"
if [[ "$MERGE_UNKNOWN" -ge 3 ]]; then
@@ -609,8 +712,34 @@ while :; do
fi
MERGE_UNKNOWN=0
OUTCOME='terminal'
break
terminal_fingerprint="$(terminal_rollup_fingerprint "$SUMMARY_JSON")"
if [[ "$terminal_fingerprint" == "$TERMINAL_ROLLUP_FINGERPRINT" ]]; then
TERMINAL_ROLLUP_STREAK=$((TERMINAL_ROLLUP_STREAK + 1))
else
TERMINAL_ROLLUP_FINGERPRINT="$terminal_fingerprint"
TERMINAL_ROLLUP_STREAK=1
fi
if [[ "$TERMINAL_ROLLUP_STREAK" -lt 2 ]]; then
TERMINAL_ROLLUP_PROVISIONAL=1
progress "poll ${POLLS}: terminal check rollup is provisional; waiting for a matching snapshot"
if ! sleep_or_stop; then
OUTCOME='deadline'
break
fi
continue
fi
TERMINAL_ROLLUP_PROVISIONAL=0
if [[ "$MODE" != 'strict-deadline' ]]; then
OUTCOME='terminal'
break
fi
progress "poll ${POLLS}: terminal evidence collected; continuing through the strict deadline"
if ! sleep_or_stop; then
strict_deadline_reached
break
fi
continue
fi
if ! sleep_or_stop; then
@@ -665,6 +794,7 @@ threads_known_final="$(jq -r 'if .unresolved_review_thread_count == null then "n
base_advanced="$(jq -r 'if .pr.base_advanced_since_head == true then 1 else 0 end' <<<"$SUMMARY_JSON")"
mergeable="$(jq -r '.mergeable // "UNKNOWN"' <<<"$MERGE_JSON")"
merge_status="$(jq -r '.mergeStateStatus // "UNKNOWN"' <<<"$MERGE_JSON")"
review_decision="$(jq -r '.reviewDecision // "UNKNOWN"' <<<"$MERGE_JSON")"
conflicted=0
[[ "$mergeable" == 'CONFLICTING' || "$merge_status" == 'DIRTY' ]] && conflicted=1
@@ -675,8 +805,9 @@ conflicted=0
findings=$((failed_n + threads_total_n + comments_n + conflicted + changes_req_n + blocked_rev_n + base_advanced))
case "$OUTCOME" in
blocked-pr-state | blocked-approval | blocked-evidence | blocked-merge-unknown) code=3 ;;
blocked-pr-state | blocked-approval | blocked-evidence | blocked-merge-unknown | blocked-required-statuses) code=3 ;;
deadline) code=2 ;;
strict-terminal) [[ "$findings" -gt 0 ]] && code=1 || code=0 ;;
*) [[ "$findings" -gt 0 ]] && code=1 || code=0 ;;
esac
@@ -712,7 +843,7 @@ printf 'gate: %s | outcome: %s | waited %dm%02ds over %d poll(s)\n' \
"$MODE" "$OUTCOME" "$((WAITED / 60))" "$((WAITED % 60))" "$POLLS"
[[ "$HEAD_CHANGES" -gt 0 ]] && printf 'note: head moved %d time(s) during the wait\n' "$HEAD_CHANGES"
printf 'checks: %s passed, %s failed, %s pending\n' "$passed_n" "$failed_n" "$pending_n"
printf 'merge: %s / %s\n' "$mergeable" "$merge_status"
printf 'merge: %s / %s | review: %s\n' "$mergeable" "$merge_status" "$review_decision"
printf 'toolchain: %s\n' "$TOOLCHAIN"
if [[ "$failed_n" -gt 0 ]]; then
@@ -761,16 +892,25 @@ case "$OUTCOME" in
"${error_sources:-null unresolved thread count}" ;;
blocked-merge-unknown)
printf 'GitHub still reports mergeability as UNKNOWN. Re-run before treating this head as clean.\n' ;;
blocked-required-statuses)
printf 'required status check policy is unavailable. Treat the check rollup as unknown, not clean.\n' ;;
deadline)
if [[ "$(jq -r '.check_count // 0' <<<"$SUMMARY_JSON")" -eq 0 ]]; then
if [[ "$TERMINAL_ROLLUP_PROVISIONAL" -eq 1 ]]; then
printf 'deadline hit while confirming the terminal check rollup. Re-run or raise --deadline-min.\n'
elif [[ "$(jq -r '.check_count // 0' <<<"$SUMMARY_JSON")" -eq 0 ]]; then
printf 'deadline hit with no checks registered yet at this head. Re-run or raise --deadline-min.\n'
else
printf 'deadline hit with %s check(s) pending. Re-run or raise --deadline-min.\n' "$pending_n"
fi
;;
strict-terminal)
printf 'fixed-duration wait ended with terminal evidence; review the findings above.\n'
;;
*)
if [[ "$conflicted" -eq 1 ]]; then
printf 'resolve the merge conflict before triaging checks or threads.\n'
elif [[ "$findings" -eq 0 && "$review_decision" == 'REVIEW_REQUIRED' && "$merge_status" == 'BLOCKED' ]]; then
printf 'checks and reviews are clean, but human approval is required before merge.\n'
elif [[ "$findings" -eq 0 ]]; then
printf 'clean at this head.\n'
elif [[ "$failed_n" -eq 0 && "$threads_total_n" -eq 0 ]]; then
+134 -8
View File
@@ -88,6 +88,8 @@ snapshot() {
failed_checks: $f, pending_checks: $p, passed_check_count: $passed,
check_count: ($passed + $failed + $pending),
checks_head_sha: $head, checks_snapshot_complete: $complete,
terminal_checks: [],
required_status_checks: [], required_status_checks_known: true,
approval_required_runs: [], unresolved_review_thread_count: 0,
unresolved_threads: [], hidden_unresolved_threads: [],
actionable_issue_comment_count: 0, errors: [],
@@ -98,7 +100,7 @@ snapshot() {
run_await() {
local dir=$1; shift
FAKE_DIR="$dir" PR_AWAIT_PR_STATE="$dir/pr-state" PR_AWAIT_GH="$dir/gh" \
PR_AWAIT_SLEEP="$dir/sleep" \
PR_AWAIT_SLEEP="${PR_AWAIT_SLEEP_OVERRIDE:-$dir/sleep}" \
PR_AWAIT_JQ="${PROBE_JQ:-$dir/jq-ok}" PR_AWAIT_PROBE_BASH="${PROBE_BASH:-$dir/bash-ok}" \
"$SCRIPT" "$@"
}
@@ -175,8 +177,8 @@ snapshot "$d" 1 20 0 0 false
snapshot "$d" 2 20 0 0 true
out="$(run_await "$d" 12 --interval-sec 1 2>"$d/err")" && rc=0 || rc=$?
[[ "$rc" -eq 0 ]] || fail "expected 0 after snapshot completed, got $rc" "$out"
grep -q '2 poll' <<<"$out" || fail "should have polled twice, not stopped on the incomplete snapshot" "$out"
pass "checks_snapshot_complete=false keeps waiting"
grep -q '3 poll' <<<"$out" || fail "should confirm the terminal rollup after the incomplete snapshot" "$out"
pass "checks_snapshot_complete=false and the first terminal snapshot keep waiting"
# --- deadline ---------------------------------------------------------------
d="$(make_tmp_dir)"; setup_fake "$d"
@@ -187,6 +189,50 @@ grep -q 'STILL PENDING' <<<"$out" || fail "deadline should name pending checks"
grep -q 'Pending 0' <<<"$out" || fail "deadline should list check names" "$out"
pass "deadline exits 2 and names the pending checks"
# --- strict-deadline holds a fixed-duration request after terminal CI --------
d="$(make_tmp_dir)"; setup_fake "$d"
snapshot "$d" 1 20 0 0
cat >"$d/slow-sleep" <<'SLOWSLEEP'
#!/usr/bin/env bash
printf 'slept %s\n' "$1" >> "$FAKE_DIR/sleeps"
/usr/bin/sleep "$1"
SLOWSLEEP
chmod +x "$d/slow-sleep"
out="$(PR_AWAIT_DEADLINE_SEC=5 PR_AWAIT_SLEEP_OVERRIDE="$d/slow-sleep" \
run_await "$d" 12 --mode strict-deadline --interval-sec 1 --quiet --format json 2>/dev/null)" \
&& rc=0 || rc=$?
[[ "$rc" -eq 0 ]] || fail "strict-deadline clean terminal should exit 0, got $rc" "$out"
[[ "$(jq -r '.outcome' <<<"$out")" == 'strict-terminal' ]] \
|| fail "strict-deadline should report its fixed-duration terminal outcome" "$out"
[[ "$(jq -r '.mode' <<<"$out")" == 'strict-deadline' ]] \
|| fail "strict-deadline mode should be recorded" "$out"
[[ "$(wc -l < "$d/sleeps" | tr -d ' ')" -ge 1 ]] \
|| fail "strict-deadline should wait after terminal evidence" "$out"
pass "strict-deadline holds a terminal-clean wait through its requested duration"
# --- strict-deadline returns findings or pending work with distinct statuses -
d="$(make_tmp_dir)"; setup_fake "$d"
snapshot "$d" 1 20 1 0
cat >"$d/slow-sleep" <<'SLOWSLEEP'
#!/usr/bin/env bash
/usr/bin/sleep "$1"
SLOWSLEEP
chmod +x "$d/slow-sleep"
out="$(PR_AWAIT_DEADLINE_SEC=5 PR_AWAIT_SLEEP_OVERRIDE="$d/slow-sleep" \
run_await "$d" 12 --mode strict-deadline \
--interval-sec 1 --quiet --format json 2>/dev/null)" && rc=0 || rc=$?
[[ "$rc" -eq 1 ]] || fail "strict-deadline terminal findings should exit 1, got $rc" "$out"
[[ "$(jq -r '.summary.failed_checks | length' <<<"$out")" == '1' ]] \
|| fail "strict-deadline should preserve terminal findings" "$out"
pass "strict-deadline reports terminal findings as exit 1"
d="$(make_tmp_dir)"; setup_fake "$d"
snapshot "$d" 1 10 0 1
out="$(PR_AWAIT_DEADLINE_SEC=0 run_await "$d" 12 --mode strict-deadline \
--interval-sec 1 --quiet --format json 2>/dev/null)" && rc=0 || rc=$?
[[ "$rc" -eq 2 ]] || fail "strict-deadline pending work should exit 2, got $rc" "$out"
pass "strict-deadline reports pending work as exit 2"
# --- approval-required is blocked, not green --------------------------------
d="$(make_tmp_dir)"; setup_fake "$d"
snapshot "$d" 1 0 0 0 true aaaaaaaaaaaa \
@@ -214,6 +260,16 @@ out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
grep -q 'resolve the merge conflict' <<<"$out" || fail "conflict should lead the NEXT line" "$out"
pass "a merge conflict is surfaced even when every check passed"
# --- clean checks can still require human approval --------------------------
d="$(make_tmp_dir)"; setup_fake "$d"
snapshot "$d" 1 20 0 0
printf '{"state":"OPEN","mergeable":"MERGEABLE","mergeStateStatus":"BLOCKED","reviewDecision":"REVIEW_REQUIRED","headRefOid":"aaaaaaaaaaaa"}' > "$d/gh.json"
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
[[ "$rc" -eq 0 ]] || fail "approval-gated clean checks should exit 0, got $rc" "$out"
grep -q 'review: REVIEW_REQUIRED' <<<"$out" || fail "wait output should expose the review decision" "$out"
grep -qi 'human approval is required' <<<"$out" || fail "wait output should distinguish the approval gate" "$out"
pass "clean checks report the human approval gate without a code failure"
# --- transient pr-state failures recover; sustained ones block --------------
d="$(make_tmp_dir)"; setup_fake "$d"
: > "$d/seq/1.fail"; snapshot "$d" 1 0 0 0
@@ -282,8 +338,8 @@ d="$(make_tmp_dir)"; setup_fake "$d"
for i in 1 2 3 4 5 6 7; do snapshot "$d" "$i" "$i" 0 $((8 - i)); done
snapshot "$d" 8 20 0 0
run_await "$d" 12 --interval-sec 1 --quiet >/dev/null 2>&1 || true
[[ "$(wc -l < "$d/sleeps" | tr -d ' ')" -eq 7 ]] || fail "expected 7 internal sleeps" "$(cat "$d/sleeps")"
pass "eight polls happen inside a single invocation (7 internal sleeps, 0 caller round-trips)"
[[ "$(wc -l < "$d/sleeps" | tr -d ' ')" -eq 8 ]] || fail "expected 8 internal sleeps" "$(cat "$d/sleeps")"
pass "nine polls happen inside a single invocation (8 internal sleeps, 0 caller round-trips)"
# --- P1: a snapshot carrying errors is never clean ---------------------------
d="$(make_tmp_dir)"; setup_fake "$d"
@@ -605,8 +661,8 @@ for i in 1 2 3; do snapshot "$d" "$i" 0 0 0; done
snapshot "$d" 4 20 0 0
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
[[ "$rc" -eq 0 ]] || fail "expected clean once checks appeared, got $rc" "$out"
grep -q '4 poll' <<<"$out" || fail "must wait for checks to appear, not stop at the empty rollup" "$out"
pass "an empty check rollup keeps waiting instead of reporting clean"
grep -q '5 poll' <<<"$out" || fail "must wait for checks to appear and stabilize, not stop at the empty rollup" "$out"
pass "an empty check rollup keeps waiting until the terminal rollup stabilizes"
d="$(make_tmp_dir)"; setup_fake "$d"
for i in 1 2 3 4 5; do snapshot "$d" "$i" 0 0 0; done
@@ -625,12 +681,82 @@ pass "a zero-check deadline is distinguished from a deadline with pending checks
for conclusion in skipped neutral; do
d="$(make_tmp_dir)"; setup_fake "$d"
snapshot "$d" 1 0 0 0 true aaaaaaaaaaaa "{\"check_count\":1,\"terminal_conclusions\":[\"$conclusion\"]}"
out="$(run_await "$d" 12 --interval-sec 1 --deadline-min 0 --quiet --format json 2>/dev/null)" && rc=0 || rc=$?
out="$(run_await "$d" 12 --interval-sec 1 --deadline-min 1 --quiet --format json 2>/dev/null)" && rc=0 || rc=$?
[[ "$rc" -eq 0 ]] || fail "$conclusion-only check rollup should be terminal, got $rc" "$out"
[[ "$(jq -r '.outcome' <<<"$out")" == 'terminal' ]] || fail "$conclusion-only rollup should emit terminal" "$out"
done
pass "skipped and neutral terminal checks do not look like an empty rollup"
# --- a sparse non-empty rollup is provisional -------------------------------
# Immediately after a push, GitHub can report a couple of terminal checks
# before the rest of the required workflows materialize. A non-zero check count
# must not be treated as proof that the rollup is complete.
d="$(make_tmp_dir)"; setup_fake "$d"
snapshot "$d" 1 2 0 0 true aaaaaaaaaaaa
snapshot "$d" 2 45 0 0 true aaaaaaaaaaaa
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
[[ "$rc" -eq 0 ]] || fail "sparse rollup should settle to clean, got $rc" "$out"
grep -q '45 passed' <<<"$out" || fail "must report the materialized full rollup, not the sparse one" "$out"
grep -q '3 poll' <<<"$out" || fail "must confirm the expanded rollup before returning" "$out"
pass "a sparse non-empty rollup stays provisional until the full set stabilizes"
# The set of checks is logical state, not an API-order-sensitive sequence. A
# reordered response must not reset the terminal confirmation streak.
d="$(make_tmp_dir)"; setup_fake "$d"
snapshot "$d" 1 2 0 0 true aaaaaaaaaaaa \
'{"successful_checks":[{"name":"check-b","workflow":"ci","status":"completed","conclusion":"success"},{"name":"check-a","workflow":"ci","status":"completed","conclusion":"success"}]}'
snapshot "$d" 2 2 0 0 true aaaaaaaaaaaa \
'{"successful_checks":[{"name":"check-a","workflow":"ci","status":"completed","conclusion":"success"},{"name":"check-b","workflow":"ci","status":"completed","conclusion":"success"}]}'
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
[[ "$rc" -eq 0 ]] || fail "reordered terminal checks should settle clean, got $rc" "$out"
grep -q '2 poll' <<<"$out" || fail "reordered terminal checks should not reset the confirmation streak" "$out"
pass "terminal rollup fingerprint ignores API ordering"
# --- required contexts prevent an optional-only rollup from becoming clean ---
d="$(make_tmp_dir)"; setup_fake "$d"
snapshot "$d" 1 1 0 0 true aaaaaaaaaaaa \
'{"required_status_checks":["E2E Tests Passed"],"required_status_checks_known":true,
"successful_checks":[{"name":"CodeRabbit","workflow":"review","status":"completed","conclusion":"success"}]}'
out="$(run_await "$d" 12 --interval-sec 1 --deadline-min 0 --quiet --format json 2>/dev/null)" && rc=0 || rc=$?
[[ "$rc" -eq 2 ]] || fail "an optional-only rollup must hit the deadline, got $rc" "$out"
[[ "$(jq -r '.outcome' <<<"$out")" == 'deadline' ]] || fail "optional-only rollup outcome missing" "$out"
grep -q 'E2E Tests Passed' <<<"$(jq -r '.summary.pending_checks[].name' <<<"$out")" \
|| fail "missing required context should remain pending" "$out"
pass "an optional-only rollup does not become clean while required contexts are absent"
d="$(make_tmp_dir)"; setup_fake "$d"
for i in 1 2 3; do
snapshot "$d" "$i" 1 0 0 true aaaaaaaaaaaa \
'{"required_status_checks_known":false}'
done
out="$(run_await "$d" 12 --interval-sec 1 --deadline-min 1 --quiet --format json 2>/dev/null)" && rc=0 || rc=$?
[[ "$rc" -eq 3 ]] || fail "an unavailable required-status policy should block, got $rc" "$out"
[[ "$(jq -r '.outcome' <<<"$out")" == 'blocked-required-statuses' ]] \
|| fail "an unavailable required-status policy should report its blocked outcome" "$out"
pass "an unavailable required-status policy never becomes clean"
d="$(make_tmp_dir)"; setup_fake "$d"
terminal_required='{"check_count":1,"required_status_checks":["Skipped required"],"required_status_checks_known":true,"terminal_checks":[{"name":"Skipped required","workflow":"ci","status":"completed","conclusion":"skipped"}]}'
snapshot "$d" 1 0 0 0 true aaaaaaaaaaaa "$terminal_required"
snapshot "$d" 2 0 0 0 true aaaaaaaaaaaa "$terminal_required"
out="$(run_await "$d" 12 --interval-sec 1 --deadline-min 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
[[ "$rc" -eq 0 ]] || fail "a skipped required context should be terminal, got $rc" "$out"
grep -q '0 pending' <<<"$out" || fail "a skipped required context must not be synthesized as pending" "$out"
pass "skipped required contexts remain terminal instead of pending"
d="$(make_tmp_dir)"; setup_fake "$d"
snapshot "$d" 1 1 0 0 true aaaaaaaaaaaa \
'{"required_status_checks":["E2E Tests Passed"],"required_status_checks_known":true,
"successful_checks":[{"name":"CodeRabbit","workflow":"review","status":"completed","conclusion":"success"}]}'
snapshot "$d" 2 2 0 0 true aaaaaaaaaaaa \
'{"required_status_checks":["E2E Tests Passed"],"required_status_checks_known":true,
"successful_checks":[{"name":"CodeRabbit","workflow":"review","status":"completed","conclusion":"success"},
{"name":"E2E Tests Passed","workflow":"e2e","status":"completed","conclusion":"success"}]}'
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
[[ "$rc" -eq 0 ]] || fail "a materialized required context should allow clean, got $rc" "$out"
grep -q '3 poll' <<<"$out" || fail "required context should settle after it materializes" "$out"
pass "a required context that materializes allows the terminal verdict"
# --- a base that advanced leaves the merge result untested -----------------
d="$(make_tmp_dir)"; setup_fake "$d"
snapshot "$d" 1 20 0 0 true aaaaaaaaaaaa '{"pr":{"base_advanced_since_head":true}}'
+52
View File
@@ -10,6 +10,10 @@
# Print the full thread/comment body plus path, line, author, commit id,
# and URL. Use before deciding how to answer a preview from list.
#
# scripts/pr-resolve reopen <PR> <thread_id>
# Reopen an already-resolved review thread after an authorized later
# change invalidates its resolution rationale.
#
# scripts/pr-resolve reply <PR> <comment_id> <thread_id> <body>
# scripts/pr-resolve reply <PR> <comment_id> <thread_id> --body-file <path>
# Reply to the thread starter, resolve the thread, and add a +1
@@ -58,6 +62,14 @@ if [[ "$cmd" == "reply" && ( $# -eq 4 || $# -eq 5 ) ]]; then
fi
fi
if [[ "$cmd" == "reopen" ]]; then
[ $# -eq 2 ] || usage
[[ "$2" == PRRT_* ]] || {
echo "reopen expects a review thread ID as its second argument" >&2
exit 1
}
fi
read -r OWNER REPO < <(gh repo view --json owner,name --jq '"\(.owner.login) \(.name)"')
case "$cmd" in
@@ -159,6 +171,46 @@ case "$cmd" in
esac
;;
reopen)
PR=$1; THREAD_ID=$2
if ! THREAD_JSON=$(gh api graphql -f query='
query($id: ID!) {
node(id: $id) {
... on PullRequestReviewThread {
id
isResolved
pullRequest { number repository { nameWithOwner } }
}
}
}' -f id="$THREAD_ID"); then
echo "failed to fetch review thread $THREAD_ID for PR #$PR" >&2
exit 1
fi
if ! jq -e --arg pr "$PR" --arg thread_id "$THREAD_ID" --arg repository "$OWNER/$REPO" '
.data.node as $thread
| if $thread == null then error("review thread \($thread_id) not found on PR #\($pr)")
elif (($thread.pullRequest.number // -1) | tostring) != $pr then
error("thread belongs to PR #\($thread.pullRequest.number), not #\($pr)")
elif (($thread.pullRequest.repository.nameWithOwner // "") != $repository) then
($thread.pullRequest.repository.nameWithOwner // "unknown") as $thread_repository
| error("thread belongs to repository \($thread_repository), not \($repository)")
elif $thread.isResolved != true then error("thread is not resolved")
else true
end' <<<"$THREAD_JSON" >/dev/null; then
exit 1
fi
if ! UNRESOLVE_JSON=$(gh api graphql -f query='mutation($t: ID!) { unresolveReviewThread(input: {threadId: $t}) { thread { isResolved } } }' \
-f t="$THREAD_ID"); then
echo "failed to reopen review thread $THREAD_ID on PR #$PR" >&2
exit 1
fi
if ! jq -e '.data.unresolveReviewThread.thread.isResolved == false' <<<"$UNRESOLVE_JSON" >/dev/null; then
echo "GitHub returned an invalid reopen response for review thread $THREAD_ID on PR #$PR" >&2
exit 1
fi
echo "ok $THREAD_ID"
;;
reply)
[[ $# -eq 4 || $# -eq 5 ]] || usage
PR=$1; COMMENT_ID=$2; THREAD_ID=$3
+129
View File
@@ -27,6 +27,10 @@ run_invalid_reply() {
PATH="$TMP_DIR/bin:$PATH" "$SCRIPT" reply 123 "$@" 2>"$TMP_DIR/stderr"
}
run_invalid_reopen() {
PATH="$TMP_DIR/bin:$PATH" "$SCRIPT" reopen 123 "$@" 2>"$TMP_DIR/stderr"
}
if PATH="$TMP_DIR/bin:$PATH" "$SCRIPT" --help >"$TMP_DIR/help" 2>"$TMP_DIR/stderr"; then
if grep -q "scripts/pr-resolve list <PR>" "$TMP_DIR/help"; then
pass "--help prints usage without gh"
@@ -37,6 +41,25 @@ else
fail "--help exits zero"
fi
if PATH="$TMP_DIR/bin:$PATH" "$SCRIPT" --help >"$TMP_DIR/help" 2>"$TMP_DIR/stderr"; then
if grep -q "scripts/pr-resolve reopen <PR> <thread_id>" "$TMP_DIR/help"; then
pass "--help documents authorized thread reopening"
else
fail "--help documents authorized thread reopening"
fi
else
fail "--help exits zero for reopen documentation"
fi
if run_invalid_reopen 456; then
fail "non-thread id in reopen position fails"
fi
if grep -q "review thread ID as its second argument" "$TMP_DIR/stderr"; then
pass "reopen validates the thread id"
else
fail "reopen validates the thread id"
fi
if run_invalid_reply PRRT_bad 456 body; then
fail "thread id in comment position fails"
fi
@@ -66,6 +89,112 @@ else
fail "empty body file has clear error"
fi
NULL_GH_DIR="$TMP_DIR/null-gh"
mkdir -p "$NULL_GH_DIR"
cat >"$NULL_GH_DIR/gh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$1" == "repo" && "$2" == "view" ]]; then
printf 'kdlbs kandev\n'
exit 0
fi
if [[ "$1" == "api" && "$2" == "graphql" ]]; then
printf '%s\n' '{"data":{"node":null}}'
exit 0
fi
echo "unexpected gh arguments: $*" >&2
exit 1
EOF
chmod +x "$NULL_GH_DIR/gh"
if PATH="$NULL_GH_DIR:$PATH" "$SCRIPT" reopen 123 PRRT_missing 2>"$TMP_DIR/stderr"; then
fail "missing review thread fails clearly"
fi
if grep -q "review thread PRRT_missing not found on PR #123" "$TMP_DIR/stderr"; then
pass "missing review thread includes its id and PR"
else
fail "missing review thread includes its id and PR"
fi
REOPEN_GH_DIR="$TMP_DIR/reopen-gh"
mkdir -p "$REOPEN_GH_DIR"
cat >"$REOPEN_GH_DIR/gh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$1" == "repo" && "$2" == "view" ]]; then
printf 'kdlbs kandev\n'
exit 0
fi
if [[ "$1" == "api" && "$2" == "graphql" ]]; then
if [[ "$*" == *"unresolveReviewThread"* ]]; then
case "${REOPEN_SCENARIO:-resolved}" in
mutation-failed)
echo "mutation failed" >&2
exit 1
;;
mutation-malformed)
printf '%s\n' '{"data":{"unresolveReviewThread":{"thread":null}}}'
exit 0
;;
*)
printf '%s\n' '{"data":{"unresolveReviewThread":{"thread":{"isResolved":false}}}}'
exit 0
;;
esac
fi
case "${REOPEN_SCENARIO:-resolved}" in
unresolved)
printf '%s\n' '{"data":{"node":{"id":"PRRT_target","isResolved":false,"pullRequest":{"number":123,"repository":{"nameWithOwner":"kdlbs/kandev"}}}}}'
;;
foreign)
printf '%s\n' '{"data":{"node":{"id":"PRRT_target","isResolved":true,"pullRequest":{"number":123,"repository":{"nameWithOwner":"other/kandev"}}}}}'
;;
*)
printf '%s\n' '{"data":{"node":{"id":"PRRT_target","isResolved":true,"pullRequest":{"number":123,"repository":{"nameWithOwner":"kdlbs/kandev"}}}}}'
;;
esac
exit 0
fi
echo "unexpected gh arguments: $*" >&2
exit 1
EOF
chmod +x "$REOPEN_GH_DIR/gh"
if REOPEN_SCENARIO=resolved PATH="$REOPEN_GH_DIR:$PATH" "$SCRIPT" reopen 123 PRRT_target >"$TMP_DIR/out" 2>"$TMP_DIR/stderr" \
&& grep -q "ok PRRT_target" "$TMP_DIR/out"; then
pass "reopen succeeds for a resolved same-repository thread"
else
fail "reopen succeeds for a resolved same-repository thread"
fi
if REOPEN_SCENARIO=unresolved PATH="$REOPEN_GH_DIR:$PATH" "$SCRIPT" reopen 123 PRRT_target >"$TMP_DIR/out" 2>"$TMP_DIR/stderr"; then
fail "reopen rejects an unresolved thread"
fi
grep -q "thread is not resolved" "$TMP_DIR/stderr" \
&& pass "reopen rejects an unresolved thread" \
|| fail "reopen rejects an unresolved thread"
if REOPEN_SCENARIO=foreign PATH="$REOPEN_GH_DIR:$PATH" "$SCRIPT" reopen 123 PRRT_target >"$TMP_DIR/out" 2>"$TMP_DIR/stderr"; then
fail "reopen rejects a same-number thread from another repository"
fi
grep -q "thread belongs to repository other/kandev" "$TMP_DIR/stderr" \
&& pass "reopen rejects a same-number thread from another repository" \
|| fail "reopen rejects a same-number thread from another repository"
if REOPEN_SCENARIO=mutation-failed PATH="$REOPEN_GH_DIR:$PATH" "$SCRIPT" reopen 123 PRRT_target >"$TMP_DIR/out" 2>"$TMP_DIR/stderr"; then
fail "reopen reports a failed mutation"
fi
grep -q "failed to reopen review thread PRRT_target on PR #123" "$TMP_DIR/stderr" \
&& pass "reopen reports a failed mutation" \
|| fail "reopen reports a failed mutation"
if REOPEN_SCENARIO=mutation-malformed PATH="$REOPEN_GH_DIR:$PATH" "$SCRIPT" reopen 123 PRRT_target >"$TMP_DIR/out" 2>"$TMP_DIR/stderr"; then
fail "reopen reports a malformed mutation response"
fi
grep -q "invalid reopen response for review thread PRRT_target on PR #123" "$TMP_DIR/stderr" \
&& pass "reopen reports a malformed mutation response" \
|| fail "reopen reports a malformed mutation response"
if run_invalid_reply 456 PRRT_xyz --body-file; then
fail "missing body file path fails"
fi
+155 -14
View File
@@ -3,7 +3,7 @@
#
# Usage:
# scripts/pr-state [--summary] [--all] [--trusted-reviewer <login>] <PR>
# scripts/pr-state --comment <comment_id>
# scripts/pr-state --comment <comment_id> # review or top-level issue comment
# scripts/pr-state --job-log <job_id>
#
# Raw output:
@@ -15,6 +15,8 @@
# - checks: [{ type, name, workflow, status, conclusion, details_url, target_url, run_id, job_id }]
# - checks_head_sha: <SHA or null>
# - checks_snapshot_complete: <boolean>
# - required_status_checks: [<status context>]
# - required_status_checks_known: <boolean>
# - approval_required_runs: [{ run_id, name, workflow, status, conclusion, head_sha, url }]
# - review_threads: [{ thread_id, is_resolved, path, comment_id, comment_created_at, author, body_preview }]
# - hidden_unresolved_threads: [{ thread_id, comment_id, author, path, body_preview }]
@@ -38,9 +40,12 @@
# - failed_checks: [{ name, workflow, status, conclusion, run_id, job_id, details_url, target_url }]
# - pending_checks: [{ name, workflow, status, run_id, job_id, details_url, target_url }]
# - successful_checks: [{ name, workflow, status, conclusion, run_id, job_id, details_url, target_url }]
# - terminal_checks: [{ name, workflow, status, conclusion, run_id, job_id, details_url, target_url }]
# - passed_check_count: <N>
# - checks_head_sha: <SHA or null>
# - checks_snapshot_complete: <boolean>
# - required_status_checks: [<status context>]
# - required_status_checks_known: <boolean>
# - approval_required_runs: [{ run_id, name, workflow, status, conclusion, head_sha, url }]
# - unresolved_review_thread_count: <N>
# - filtered_review_thread_count: <N>
@@ -56,8 +61,9 @@
# - errors: [{ source, message }]
#
# Comment output (--comment):
# JSON object for one PR review comment:
# - comment_id, author, body, path, line, original_line, commit_id, html_url, created_at, updated_at
# JSON object for one PR review or top-level issue comment:
# - comment_id, comment_type, author, body, path, line, original_line,
# commit_id, html_url, created_at, updated_at
#
# Job-log output (--job-log):
# Bounded context around the most likely failing test or step. The GitHub
@@ -154,6 +160,8 @@ pr_view_succeeded=false
checks_json='[]'
checks_head_sha=''
checks_snapshot_complete=false
required_status_checks_json='[]'
required_status_checks_known=false
base_ref_name=''
base_head_oid=''
merge_base_oid=''
@@ -188,6 +196,7 @@ emit_comment_json() {
local owner
local repo
local comment_json
local comment_type
repo_json="$(gh_json repo view --json owner,name)"
owner="$(jq -r '.owner.login // ""' <<<"$repo_json")"
@@ -197,13 +206,18 @@ emit_comment_json() {
return 1
fi
if ! comment_json="$(gh_json api "repos/$owner/$repo/pulls/comments/$comment_id")"; then
if comment_json="$(gh_json api "repos/$owner/$repo/pulls/comments/$comment_id")"; then
comment_type='review'
elif comment_json="$(gh_json api "repos/$owner/$repo/issues/comments/$comment_id")"; then
comment_type='issue'
else
printf 'failed to fetch PR comment %s\n' "$comment_id" >&2
return 1
fi
jq -c '{
jq -c --arg comment_type "$comment_type" '{
comment_id: (.id // null),
comment_type: $comment_type,
author: (.user.login // "unknown"),
body: (.body // ""),
path: (.path // null),
@@ -328,6 +342,8 @@ extract_issue_comments_json() {
actionable: (
(((.author.login // "unknown") == "github-actions") or ((.author.login // "unknown") == "github-actions[bot]"))
and (
(((.body // "") | contains("<!-- kandev-docs-cloudflare-preview -->")) | not)
and
(((.body // "") | gsub("^[[:space:]]+|[[:space:]]+$"; ""))
| test("^<!-- opencode-review:no-findings -->\\n\\*\\*OpenCode review complete\\*\\*\\n\\nOpenCode model: `[^`\\n]+`\\n\\nNo suggestions found for commit `[0-9A-Fa-f]{7,40}`\\.$")
| not)
@@ -562,29 +578,45 @@ summarize_state_json() {
(.status == "completed" and .conclusion == "success")
or (.status == "success" and (.conclusion == null or .conclusion == "success"));
{
(effective_checks) as $effective
| (if .required_status_checks_known == true then
[
(.required_status_checks // [])[] as $required
| select(any($effective[]?; .name == $required) | not)
| {name: $required, workflow: null, status: "missing", run_id: null,
job_id: null, details_url: null, target_url: null}
]
else [] end) as $missing_required
| {
pr,
since,
checks_head_sha,
checks_snapshot_complete,
required_status_checks: (.required_status_checks // []),
required_status_checks_known: (.required_status_checks_known == true),
approval_required_runs: (.approval_required_runs // []),
failed_checks: [
effective_checks[]
$effective[]
| select(terminal_failure_status and bad_conclusion)
| {name, workflow, status, conclusion, run_id, job_id, details_url, target_url}
],
pending_checks: [
effective_checks[]
pending_checks: ([
$effective[]
| select(pending_status and .conclusion == null)
| {name, workflow, status, run_id, job_id, details_url, target_url}
],
] + $missing_required),
successful_checks: [
effective_checks[]
$effective[]
| select(successful_check)
| {name, workflow, status, conclusion, run_id, job_id, details_url, target_url}
],
check_count: ([effective_checks[]] | length),
passed_check_count: ([effective_checks[] | select(successful_check)] | length),
terminal_checks: [
$effective[]
| select(.conclusion == "skipped" or .conclusion == "neutral")
| {name, workflow, status, conclusion, run_id, job_id, details_url, target_url}
],
check_count: (($effective | length) + ($missing_required | length)),
passed_check_count: ([$effective[] | select(successful_check)] | length),
unresolved_review_thread_count,
filtered_review_thread_count,
review_evidence,
@@ -597,6 +629,11 @@ summarize_state_json() {
hidden_unresolved_threads: (.hidden_unresolved_threads // []),
summary_notes: (
[
if .required_status_checks_known != true then
"required status check policy is unavailable; terminal check counts are not sufficient"
else
empty
end,
if (
(.unresolved_review_thread_count | type) == "number"
and .unresolved_review_thread_count > 0
@@ -700,6 +737,95 @@ fetch_base_ref_json() {
}'
}
fetch_legacy_required_status_checks_json() {
local response status body succeeded=false
if response="$(gh_json api "$1" --include)"; then
succeeded=true
fi
status="$(awk '/^HTTP\// {code=$2} END {print code}' <<<"$response")"
body="$(awk 'body {print} !body && /^\r?$/ {body=1}' <<<"$response")"
# A generic 404 can hide inaccessible policy. Only the explicit absence
# response establishes that this branch has no legacy status requirements.
if [[ "$status" == '404' ]] && jq -e '.message == "Branch not protected"' >/dev/null 2>&1 <<<"$body"; then
printf '[]\n'
elif [[ "$succeeded" == true && "$status" == '200' ]]; then
jq -ce 'select(type == "object" and (.contexts | type) == "array" and (.checks | type) == "array")
| [.checks[]?.context, .contexts[]?] | map(select(type == "string" and length > 0)) | unique' <<<"$body"
else
return 1
fi
}
fetch_required_status_checks_json() {
local owner=$1
local repo=$2
local base_ref=$3
local default_ref=$4
local rulesets_pages rulesets_json details='[]' ruleset_id detail
local ruleset_required='[]' legacy_required='[]'
[[ -n "$owner" && -n "$repo" && -n "$base_ref" ]] || return 1
# Rulesets expose the required status contexts, while the list endpoint only
# exposes their IDs. Fetch the active rule definitions before deciding that a
# small rollup is complete. Legacy branch protection can coexist with
# rulesets, so query it as well and union both policy sources.
if rulesets_pages="$(gh_json api --paginate "repos/$owner/$repo/rulesets?per_page=100")"; then
rulesets_json="$(jq -cs '[.[][]?]' <<<"$rulesets_pages")" || return 1
while IFS= read -r ruleset_id; do
[[ -n "$ruleset_id" ]] || continue
if ! detail="$(gh_json api "repos/$owner/$repo/rulesets/$ruleset_id")"; then
return 1
fi
jq -e . >/dev/null 2>&1 <<<"$detail" || return 1
details="$(jq -cn --argjson prev "$details" --argjson detail "$detail" '$prev + [$detail]')" || return 1
done < <(jq -r '.[] | select(.target == "branch" and .enforcement == "active") | .id // empty' <<<"$rulesets_json")
# GitHub uses fnmatch semantics for ref conditions. Exact names and the
# documented special values are evaluated below; unsupported glob syntax
# must make policy discovery unknown instead of silently dropping a rule.
ruleset_required="$(jq -c --arg base "$base_ref" --arg default "$default_ref" '
def matches_ref($candidate):
$candidate == "~ALL"
or $candidate == $base
or $candidate == ("refs/heads/" + $base)
or ($candidate == "~DEFAULT_BRANCH" and $default != "" and $base == $default);
def unsupported_ref_pattern:
contains("*") or contains("?") or contains("[") or contains("]") or contains("\\");
if any(
.[]
| select(.enforcement == "active")
| ((.conditions.ref_name.include // []) + (.conditions.ref_name.exclude // []))[]?
; unsupported_ref_pattern
) then
error("ruleset ref pattern cannot be evaluated safely")
else
[
.[]
| select(.enforcement == "active")
| select(any((.conditions.ref_name.include // [])[]?; matches_ref(.)))
| select(any((.conditions.ref_name.exclude // [])[]?; matches_ref(.)) | not)
| .rules[]?
| select(.type == "required_status_checks")
| .parameters.required_status_checks[]?
] as $required_checks
| if any($required_checks[]?; (.integration_id? // null) != null) then
error("ruleset required check integration source cannot be verified")
else
[$required_checks[]? | .context // empty] | unique
end
end
' <<<"$details" 2>/dev/null)" || return 1
legacy_required="$(fetch_legacy_required_status_checks_json "repos/$owner/$repo/branches/$base_ref/protection/required_status_checks")" || return 1
jq -cn --argjson ruleset "$ruleset_required" --argjson legacy "$legacy_required" '$ruleset + $legacy | unique'
return
fi
return 1
}
fetch_merge_base_json() {
local owner=$1
local repo=$2
@@ -790,9 +916,11 @@ fetch_review_threads_nodes_json() {
repo_owner=''
repo_name=''
if repo_json="$(gh_json repo view --json owner,name)"; then
default_branch_name=''
if repo_json="$(gh_json repo view --json owner,name,defaultBranchRef)"; then
repo_owner="$(jq -r '.owner.login // ""' <<<"$repo_json")"
repo_name="$(jq -r '.name // ""' <<<"$repo_json")"
default_branch_name="$(jq -r '.defaultBranchRef.name // ""' <<<"$repo_json")"
else
add_error "repo" "gh repo view failed"
fi
@@ -844,6 +972,14 @@ if [[ "$pr_view_succeeded" == "true" && -n "$repo_owner" && -n "$repo_name" && -
add_error "base" "gh api graphql base ref lookup failed"
fi
if [[ -n "$base_ref_name" ]]; then
if required_status_checks_json="$(fetch_required_status_checks_json "$repo_owner" "$repo_name" "$base_ref_name" "$default_branch_name")"; then
required_status_checks_known=true
else
add_error "required_status_checks" "required status check policy lookup failed"
fi
fi
if [[ -n "$base_head_oid" && -n "$checks_head_sha" ]]; then
if merge_base_json="$(fetch_merge_base_json "$repo_owner" "$repo_name" "$base_head_oid" "$checks_head_sha")"; then
merge_base_oid="$(jq -r '.merge_base_oid // ""' <<<"$merge_base_json")"
@@ -1013,6 +1149,7 @@ trap cleanup_state_tmp EXIT
printf '%s\n' "$pr_json" >"$state_tmp_dir/pr.json"
printf '%s\n' "$since_json" >"$state_tmp_dir/since.json"
printf '%s\n' "$checks_json" >"$state_tmp_dir/checks.json"
printf '%s\n' "$required_status_checks_json" >"$state_tmp_dir/required_status_checks.json"
printf '%s\n' "$approval_required_runs_json" >"$state_tmp_dir/approval_required_runs.json"
printf '%s\n' "$review_threads_json" >"$state_tmp_dir/review_threads.json"
printf '%s\n' "$hidden_unresolved_threads_json" >"$state_tmp_dir/hidden_unresolved_threads.json"
@@ -1025,6 +1162,7 @@ state_json="$(jq -cn \
--slurpfile pr "$state_tmp_dir/pr.json" \
--slurpfile since "$state_tmp_dir/since.json" \
--slurpfile checks "$state_tmp_dir/checks.json" \
--slurpfile required_status_checks "$state_tmp_dir/required_status_checks.json" \
--slurpfile approval_required_runs "$state_tmp_dir/approval_required_runs.json" \
--slurpfile review_threads "$state_tmp_dir/review_threads.json" \
--slurpfile hidden_unresolved_threads "$state_tmp_dir/hidden_unresolved_threads.json" \
@@ -1033,6 +1171,7 @@ state_json="$(jq -cn \
--slurpfile issue_comments "$state_tmp_dir/issue_comments.json" \
--arg checks_head_sha "$checks_head_sha" \
--argjson checks_snapshot_complete "$checks_snapshot_complete" \
--argjson required_status_checks_known "$required_status_checks_known" \
--argjson unresolved_review_thread_count "$unresolved_review_thread_count" \
--argjson filtered_review_thread_count "$filtered_review_thread_count" \
--slurpfile errors "$state_tmp_dir/errors.json" \
@@ -1042,6 +1181,8 @@ state_json="$(jq -cn \
checks: $checks[0],
checks_head_sha: (if $checks_head_sha == "" then null else $checks_head_sha end),
checks_snapshot_complete: $checks_snapshot_complete,
required_status_checks: $required_status_checks[0],
required_status_checks_known: $required_status_checks_known,
approval_required_runs: $approval_required_runs[0],
review_threads: $review_threads[0],
hidden_unresolved_threads: $hidden_unresolved_threads[0],
+229 -8
View File
@@ -64,7 +64,7 @@ if [[ "${GH_FAIL_REPO:-0}" == "1" && "$1" == "repo" && "$2" == "view" ]]; then
exit 1
fi
if [[ "${GH_FAIL_COMMENT:-0}" == "1" && "$1" == "api" && "$2" == repos/kdlbs/kandev/pulls/comments/* ]]; then
if [[ "${GH_FAIL_COMMENT:-0}" == "1" && "$1" == "api" && ("$2" == repos/kdlbs/kandev/pulls/comments/* || "$2" == repos/kdlbs/kandev/issues/comments/*) ]]; then
echo "comment api failed" >&2
exit 1
fi
@@ -75,10 +75,53 @@ if [[ "${GH_FAIL_APPROVAL_RUNS:-0}" == "1" && "$*" == *"repos/kdlbs/kandev/actio
fi
if [[ "$1" == "repo" && "$2" == "view" ]]; then
printf '{"owner":{"login":"kdlbs"},"name":"kandev"}\n'
printf '{"owner":{"login":"kdlbs"},"name":"kandev","defaultBranchRef":{"name":"main"}}\n'
exit 0
fi
if [[ "$*" == *"repos/kdlbs/kandev/rulesets?per_page=100"* ]]; then
if [[ "${GH_FAIL_RULESETS:-0}" == "1" ]]; then
exit 1
fi
if [[ "${GH_REQUIRED_RULESET:-0}" == "1" ]]; then
printf '%s\n' '[{"id":7,"target":"branch","enforcement":"active"}]'
else
printf '%s\n' '[]'
fi
exit 0
fi
if [[ "$1" == "api" && "$2" == "repos/kdlbs/kandev/rulesets/7" ]]; then
if [[ "${GH_RULESET_PATTERN:-0}" == "1" ]]; then
printf '%s\n' '{"id":7,"target":"branch","enforcement":"active","conditions":{"ref_name":{"include":["release/*"]}},"rules":[{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"Pattern required check"}]}}]}'
elif [[ "${GH_RULESET_INTEGRATION:-0}" == "1" ]]; then
printf '%s\n' '{"id":7,"target":"branch","enforcement":"active","conditions":{"ref_name":{"include":["~ALL"]}},"rules":[{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"App required check","integration_id":42}]}}]}'
elif [[ "${GH_RULESET_EXCLUDES_MAIN:-0}" == "1" ]]; then
printf '%s\n' '{"id":7,"target":"branch","enforcement":"active","conditions":{"ref_name":{"include":["~ALL"],"exclude":["main"]}},"rules":[{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"Excluded required check"}]}}]}'
else
printf '%s\n' '{"id":7,"target":"branch","enforcement":"active","conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"]}},"rules":[{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"E2E Tests Passed"},{"context":"Run Backend Tests"}]}}]}'
fi
exit 0
fi
if [[ "$1" == "api" && "$2" == "repos/kdlbs/kandev/branches/main/protection/required_status_checks" ]]; then
case "${GH_LEGACY_FAILURE:-}" in
forbidden) printf 'HTTP/2.0 403 Forbidden\r\n\r\n{"message":"Forbidden"}\n'; exit 1 ;;
unavailable) printf 'HTTP/2.0 503 Unavailable\r\n\r\n{"message":"Unavailable"}\n'; exit 1 ;;
hidden) printf 'HTTP/2.0 404 Not Found\r\n\r\n{"message":"Not Found"}\n'; exit 1 ;;
network) exit 1 ;;
esac
if [[ "${GH_LEGACY_REQUIRED:-0}" == "1" ]]; then
if [[ "$*" == *"--include"* ]]; then
printf 'HTTP/2.0 200 OK\r\nContent-Type: application/json\r\n\r\n'
fi
printf '%s\n' '{"contexts":["Legacy required check"],"checks":[{"context":"Legacy checked required"}]}'
exit 0
fi
printf 'HTTP/2.0 404 Not Found\r\n\r\n{"message":"Branch not protected"}\n'
exit 1
fi
if [[ "$1" == "api" && "$2" == repos/kdlbs/kandev/compare/* ]]; then
merge_base="${GH_MERGE_BASE:-base-branch-sha}"
if [[ "${GH_BASE_SEQUENCE:-stable}" == "race" && "$2" == *"late-base-head-sha..."* ]]; then
@@ -111,6 +154,20 @@ JSON
exit 0
fi
if [[ "$1" == "api" && "$2" == "repos/kdlbs/kandev/issues/comments/222" ]]; then
cat <<'JSON'
{
"id": 222,
"user": { "login": "github-actions[bot]" },
"body": "<!-- kandev-docs-cloudflare-preview -->\nPreview is ready.",
"html_url": "https://github.com/kdlbs/kandev/issues/123#issuecomment-222",
"created_at": "2026-06-01T11:00:00Z",
"updated_at": "2026-06-01T11:01:00Z"
}
JSON
exit 0
fi
if [[ "$*" == *"repos/kdlbs/kandev/actions/runs?event=pull_request&head_sha=abc123&per_page=100"* ]]; then
if [[ "${GH_APPROVAL_REQUIRED:-0}" == "1" ]]; then
cat <<'JSON'
@@ -175,6 +232,34 @@ JSON
exit 0
fi
if [[ "${GH_TERMINAL_CHECKS:-0}" == "1" && "$1" == "pr" && "$2" == "view" && "$4" == "--json" ]]; then
cat <<'JSON'
{
"number": 123,
"baseRefName": "main",
"headRefName": "feat/pr-state",
"headRefOid": "abc123",
"headRepositoryOwner": { "login": "kdlbs" },
"headRepository": { "name": "kandev" },
"maintainerCanModify": true,
"isCrossRepository": false,
"url": "https://github.com/kdlbs/kandev/pull/123",
"comments": [],
"statusCheckRollup": [
{
"__typename": "CheckRun",
"name": "skipped required check",
"workflowName": "CI",
"status": "COMPLETED",
"conclusion": "SKIPPED",
"detailsUrl": "https://github.com/kdlbs/kandev/actions/runs/27340000010/job/55150000010"
}
]
}
JSON
exit 0
fi
if [[ "$1" == "pr" && "$2" == "view" && "$4" == "--json" ]]; then
if [[ "${GH_NO_PR_URL:-0}" == "1" ]]; then
pr_url='null'
@@ -234,6 +319,11 @@ if [[ "$1" == "pr" && "$2" == "view" && "$4" == "--json" ]]; then
"author": { "login": "github-actions" },
"body": "<!-- opencode-review:fallback-findings --> finding",
"createdAt": "2026-06-01T13:06:00Z"
},
{
"author": { "login": "github-actions" },
"body": "<!-- kandev-docs-cloudflare-preview -->\\n## Cloudflare Pages docs preview\\n\\n[Open the docs preview](https://preview.example/docs)\\n\\nBuilt from docs commit `abc1234`.",
"createdAt": "2026-06-01T13:07:00Z"
}
],
"statusCheckRollup": [
@@ -553,7 +643,7 @@ if [[ "$1" == "api" && "$2" == "graphql" ]]; then
"data": {
"repository": {
"pullRequest": {
"baseRefName": "main",
"baseRefName": "'"${GH_BASE_REF_NAME:-main}"'",
"baseRefOid": "'"$base_head"'"
}
}
@@ -755,9 +845,10 @@ test_snapshot_happy_path() {
assert_jq "thread comment timestamp" '.review_threads[] | select(.thread_id == "PRRT_1") | .comment_created_at == "2026-06-01T13:00:00Z"' "$json"
assert_jq "reviews count" '.reviews | length == 1' "$json"
assert_jq "review author" '.reviews[] | select(.author == "cubic-dev-ai[bot]") | .author == "cubic-dev-ai[bot]"' "$json"
assert_jq "issue comments count" '.issue_comments | length == 7' "$json"
assert_jq "issue comments count" '.issue_comments | length == 8' "$json"
assert_jq "issue comment author" 'any(.issue_comments[]; .author == "github-actions" and (.body | contains("Verdict")))' "$json"
assert_jq "only GitHub Actions noncanonical output is actionable" '([.issue_comments[] | select(.actionable == false)] | length) == 2 and .actionable_issue_comment_count == 5' "$json"
assert_jq "only GitHub Actions noncanonical output is actionable" '([.issue_comments[] | select(.actionable == false)] | length) == 3 and .actionable_issue_comment_count == 5' "$json"
assert_jq "Cloudflare preview marker is informational" 'any(.issue_comments[]; (.body | contains("<!-- kandev-docs-cloudflare-preview -->")) and .actionable == false)' "$json"
assert_jq "non-GitHub-Actions canonical marker is nonactionable while workflow diagnostic and fallback remain actionable" 'any(.issue_comments[]; .author == "other-bot[bot]" and .actionable == false) and all(.issue_comments[] | select(.body | test("Blocker: real|diagnostic|fallback"; "i")); .actionable == true)' "$json"
assert_jq "no errors" '.errors == []' "$json"
assert_jq "raw review evidence is complete" '.review_evidence.complete == true and .review_evidence.current_head_sha == "abc123"' "$json"
@@ -1129,7 +1220,7 @@ test_partial_failure_records_error_but_keeps_other_data() {
assert_jq "reviews empty on failure" '.reviews == []' "$json"
assert_jq "checks still present" '.checks | length < 9' "$json"
assert_jq "new issue comments still present" '.issue_comments | length == 7' "$json"
assert_jq "new issue comments still present" '.issue_comments | length == 8' "$json"
assert_jq "partial failure recorded" '.errors | length == 1' "$json"
assert_jq "partial failure source" '.errors[0].source == "reviews"' "$json"
pass "partial failure records error but keeps other data"
@@ -1186,7 +1277,7 @@ test_graphql_failure_records_error_but_keeps_other_data() {
assert_jq "graphql failure records review_threads error" '.errors[] | select(.source == "review_threads") | .message == "gh api graphql reviewThreads failed"' "$json"
assert_jq "graphql failure records closing head error" '.errors[] | select(.source == "closing_head") | .message == "gh api graphql closing head commit failed"' "$json"
assert_jq "graphql failure records closing base error" '.errors[] | select(.source == "closing_base") | .message == "gh api graphql base ref lookup failed after evidence collection"' "$json"
assert_jq "since fallback includes all historical comments" '.issue_comments | length == 8' "$json"
assert_jq "since fallback includes all historical comments" '.issue_comments | length == 9' "$json"
pass "graphql failure records error but keeps other data"
}
@@ -1218,7 +1309,7 @@ test_all_flag_includes_historical_comments_and_reviews() {
json="$(<"$tmp/out.json")"
assert_jq "since omitted in all mode" '.since == null' "$json"
assert_jq "all issue comments present" '.issue_comments | length == 8' "$json"
assert_jq "all issue comments present" '.issue_comments | length == 9' "$json"
assert_jq "all reviews present" '.reviews | length == 2' "$json"
assert_jq "all review threads present" '.review_threads | length == 2' "$json"
assert_jq "all mode keeps historical thread comment" '.review_threads[] | select(.thread_id == "PRRT_1") | .comment_id == 111' "$json"
@@ -1261,6 +1352,110 @@ test_summary_mode_returns_compact_fixup_state() {
pass "--summary returns compact fixup state"
}
test_summary_keeps_failed_policy_sources_unknown() {
local tmp json failure
make_tmp_dir tmp
make_mock_gh "$tmp/bin"
for failure in forbidden unavailable hidden network; do
GH_LEGACY_FAILURE="$failure" PATH="$tmp/bin:$PATH" "$SCRIPT" --summary 123 >"$tmp/out.json"
json="$(<"$tmp/out.json")"
assert_jq "legacy $failure keeps policy unknown" '.required_status_checks_known == false' "$json"
assert_jq "legacy $failure records missing evidence" 'any(.errors[]; .source == "required_status_checks")' "$json"
done
GH_FAIL_RULESETS=1 GH_LEGACY_REQUIRED=1 PATH="$tmp/bin:$PATH" "$SCRIPT" --summary 123 >"$tmp/out.json"
assert_jq "legacy success cannot hide failed rulesets" '.required_status_checks_known == false' "$(<"$tmp/out.json")"
PATH="$tmp/bin:$PATH" "$SCRIPT" --summary 123 >"$tmp/out.json"
assert_jq "explicitly absent legacy protection is known" '.required_status_checks_known == true and .required_status_checks == []' "$(<"$tmp/out.json")"
pass "policy discovery distinguishes absent protection from unavailable evidence"
}
test_summary_reports_required_status_contexts_from_rulesets() {
local tmp
make_tmp_dir tmp
make_mock_gh "$tmp/bin"
local json
GH_REQUIRED_RULESET=1 PATH="$tmp/bin:$PATH" "$SCRIPT" --summary 123 >"$tmp/out.json"
json="$(<"$tmp/out.json")"
assert_jq "required status policy is known" '.required_status_checks_known == true' "$json"
assert_jq "required status contexts are listed" '.required_status_checks == ["E2E Tests Passed", "Run Backend Tests"]' "$json"
assert_jq "missing required contexts remain pending" '.pending_checks | map(.name) | index("E2E Tests Passed") != null' "$json"
pass "summary reports required status contexts from the active ruleset"
}
test_summary_unions_legacy_and_ruleset_required_status_contexts() {
local tmp
make_tmp_dir tmp
make_mock_gh "$tmp/bin"
local json
GH_REQUIRED_RULESET=1 GH_LEGACY_REQUIRED=1 PATH="$tmp/bin:$PATH" "$SCRIPT" --summary 123 >"$tmp/out.json"
json="$(<"$tmp/out.json")"
assert_jq "legacy and ruleset policies are both known" '.required_status_checks_known == true' "$json"
assert_jq "legacy and ruleset contexts are unioned" '.required_status_checks == ["E2E Tests Passed", "Legacy checked required", "Legacy required check", "Run Backend Tests"]' "$json"
pass "summary unions legacy and ruleset required status contexts"
}
test_summary_ignores_excluded_ruleset_branch() {
local tmp
make_tmp_dir tmp
make_mock_gh "$tmp/bin"
local json
GH_REQUIRED_RULESET=1 GH_RULESET_EXCLUDES_MAIN=1 PATH="$tmp/bin:$PATH" "$SCRIPT" --summary 123 >"$tmp/out.json"
json="$(<"$tmp/out.json")"
assert_jq "excluded ruleset policy is known" '.required_status_checks_known == true' "$json"
assert_jq "excluded ruleset contributes no required context" '.required_status_checks == []' "$json"
pass "summary honors ruleset branch exclusions"
}
test_summary_marks_unsupported_ruleset_patterns_unknown() {
local tmp
make_tmp_dir tmp
make_mock_gh "$tmp/bin"
local json
GH_REQUIRED_RULESET=1 GH_RULESET_PATTERN=1 GH_BASE_REF_NAME=release/1.2 PATH="$tmp/bin:$PATH" "$SCRIPT" --summary 123 >"$tmp/out.json"
json="$(<"$tmp/out.json")"
assert_jq "unsupported ruleset patterns make policy unknown" '.required_status_checks_known == false' "$json"
assert_jq "unsupported ruleset pattern is reported" '.errors[] | select(.source == "required_status_checks") | .message == "required status check policy lookup failed"' "$json"
pass "summary does not guess at unsupported ruleset patterns"
}
test_summary_marks_integration_scoped_rules_unknown() {
local tmp
make_tmp_dir tmp
make_mock_gh "$tmp/bin"
local json
GH_REQUIRED_RULESET=1 GH_RULESET_INTEGRATION=1 PATH="$tmp/bin:$PATH" "$SCRIPT" --summary 123 >"$tmp/out.json"
json="$(<"$tmp/out.json")"
assert_jq "integration-scoped rules make policy unknown" '.required_status_checks_known == false' "$json"
assert_jq "integration-scoped policy failure is reported" '.errors[] | select(.source == "required_status_checks") | .message == "required status check policy lookup failed"' "$json"
pass "summary does not accept an unverified required-check integration"
}
test_summary_preserves_terminal_skipped_contexts() {
local tmp
make_tmp_dir tmp
make_mock_gh "$tmp/bin"
local json
GH_TERMINAL_CHECKS=1 PATH="$tmp/bin:$PATH" "$SCRIPT" --summary 123 >"$tmp/out.json"
json="$(<"$tmp/out.json")"
assert_jq "skipped check remains terminal evidence" '.terminal_checks | length == 1 and .[0].name == "skipped required check" and .[0].conclusion == "skipped"' "$json"
pass "summary preserves skipped and neutral terminal checks"
}
test_summary_reports_current_head_fork_approval_runs() {
local tmp
make_tmp_dir tmp
@@ -1375,6 +1570,7 @@ test_comment_mode_returns_full_review_comment() {
json="$(<"$tmp/out.json")"
assert_jq "comment id" '.comment_id == 111' "$json"
assert_jq "comment type" '.comment_type == "review"' "$json"
assert_jq "comment body is full" '.body | contains("Full rationale here.")' "$json"
assert_jq "comment path" '.path == "apps/web/file.ts"' "$json"
assert_jq "comment line" '.line == 42' "$json"
@@ -1382,6 +1578,23 @@ test_comment_mode_returns_full_review_comment() {
pass "--comment returns full review comment"
}
test_comment_mode_falls_back_to_top_level_issue_comment() {
local tmp
make_tmp_dir tmp
make_mock_gh "$tmp/bin"
local json
PATH="$tmp/bin:$PATH" "$SCRIPT" --comment 222 >"$tmp/out.json"
json="$(<"$tmp/out.json")"
assert_jq "issue comment id" '.comment_id == 222' "$json"
assert_jq "issue comment type" '.comment_type == "issue"' "$json"
assert_jq "issue comment body is full" '.body | contains("Preview is ready.")' "$json"
assert_jq "issue comment has no review path" '.path == null and .line == null' "$json"
assert_jq "issue comment author" '.author == "github-actions[bot]"' "$json"
pass "--comment falls back to top-level issue comments"
}
test_comment_mode_reports_fetch_failure() {
local tmp
make_tmp_dir tmp
@@ -1594,6 +1807,13 @@ test_graphql_failure_records_error_but_keeps_other_data
test_graphql_pagination_collects_all_threads
test_all_flag_includes_historical_comments_and_reviews
test_summary_mode_returns_compact_fixup_state
test_summary_keeps_failed_policy_sources_unknown
test_summary_reports_required_status_contexts_from_rulesets
test_summary_unions_legacy_and_ruleset_required_status_contexts
test_summary_ignores_excluded_ruleset_branch
test_summary_marks_unsupported_ruleset_patterns_unknown
test_summary_marks_integration_scoped_rules_unknown
test_summary_preserves_terminal_skipped_contexts
test_summary_reports_current_head_fork_approval_runs
test_summary_reports_base_not_advanced_when_head_matches_merge_base
test_summary_revalidates_base_at_closing_head
@@ -1602,6 +1822,7 @@ test_summary_all_flag_includes_historical_unresolved_threads
test_job_log_mode_emits_bounded_failure_context
test_job_log_mode_unpacks_zip_responses
test_comment_mode_returns_full_review_comment
test_comment_mode_falls_back_to_top_level_issue_comment
test_comment_mode_reports_fetch_failure
test_comment_mode_rejects_incompatible_flags
test_array_expansions_are_safe_under_set_u
+20
View File
@@ -912,6 +912,7 @@ function assertExperimentalCalloutPlacement(file, markdown) {
*/
async function assertLocalLinks(docsDir, file, markdown) {
const source = stripMarkdownCode(markdown);
assertNoNestedLocalImages(file, source);
const definitionPattern = /^\s{0,3}\[([^\]\n]+)\]:\s*(\S.*)$/gm;
const referencePattern = /!?\[([^\]\n]+)\]\[([^\]\n]*)\]/g;
const shortcutReferencePattern = /(?<![!\\\[\]])\[([^\]\n]+)\](?![\[(:])/g;
@@ -992,6 +993,25 @@ async function assertLocalLinks(docsDir, file, markdown) {
}
}
/**
* Reject nested local image links unsupported by the public-doc publisher.
*
* @param {string} file Relative source path used in validation errors.
* @param {string} markdown Markdown with code regions removed.
* @returns {void}
*/
function assertNoNestedLocalImages(file, markdown) {
const nestedImagePattern =
/\[[^\]\r\n]*!\[[^\]\r\n]*\]\(([^)\r\n]+)\)\]\(([^)\r\n]+)\)/g;
for (const match of markdown.matchAll(nestedImagePattern)) {
const imageHref = parseMarkdownDestination(match[1]);
if (!imageHref || isExternalDestination(imageHref)) continue;
throw new Error(
`${file} uses a nested local image link: ${imageHref}; Landing copies only a direct plain Markdown image`,
);
}
}
/**
* Require a local Markdown fragment to match the published heading identifier.
*
+20 -3
View File
@@ -566,13 +566,14 @@ test("rejects a DocsVideo attribute that points to missing media", async () => {
);
});
test("rejects a broken local image nested inside a link", async () => {
test("rejects a local image nested inside a link", async () => {
const dir = await createDocs(
{
"index.md": validPage.replace(
"Page body.",
"[![Missing](assets/missing.png)](guide.md)",
"[![Screenshot](assets/screenshot.png)](guide.md)",
),
"assets/screenshot.png": "image",
"guide.md": validPage,
},
{ pages: ["index", "guide"] },
@@ -580,10 +581,26 @@ test("rejects a broken local image nested inside a link", async () => {
await assert.rejects(
validatePublicDocs(dir),
/index.md links to missing local target: assets\/missing.png/,
/index.md uses a nested local image link: assets\/screenshot.png; Landing copies only a direct plain Markdown image/,
);
});
test("accepts a direct local image with a separate full-size link", async () => {
const dir = await createDocs(
{
"index.md": validPage.replace(
"Page body.",
"![Screenshot](assets/screenshot.png)\n\n[Open full size](guide.md)",
),
"assets/screenshot.png": "image",
"guide.md": validPage,
},
{ pages: ["index", "guide"] },
);
await assert.doesNotReject(validatePublicDocs(dir));
});
test("accepts external, existing heading, and fenced-code links", async () => {
const dir = await createDocs(
{