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
14 changes: 13 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,15 @@ jobs:
run: dotnet build dirs.proj -c Release

- name: Test
run: dotnet test dirs.proj -c Release --no-build
run: >
dotnet test dirs.proj -c Release --no-build
--collect:"XPlat Code Coverage" --results-directory TestResults/coverage

# Coverage is a ratchet, not a target: the floors in build/coverage-floors.json sit just under
# the numbers at the time they were recorded, so an unrelated change cannot trip them while a
# real regression still fails the build. Raise a floor when coverage improves.
- name: Coverage floors
run: python3 build/coverage-summary.py --results TestResults/coverage

studio:
name: Studio build & test
Expand All @@ -52,6 +60,10 @@ jobs:
- name: Test (type-check + Vitest)
run: npm test

# Thresholds live in vite.config.ts alongside the rest of the Vitest setup.
- name: Coverage floors
run: npm run coverage

- name: Build (production bundle)
run: npm run build

Expand Down
54 changes: 54 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,57 @@ jobs:
# Publish last: this is when GitHub freezes the release, with every asset attached.
gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \
--draft=false --prerelease="$pre" --latest="$latest"

# release-please only ever cuts the exact tag (v0.7.1), but the action is documented as
# `uses: hacks4snacks/tmforge@v0.7` (action.yml, docs/deployment.md, docs/analysis-rules.md),
# so the floating refs have to be maintained here or the documented usage resolves to nothing.
# Skipped for prereleases, and a tag only moves when this release is the newest in its own
# series, so re-releasing an older patch line cannot drag a floating tag backwards.
- name: Update floating major and major.minor tags
if: steps.meta.outputs.prerelease != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
major="v${version%%.*}"
rest="${version#*.}"
minor="$major.${rest%%.*}"

# Resolve through the tag object so an annotated tag still yields the commit.
target="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_REF_NAME" --jq .sha)"

# Every released version tag, oldest first. The three-component filter drops the
# floating tags themselves and every prerelease tag.
released="$(gh api --paginate "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \
--jq '.[].ref' \
| sed 's:^refs/tags/::' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V)"

move() {
alias="$1"
newest=""
while read -r tag; do
case "$tag" in "$alias".*) newest="$tag" ;; esac
done <<< "$released"

if [ "$newest" != "$GITHUB_REF_NAME" ]; then
echo "Leaving $alias where it is: $newest is newer than $GITHUB_REF_NAME."
return
fi

if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$alias" >/dev/null 2>&1; then
gh api --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/$alias" \
-f "sha=$target" -F force=true >/dev/null
echo "Moved $alias to $target ($GITHUB_REF_NAME)."
else
gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
-f "ref=refs/tags/$alias" -f "sha=$target" >/dev/null
echo "Created $alias at $target ($GITHUB_REF_NAME)."
fi
}

move "$major"
move "$minor"
4 changes: 4 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
<PackageVersion Include="StyleCop.Analyzers" Version="1.1.118" />
<PackageVersion Include="System.Text.Json" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<!-- Test-only: hosts the /v1 API in memory so the endpoints are exercised over real HTTP
(routing, status codes, content types, model binding) without binding a port. Version
tracks the ASP.NET Core 10 packages above. Never referenced by a shipped artifact. -->
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageVersion Include="MSTest.TestAdapter" Version="4.2.3" />
<PackageVersion Include="MSTest.TestFramework" Version="4.2.3" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
Expand Down
13 changes: 12 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ version: ## Print the resolved project version
# ===========================================================================
# Build & test
# ===========================================================================
.PHONY: restore build test test-studio format format-check
.PHONY: restore build test test-studio format format-check coverage coverage-accept
restore: ## Restore NuGet packages
$(DOTNET) restore $(TRAVERSAL)

Expand All @@ -91,6 +91,17 @@ test: ## Run the .NET test suite
test-studio: ## Run the Studio type-check + Vitest suite
cd $(STUDIO_DIR) && $(NPM) ci && $(NPM) test

coverage: ## Measure coverage on both sides and enforce the committed floors
$(DOTNET) test $(TRAVERSAL) -c $(CONFIG) \
--collect:"XPlat Code Coverage" --results-directory TestResults/coverage
python3 build/coverage-summary.py --results TestResults/coverage
cd $(STUDIO_DIR) && $(NPM) run coverage

coverage-accept: ## Rewrite the .NET floors from the current run (after a deliberate improvement)
$(DOTNET) test $(TRAVERSAL) -c $(CONFIG) \
--collect:"XPlat Code Coverage" --results-directory TestResults/coverage
python3 build/coverage-summary.py --results TestResults/coverage --update-floors

format: ## Apply `dotnet format` (whitespace, style, analyzers)
$(DOTNET) format $(SOLUTION)

Expand Down
4 changes: 2 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: hacks4snacks/tmforge@v0.3 # pin the action ref
# - uses: hacks4snacks/tmforge@v0.7 # pin the action ref
# with:
# version: '0.3' # pin the engine image tag too
# version: '0.7' # pin the engine image tag too
# models: '**/*.tm7'
# max-severity: warning
#
Expand Down
16 changes: 16 additions & 0 deletions build/coverage-floors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"_comment": "Minimum line coverage per project, recorded from a RELEASE run \u2014 the configuration CI enforces. Release reports far fewer coverable lines than Debug (optimized-away sequence points), so its percentages read several points higher; regenerating these from a Debug run would leave the ratchet slack. Raise a floor as coverage improves; never lower one without saying why in the pull request. Regenerate with `make coverage-accept`.",
"total": 86.5,
"projects": {
"ThreatModelForge.Analysis": 90.8,
"ThreatModelForge.Analysis.Reporting": 97.9,
"ThreatModelForge.Analysis.Rules": 96.3,
"ThreatModelForge.Api": 98.9,
"ThreatModelForge.Cli": 69.5,
"ThreatModelForge.Core": 95.8,
"ThreatModelForge.Editing": 88.6,
"ThreatModelForge.Engine": 89.9,
"ThreatModelForge.Formats": 93.2,
"ThreatModelForge.Reporting": 92.7
}
}
151 changes: 151 additions & 0 deletions build/coverage-summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Summarize .NET code coverage and enforce the committed floors.

Reads every `coverage.cobertura.xml` produced by
`dotnet test --collect:"XPlat Code Coverage"`, prints a per-project table, and fails when a project
has slipped below the minimum recorded in `build/coverage-floors.json`.

Two details handled here:

* Each report's `filename` values are relative to *that report's* own `<source>` root, and the root
differs between reports. Joining against the wrong root silently invents projects and misattributes
files, which makes the whole table quietly wrong rather than obviously broken.
* Generated sources (anything under `obj/`, `*.Designer.cs`, `*.generated.cs`) are excluded. The API
project alone carries ~1,850 lines of generated OpenAPI support, which would otherwise dominate its
score and mask the hand-written code the floors exist to protect.
"""
from __future__ import annotations

import argparse
import glob
import json
import os
import sys
import xml.etree.ElementTree as ET
from collections import defaultdict

GENERATED_MARKERS = ("/obj/", ".generated.cs", ".g.cs", ".Designer.cs")


def is_generated(path: str) -> bool:
return any(marker in path for marker in GENERATED_MARKERS)


def collect(results_dir: str, repo_root: str) -> dict[str, dict[int, int]]:
"""Returns {repo-relative file: {line number: hits}}, unioned across every report."""
files: dict[str, dict[int, int]] = defaultdict(dict)
reports = glob.glob(os.path.join(results_dir, "**", "coverage.cobertura.xml"), recursive=True)
if not reports:
raise SystemExit(f"No coverage reports under {results_dir}. Did the test run collect coverage?")

for report in reports:
root = ET.parse(report).getroot()
sources = [s.text for s in root.iter("source") if s.text]
base = sources[0] if sources else repo_root
for cls in root.iter("class"):
raw = (cls.get("filename") or "").replace("\\", "/")
if not raw:
continue
absolute = raw if raw.startswith("/") else os.path.normpath(os.path.join(base, raw))
relative = os.path.relpath(absolute, repo_root)
for line in cls.iter("line"):
number = int(line.get("number"))
hits = int(line.get("hits"))
files[relative][number] = max(files[relative].get(number, 0), hits)
return files


def project_of(path: str) -> str:
parts = path.split("/")
return parts[1] if len(parts) > 1 and parts[0] == "src" else parts[0]


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--results", default="TestResults/coverage", help="where the reports were written")
parser.add_argument("--floors", default="build/coverage-floors.json", help="committed minimums")
parser.add_argument("--repo-root", default=".", help="repository root the reports are relative to")
parser.add_argument("--update-floors", action="store_true", help="rewrite the floors to today's numbers")
args = parser.parse_args()

repo_root = os.path.abspath(args.repo_root)
files = collect(os.path.abspath(args.results), repo_root)

totals: dict[str, list[int]] = defaultdict(lambda: [0, 0])
for path, lines in files.items():
if path.startswith("test/") or is_generated(path):
continue
covered = sum(1 for hits in lines.values() if hits > 0)
totals[project_of(path)][0] += covered
totals[project_of(path)][1] += len(lines)

if not totals:
raise SystemExit("No hand-written source found in the coverage reports.")

percentages = {name: 100.0 * c / t for name, (c, t) in totals.items() if t}
grand_covered = sum(c for c, _ in totals.values())
grand_total = sum(t for _, t in totals.values())
grand = 100.0 * grand_covered / grand_total

floors_path = os.path.join(repo_root, args.floors)
if args.update_floors:
payload = {
"_comment": "Minimum line coverage per project, recorded from a RELEASE run — the "
"configuration CI enforces. Release reports far fewer coverable lines than "
"Debug (optimized-away sequence points), so its percentages read several "
"points higher; regenerating these from a Debug run would leave the ratchet "
"slack. Raise a floor as coverage improves; never lower one without saying "
"why in the pull request. Regenerate with `make coverage-accept`.",
"total": round(grand - 0.5, 1),
"projects": {name: round(value - 0.5, 1) for name, value in sorted(percentages.items())},
}
with open(floors_path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2)
handle.write("\n")
print(f"Wrote {args.floors} from the current run.")
return 0

with open(floors_path, encoding="utf-8") as handle:
floors = json.load(handle)

print(f"{'PROJECT':<40} {'LINES':>13} {'LINE%':>7} {'FLOOR':>7} ")
print("-" * 74)
failures: list[str] = []
for name in sorted(percentages, key=lambda key: percentages[key]):
covered, total = totals[name]
floor = floors.get("projects", {}).get(name)
actual = percentages[name]
marker = ""
if floor is not None and actual + 1e-9 < floor:
marker = " BELOW FLOOR"
failures.append(f"{name}: {actual:.1f}% is under its {floor:.1f}% floor")
print(f"{name:<40} {covered:>5}/{total:<7} {actual:>7.1f} {floor if floor is not None else '-':>7}{marker}")

print("-" * 74)
total_floor = floors.get("total")
print(f"{'TOTAL':<40} {grand_covered:>5}/{grand_total:<7} {grand:>7.1f} "
f"{total_floor if total_floor is not None else '-':>7}")
if total_floor is not None and grand + 1e-9 < total_floor:
failures.append(f"total: {grand:.1f}% is under the {total_floor:.1f}% floor")

# A floor is only enforced for a project that actually reported coverage, so a project whose tests
# stopped running would drop out of the table and take its floor with it — the run would go green
# on the very regression the floors exist to catch. Treat a floor with no measurement as a failure.
for name in sorted(floors.get("projects", {})):
if name not in percentages:
failures.append(
f"{name}: has a floor but produced no coverage — its tests did not run, "
f"or the project was renamed or removed")

if failures:
print()
for failure in failures:
print(f"::error::Coverage regressed — {failure}")
return 1

print("\nEvery project is at or above its floor.")
return 0


if __name__ == "__main__":
sys.exit(main())
8 changes: 4 additions & 4 deletions docs/analysis-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -650,9 +650,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hacks4snacks/tmforge@v0.3
- uses: hacks4snacks/tmforge@v0.7
with:
version: "0.3" # pin the engine image, not just the action
version: "0.7" # pin the engine image, not just the action
models: "**/*.tm7"
rules: rules/corporate.tmrules.json
suppression-file: .tmforge/suppressions.json
Expand Down Expand Up @@ -690,7 +690,7 @@ system. Drift detection covers that gap: it reports a change that touched archit
without touching a threat model.

```yaml
- uses: hacks4snacks/tmforge@v0.3
- uses: hacks4snacks/tmforge@v0.7
with:
drift: notice # 'off', 'notice' (default), or 'fail'
drift-watched-paths: |
Expand Down Expand Up @@ -756,7 +756,7 @@ Drift asks whether the model was updated. Review shows **how** it changed, so a
have to read a diff of serialized XML:

```yaml
- uses: hacks4snacks/tmforge@v0.3
- uses: hacks4snacks/tmforge@v0.7
with:
review: "on"
review-comment: "true" # optional; needs pull-requests: write
Expand Down
26 changes: 26 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,32 @@ and `/openapi` are matched first.
`<format>` is one of `tm7`, `tmforge-json`, `drawio`, or `vsdx`. See
[Formats & interoperability](formats.md).

## Errors

Failures are answered as [RFC 9457 problem documents](https://www.rfc-editor.org/rfc/rfc9457)
(`application/problem+json`), and the status separates what you can fix from what you cannot:

| Status | Meaning | Examples |
| --- | --- | --- |
| `400` | The request was unusable as sent. Retrying it unchanged cannot help. | Body that is not JSON; a missing `?to=`; an unregistered format id; content that is not valid base64; uploaded bytes that are not the format they claim to be. |
| `404` | The path is not part of the `/v1` API, or the content was not recognized. | A mistyped endpoint; `POST /v1/detect` on bytes matching no known format. |
| `500` | The server failed. This one is worth reporting. | Anything unexpected — the classification above is deliberately narrow, so a real fault is never disguised as your mistake. |

The `detail` of a `400` names what was unusable, so the request can be corrected without guesswork:

```json
{
"status": 400,
"title": "The request could not be processed as sent.",
"detail": "No threat model format with id 'nonsense'.",
"instance": "/v1/model/convert"
}
```

An unmatched path under `/v1` answers `404` as the API rather than falling through to the Studio's
HTML shell, so a mistyped endpoint fails as a client error instead of returning a page a JSON client
cannot parse. Paths outside `/v1` still reach the SPA, which is what makes client-side routing work.

## One analysis action

Findings and threats are the same detection: a threat is a finding from a rule that declares a threat
Expand Down
4 changes: 2 additions & 2 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,9 @@ permissions:

steps:
- uses: actions/checkout@v4
- uses: hacks4snacks/tmforge@v0.3
- uses: hacks4snacks/tmforge@v0.7
with:
version: "0.3" # pin the engine image, not just the action ref
version: "0.7" # pin the engine image, not just the action ref
models: "**/*.tm7"
max-severity: warning
```
Expand Down
Loading
Loading