diff --git a/.claude/agents/squad-security.md b/.claude/agents/squad-security.md index 0eb73be..c74a3ea 100644 --- a/.claude/agents/squad-security.md +++ b/.claude/agents/squad-security.md @@ -45,7 +45,9 @@ derived values and expect the Tester's test that pins the claim. A security prop rests on measured or library behavior (a deadline that interrupts a query step, a limit a parser enforces, a server's flush semantics) needs its measurement (`.squad/team.md`, *Test doubles that model a real component*): in mode `plan` you run it once before you approve, in mode `diff` you check the Dev's; an -unverified claim of that kind is a blocking finding. +unverified claim of that kind is a blocking finding. If the measurement cannot run because a download or tool +is denied, report it as not run and name what it needs; the orchestrator handles it (`.squad/orchestration.md`, +*A denied measurement*). Rules: diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 2d7d614..578fae6 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -12,7 +12,7 @@ bash "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/install-linters.sh" cd "${CLAUDE_PROJECT_DIR:-$(pwd)}" -# .NET global tools (reihitsu-format) need DOTNET_ROOT when dotnet is not installed in a default location. +# .NET tools (reihitsu-format) need DOTNET_ROOT when dotnet is not installed in a default location. dotnet_root="$(dirname "$(readlink -f "$(command -v dotnet)")")" export DOTNET_ROOT="$dotnet_root" export PATH="$PATH:$HOME/.dotnet/tools" @@ -23,14 +23,12 @@ if [[ -n "${CLAUDE_ENV_FILE:-}" ]]; then } >> "$CLAUDE_ENV_FILE" fi -# Formatter (*Format* in .squad/stack.md), run by the squad member that changed the files -if ! command -v reihitsu-format >/dev/null 2>&1; then - dotnet tool install -g Reihitsu.Cli -fi - -# Local tools of the repository's manifest (dotnet-sonarscanner for the *Sonar gate*, a formatter pinned there). +# Local tools of the repository's manifest: the formatter (*Format* in .squad/stack.md) and dotnet-sonarscanner +# (*Sonar gate*), both 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 + dotnet tool restore || echo "dotnet tool restore failed; run it before Format and the Sonar gate" >&2 +else + echo "no dotnet tool manifest (.config/dotnet-tools.json); the formatter is not installed" >&2 fi # Restore every solution at the repository root (the squad's settings name the one the gates build). diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 267cd41..a2a88b6 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -2,6 +2,13 @@ "version": 1, "isRoot": true, "tools": { + "reihitsu.cli": { + "version": "1.1.0", + "commands": [ + "reihitsu-format" + ], + "rollForward": false + }, "dotnet-sonarscanner": { "version": "12.0.0", "commands": [ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a1d88a7..48e1fed 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -81,8 +81,8 @@ Run from the repository root, where the solution file lives, in this order: *Res *Test*, *Analyzer gate*, and after *Test with coverage* the *Coverage gate*. Use the commands of those names in `.squad/stack.md` as they stand, never a copy from memory: they carry the solution name, the build configuration the project tests in, and the formatter's `--force` (`reihitsu-format` asks for confirmation -for more than 25 files and, without a terminal, formats nothing). The formatter is installed with -`dotnet tool install -g Reihitsu.Cli`. +for more than 25 files and, without a terminal, formats nothing). The formatter is restored from the repository's tool +manifest (`.config/dotnet-tools.json`) with `dotnet tool restore` and run as `dotnet tool run reihitsu-format`. All commands, with what each one checks, are listed in [`.squad/stack.md`](/.squad/stack.md). diff --git a/.squad/orchestration.md b/.squad/orchestration.md index b85e9bf..83f963b 100644 --- a/.squad/orchestration.md +++ b/.squad/orchestration.md @@ -168,6 +168,14 @@ state verbatim, is an ordinary fix for its owner with a delta round. One excepti statement an acceptance criterion or a guarantee in `.squad/project.md` relies on is not applied; the reviewer says so, and the Lead decides (mode `decide`). +## A denied measurement + +When `squad-security` in mode `plan` reports a required measurement as not run because a download or tool was +denied (e.g. a vendor release binary refused by the permission classifier), the orchestrator asks the Product +Manager once for exactly that download, runs the measurement itself once it is allowed, and passes the measured +result with the plan to the Lead (mode `revise`). Security re-runs the review in round 2 against the revised +plan and the measurement. Not run is never approved, and the denial alone is no reason to skip the measurement. + ## Unlisted security areas A Reviewer finding *unlisted security area* — the diff touches a security area the plan's *Security areas diff --git a/.squad/stack.md b/.squad/stack.md index 18cf32b..170129e 100644 --- a/.squad/stack.md +++ b/.squad/stack.md @@ -7,13 +7,14 @@ profile and owned by this repository: keep it true when the build changes. ## Toolchain - .NET SDK `10.0.x` (target framework `net10.0`), solution `DockerUpdateGuard.slnx`. -- Formatter: `reihitsu-format` (`dotnet tool install -g Reihitsu.Cli`), on the same release line as the +- Formatter: `reihitsu-format`, restored from the tool manifest `.config/dotnet-tools.json` (`dotnet tool restore`, + pinned there next to `dotnet-sonarscanner` so Dependabot raises them with the analyzer), on the same release line as the **Reihitsu.Analyzer** version pinned in `Directory.Packages.props`; otherwise the formatter can revert code the analyzer considers correct. - Analyzers: **SonarAnalyzer.CSharp** in every project via `Directory.Build.props` (the Sonar C# rules locally, so SonarQube issues surface before the push), **Reihitsu.Analyzer** likewise via `Directory.Build.props`, both configured by the rulesets in `rules/`, with warnings treated as errors; the MSTest analyzers come with the MSTest package in the test projects. -- The SessionStart hook `.claude/hooks/session-start.sh` installs the formatter, sets `DOTNET_ROOT` and +- The SessionStart hook `.claude/hooks/session-start.sh` restores the formatter, sets `DOTNET_ROOT` and restores the solution in remote sessions. ## Layout @@ -30,8 +31,8 @@ profile and owned by this repository: keep it true when the build changes. | Name | Command | | ---- | ------- | | *Restore* | `dotnet restore DockerUpdateGuard.slnx` | -| *Format* (in the squad: by the member that changed the files, `.squad/team.md`) | `reihitsu-format --force ./` | -| *Format check* | `reihitsu-format --check ./` | +| *Format* (in the squad: by the member that changed the files, `.squad/team.md`) | `dotnet tool run reihitsu-format --force ./` | +| *Format check* | `dotnet tool run reihitsu-format --check ./` | | *Build* | `dotnet build DockerUpdateGuard.slnx -c Release --no-restore` | | *Test* | `dotnet test DockerUpdateGuard.slnx -c Release --no-build` | | *Single test* | `dotnet test src/Tests/DockerUpdateGuard.Tests/DockerUpdateGuard.Tests.csproj --filter "FullyQualifiedName~ClassName.MethodName"` | diff --git a/.squad/template.json b/.squad/template.json index 367d63c..4dc2850 100644 --- a/.squad/template.json +++ b/.squad/template.json @@ -1,6 +1,6 @@ { "repository": "LarsLaskowski/Squad-Spec-Repository-Template", - "commit": "b38a99f77efcbab02f0e1c04c55f301682789111", + "commit": "cee6347c97940443a9638ec2a4fb3f47a6c400af", "profile": "dotnet", "profiles": [ "dotnet" diff --git a/.squad/tools/plan-check.py b/.squad/tools/plan-check.py index 6769071..19b7fef 100644 --- a/.squad/tools/plan-check.py +++ b/.squad/tools/plan-check.py @@ -72,7 +72,7 @@ r"NNNN-title", rf"\bAC\d+: {ELLIPSIS}\s*$", rf"^\s*- [SE]\d+: {ELLIPSIS}\s*$", rf"^\|.*\|\s*{ELLIPSIS}\s*\|")] EMPTY_CELLS = {"", "-", "\u2013", "\u2014", "...", "\u2026", "none", "n/a", "tbd", "todo"} -COMMENT = re.compile(r"", re.S) +COMMENT = re.compile(r"(```.*?```|`[^`\n]*`)|", re.S) # a code span or fenced block is kept whole EMPHASIS = "`*_" SPEC = "spec.md" SPECS_PREFIX = "specs/" @@ -87,6 +87,11 @@ TOP_ITEM = re.compile(r"^(?:[-*]|\d+[.)])\s+") +def strip_comments(text): + """The text without its HTML comments; a comment opener inside a code span or fenced block is no comment.""" + return COMMENT.sub(lambda match: match.group(1) or "", text) + + def read(path, errors): if not os.path.isfile(path): return None @@ -124,7 +129,7 @@ def ids(text, kind=None): def sections(text): """{heading: body} for every `## ` section, without the template's guidance comments.""" result, current = {}, None - for line in COMMENT.sub("", text).splitlines(): + for line in strip_comments(text).splitlines(): if line.startswith("## "): current = line[3:].strip() result[current] = [] @@ -175,7 +180,7 @@ def empty(value): def check_placeholders(name, text, errors): - for line in COMMENT.sub("", text).splitlines(): + for line in strip_comments(text).splitlines(): if any(pattern.search(line) for pattern in PLACEHOLDERS): errors.append(f"{name}: template placeholder left: {line.strip()}") @@ -289,7 +294,7 @@ def signature_names(body): """The identifier of every backticked declaration in the plan's *Signatures* section (`Options.Kernel` and `func (o *Options) Kernel() string` give `Kernel`); a "none" section gives none.""" names = [] - for span in SPAN.findall(COMMENT.sub("", body or "")): + for span in SPAN.findall(strip_comments(body or "")): call = re.search(r"([A-Za-z_]\w*)\s*\(", span) words = [w for w in re.findall(r"[A-Za-z_][\w.]*", span) if w not in DECLARATION_KEYWORDS] name = words[0].split(".")[-1] if words else "" @@ -399,7 +404,7 @@ def check_plan(plan, spec, errors): if name not in TIERS or tier.group(2).lstrip().startswith("|"): errors.append(f"plan.md: the *Tier* line must name exactly one of {', '.join(TIERS)}") check_security_areas(plan, errors) - own = set(CRITERION.findall(COMMENT.sub("", plan))) + own = set(CRITERION.findall(strip_comments(plan))) if spec is None: criteria = own if not criteria: @@ -422,7 +427,7 @@ def check_spec_only(folder): check_spec(spec, errors) check_placeholders(SPEC, spec, errors) check_control_characters(folder, errors) - words = len(COMMENT.sub("", spec).split()) + words = len(strip_comments(spec).split()) if words > SPEC_MAX_WORDS: return errors, [f"spec.md has {words} words (SPEC_MAX_WORDS {SPEC_MAX_WORDS}), and every later launch reads it: " "keep one scenario per behavior a user can tell apart and only the edge cases that change " diff --git a/.squad/tools/sonar-check.py b/.squad/tools/sonar-check.py index cb7fe4d..df4623f 100644 --- a/.squad/tools/sonar-check.py +++ b/.squad/tools/sonar-check.py @@ -304,16 +304,15 @@ def branch_preconditions(): def findings_on_branch(key, branch, token): - """Open issues and hotspots of the analysed branch on the lines the branch changed.""" + """Open issues and hotspots of the analysed branch on the lines the branch changed. The issues are read for + the whole project and filtered by path here: a `componentKeys` list mixing source files (`FIL`) and test + files (`UTS`) is refused with HTTP 400.""" lines = changed_line_map(merge_base_or_raise()) paths = sorted(lines) - found = [] + issues = paged("issues/search", {"componentKeys": key, "branch": branch, "resolved": "false"}, "issues", token) + found = [finding_line(i, key, "issue") for i in issues if on_changed_line(i, key, lines)] for start in range(0, len(paths), FILES_PER_QUERY): chunk = paths[start:start + FILES_PER_QUERY] - components = ",".join(f"{key}:{path}" for path in chunk) - issues = paged("issues/search", {"componentKeys": components, "branch": branch, "resolved": "false"}, - "issues", token) - found += [finding_line(i, key, "issue") for i in issues if on_changed_line(i, key, lines)] hotspots = paged("hotspots/search", {"projectKey": key, "branch": branch, "files": ",".join(chunk), "status": "TO_REVIEW"}, "hotspots", token) found += [finding_line(h, key, "hotspot") for h in hotspots if on_changed_line(h, key, lines)] @@ -339,7 +338,14 @@ def check_branch(key, command, token): result = wait_for_task(task, token) if result["status"] != "SUCCESS": raise NotChecked(f"SonarQube processed the report with status {result['status']}") - return report(findings_on_branch(key, result.get("branch") or branch, token)) + try: + findings = findings_on_branch(key, result.get("branch") or branch, token) + except ApiError as error: + if "non main branch" not in str(error): + raise + raise NotChecked("the organization cannot read data of non-main branches through the API; read the " + "analysis on the draft pull request with `sonar-check.py pr ` in step 11") from error + return report(findings) def parse(argv): diff --git a/AGENTS.md b/AGENTS.md index e695f38..5645015 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,8 +82,8 @@ Run from the repository root, where the solution file lives, in this order: *Res *Test*, *Analyzer gate*, and after *Test with coverage* the *Coverage gate*. Use the commands of those names in `.squad/stack.md` as they stand, never a copy from memory: they carry the solution name, the build configuration the project tests in, and the formatter's `--force` (`reihitsu-format` asks for confirmation -for more than 25 files and, without a terminal, formats nothing). The formatter is installed with -`dotnet tool install -g Reihitsu.Cli`. +for more than 25 files and, without a terminal, formats nothing). The formatter is restored from the repository's tool +manifest (`.config/dotnet-tools.json`) with `dotnet tool restore` and run as `dotnet tool run reihitsu-format`. All commands, with what each one checks, are listed in [`.squad/stack.md`](/.squad/stack.md). diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index b0e65f6..02b4c5f 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -83,7 +83,7 @@ When a PR is related to an issue, use the `Closes #issuenumber` syntax so the is PR automatically and closes when the PR is merged. The PR's label decides its section in the generated release notes (`.github/release.yml`): `breaking-change`, -`enhancement`, `security`, `bug`; any other PR is listed under *Other changes*, and `ignore-for-release` leaves it out. +`enhancement` or `feature`, `security`, `bug`; any other PR is listed under *Other changes*, and `ignore-for-release` leaves it out. Follow the PR template in [`.github/pull_request_template.md`](../.github/pull_request_template.md). @@ -104,7 +104,8 @@ is agreed as acceptance criteria before it is built, and the maintainers own tho on the first two. Open an issue instead. - **Checks on a fork:** pull requests from forks run without repository secrets, so the analysis that needs one (e.g. SonarQube Cloud) does not run there; a maintainer runs it before merging. Run the quality gates below - locally so the pull request arrives clean. + locally so the pull request arrives clean. The CI run of a first-time contributor's fork waits for a maintainer's + approval before it starts. - Maintainers may push to your branch ("Allow edits by maintainers") or carry the change on in a branch of their own; you stay credited as co-author of the squashed commit.