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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .claude/agents/squad-devils-advocate.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ Your job is to find what the plan got wrong **before** it is built, not to revie
with the same defect, a missed caller, a documented guarantee the plan does not mention).
- **Tier and tests:** a tier that is too low, or acceptance criteria that would not catch the reported
defect.
- **Target host:** a plan for an installer or deployment (or a document for an operator) that does not match
the list of what the target host has and may have in `spec.md`, or a spec for one that has no such list, is a
`major` objection (*Operator environments and documented procedures* in `.squad/routing.md`).
- **Sample:** a plan for a parser, decoder or reader of an external format that names no real sample of it
(under the test data layout or on the issue) is built from a description and is a `major` objection.

Expand Down
4 changes: 3 additions & 1 deletion .claude/agents/squad-lead.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ The orchestrator tells you which **mode** to run:
the behavior. Size the spec to the feature: one scenario per behavior a user can tell apart, only the edge
cases that change what the user sees, and a link instead of a restatement of the issue, an area document or a
record β€” a small feature gets a short spec. Check every claim the issue makes about today's behavior against
the code before you build a scenario on it. A feature that already exists or contradicts an accepted decision record is
the code before you build a scenario on it. For an installer or deployment feature, ask the Product Manager
what the target host has and may have (`RESULT: ESCALATE`, *Operator environments and documented procedures*
in `.squad/routing.md`) and record the answers as scenarios and criteria. A feature that already exists or contradicts an accepted decision record is
`RESULT: NO CHANGE` with the reason and a proposed issue comment.
- `plan` β€” pick the tier on the change (`.squad/routing.md`, *Tiers*). For tiers `docs` and `patch` write
**no** `plan.md`: return the tier with its justification, the files and the exact edits, and the acceptance
Expand Down
8 changes: 8 additions & 0 deletions .claude/agents/squad-tester.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ Mode `tests-first`:
base input), never as an absolute duration; the limit is at least three times the highest ratio you
measured, with the instrumentation of *Test with coverage* included (`docs/UNIT_TESTS.md`).
A case about an escape keeps its backslash on disk and asserts it (*Escapes in files* in `.squad/team.md`).
For a documented shell procedure derive the cases from the plan's list of what happens when the lines are
pasted at once or one at a time, one fails or a variable is lost; a test that reads a repository document
normalizes `\r\n` (`docs/UNIT_TESTS.md`); for a script only CI can run, run the local smoke substitute
(*Writing tests* in `.squad/stack.md`) before you hand back.
For a feature, the criteria are `spec.md`'s and their tests the plan's *Tests per criterion*: every field
and value a criterion names, and the scenarios and edge cases it lists, are asserted by a test; name one no
test asserts in your report.
Expand Down Expand Up @@ -78,5 +82,9 @@ Rules:
detects the failure, and measure before handing over.
- Never weaken a test to make it pass β€” a test the Dev disputes goes to the Lead. Never write production
code (also not in a scratch copy; the one exception is the throwaway mutation of mode `coverage`).
- A file that cannot run in the session (a script only CI can run, a generated file) is edited only with exact,
unique string replacement (the Edit tool), never by slicing the file on a search marker: a marker can match
more than one place, and a rebuilt file keeps the duplicate. After the edit, compare `git diff --stat` with the
intended size and list the case or function headers (`grep -n`) to show each appears once.

Report: tests added (names), their result, the coverage output.
6 changes: 3 additions & 3 deletions .claude/hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ if ! command -v reihitsu-format >/dev/null 2>&1; then
dotnet tool install -g Reihitsu.Cli
fi

# Scanner of the *Sonar gate* in .squad/stack.md; the gate reports NOT CHECKED when it is missing, so a failed install does not fail the session.
if ! command -v dotnet-sonarscanner >/dev/null 2>&1; then
dotnet tool install -g dotnet-sonarscanner || echo "dotnet-sonarscanner could not be installed; the Sonar gate will report NOT CHECKED" >&2
# Local tools of the repository's manifest (dotnet-sonarscanner for the *Sonar gate*, a formatter pinned there).
if [[ -e dotnet-tools.json || -e .config/dotnet-tools.json ]]; then
dotnet tool restore || echo "dotnet tool restore failed; run it before the Sonar gate" >&2
fi

# Restore every solution at the repository root (the squad's settings name the one the gates build).
Expand Down
5 changes: 5 additions & 0 deletions .claude/skills/create-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ repository.
verified) β€” including the unit-test, formatting, analyzer, coverage,
documentation and dependency items, not just the general ones
- wrap the body in a HEREDOC so the formatting survives
- set one label on the PR (`issue_write`, method `update`, `labels`) for the release notes
(`.github/release.yml`): `breaking-change` for a change that breaks existing users, else `enhancement` for a
feature, `security` for a security fix, `bug` for a bug fix (with a linked issue: the matching label of the
issue, else judge from the change); none for anything else (it lands under *Other
changes*), `ignore-for-release` for a change that is not worth a release note
- read the body back right after creating or updating the PR
(`pull_request_read`, method `get`) and compare its links and code spans
with what you sent; a mangled one is fixed with `update_pull_request`
Expand Down
5 changes: 4 additions & 1 deletion .claude/skills/squad-issue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ every Git and GitHub action yourself, including follow-up issues the Lead decide
agent is not the member's last fresh launch) on the call that carries its numbers β€” the amend of its pending
row β€” with the notification's numbers unchanged (only the tokens are cumulative). Name a fix, delta or revise round as such in its step
cell; a continuation at another step than its fresh launch draws a warning (a new mode is a fresh launch).
- Rows of the Orchestrator, the Product Manager and the User take no metric option.
- Rows of the Orchestrator, the Product Manager and the User take no metric option; their member cells are
exactly `Orchestrator`, `Product Manager` and `User` (`squad-orchestrator` is written as `Orchestrator`).
- `--continued-from` is only for continuing the same agent; a fresh launch of the same role at another step
is logged without it.
- After step 10 every call β€” row, amend, summary β€” needs `--stdin` (step 10); without it, it fails.
- Everything else, and what each refusal means: `python3 .squad/tools/squad-log.py --help`.

Expand Down
13 changes: 13 additions & 0 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-sonarscanner": {
"version": "12.0.0",
"commands": [
"dotnet-sonarscanner"
],
"rollForward": false
}
}
}
27 changes: 27 additions & 0 deletions .github/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Controls the notes GitHub generates for a release ("Generate release notes" on the release page).
# GitHub groups by pull request label only (not by title); the squad's create-pr sets the label.
# Nothing else is excluded: a pull request without a known label (Dependabot's included) is listed under
# "Other changes".
# A pull request lands in the first category whose labels it carries, so the order matters.
# https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes
changelog:
exclude:
labels:
- ignore-for-release
categories:
- title: "Breaking changes"
labels:
- breaking-change
- title: "Features"
labels:
- enhancement
- feature
- title: "Security fixes"
labels:
- security
- title: "Bug fixes"
labels:
- bug
- title: "Other changes"
labels:
- "*"
2 changes: 1 addition & 1 deletion .squad/orchestration.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ review (`update_pull_request` with `draft: false`) only when all of this holds o
waited for, not counted as passed);
- `python3 .squad/tools/sonar-check.py pr <number>` exits 0: the CI code analysis of **this** head passed its
quality gate with **no new issue** and no hotspot to review (a passed gate with new issues is still
rework). PENDING (exit 3): wait and run it again, never read a `Quality Gate passed` comment instead; still
rework). PENDING (exit 3) means a newer analysis than the shown one is still to come, not a problem of the head (the analysis can lag the pushes by several commits): wait and run it again, never read a `Quality Gate passed` comment instead; still
PENDING once every CI check of the head has finished means the analysis cannot be read;
- no commit for a known failure or finding is still to be pushed, and after a fix in the squad skills the
re-approval of step 9 has passed on this head.
Expand Down
24 changes: 24 additions & 0 deletions .squad/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,30 @@ plan names it as the criterion's verification with the Dev as its owner. A build
working tree does not count: it restores with every source present. A red CI on such a criterion after that
check is worked in step 11.

### Operator environments and documented procedures

A criterion or document that depends on the operator's environment (network, DNS, access policy, console,
upload tools, what the target host has installed) is planned against that environment, not against the
session's:

- **Target host (install and deploy features).** The spec asks the Product Manager what the target host has
and may have (tools such as `git`, network access to which hosts, DNS, the console it is used from, the
order of steps the operator will paste) and records the answers as scenarios and acceptance criteria. The
Devil's Advocate checks the plan against that list.
- **Dry run.** The plan schedules a dry run of the documented steps by the Product Manager (or a stated
stand-in) **before** the documentation is reviewed, and lists the hosts and software versions the run needs
(for example the backend version with the API the procedure calls). Every statement of the document that no
run has shown is marked as such in the document. When an external page cannot be reached from the session,
the plan asks the Product Manager to confirm those statements in the earliest real run, not at the end.
- **Shell procedures.** For a documented shell procedure the plan states, before Security's plan review, what
happens when (a) all lines are pasted at once, (b) one line at a time, (c) one line fails, (d) a variable
is lost, and names the tests (unit and CI) that hold each answer; the Tester derives its cases from that
list. A line that continues after a failed step, so that a half-finished state can still be run, is a
defect of the design.
- **CI-only rows.** A script only CI can run (it needs systemd, root or a service the session lacks) has a
local smoke substitute (*Writing tests* in `.squad/stack.md`); the plan lists the rows that can run in CI
only, and the Tester runs the substitute before handing back.

### Code a unit test cannot cover

A file of the change that no unit test can cover β€” a browser script the stack has no test runner for, a
Expand Down
15 changes: 11 additions & 4 deletions .squad/stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,13 @@ untracked files other than the coverage reports) and needs a SonarQube credentia
else `SONAR_TOKEN` in the environment, which every process of the session can read. The scanner also needs
network access to its download hosts (`api.sonarcloud.io` and `scanner.sonarcloud.io`, which the secret's
hosts cover, and the package registry); without them it reports NOT CHECKED, which on a branch without a pull request yet is not final: step 11 reads the draft pull
request with `python3 .squad/tools/sonar-check.py pr <number>` instead, and that result counts. The SessionStart hook installs
`dotnet-sonarscanner` and puts `~/.dotnet/tools` on the `PATH`; a failed `sonar-check.py` read names the request and
request with `python3 .squad/tools/sonar-check.py pr <number>` instead, and that result counts. The SessionStart hook restores
`dotnet-sonarscanner` from the tool manifest and puts `~/.dotnet/tools` on the `PATH`; a failed `sonar-check.py` read names the request and
the response body (HTTP status included), so the cause of an HTTP error can be told from the output. A finding of a rule no local analyzer reports is noted in this section, in the same change, instead of a
squad lesson.
The .NET scanner is the `dotnet-sonarscanner` tool, installed globally with `dotnet tool install -g dotnet-sonarscanner`
(a local install writes an untracked `dotnet-tools.json` to the repository root, which the gate refuses); its `begin`
The .NET scanner is the `dotnet-sonarscanner` tool, pinned in the tool manifest `.config/dotnet-tools.json`, which
`dotnet tool restore` must have restored before the gate in a fresh cloud session: the SessionStart hook does it, run it
by hand where the hook did not; its `begin`
arguments mirror the CI step, plus the branch name and the repository root as base
directory, and it needs the full, non-incremental build between `begin` and `end`. The local coverage report is
Cobertura, which the scanner does not read, so the branch analysis carries no coverage figure: the *Coverage
Expand Down Expand Up @@ -172,6 +173,12 @@ the variable name, for example) whose text a test pins, never a suppression.

## Writing tests

**Scripts only CI can run.** A script that needs systemd, root or a service the session lacks (an
end-to-end install check, for example) has a local smoke substitute, which the Tester runs before handing back:
`bash -n` and `shellcheck` on the script, and a mode of the script (or a test) that runs the documented lines
against a scratch directory with stub commands, without systemd. The plan lists the rows that can only run in
CI (`.squad/routing.md`, *Operator environments and documented procedures*).

See `docs/UNIT_TESTS.md` β€” MSTest with **NSubstitute** for collaborators that cross an infrastructure boundary,
EF Core InMemory / SQLite for data tests. The rules the MSTest and Sonar analyzers enforce and the Code Officer cannot fix
without handing back: pass `TestContext.CancellationToken` to every call that accepts a token
Expand Down
2 changes: 1 addition & 1 deletion .squad/template.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"repository": "LarsLaskowski/Squad-Spec-Repository-Template",
"commit": "cf3cc08ad9f7a54b7f33c9509625af22c57a7a54",
"commit": "b38a99f77efcbab02f0e1c04c55f301682789111",
"profile": "dotnet",
"profiles": [
"dotnet"
Expand Down
51 changes: 43 additions & 8 deletions .squad/tools/sonar-check.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
server from `SONAR_HOST` (default https://sonarcloud.io); the scanner's report file from `SONAR_REPORT_TASK`
(default `.scannerwork/report-task.txt`).

Two reads have a second endpoint for a credential that may browse the project but not use the first one (a
403 from the proxy-authenticated session): the pull request's commit comes from `project_analyses/search` when
`project_pull_requests/list` is refused, and the scan's processing status from `ce/activity` when `ce/task` is
refused.

A read that SonarQube answers with an HTTP error is NOT CHECKED with the request (path and parameters, never the
token) and the response body, so a rejected parameter can be told from a missing project or the proxy.

Expand Down Expand Up @@ -77,6 +82,10 @@ class NotChecked(Exception):
class ApiError(OSError):
"""SonarQube answered a request with an HTTP error; the message names the request and the response."""

def __init__(self, message, status=None):
super().__init__(message)
self.status = status


class Usage(argparse.ArgumentParser):
def error(self, message):
Expand Down Expand Up @@ -118,7 +127,7 @@ def api(path, params, token=None):
except urllib.error.HTTPError as error:
# The URL carries the parameters but never the token (it travels in a header), so it may be printed.
body = error.read().decode("utf-8", "replace").strip()[:ERROR_BODY_CHARS]
raise ApiError(f"HTTP {error.code} for GET {url}: {body or 'empty response body'}") from error
raise ApiError(f"HTTP {error.code} for GET {url}: {body or 'empty response body'}", error.code) from error


def proxy_credential():
Expand Down Expand Up @@ -178,15 +187,27 @@ def report(findings, gate=None):
return CLEAN


def analysed_commit(key, number, token=None):
"""The commit SonarQube analysed last for the pull request, or "" when there is no analysis."""
try:
analyses = api("project_pull_requests/list", {"project": key}, token).get("pullRequests", [])
except ApiError as error:
if error.status != 403:
raise
found = api("project_analyses/search", {"project": key, "pullRequest": number, "ps": 1}, token)
return next((a.get("revision", "") for a in found.get("analyses", [])), "")
analysis = next((a for a in analyses if a.get("key") == str(number)), None)
return (analysis or {}).get("commit", {}).get("sha", "")


def check_pr(key, number, head, token=None):
"""The analysis of the pull request, matched to the head."""
analyses = api("project_pull_requests/list", {"project": key}, token).get("pullRequests", [])
analysis = next((a for a in analyses if a.get("key") == str(number)), None)
analysed = (analysis or {}).get("commit", {}).get("sha", "")
if not analysis or len(head) < 7 or not analysed.startswith(head): # a full SHA, or an abbreviation of it
analysed = analysed_commit(key, number, token)
if len(head) < 7 or not analysed or len(head) < 7 or not analysed.startswith(head): # a full SHA, or an abbreviation of it
print(f"PENDING: SonarQube has no analysis of pull request {number} at {head[:12]} yet"
+ (f" (latest: {analysed[:12]})" if analysed else "") + "; wait and run again. Still PENDING after "
"every CI check of this head finished: the analysis cannot be read, and the PR stays a draft.")
+ (f" (latest: {analysed[:12]})" if analysed else "") + ". PENDING says that a newer analysis than "
"the one shown is still to come, not that the head has a problem: wait and run again. Still PENDING "
"after every CI check of this head finished: the analysis cannot be read, and the PR stays a draft.")
return PENDING
params = {"projectKey": key, "pullRequest": number}
gate = api("qualitygates/project_status", params, token)["projectStatus"]["status"]
Expand All @@ -210,11 +231,25 @@ def ce_task_id():
return None


def ce_task(task, token):
"""The compute engine task; `ce/activity` answers for a credential that `ce/task` refuses."""
try:
return api("ce/task", {"id": task}, token)["task"]
except ApiError as error:
if error.status != 403:
raise
key = project_key()
for candidate in api("ce/activity", {"component": key, "ps": 20}, token).get("tasks", []):
if candidate.get("id") == task:
return candidate
return {"id": task, "status": "PENDING"}


def wait_for_task(task, token):
"""Wait until SonarQube has processed the uploaded report; return the task (its status says how it ended)."""
deadline = time.monotonic() + WAIT_SECONDS
while True:
result = api("ce/task", {"id": task}, token)["task"]
result = ce_task(task, token)
if result["status"] not in ("PENDING", "IN_PROGRESS"):
return result
if time.monotonic() >= deadline:
Expand Down
Loading
Loading