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
191 changes: 191 additions & 0 deletions scripts/proof-guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""proof-guard.py: refuse a file that a proof must not publish.

Usage:
proof-guard.py FILE

Run it inside the project, on one file. FILE is read from the folder the command runs in, and
where it leads counts, not how it is spelled: a link is judged by the file at its end.

status 0 The file may be published as a proof. Nothing is printed.
status 1 The file is refused: one sentence on stdout names the reason, and repeats nothing
from inside the file.
any other The guard could not answer: one line on stderr. That is never a refusal, and
never a yes.

A file is refused when it is outside the repository the command runs in; not there, or not a
file; inside .ck/, the run cache, or .git/; written by a pipeline command, which is every
document /ck:next looks for and any plan under docs/plan/; ignored by git; not a .md file; not
UTF-8 text; or empty. The file is opened only for the last two. Standard library only. Needs
git on PATH.
"""
import os
import subprocess
import sys

# The documents the pipeline commands write, as the look in skills/next/SKILL.md lists them, each
# with the command that writes it. The suite reads that look at run time and tries every document
# in it against the guard, so a document added there without a line here fails the suite.
OWNED = {
'docs/opportunity.md': 'opportunity',
'docs/market-research.md': 'market-research',
'docs/brief.md': 'brief',
'docs/TEAM.md': 'team',
'docs/PRD.md': 'prd',
'ROADMAP.md': 'roadmap',
'docs/ARCHITECTURE.md': 'architecture',
'docs/brand-guide.md': 'brand-guide',
'docs/build-plan.md': 'plan',
}
PLANS = 'docs/plan' # every plan for one piece of work: the folder belongs to /ck:plan
CACHE = '.ck' # the run cache; a command tells git to ignore it when it first mints a run
GIT = '.git' # git's own files, which check-ignore does not report as ignored


class Fail(Exception):
"""One plain line for stderr: the guard could not answer. Status 2."""


class Refuse(Exception):
"""One sentence for stdout: the file is not published. Status 1."""


def run_git(*args):
"""(status, stdout, the first line of stderr) of a git command."""
try:
done = subprocess.run(['git', *args], capture_output=True, stdin=subprocess.DEVNULL)
except OSError:
raise Fail('proof-guard.py needs git on PATH') from None
return (done.returncode, done.stdout.decode('utf-8', 'surrogateescape').strip(),
done.stderr.decode('utf-8', 'replace').strip().split('\n')[0])


def shown(path):
"""The path as typed, kept to one line: a character that cannot be printed becomes a space."""
return ''.join(c if c.isprintable() else ' ' for c in path)


def same(a, b):
"""True when two paths lead to one file. A file system that ignores case says so here, where
the spelling of a path cannot."""
try:
return os.path.samefile(a, b)
except OSError:
return False


def inside(resolved, top, folder):
"""True when the file is under top/folder, however the path spells the folder."""
target = os.path.join(top, folder)
parent = os.path.dirname(resolved)
while len(parent) > len(top):
if same(parent, target):
return True
parent = os.path.dirname(parent)
return False


def owner(resolved, top, rel):
"""The command that writes this file, or None."""
if rel in OWNED:
return OWNED[rel]
for path, command in OWNED.items():
if same(resolved, os.path.join(top, path)):
return command
if rel.startswith(PLANS + '/') or inside(resolved, top, PLANS):
return 'plan'
return None


def check(path):
"""Returns when the file may be published; raises Refuse or Fail otherwise."""
status, out, err = run_git('rev-parse', '--show-toplevel')
if status:
raise Fail(err or 'git rev-parse stopped with status %d' % status)
top = os.path.realpath(out)
# The folder the command runs in is resolved first, so that a link on the way to it (such as
# /var on macOS) does not count as a link on the way to the file.
spelled = os.path.normpath(os.path.join(os.path.realpath(os.getcwd()), path))
resolved = os.path.realpath(spelled)
name = shown(path)
be = '%s leads to a file that is' % name if resolved != spelled else '%s is' % name
if os.path.commonpath([resolved, top]) != top:
raise Refuse('%s outside the repository, and a proof publishes only a file inside it.' % be)
if not os.path.exists(resolved):
raise Refuse('%s not there.' % be)
if os.path.isdir(resolved):
raise Refuse('%s a folder, not a file.' % ('%s leads to' % name if resolved != spelled else '%s is' % name))
if not os.path.isfile(resolved):
raise Refuse('%s is not a file.' % name)
rel = os.path.relpath(resolved, top)
if rel.startswith(CACHE + '/') or inside(resolved, top, CACHE):
raise Refuse('%s inside .ck/, the run cache, which is never published.' % be)
if rel.startswith(GIT + '/') or inside(resolved, top, GIT):
raise Refuse('%s inside .git/, which holds the repository itself and is never published.' % be)
command = owner(resolved, top, rel)
if command:
raise Refuse('%s written by /ck:%s: run that command for its proof.' % (be, command))
# Git is asked about the file where it is, not where the path started: a link to an ignored
# file is itself not ignored. A tracked file is not ignored whatever the rules say, which is
# git's own reading; the security read of this guard decides whether that is enough.
status, _, err = run_git('-C', top, 'check-ignore', '-q', '--', rel)
if status == 0:
raise Refuse('%s ignored by git, and a file git ignores is never published.' % be)
if status != 1:
raise Fail(err or 'git check-ignore stopped with status %d' % status)
if os.path.splitext(rel)[1].lower() != '.md':
raise Refuse('%s not markdown, and a proof is made from a .md file.' % be)
try:
with open(resolved, 'rb') as handle:
data = handle.read()
except OSError as error:
raise Fail('cannot read %s: %s' % (name, error.strerror or 'it is not a file')) from None
try:
text = data.decode('utf-8-sig') # a byte order mark is not a word
except UnicodeDecodeError:
raise Refuse('%s not UTF-8 text, so it cannot be read as markdown.' % be) from None
if not text.split():
raise Refuse('%s empty: it has no words to publish.' % be)


def emit(stream, text):
try:
stream.buffer.write((text + '\n').encode('utf-8', 'replace'))
stream.buffer.flush()
except BrokenPipeError:
os.dup2(os.open(os.devnull, os.O_WRONLY), stream.fileno()) # the reader left; stay quiet


def one_line(text):
return text.replace('\r', ' ').replace('\n', ' ')


def main(argv):
try:
# Status 0 means yes, so even the help leaves with another status: nothing was checked.
if argv in (['-h'], ['--help']):
emit(sys.stdout, __doc__.strip())
return 2
if len(argv) != 1 or not argv[0] or argv[0].startswith('-'):
emit(sys.stderr, 'usage: proof-guard.py FILE')
return 2
try:
check(argv[0])
except Fail as failure:
emit(sys.stderr, one_line(str(failure)))
return 2
except Refuse as refusal:
emit(sys.stdout, one_line(str(refusal)))
return 1
except Exception as error: # never a traceback, whatever the input
emit(sys.stderr, 'proof-guard.py could not finish: %s' % type(error).__name__)
return 2
return 0
except KeyboardInterrupt:
return 130
except Exception:
return 2


if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
72 changes: 67 additions & 5 deletions tests/pending/P1-S1.sh → tests/checks/P1-S1.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# tests/pending/P1-S1.sh: the checks for stage P1-S1, Build the publish guard.
# tests/checks/P1-S1.sh: the checks for stage P1-S1, Build the publish guard.
#
# Written in stage P0-S1, before the work. Section 21 of tests/run.sh says how this file is run
# and what it may use. The stage is done when every check here passes and this file has moved to
# tests/checks/. Tighten a check when the work shows it is too weak. Never loosen one.
# Written in stage P0-S1, before the work, and moved here when the work was done. Section 21 of
# tests/run.sh says how this file is run and what it may use. Tighten a check when the work shows
# it is too weak. Never loosen one.
#
# The guard, as these checks read it. It is one command, run inside the project, on one file:
# python3 scripts/proof-guard.py <file>
Expand Down Expand Up @@ -104,8 +104,9 @@ check "a file outside the repository is refused, however the path is written and
"$(refused "$outside/notes.md" "$in_repo")" "$(refused ../outside/notes.md "$in_repo")" \
"$(refused ../../outside/notes.md "$in_repo" "$repo/docs")" "$(accepted ../README.md "$repo/docs")"

# The pattern was any reason; the stage that built the guard made it the reason at the link's end.
check "a link inside the repository is refused when it leads out of it, or to a file git ignores" \
"$(refused docs/note/linked-out.md .)" "$(refused docs/note/linked-ignored.md .)"
"$(refused docs/note/linked-out.md "$in_repo")" "$(refused docs/note/linked-ignored.md ignore)"

check "a file that is not markdown is refused" \
"$(refused docs/note/notes.txt 'markdown|\.md')" "$(refused docs/note/page.html 'markdown|\.md')"
Expand Down Expand Up @@ -151,3 +152,64 @@ check "every document /ck:next looks for is refused, and the sentence names the
doc twenty-one > "$repo/docs/build-plan.md"
check "a staged plan is refused, and the sentence names /ck:plan" \
"$(refused docs/build-plan.md '/ck:plan([^a-z-]|$)')" "$(refused docs/plan/2026-10-12-pricing.md '/ck:plan([^a-z-]|$)')"

# ─── Added by the stage that built the guard ─────────────────────────────────
# Each check below pins a choice the guard made that the checks above leave open, so that a
# later change to it is seen here.

# A path that is not there, and a folder, are refused like any other file: status 1 and one
# sentence, so the command can say it. The guard fails, with neither a yes nor a refusal, only
# when it was not given one file.
no_answer() { # <arguments>...: prints nothing when the guard stops with a status other than 0 and 1.
local rc
[[ -f "$guard" ]] || { printf 'scripts/proof-guard.py is not written'; return; }
(cd "$repo" && python3 "$guard" "$@") > "$SCRATCH/said" 2>&1; rc=$?
[[ $rc -ne 0 && $rc -ne 1 ]] || printf 'given "%s" the guard stops with status %s, which is an answer' "$*" "$rc"
}
check "a path that is not there, and a folder, are refused with one sentence, and the guard fails only when it is not given one file" \
"$(refused docs/note/missing.md 'not there')" "$(refused docs/note folder)" \
"$(no_answer)" "$(no_answer README.md docs/note/draft.md)" "$(no_answer --help)"

# Where a path leads counts: an absolute path, and one that wanders, lead to the same file as the
# plain one; a link to a folder is followed like a link to a file; and a link is judged by the
# file at its end, whatever the link itself is named.
ln -s ../../private "$repo/docs/note/linked-folder"
ln -s notes.txt "$repo/docs/note/linked-txt.md"
ln -s nowhere.md "$repo/docs/note/linked-gone.md"
check "a file inside the repository is accepted by an absolute path, and by one that wanders" \
"$(accepted "$repo/README.md")" "$(accepted ./docs/../README.md)"
check "a link is followed wherever it stands on the path, and judged by the file at its end" \
"$(refused docs/note/linked-folder/plan.md ignore)" "$(refused docs/note/linked-txt.md 'markdown|\.md')" \
"$(refused docs/note/linked-gone.md 'not there')"

# A byte order mark is not a word. And the sentence is one line whatever the path holds, so a
# name with a line break in it is shown on one.
printf '\xef\xbb\xbf' > "$repo/docs/note/bom.md"
: > "$repo/docs/note/two"$'\n'"lines.md"
check "a file that holds only a byte order mark is empty, and the sentence stays one line when the path holds a line break" \
"$(refused docs/note/bom.md 'empty|no words|nothing')" "$(refused "docs/note/two"$'\n'"lines.md" 'empty|no words|nothing')"

# docs/plan/ belongs to /ck:plan whole, so a plan deeper inside it is refused too.
mkdir -p "$repo/docs/plan/old"; doc twenty-two > "$repo/docs/plan/old/2026-01-01-pricing.md"
check "a plan anywhere under docs/plan/ is refused, and the sentence names /ck:plan" \
"$(refused docs/plan/old/2026-01-01-pricing.md '/ck:plan([^a-z-]|$)')"

# On a file system that ignores case, docs/prd.md leads to docs/PRD.md, and where a path leads
# counts. On one that tells case apart, the other spelling leads nowhere.
spelled() { # <a path in another case> <the reason when it leads to the file>: like refused.
if [[ -e "$repo/$1" ]]; then refused "$1" "$2"; else refused "$1" 'not there'; fi
}
check "a path in another case is judged by where it leads on this file system" \
"$(spelled docs/prd.md '/ck:prd([^a-z-]|$)')" "$(spelled Docs/Plan/2026-10-12-pricing.md '/ck:plan([^a-z-]|$)')" \
"$(spelled .CK/runs/20261012T000000Z-pricing-proof/review.md '\.ck|cache')"

# .git/ is never published, and git itself does not report a file inside it as ignored.
mkdir -p "$repo/.git/note"; doc twenty-three > "$repo/.git/note/plan.md"
check "a file inside .git/ is refused" "$(refused .git/note/plan.md '\.git')"

# The guard opens a file only to see whether it has words: a file it refuses for where it is, or
# for its name, is never read. These cannot be read at all, and are refused all the same.
doc twenty-four > "$repo/private/sealed.md"; doc twenty-five > "$repo/docs/note/sealed.txt"; doc twenty-six > "$outside/sealed.md"
chmod 000 "$repo/private/sealed.md" "$repo/docs/note/sealed.txt" "$outside/sealed.md"
check "a file refused for where it is, or for its name, is never opened" \
"$(refused private/sealed.md ignore)" "$(refused docs/note/sealed.txt 'markdown|\.md')" "$(refused ../outside/sealed.md "$in_repo")"