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
33 changes: 0 additions & 33 deletions TODO.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,38 +22,6 @@ The steps below are followed in order rather than sampled.

## Work Clusters

### The Prose Gate Scope Floor

One pull request making [`prose_lint.py`][prose-lint] assert a floor on its own scope, which every verdict below it depends on, since a gate that finds nothing is indistinguishable from a gate with nothing to find.

**State** `ready`. **Touches** [`scripts/prose_lint.py`][prose-lint] and its test file. **Cost** one hub edit, hub-only, no sweep.

- **Assert a floor on what a `--diff` run actually scanned.** A run that resolves a non-empty diff and then matches zero files has almost certainly failed to scope rather than found a clean change, so it says so instead of exiting 0.
- **Blocked by** - Nothing.
- **Issue** - None filed.
- **Checked** - `develop` at `1ed0cc8` on 2026-08-03, where four separate guards each close one route to the same false clean.
- **Open** - Nothing.
- **Settled** - Four routes to the same false clean are on record from one session, an unresolvable base widening to a whole-tree scan, a multi-line `paths` input read only to its first newline, a diff taken in one repository while scanning another, and a path under no repository at all.
- **Settled** - Per-route guards are the wrong shape, because the fifth route needs a fifth guard and gets found by a reviewer rather than by the gate, which is what a floor assertion covers.
- **Settled** - The honest limit is that a change touching only files the gate does not read, an image or a lock file, legitimately scopes to zero, so the assertion compares against the diff's own file list rather than against zero alone.
- **Settled** - `LEAST_PLAUSIBLE` at 60 is the existing floor on a whole-tree sweep, so the shape is already in the file and the `--diff` path is what lacks it.

### The Representative-Data Path Check

One pull request gating the pattern-detectable half of the representative-data rule, which is worth having only once the gate can prove it read something.

**State** `blocked` on "The Prose Gate Scope Floor". **Touches** [`scripts/prose_lint.py`][prose-lint] and its test file. **Cost** one hub edit, hub-only, no sweep.

- **Flag an absolute home path or a bare drive letter in committed prose, a comment, or a fixture.** [`GOVERNANCE.md`][governance] "Representative Data in Agent-Authored Text" states the rule and says why a check is a floor rather than an answer.
- **Blocked by** - The Prose Gate Scope Floor.
- **Issue** - None filed.
- **Checked** - `develop` at `1ed0cc8` on 2026-08-03, where the rule is stated and no check reads for it.
- **Open** - Whether a home path in an operational repo's runbook is a finding, since it may be the literal path an operator types, which is the repo's own content rather than an agent quoting the maintainer's environment, so the answer is a scope by file, by repo type, or left to the author.
- **Settled** - The shapes are `/home/<name>`, `/Users/<name>`, `C:\Users\<name>`, and a bare drive letter.
- **Settled** - The check is introduced as covering the easy half or it gets read as closing the rule, which is the specific way it would make things worse.
- **Settled** - The exemption carries the whole burden, since the rule's own wording, the [`host-setup/`][agent-safety] docs, and the audit's examples all quote path shapes in order to describe them, and a wrong exemption hands out a work list that damages correct documents.
- **Settled** - The leak that motivated the rule was in a pull request comment, which no committed-file linter reads, so the gate says what surface it covers rather than letting its name imply the rule.

### The Prose Content Backlog

One pull request clearing prose findings, leading with [`catalog/snippets/`][snippets] because a non-conformant snippet seeds its violations into every repo that adopts it and the downstream repo is then flagged for content it was handed.
Expand DownExpand Up@@ -535,7 +503,6 @@ Each was checked against the tree and has nothing left to do anywhere. Closing i
[pr-review]: ./scripts/pr_review.py
[project-types]: ./spec/project-types.json
[prose-gate]: ./.github/actions/prose-gate/action.yml
[prose-lint]: ./scripts/prose_lint.py
[readme]: ./README.md
[readme-structure]: ./spec/readme-structure.md
[repo-gate]: ./scripts/repo_gate.py
Expand Down
64 changes: 63 additions & 1 deletion scripts/prose_lint.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@
dupword No duplicated consecutive word.
sentence-split A sentence must not wrap across lines (one sentence per line).
spelling No British spelling, the repo-wide convention being US English.
home-path No absolute home path naming a real account, per the representative-data rule.

Exit 1 if any violation is found. Read-only, never edits.
"""
Expand All@@ -31,9 +32,10 @@
'dupword': 'a duplicated consecutive word',
'sentence-split': 'a sentence wrapping across lines',
'spelling': 'a British spelling where the repo convention is US English',
'home-path': 'an absolute home path naming a real account',
}
DEFAULT_RULES = frozenset({'charset', 'charset-unknown', 'semicolon', 'dash', 'dupword',
'spelling', 'comment-wrap', 'comment-case'})
'spelling', 'comment-wrap', 'comment-case', 'home-path'})

# Trees this repo generates rather than authors, skipped when a wider scan expands into them.
# The gate then measures hand-written prose.
Expand All@@ -53,6 +55,24 @@
# A sweep that quietly stops finding files satisfies every rule by having nothing to read.
LEAST_PLAUSIBLE = 60

# The pattern-detectable half of the representative-data rule, and only that half.
# A real user segment is required, so a documented placeholder describes the shape unmatched.
# That is how the rule's own wording escapes its own gate, with no exemption naming files.
# A bare drive letter is deliberately not a shape here.
# Measured against this repo it matched 11 files and named a path in none of them.
# An escaped newline after a word ending in a letter and a colon reads as a drive letter.
# `Users` is matched case-insensitively on the Windows branch alone, since that filesystem is.
# The POSIX branches stay case-sensitive, since a lowercase `/users/` is a common REST path.
# An API route is not a home directory, and widening this would flag one in every doc.
HOME_PATH = re.compile(
r'(?:/home/|/Users/|[A-Za-z]:\\(?i:users)\\)(?P<user>[A-Za-z][A-Za-z0-9._-]*)')

# Accounts that belong to a container or a runner rather than to a person.
# Every one is a fixed name an image ships, so a path under it names no environment.
# `vscode` is the devcontainer user this repo's own snippets mount into.
# `runner` is the GitHub Actions user, and the rest are stock image accounts.
SERVICE_ACCOUNTS = frozenset({'vscode', 'runner', 'root', 'ubuntu', 'node', 'shared', 'public'})


def rel(path: Path) -> str:
"""The repo-relative posix key a git diff uses for this path.
Expand DownExpand Up@@ -128,6 +148,35 @@ def unread_diff_files(scope: dict[str, set[int]], paths: list[str],
return out


def home_path_findings(lineno: int, line: str) -> list[tuple[int, str, str]]:
"""Absolute home paths on this line that name a real account.

The exposure this gates was a maintainer's own path reaching a public comment, so the unit
is the raw line rather than stripped prose. A path is the same exposure in a JSON config
value, in a fenced transcript pasted from a terminal, and in a sentence.
"""
out = []
for m in HOME_PATH.finditer(line):
if m.group('user').lower() in SERVICE_ACCOUNTS:
continue
out.append((lineno, 'home-path',
f'absolute home path {m.group(0)!r} -> use a constructed path, not an '
'observed one'))
return out


def operational_checkout(root: Path) -> bool:
"""Whether this checkout is an operational repository, read from what it carries.

`spec/files.json` declares `repo-config/operational/develop.json` for the operational model
and `repo-config/develop.json` for the release one, so a repository states its own model and
nothing has to reach the hub registry to ask. The hub itself carries both payloads, being the
template for each, so carrying the release payload decides it.
"""
return ((root / 'repo-config' / 'operational' / 'develop.json').is_file()
and not (root / 'repo-config' / 'develop.json').is_file())


def repo_prefix(root: Path) -> str:
"""Where `root` sits inside its repository, as a posix prefix, or '' when git cannot say.

Expand DownExpand Up@@ -934,6 +983,10 @@ def check_file(path: Path, rules: set[str]) -> list[tuple[int, str, str]]:
prev_no = 0
for i, line in enumerate(lines, 1):
line = line.rstrip('\r')
# Judged before the fence and inline-code handling below, deliberately.
# A path pasted inside a fenced transcript is the same exposure as one in a sentence.
if 'home-path' in rules:
out.extend(home_path_findings(i, line))
if CODE_FENCE.match(line):
in_fence = not in_fence
prev_txt = ''
Expand DownExpand Up@@ -1022,6 +1075,15 @@ def main(argv: list[str] | None = None) -> int:

rules = set(a.checks or DEFAULT_RULES)

# An operational repository's runbook carries the literal path an operator types.
# That is the repository's own content, not an agent quoting an environment it observed.
# The skip is announced, since a rule that silently stops running reads as one that passed.
# That is the same failure the diff-scope floor below exists to prevent.
if 'home-path' in rules and operational_checkout(Path(repo_root(Path('.')) or '.')):
rules.discard('home-path')
print('note: home-path is not checked in an operational repository, where an absolute '
'path is the operator instruction rather than observed data.', file=sys.stderr)

# Checked before discovery, which reads every tracked file to classify it as text.
# A run this rejects would otherwise pay that cost and throw the result away.
# `--list-files` is exempt, since it reports the scan scope and never consults the diff.
Expand Down
Loading
Loading