diff --git a/.fm-outward-allow b/.fm-outward-allow new file mode 100644 index 0000000000..9926f64d32 --- /dev/null +++ b/.fm-outward-allow @@ -0,0 +1,13 @@ +# Identifiers that bin/fm-outward-text-check.sh would otherwise report, kept +# here because a reader of this repository really can resolve them. +# One token per line; `#` starts a comment. See docs/outward-facing-text.md. +# +# Adding a line is the reviewable act that justifies a new outward reference, +# and it settles REVIEWABLE findings only: an unresolvable object id or a URL +# naming another repository. +# +# A machine-local path, a private task id, or another project's name is +# blocking and can never be settled here. Recording one would publish, in a +# tracked file, the exact identifier the check exists to keep unpublished, so +# the check reports such an entry alongside the finding instead of honoring it. +# Those belong out of the published text, not in this file. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5495ec4494..4f3c43561c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -372,7 +372,12 @@ jobs: name: Repo invariants runs-on: ubuntu-latest steps: + # Full history: the outward-text check decides whether an id resolves in + # THIS repository, so a shallow clone would report the repo's own commits + # as foreign. - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Symlinks must stay intact run: | set -eu @@ -387,3 +392,12 @@ jobs: printf '%s\n' "$tracked" exit 1 fi + # Backstop for the prose and the commit messages this branch publishes into + # the repository. This step runs without --home, so a machine-local path is + # the only blocking category that can fail it; private fleet identifiers + # (a foreign task id, another project's name) are caught at the worker's + # pre-publication check, which runs where a firstmate home exists. + # Unresolvable ids are printed for review because a named upstream can make + # one legitimate, and .fm-outward-allow settles those once. + - name: Added prose and commit messages must not carry foreign identifiers + run: bin/fm-outward-text-check.sh --diff --block-only diff --git a/AGENTS.md b/AGENTS.md index bd40813bf7..8b8734b7c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -498,6 +498,8 @@ Preserve durable structured identifiers, dependencies, and completion artifact l `bin/fm-brief.sh` and its help own scaffold syntax, generated variants, status protocol, delivery-mode definitions of done, and exact safety mechanics. Use its scaffold as the contract, then replace every `{TASK}` placeholder with a clear task description, acceptance criteria, constraints, and necessary context before dispatch or seeding. Keep additions task-specific rather than repeating lifecycle instructions, and alter generated sections only when the task genuinely differs from the standard shape. +A ship brief's `# Task` section is the source of the published PR description, so scope every identifier in it to the repository under change and keep another repository's commit ids, another task's name, and machine-local paths out of it, while keeping the accepted requirements whole. +[`docs/outward-facing-text.md`](docs/outward-facing-text.md) owns that contract, and the generated brief carries the worker's own pre-publication check. Every ship brief must retain the worktree-isolation assertion and stop if launched in the primary checkout. If a ship task touches firstmate's shared tracked material, explicitly require `firstmate-coding-guidelines` before editing. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8fa1f30c56..be152324ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,8 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star - Harness-adapter ownership spans detection in `bin/fm-harness.sh`, launch and hook mechanics in `bin/fm-spawn.sh`, semantic busy sources and trust gates in `bin/fm-busy-lib.sh`, delivery-only rendered guards in `bin/fm-composer-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in `.agents/skills/harness-adapters/SKILL.md`; the `firstmate-coding-guidelines` skill owns the validation policy for checks that depend on those harnesses. - Changes to runtime session backends (`bin/fm-backend.sh`, `bin/backends/`, and the scripts that dispatch through them) keep current setup and limits in the relevant backend guide and active empirical evidence in [`docs/verification/runtime-backends.md`](docs/verification/runtime-backends.md). - [`docs/documentation-audiences.md`](docs/documentation-audiences.md) and its machine-consumed inventory own prose classification; run `bin/fm-doc-audience-check.sh` after documentation changes. +- [`docs/outward-facing-text.md`](docs/outward-facing-text.md) owns what may appear in text this repository publishes and never retracts - a PR title, a PR description, a commit message, tracked prose. + Every identifier must be resolvable by a reader holding this repository alone; run `bin/fm-outward-text-check.sh --diff` before pushing, and record a genuinely resolvable upstream reference in `.fm-outward-allow` in the same change. - In Markdown, put each full sentence on its own line. - `README.md` stays a concise overview plus pointers: it never carries a wall of inline detail. Route detail to the most specific `docs/` file (architecture, configuration, or a backend guide) and link to it instead. diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index a873c84051..53a75d6bb1 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -41,6 +41,11 @@ # to launch a ship task whose explicit --mode disagrees, so an adjusted brief and the # recorded task metadata cannot drift apart. # Ship briefs begin with a worktree-isolation assertion before the branch step. +# The no-mistakes and direct-PR briefs, the two that publish text outward, require +# the worker to scan the run intent or the PR body with bin/fm-outward-text-check.sh +# before publishing it. The local-only brief carries no such step because it +# publishes nothing outward. +# docs/outward-facing-text.md owns what that scan enforces. # --mode is refused on scout and secondmate scaffolds: a scout's deliverable is a # report rather than a merge, and a charter is not a delivery contract. # There is no --yolo flag here. The worker never owns approval decisions, so yolo is @@ -177,6 +182,10 @@ shell_quote() { } STATUS_FILE=$(shell_quote "$STATE/$ID.status") +# The pre-publication scan for the two ship modes that publish text outward. +# --task names this task so its own id, already public through the branch name, +# is not reported back as foreign. +OUTWARD_CHECK="$FM_ROOT/bin/fm-outward-text-check.sh --home $(shell_quote "$FM_HOME") --task $ID" if [ "$KIND" = secondmate ]; then SECONDMATE_PROJECTS="" @@ -362,6 +371,10 @@ This task ships **direct-PR**: you raise the PR yourself, without the no-mistake The task is complete only when committed on your branch. When it is implemented and committed, push your branch and open a PR with \`gh-axi\`, then append \`done: PR {url}\` to the status file and stop. Do NOT run /no-mistakes. The configured merge authority decides whether to merge the PR; firstmate relays the outcome. + +Before you open the PR, write its title and description to a file and check that file with \`$OUTWARD_CHECK \`. +A published PR description stays readable even after the PR is closed, so it may carry only what a reader holding this repository alone could resolve. +Clear every finding by removing the identifier, never by dropping accepted requirements - the requirements are exactly what the description is for. EOF ;; local-only) @@ -391,6 +404,8 @@ Firstmate will then instruct you to run /no-mistakes to validate and ship a PR. You drive no-mistakes by responding to its gates, not by implementing fixes. Follow the guidance no-mistakes itself provides for the mechanics: it loads when you invoke /no-mistakes, and \`no-mistakes axi run --help\` plus the \`help\` lines in each \`axi\` response are authoritative and version-matched to the installed binary. When starting no-mistakes, make \`--intent\` preserve all relevant content from this brief's \`# Task\` section plus every later accepted Firstmate requirement, clarification, constraint, exclusion, and supersession, carrying only each requirement's current accepted form; retain direct requirements instead of substituting a diff summary, and exclude generic operational, status, delivery, and other scaffold boilerplate unless it is task-specific. +no-mistakes publishes that intent as the PR description, and a published description stays readable even after the PR is closed, so write the intent to a file and check that file with \`$OUTWARD_CHECK \` BEFORE you start the run. +The intent may carry only what a reader holding this repository alone could resolve; clear every finding by removing the identifier, never by dropping accepted requirements, which are exactly what the intent is for. Do not hand-edit, commit, or fix findings yourself while a run is active - the pipeline applies every fix. Two firstmate-specific rules layer on top of that guidance: diff --git a/bin/fm-outward-text-check.sh b/bin/fm-outward-text-check.sh new file mode 100755 index 0000000000..831204a310 --- /dev/null +++ b/bin/fm-outward-text-check.sh @@ -0,0 +1,554 @@ +#!/usr/bin/env bash +# fm-outward-text-check.sh - find identifiers that do not belong to the +# repository under change in text that is about to be published outward. +# +# Outward-facing published text is anything a reader of the destination +# repository keeps: a PR title, a PR description (which `no-mistakes axi run` +# composes from `--intent`), a commit message, and tracked repository content. +# Publication is not retractable - closing a PR leaves its description readable - +# so the scan belongs before the text is published, not after. +# +# The rule this check enforces is a scoping rule, never a length rule: an +# identifier may appear only when a reader holding just the repository under +# change can resolve it. The accepted requirements themselves are exactly what +# such text is for, so nothing here asks for shorter text. +# docs/outward-facing-text.md owns the contract and the categories' rationale. +# +# Usage: +# bin/fm-outward-text-check.sh [options] ... # scan files ("-" is stdin) +# bin/fm-outward-text-check.sh [options] --diff # scan the prose lines and +# # the commit messages this +# # branch adds +# +# Options: +# --repo repository under change (default: the git toplevel of the cwd) +# --home firstmate home whose OTHER task and project names are foreign +# here (default: $FM_HOME; without one, those two categories +# are skipped and the skip is reported, never silent) +# --task the task under change; its own id is already outward-facing +# through the branch name, so it is not reported +# --base with --diff, the ref to compare against (default: the first +# resolvable of origin/HEAD, origin/main, origin/master, main, master) +# --include with --diff, a pathspec to scan instead of the prose default +# (repeatable; default: *.md *.mdx *.rst *.txt docs/examples/*) +# --allow exempt one exact literal reviewable token, repeatable, for +# an identifier that is genuinely resolvable to this audience +# --block-only exit non-zero for blocking findings only, still printing +# the reviewable ones; for an unattended gate +# --json machine-readable findings +# +# An identifier a reader really can resolve - an upstream vendor's release id or +# a named upstream project's commit in a verification record - is exempted by +# listing it in the repository's tracked `.fm-outward-allow` file, one token per +# line with `#` comments. That keeps the exception explicit and reviewable in the +# same change that introduces the reference, instead of silent. +# +# No exemption, from that file or from --allow, can settle a BLOCKING finding. +# Writing a machine-local path or a private fleet name into a tracked file +# publishes the exact value this check exists to keep unpublished, so such an +# entry is reported with the finding rather than honored. +# +# Exit status: 0 clean, 1 findings reported, 2 usage or environment error. +# +# Categories, all decided against the repository under change rather than by +# keyword heuristics, so legitimate prose about dates, versions, commands, and +# relative paths is never matched. Severity is what an unattended gate can +# safely act on alone, not how bad a leak is: +# +# blocking - nothing outside this machine or this fleet can ever resolve these, +# so no published text has a legitimate use for them: +# machine-local-path an absolute path under a user home or a per-run temp root +# foreign-task-id a task id from the firstmate home other than --task +# foreign-project a project name from the firstmate home other than this repo +# +# reviewable - a reader can resolve these when the text names the upstream they +# come from, which no check can confirm, so they are reported for judgment and +# are the only findings `.fm-outward-allow` can settle: +# foreign-object a hex object id that does not resolve in this repository +# foreign-repo-url a forge URL naming a repository other than this origin +# +# Known bounds, deliberate so the check stays high-precision: +# - A hex token is treated as an identifier only when it mixes digits and +# letters, or is at least 32 characters. A short abbreviation drawn entirely +# from [a-f] or entirely from [0-9] reads as a word or a number here. +# - --diff scans committed prose and the branch's commit messages only. +# Functional hashes in code (a pinned download checksum, a fixture digest) +# are legitimate and out of scope. +set -eu + +usage() { + awk ' + NR == 1 { next } + /^#/ { sub(/^# ?/, ""); print; next } + { exit } + ' "$0" +} + +case "${1:-}" in + -h|--help) usage; exit 0 ;; +esac + +# The implementation below is fed to python3 on stdin, so a "-" input has to be +# captured here, before that redirect, or the scan would silently read the +# program text instead of the caller's piped text. +STDIN_FILE= +# shellcheck disable=SC2329 # Invoked indirectly by the traps below. +cleanup() { + if [ -n "$STDIN_FILE" ]; then + rm -f "$STDIN_FILE" + fi + return 0 +} +trap cleanup EXIT INT TERM +for arg in "$@"; do + if [ "$arg" = "-" ]; then + STDIN_FILE=$(mktemp "${TMPDIR:-/tmp}/fm-outward-stdin.XXXXXX") || exit 2 + cat > "$STDIN_FILE" + break + fi +done + +STATUS=0 +python3 - --stdin-file "$STDIN_FILE" "$@" <<'PY' || STATUS=$? +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +PROSE_PATHSPEC = ["*.md", "*.mdx", "*.rst", "*.txt", "docs/examples/*"] +BASE_CANDIDATES = ["origin/HEAD", "origin/main", "origin/master", "main", "master"] +ALLOW_FILE = ".fm-outward-allow" +BLOCKING = {"machine-local-path", "foreign-task-id", "foreign-project"} + +# A hex run bounded by non-identifier characters. "0xdeadbeef" and "v1.2.3" do +# not match because the preceding character is part of the same word. The run +# has no upper bound, so a 64-character digest is one token rather than a span +# too long for any window to cover. +HEX_RE = re.compile(r"(? +# is deliberately absent: it identifies nobody, while a per-run temp root such +# as /var/folders/... or a clone id under it does. +MACHINE_LOCAL_RE = re.compile( + r"(?,;:]+)" +) +MIN_PRIVATE_NAME = 4 +MIN_PROJECT_NAME = 6 + + +class CheckError(Exception): + """One environment or usage failure that stops the scan.""" + + +def git(repo: Path, *args: str) -> tuple[int, str]: + proc = subprocess.run( + ["git", "-C", str(repo), *args], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + return proc.returncode, proc.stdout.decode("utf-8", "replace") + + +def resolve_repo(raw: str | None) -> Path: + start = Path(raw).expanduser() if raw else Path.cwd() + if not start.is_dir(): + raise CheckError(f"repository under change is not a directory: {start}") + code, out = git(start, "rev-parse", "--show-toplevel") + if code != 0 or not out.strip(): + raise CheckError(f"not inside a git repository: {start}") + return Path(out.strip()) + + +def origin_slug(repo: Path) -> tuple[str, str] | None: + code, out = git(repo, "remote", "get-url", "origin") + if code != 0 or not out.strip(): + return None + url = out.strip() + url = url[:-4] if url.endswith(".git") else url + match = re.search(r"[:/]([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)$", url) + if not match: + return None + return match.group(1), match.group(2) + + +class ObjectResolver: + """Answers 'does this hex id name an object in the repository under change'.""" + + def __init__(self, repo: Path) -> None: + self.repo = repo + self.cache: dict[str, bool] = {} + + def resolves(self, token: str) -> bool: + cached = self.cache.get(token) + if cached is not None: + return cached + code, _ = git(self.repo, "rev-parse", "--verify", "--quiet", f"{token}^{{object}}") + found = code == 0 + if not found: + # An ambiguous prefix fails --verify precisely because several local + # objects share it, which still makes the id resolvable here. + # --disambiguate takes its prefix as `=`; passing it as a + # separate word makes git read the token as a revision instead and + # accept any well-formed full object name, resolvable or not. + _, out = git(self.repo, "rev-parse", f"--disambiguate={token.lower()}") + found = bool(out.strip()) + self.cache[token] = found + return found + + +def read_home(raw: str | None) -> Path | None: + if not raw: + return None + home = Path(raw).expanduser() + if not home.is_dir(): + raise CheckError(f"firstmate home is not a directory: {home}") + return home + + +def home_task_ids(home: Path, current: str | None) -> list[str]: + ids: set[str] = set() + data = home / "data" + if data.is_dir(): + ids.update(p.name for p in data.iterdir() if p.is_dir() and (p / "brief.md").is_file()) + state = home / "state" + if state.is_dir(): + ids.update(p.name[: -len(".meta")] for p in state.glob("*.meta")) + ids.discard(current or "") + return sorted(i for i in ids if len(i) >= MIN_PRIVATE_NAME) + + +def home_projects(home: Path, repo: Path, slug: tuple[str, str] | None) -> list[str]: + projects = home / "projects" + if not projects.is_dir(): + return [] + own = {repo.name} + if slug: + own.add(slug[1]) + names = {p.name for p in projects.iterdir() if p.is_dir()} - own + return sorted(n for n in names if len(n) >= MIN_PROJECT_NAME) + + +def read_allow_file(repo: Path) -> set[str]: + path = repo / ALLOW_FILE + if not path.is_file(): + return set() + try: + body = path.read_text(encoding="utf-8") + except OSError as exc: + raise CheckError(f"cannot read {ALLOW_FILE}: {exc}") from exc + tokens = set() + for line in body.splitlines(): + token = line.split("#", 1)[0].strip() + if token: + tokens.add(token) + return tokens + + +def literal_re(name: str) -> re.Pattern[str]: + return re.compile(rf"(? str: + return f"{number} {noun}" + ("" if number == 1 else "s") + + +def looks_like_an_id(token: str) -> bool: + if len(token) >= 32: + return True + lowered = token.lower() + return any(c.isdigit() for c in lowered) and any(c in "abcdef" for c in lowered) + + +class Scanner: + def __init__(self, args, repo: Path, home: Path | None) -> None: + self.repo = repo + self.allow = set(args.allow or []) | read_allow_file(repo) + self.seen: set[tuple[str, str, str]] = set() + self.objects = ObjectResolver(repo) + self.slug = origin_slug(repo) + self.task = args.task + self.checked = ["foreign-object", "machine-local-path"] + self.skipped: list[str] = [] + self.findings: list[dict] = [] + + if self.slug: + self.checked.append("foreign-repo-url") + else: + self.skipped.append("foreign-repo-url (repository under change has no origin remote)") + + if home: + self.task_ids = [(i, literal_re(i)) for i in home_task_ids(home, self.task)] + self.projects = [(n, literal_re(n)) for n in home_projects(home, repo, self.slug)] + self.checked += ["foreign-task-id", "foreign-project"] + else: + self.task_ids = [] + self.projects = [] + self.skipped.append("foreign-task-id, foreign-project (no firstmate home; pass --home)") + + def record(self, category: str, value: str, where: str, why: str) -> None: + # Severity is decided before the exemption is consulted, because an + # exemption may settle a reviewable finding and must never settle a + # blocking one: recording such a value in a tracked file publishes the + # identifier this check exists to keep out of published text. + severity = "blocking" if category in BLOCKING else "reviewable" + if value in self.allow: + if severity == "reviewable": + return + why = f"{why}; the exemption naming it cannot settle a blocking finding" + key = (category, value, where) + if key in self.seen: + return + self.seen.add(key) + self.findings.append( + { + "category": category, + "severity": severity, + "value": value, + "location": where, + "reason": why, + } + ) + + def scan_line(self, text: str, where: str) -> None: + for match in HEX_RE.finditer(text): + token = match.group(1) + if not looks_like_an_id(token) or self.objects.resolves(token): + continue + self.record( + "foreign-object", token, where, "does not name an object in the repository under change" + ) + for match in FORGE_URL_RE.finditer(text): + owner, name = match.group(2), match.group(3) + name = name[:-4] if name.endswith(".git") else name + if not self.slug or (owner.lower(), name.lower()) == ( + self.slug[0].lower(), + self.slug[1].lower(), + ): + continue + self.record("foreign-repo-url", f"{owner}/{name}", where, "names another repository") + for match in MACHINE_LOCAL_RE.finditer(text): + # A path at the end of a sentence keeps its own dots but not the + # sentence's, so the reported value stays copy-pasteable. + path = match.group(1).rstrip(".") + if "<" in path or ">" in path: + # An angle-bracket segment such as /Users//... is a + # documented placeholder, which names no machine and no person. + continue + self.record("machine-local-path", path, where, "absolute path on this machine only") + for name, pattern in self.task_ids: + if pattern.search(text): + self.record("foreign-task-id", name, where, "private task id from the firstmate home") + for name, pattern in self.projects: + if pattern.search(text): + self.record("foreign-project", name, where, "another project in the firstmate home") + + +def scan_files(scanner: Scanner, paths: list[str], stdin_file: str | None) -> int: + count = 0 + for raw in paths: + if raw == "-": + if not stdin_file: + raise CheckError("no piped text was captured for \"-\"") + label, path = "stdin", Path(stdin_file) + else: + label, path = raw, Path(raw) + try: + body = path.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + raise CheckError(f"cannot read {label}: {exc}") from exc + count += 1 + for number, line in enumerate(body.splitlines(), start=1): + scanner.scan_line(line, f"{label}:{number}") + return count + + +def resolve_base(repo: Path, requested: str | None) -> str: + candidates = [requested] if requested else BASE_CANDIDATES + for candidate in candidates: + code, out = git(repo, "rev-parse", "--verify", "--quiet", f"{candidate}^{{commit}}") + if code == 0 and out.strip(): + return out.strip() + if requested: + raise CheckError(f"--base does not resolve to a commit: {requested}") + raise CheckError( + "no default branch to compare against (tried " + ", ".join(BASE_CANDIDATES) + "); pass --base" + ) + + +HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") +# Field and record separators git will never emit inside a commit message. +COMMIT_FIELD = "\x1f" +COMMIT_END = "\x1e" + + +def scan_commit_messages(scanner: Scanner, repo: Path, resolved: str) -> int: + """Scans the messages of the commits this branch adds over the base. + + A merged commit message is as public and as permanent as a PR description, + so it carries the same contract as the prose the branch adds. + """ + code, out = git( + repo, + "log", + "--no-color", + "--reverse", + f"--format=%H{COMMIT_FIELD}%B{COMMIT_END}", + f"{resolved}..HEAD", + ) + if code != 0: + raise CheckError(f"could not list the commits HEAD adds over {resolved}") + count = 0 + for entry in out.split(COMMIT_END): + if COMMIT_FIELD not in entry: + continue + commit, body = entry.split(COMMIT_FIELD, 1) + commit = commit.strip() + if not commit: + continue + count += 1 + for number, line in enumerate(body.strip("\n").splitlines(), start=1): + scanner.scan_line(line, f"commit {commit[:12]}:{number}") + return count + + +def scan_diff( + scanner: Scanner, repo: Path, base: str | None, include: list[str] | None +) -> tuple[int, int]: + resolved = resolve_base(repo, base) + return ( + scan_added_prose(scanner, repo, resolved, include), + scan_commit_messages(scanner, repo, resolved), + ) + + +def scan_added_prose( + scanner: Scanner, repo: Path, resolved: str, include: list[str] | None +) -> int: + pathspec = include if include else PROSE_PATHSPEC + code, out = git( + repo, "diff", "--no-color", "--unified=0", f"{resolved}...HEAD", "--", *pathspec + ) + if code != 0: + raise CheckError(f"could not diff HEAD against {resolved}") + path = "" + line_number = 0 + files: set[str] = set() + for line in out.splitlines(): + if line.startswith("+++ "): + target = line[4:].strip() + path = "" if target == "/dev/null" else target[2:] if target.startswith("b/") else target + continue + hunk = HUNK_RE.match(line) + if hunk: + line_number = int(hunk.group(1)) + continue + if line.startswith("+") and not line.startswith("+++") and path: + files.add(path) + scanner.scan_line(line[1:], f"{path}:{line_number}") + line_number += 1 + return len(files) + + +def main() -> int: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--repo") + parser.add_argument("--home") + parser.add_argument("--task") + parser.add_argument("--base") + parser.add_argument("--include", action="append") + parser.add_argument("--allow", action="append") + parser.add_argument("--diff", action="store_true") + parser.add_argument("--block-only", dest="block_only", action="store_true") + parser.add_argument("--json", action="store_true") + parser.add_argument("--stdin-file", dest="stdin_file") + parser.add_argument("files", nargs="*") + args = parser.parse_args() + + if args.diff and args.files: + raise CheckError("--diff scans the branch diff and takes no file arguments") + if not args.diff and not args.files: + raise CheckError("pass one or more files, \"-\" for stdin, or --diff") + if args.include and not args.diff: + raise CheckError("--include applies to --diff only") + + repo = resolve_repo(args.repo) + home = read_home(args.home if args.home else None) + scanner = Scanner(args, repo, home) + + if args.diff: + files, commits = scan_diff(scanner, repo, args.base, args.include) + inputs = files + commits + scope = f"{counted(files, 'changed prose file')} and {counted(commits, 'commit message')}" + else: + inputs = scan_files(scanner, args.files, args.stdin_file) + scope = counted(inputs, "input") + + blocking = [f for f in scanner.findings if f["severity"] == "blocking"] + reviewable = [f for f in scanner.findings if f["severity"] == "reviewable"] + + if args.json: + print( + json.dumps( + { + "repo": str(repo), + "inputs": inputs, + "scope": scope, + "checked": scanner.checked, + "skipped": scanner.skipped, + "findings": scanner.findings, + }, + indent=2, + ) + ) + else: + for finding in scanner.findings: + print( + f"{finding['category'].upper().replace('-', '_')}: {finding['value']} " + f"({finding['location']}) - {finding['reason']}" + ) + for note in scanner.skipped: + print(f"SKIPPED: {note}") + if scanner.findings: + print( + f"outward-text-check: {len(scanner.findings)} finding(s), " + f"{len(blocking)} blocking in {scope}" + ) + if blocking: + print( + f" {len(blocking)} blocking: remove each one from the text before publishing; " + f"{ALLOW_FILE} cannot exempt a blocking identifier" + ) + if reviewable: + print( + f" {len(reviewable)} reviewable: remove each one from the text, " + f"or justify it in {ALLOW_FILE}" + ) + else: + print( + f"outward-text-check: clean ({scope}; checked {', '.join(scanner.checked)})" + ) + + if args.block_only: + return 1 if blocking else 0 + return 1 if scanner.findings else 0 + + +try: + sys.exit(main()) +except CheckError as exc: + print(f"outward-text-check: {exc}", file=sys.stderr) + sys.exit(2) +except BrokenPipeError: + sys.exit(2) +PY +exit "$STATUS" diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 4ca26c865e..ba62f1765d 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -140,7 +140,7 @@ family_for_basename() { fm-crew-state.test.sh|fm-decision-hold-lifecycle.test.sh|\ fm-documentation-audiences.test.sh|fm-ensure-agents-md.test.sh|fm-grok-harness.test.sh|\ fm-kimi-harness.test.sh|fm-muse-harness.test.sh|fm-herdr-lab.test.sh|fm-lint.test.sh|\ - fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\ + fm-operational-input.test.sh|fm-outward-text-check.test.sh|fm-pi-primary-types.test.sh|\ fm-send-popup-settle.test.sh|fm-send-settle.test.sh|\ fm-subagent-pretool-check.test.sh|\ fm-supervision-instructions.test.sh|fm-task-delivery.test.sh|\ @@ -960,7 +960,7 @@ families_for_changed_path() { # lane's contract coverage re-runs. printf '%s\n' real-herdr-gated ;; - bin/fm-lint.sh|bin/fm-install-shellcheck.sh|\ + bin/fm-lint.sh|bin/fm-install-shellcheck.sh|bin/fm-outward-text-check.sh|\ bin/fm-brief.sh|bin/fm-ensure-agents-md.sh|bin/fm-crew-state.sh|\ bin/fm-decision-hold.sh|bin/fm-supervision*|bin/fm-transition-lib.sh|\ bin/fm-tmux-lib.sh|bin/fm-marker-lib.sh|bin/fm-operational-input.sh|bin/fm-tasks-axi-lib.sh|\ diff --git a/docs/cd-guard.md b/docs/cd-guard.md index 94f9617953..c055b162a5 100644 --- a/docs/cd-guard.md +++ b/docs/cd-guard.md @@ -70,7 +70,7 @@ Every deny carries one stable code in square brackets before its prose reason. | `persistent-cd` | A top-level `cd`/`pushd`/`popd` would persistently change the primary shell's own working directory. | The reason directs the caller to reach the target without moving the shell by using `git -C `, placing an absolute path on the intended command itself, or scoping the `cd` to a subshell. -It does not permit `cd /home/project`, because an absolute-path `cd` remains a persistent directory change and is denied. +It does not permit `cd /home//project`, because an absolute-path `cd` remains a persistent directory change and is denied. ## Transport and fail-open behavior diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 64dea78dc6..91f72dd1d7 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -276,6 +276,10 @@ "path": "docs/orca-backend.md", "audience": "operator-current" }, + { + "path": "docs/outward-facing-text.md", + "audience": "maintainer-architecture" + }, { "path": "docs/remote-secondmates.md", "audience": "operator-current" diff --git a/docs/documentation-audiences.md b/docs/documentation-audiences.md index ca569041a0..bc0b5a2aeb 100644 --- a/docs/documentation-audiences.md +++ b/docs/documentation-audiences.md @@ -25,4 +25,5 @@ bin/fm-doc-audience-check.sh The check intentionally does not lint dates, versions, commands, paths, incident language, or transcript-like prose. Those forms are legitimate in maintainer verification and require semantic review rather than keyword heuristics. +One separate contract still bounds them: [`outward-facing-text.md`](outward-facing-text.md) requires every identifier tracked prose adds, including an absolute path inside a recorded command or its output, to be resolvable by a reader holding this repository alone. For every changed prose surface, review its audience, authoritative owner, current relevance, evidence destination, and unique safety facts, then repeat that review over the complete branch diff after all fixes. diff --git a/docs/outward-facing-text.md b/docs/outward-facing-text.md new file mode 100644 index 0000000000..4b421b29b7 --- /dev/null +++ b/docs/outward-facing-text.md @@ -0,0 +1,55 @@ +# Outward-facing text + +Outward-facing text is text a destination keeps and shows to whoever can read that destination: a PR title, a PR description, a commit message, and tracked repository content. +Publication is not retractable. +Closing a PR leaves its description readable, so text that reaches a public repository is public from then on whether or not anyone meant it to be. + +Firstmate composes some of that text from a worker's working context. +Under the `no-mistakes` delivery mode, the run intent becomes the PR description verbatim, and that intent is derived from the brief's `# Task` section. +A worker's context also holds the machine it runs on, the fleet it belongs to, and every other repository that fleet touches, none of which the destination repository can resolve. + +## The contract + +**An identifier may appear in outward-facing text only when a reader holding the repository under change, and nothing else, could resolve it.** + +This is a scoping rule and never a length rule. +The accepted requirements are exactly what a PR description is for, and shortening the intent is not a way to satisfy this contract. +The problem is foreign identifiers, not volume. + +Legitimate: the accepted requirements and their rationale, behavior and interfaces, paths relative to the repository root, symbols, this change's own branch, issues and PRs in this repository, and commit ids that resolve in this repository. + +Not legitimate: commit ids, branches, or issue references belonging to another repository; a firstmate task id, brief path, worker name, or other fleet-private name other than the one this change's branch already publishes; and absolute paths naming a user home, a per-run temporary root, or a gate or worktree clone on one machine. + +An identifier a reader really can resolve, such as an upstream vendor's release id or a named upstream project's commit in a verification record, is legitimate once it is recorded in the repository's tracked `.fm-outward-allow` file. +Adding that line is the reviewable act that justifies the reference, in the same change that introduces it. + +That file settles reviewable findings only. +A machine-local path, a private task id, or another project's name is blocking and can never be settled there, because recording one would publish in a tracked file the exact identifier this contract exists to keep unpublished. +The check reports such an entry alongside the finding rather than honoring it, and `--allow` is bounded the same way. + +## The check + +`bin/fm-outward-text-check.sh` decides every category against the repository under change rather than by keyword heuristics, so ordinary prose about dates, versions, commands, and relative paths is never matched. +Its header and `--help` own the exact options, categories, and known bounds. + +```sh +bin/fm-outward-text-check.sh --home --task intent.txt # before publishing +bin/fm-outward-text-check.sh --diff --block-only # prose and commit messages this branch adds +``` + +Findings carry a severity that reflects what an unattended gate can safely act on alone, not how bad a leak is. +A machine-local path or a private fleet identifier is blocking, because nothing outside that machine or that fleet can ever resolve it. +An unresolvable commit id or a URL naming another repository is reviewable, because the surrounding text can name an upstream that makes it resolvable and no check can confirm that. + +Two places run it: + +- Every generated `no-mistakes` and `direct-PR` brief requires the worker to check the intent or the PR body before publishing, where every finding must be cleared or justified. + This is the only point that can act before a PR description exists, since a description cannot be recalled once posted. + It is also the only point that runs against a firstmate home, so a foreign task id or another project's name is caught here and nowhere else. +- The `Repo invariants` CI job scans the prose and the commit messages this branch adds, and fails on blocking findings while listing reviewable ones. + It runs without a firstmate home, so a machine-local path is the only blocking category that can fail it. + It needs full history, because deciding whether an id resolves in this repository is impossible against a shallow clone. + +Tracked prose has a second, separate owner: [`documentation-audiences.md`](documentation-audiences.md) routes task chronology, temporary paths, and one-off process identifiers to private task reports. +That policy is about where knowledge belongs, and this check is about what a reader can resolve. +The audience check deliberately does not lint prose, and this one deliberately resolves identifiers instead of matching words. diff --git a/docs/scripts.md b/docs/scripts.md index 484911c380..298785b773 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -62,6 +62,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-project-mode.sh` | Resolve a project's registered delivery posture from `data/projects.md` for fleet sync and home seeding | | `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | | `fm-review-diff.sh` | Review a crewmate branch or resolved PR head against the authoritative base | +| `fm-outward-text-check.sh` | Report identifiers no reader of the repository under change could resolve, before that text is published ([contract](outward-facing-text.md)) | | `fm-marker-lib.sh` | Compatibility entry point for the from-firstmate carrier owned by `fm-operational-input.sh` | | `fm-pending-reply-lib.sh` | Parent-owned secondmate pending-reply expectations, recovery, and keyed escalation lifecycle | | `fm-secondmate-report.sh` | Optional helper to append a correlated parent status or document-pointer report | diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index ccbccf4074..45dc983dc1 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -276,7 +276,7 @@ HERDR_LAB_HELPER=bin/fm-herdr-lab.sh HERDR_ENV=1 HERDR_PANE_ID=w1:p1 HERDR_SESSION=fm-lab-fm-herdr-env-pro-65961-25535 -HERDR_SOCKET_PATH=/Users/kunchen/.config/herdr/sessions/fm-lab-fm-herdr-env-pro-65961-25535/herdr.sock +HERDR_SOCKET_PATH=/Users//.config/herdr/sessions/fm-lab-fm-herdr-env-pro-65961-25535/herdr.sock HERDR_TAB_ID=w1:t1 HERDR_WORKSPACE_ID=w1 ``` diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index a348e2d345..84c6a54573 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -316,6 +316,12 @@ test_faster_paths_use_configured_authority_without_stacked_review() { FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" direct-proj --mode direct-PR >/dev/null 2>&1 assert_no_grep "make \`--intent\` preserve all relevant content from this brief" "$home/data/$id/brief.md" \ "direct-PR brief must not include the no-mistakes --intent contract" + # direct-PR publishes its own description, so it owes the same pre-publication + # scan; local-only publishes nothing and must not carry it. + assert_grep "bin/fm-outward-text-check.sh --home" "$home/data/$id/brief.md" \ + "direct-PR brief must check the PR body before it is published" + assert_no_grep "bin/fm-outward-text-check.sh" "$home/data/brief-local-authority-a4/brief.md" \ + "local-only brief publishes nothing outward and must not carry the check" pass "fm-brief.sh: faster paths use configured authority without stacked review" } @@ -345,6 +351,15 @@ test_no_mistakes_dod_wording() { "no-mistakes DOD must keep direct requirements and exclude generic scaffold boilerplate from --intent" assert_grep "exclude generic operational, status, delivery, and other scaffold boilerplate unless it is task-specific" "$brief" \ "no-mistakes DOD must exclude non-task-specific scaffold boilerplate from --intent" + # The intent is published verbatim as the PR description, so the scan of it + # has to come before the run starts - after publication there is nothing to + # retract, since a closed PR keeps its body. + assert_grep "bin/fm-outward-text-check.sh --home" "$brief" \ + "no-mistakes DOD must have the worker check the intent before publishing it" + assert_grep "BEFORE you start the run" "$brief" \ + "the intent check must run before the pipeline publishes the description" + assert_grep "never by dropping accepted requirements" "$brief" \ + "clearing a finding must not be answered by shortening the intent" # The apostrophe in "firstmate's authority check" is now structurally safe # (no `$(...)` wrapper around the heredoc), so it renders verbatim instead of # being reworded or escaped away. test_no_heredoc_in_command_substitution diff --git a/tests/fm-outward-text-check.test.sh b/tests/fm-outward-text-check.test.sh new file mode 100755 index 0000000000..6c61610816 --- /dev/null +++ b/tests/fm-outward-text-check.test.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash +# Behavior guard for bin/fm-outward-text-check.sh, the pre-publication scan that +# keeps identifiers belonging to another repository, a private task, or one +# machine out of text a destination publishes and never retracts. +# +# Regression origin: run intent was composed from a worker's working context and +# published verbatim as a PR description, so a private task name, two commit +# SHAs from an unrelated repository, and a machine-local gate-clone id stayed +# readable on a public PR - including after it was closed, because a closed PR +# keeps its body. The same class also reached tracked verification prose. +# +# The load-bearing property is that every verdict is decided AGAINST the +# repository under change, not by matching words: the same short hex string is +# clean when it names a commit here and reported when it does not. These tests +# drive that distinction directly rather than asserting on patterns. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +CHECK="$ROOT/bin/fm-outward-text-check.sh" +TMP_ROOT=$(fm_test_tmproot fm-outward-text-check) || exit 1 +fm_git_identity + +# One repository "under change" with a real commit, plus a second repository +# whose commits are foreign to it. Both are real git repos so object resolution +# is exercised for real. +# +# Full 40-character ids, never abbreviations: a short id is only treated as an +# identifier when it happens to mix digits and [a-f], so an abbreviated fixture +# would make these assertions depend on the commit timestamp of the run. +REPO="$TMP_ROOT/under-change" +OTHER="$TMP_ROOT/elsewhere" +fm_git_init_commit "$REPO" +fm_git_init_commit "$OTHER" +OWN_SHA=$(git -C "$REPO" rev-parse HEAD) +FOREIGN_SHA=$(git -C "$OTHER" rev-parse HEAD) + +# run_check [args...]: scan as one file in $REPO, echo the output, +# and return the check's own exit status. +run_check() { + local text=$1 out status + shift + printf '%s\n' "$text" > "$TMP_ROOT/input.txt" + out=$("$CHECK" --repo "$REPO" "$@" "$TMP_ROOT/input.txt" 2>&1) + status=$? + printf '%s\n' "$out" + return "$status" +} + +test_own_commit_id_is_not_a_finding() { + local out + out=$(run_check "restores the behavior regressed in $OWN_SHA") \ + || fail "a commit id that resolves in the repository under change must be clean" + case "$out" in + *clean*) : ;; + *) fail "expected a clean summary, got: $out" ;; + esac + pass "an id resolving in the repository under change is not reported" +} + +test_foreign_commit_id_is_reported() { + local out status + out=$(run_check "verified against $FOREIGN_SHA"); status=$? + expect_code 1 "$status" "a commit id from another repository must be reported" + case "$out" in + *"FOREIGN_OBJECT: $FOREIGN_SHA"*) : ;; + *) fail "expected the foreign id to be named, got: $out" ;; + esac + pass "an id that resolves only in another repository is reported" +} + +# The distinction above is the whole contract, so prove the two verdicts really +# diverge on the same input rather than both happening to pass. +test_the_two_verdicts_diverge_on_one_line() { + local out status + [ "$OWN_SHA" != "$FOREIGN_SHA" ] || fail "fixture repos produced the same commit id" + out=$(run_check "ported $OWN_SHA onto $FOREIGN_SHA"); status=$? + expect_code 1 "$status" "a line mixing a local and a foreign id must be reported" + case "$out" in + *"$FOREIGN_SHA"*) : ;; + *) fail "expected the foreign id on a mixed line, got: $out" ;; + esac + case "$out" in + *"FOREIGN_OBJECT: $OWN_SHA"*) fail "the local id must not be reported on a mixed line" ;; + esac + pass "one line carrying both ids reports only the foreign one" +} + +test_prose_numbers_and_words_are_not_identifiers() { + run_check "the 1048576 byte cap was defaced on 20260815 and effaced later" >/dev/null \ + || fail "decimal numbers and all-letter words must not read as object ids" + pass "decimal-only and letter-only tokens are not treated as ids" +} + +test_machine_local_path_blocks() { + local out status + out=$(run_check "evidence at /home/somebody/work/gate-clone/run.log"); status=$? + expect_code 1 "$status" "a user-home path must be reported" + case "$out" in + *"MACHINE_LOCAL_PATH: /home/somebody/work/gate-clone/run.log"*) : ;; + *) fail "expected the machine-local path to be named, got: $out" ;; + esac + case "$out" in + *"1 blocking"*) : ;; + *) fail "a machine-local path must count as blocking, got: $out" ;; + esac + pass "an absolute user-home path is reported as blocking" +} + +test_placeholder_path_is_not_a_finding() { + run_check 'the socket lands at /Users//.config/herdr/herdr.sock' >/dev/null \ + || fail "a documented placeholder path names no machine and must stay clean" + pass "an angle-bracket placeholder path is not reported" +} + +test_block_only_separates_the_two_severities() { + local out status + # Reviewable alone: an unresolvable id can be legitimate when the surrounding + # text names its upstream, so an unattended gate must not fail on it. + out=$(run_check "upstream release $FOREIGN_SHA" --block-only); status=$? + expect_code 0 "$status" "--block-only must not fail on a reviewable finding" + case "$out" in + *"FOREIGN_OBJECT: $FOREIGN_SHA"*) : ;; + *) fail "--block-only must still print reviewable findings, got: $out" ;; + esac + + # The same text with a blocking finding added must fail. + out=$(run_check "upstream release $FOREIGN_SHA at /home/somebody/clone" --block-only); status=$? + expect_code 1 "$status" "--block-only must fail on a blocking finding" + pass "--block-only fails on blocking findings only, still printing the rest" +} + +test_allow_file_settles_a_justified_reference() { + local out + printf '# justified upstream reference\n%s\n' "$FOREIGN_SHA" > "$REPO/.fm-outward-allow" + out=$(run_check "upstream release $FOREIGN_SHA") \ + || fail "an id listed in .fm-outward-allow must be clean" + rm -f "$REPO/.fm-outward-allow" + out=$(run_check "upstream release $FOREIGN_SHA") && \ + fail "removing the allow entry must restore the finding" + pass ".fm-outward-allow settles a justified reference and nothing more" +} + +# An exemption that could silence a blocking finding would be the leak itself: +# the value has to be written into a tracked file to silence it there. +test_allow_file_cannot_settle_a_blocking_finding() { + local out status + printf '# a machine-local path someone tried to settle here\n%s\n' \ + "/home/somebody/gate-clone/run.log" > "$REPO/.fm-outward-allow" + out=$(run_check "evidence at /home/somebody/gate-clone/run.log" --block-only); status=$? + rm -f "$REPO/.fm-outward-allow" + expect_code 1 "$status" "an allowed machine-local path must still fail an unattended gate" + case "$out" in + *"MACHINE_LOCAL_PATH: /home/somebody/gate-clone/run.log"*) : ;; + *) fail "an allowed machine-local path must still be reported, got: $out" ;; + esac + case "$out" in + *"cannot settle a blocking finding"*) : ;; + *) fail "the refused exemption must be named, not silently dropped, got: $out" ;; + esac + case "$out" in + *"cannot exempt a blocking identifier"*) : ;; + *) fail "the remedy for a blocking finding must not offer the allow file, got: $out" ;; + esac + pass "an allow entry naming a machine-local path is refused and reported" +} + +test_long_digest_is_not_invisible() { + local out status digest + digest=3f5e1a9b2c4d6e8f0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071 + out=$(run_check "pasted from another run log: $digest"); status=$? + expect_code 1 "$status" "a hex id longer than a commit id must still be scanned" + case "$out" in + *"FOREIGN_OBJECT: $digest"*) : ;; + *) fail "expected the 64-character digest to be reported, got: $out" ;; + esac + pass "a hex run longer than 40 characters is scanned like any other id" +} + +test_private_fleet_names_are_reported_against_a_home() { + local home out status + home="$TMP_ROOT/fm-home" + mkdir -p "$home/data/other-private-task-v1" "$home/data/this-task-v1" \ + "$home/state" "$home/projects/private-sidecar-repo" + : > "$home/data/other-private-task-v1/brief.md" + : > "$home/data/this-task-v1/brief.md" + + out=$(run_check "follows other-private-task-v1 in private-sidecar-repo" \ + --home "$home" --task this-task-v1); status=$? + expect_code 1 "$status" "another task's id and another project's name must be reported" + case "$out" in + *"FOREIGN_TASK_ID: other-private-task-v1"*) : ;; + *) fail "expected the other task id, got: $out" ;; + esac + case "$out" in + *"FOREIGN_PROJECT: private-sidecar-repo"*) : ;; + *) fail "expected the other project name, got: $out" ;; + esac + + # The task under change is already public through its own branch name. + run_check "implements this-task-v1" --home "$home" --task this-task-v1 >/dev/null \ + || fail "the task under change must not be reported as foreign" + pass "other tasks and projects are reported while the task under change is not" +} + +test_missing_home_is_reported_not_silently_passed() { + local out + out=$(run_check "plain sentence with no identifiers") \ + || fail "text with no identifiers must be clean" + case "$out" in + *"SKIPPED: foreign-task-id, foreign-project"*) : ;; + *) fail "an unavailable home must be reported as skipped, got: $out" ;; + esac + pass "checks that could not run are named instead of passing silently" +} + +test_stdin_is_scanned_and_not_swallowed() { + local out status + # The implementation is fed to its interpreter on stdin, so "-" only works if + # the caller's text is captured first. A silent empty read here would report + # clean for every piped input. + out=$(printf 'verified against %s\n' "$FOREIGN_SHA" | "$CHECK" --repo "$REPO" -); status=$? + expect_code 1 "$status" "piped text must be scanned" + case "$out" in + *"FOREIGN_OBJECT: $FOREIGN_SHA"*) : ;; + *) fail "expected the foreign id from stdin, got: $out" ;; + esac + case "$out" in + *"(stdin:1)"*) : ;; + *) fail "expected a stdin location label, got: $out" ;; + esac + pass "piped text is scanned and located as stdin" +} + +test_diff_mode_scans_only_what_the_branch_adds() { + local out status base + base=$(git -C "$REPO" rev-parse HEAD) + printf 'Pre-existing note about /home/somebody/legacy.\n' > "$REPO/OLD.md" + git -C "$REPO" add OLD.md + git -C "$REPO" -c user.name=t -c user.email=t@e commit -qm "pre-existing prose" + base=$(git -C "$REPO" rev-parse HEAD) + + printf 'A new note with no identifiers at all.\n' > "$REPO/NEW.md" + git -C "$REPO" add NEW.md + git -C "$REPO" -c user.name=t -c user.email=t@e commit -qm "clean prose" + out=$("$CHECK" --repo "$REPO" --diff --base "$base" 2>&1); status=$? + expect_code 0 "$status" "pre-existing prose must not be rescanned by --diff" + + printf 'Debug leftovers under /home/somebody/gate-clone.\n' >> "$REPO/NEW.md" + git -C "$REPO" -c user.name=t -c user.email=t@e commit -qam "leaky prose" + out=$("$CHECK" --repo "$REPO" --diff --base "$base" --block-only 2>&1); status=$? + expect_code 1 "$status" "--diff must report a leak the branch adds" + case "$out" in + *"NEW.md"*) : ;; + *) fail "expected the added file to be located, got: $out" ;; + esac + pass "--diff reports what the branch adds and leaves existing prose alone" +} + +# A merged commit message is as public and as permanent as a PR description, so +# --diff has to cover the messages the branch adds and not just its prose. +test_diff_scans_the_commit_messages_the_branch_adds() { + local out status base sha + base=$(git -C "$REPO" rev-parse HEAD) + printf 'A note carrying no identifiers of its own.\n' > "$REPO/MSG.md" + git -C "$REPO" add MSG.md + git -C "$REPO" -c user.name=t -c user.email=t@e \ + commit -qm "port fix from /home/somebody/projects/other-repo" + sha=$(git -C "$REPO" rev-parse HEAD) + + out=$("$CHECK" --repo "$REPO" --diff --base "$base" --block-only 2>&1); status=$? + expect_code 1 "$status" "a machine-local path in a commit message must fail the gate" + case "$out" in + *"MACHINE_LOCAL_PATH: /home/somebody/projects/other-repo"*) : ;; + *) fail "expected the path leaked by the commit message, got: $out" ;; + esac + case "$out" in + *"commit ${sha:0:12}"*) : ;; + *) fail "expected the finding to be located by its commit, got: $out" ;; + esac + pass "--diff reports an identifier the branch adds in a commit message" +} + +test_usage_errors_are_loud() { + local status + "$CHECK" --repo "$REPO" >/dev/null 2>&1; status=$? + expect_code 2 "$status" "no input must be a usage error, never a silent pass" + "$CHECK" --repo "$TMP_ROOT/not-a-repo" "$TMP_ROOT/input.txt" >/dev/null 2>&1; status=$? + expect_code 2 "$status" "a missing repository must be an environment error" + pass "missing input and a missing repository both fail loudly" +} + +test_own_commit_id_is_not_a_finding +test_foreign_commit_id_is_reported +test_the_two_verdicts_diverge_on_one_line +test_prose_numbers_and_words_are_not_identifiers +test_machine_local_path_blocks +test_placeholder_path_is_not_a_finding +test_block_only_separates_the_two_severities +test_allow_file_settles_a_justified_reference +test_allow_file_cannot_settle_a_blocking_finding +test_long_digest_is_not_invisible +test_private_fleet_names_are_reported_against_a_home +test_missing_home_is_reported_not_silently_passed +test_stdin_is_scanned_and_not_swallowed +test_diff_mode_scans_only_what_the_branch_adds +test_diff_scans_the_commit_messages_the_branch_adds +test_usage_errors_are_loud