prereg 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prereg-0.1.0/.claude-plugin/marketplace.json +17 -0
- prereg-0.1.0/.github/workflows/publish.yml +21 -0
- prereg-0.1.0/.gitignore +6 -0
- prereg-0.1.0/LICENSE +18 -0
- prereg-0.1.0/PKG-INFO +111 -0
- prereg-0.1.0/README.md +99 -0
- prereg-0.1.0/plugin/.claude-plugin/plugin.json +9 -0
- prereg-0.1.0/plugin/README.md +31 -0
- prereg-0.1.0/plugin/skills/prereg/SKILL.md +88 -0
- prereg-0.1.0/pyproject.toml +20 -0
- prereg-0.1.0/src/prereg/__init__.py +2 -0
- prereg-0.1.0/src/prereg/cli.py +325 -0
- prereg-0.1.0/src/prereg/template.py +82 -0
- prereg-0.1.0/tests/test_freeze.py +247 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "prereg",
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "Elliot Tower"
|
|
5
|
+
},
|
|
6
|
+
"metadata": {
|
|
7
|
+
"description": "Freeze a plan before you run it, and record what changed after",
|
|
8
|
+
"version": "0.1.0"
|
|
9
|
+
},
|
|
10
|
+
"plugins": [
|
|
11
|
+
{
|
|
12
|
+
"name": "prereg",
|
|
13
|
+
"source": "./plugin",
|
|
14
|
+
"strict": false
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
contents: read
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.11"
|
|
19
|
+
- run: pip install hatchling
|
|
20
|
+
- run: python -m hatchling build
|
|
21
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
prereg-0.1.0/.gitignore
ADDED
prereg-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Elliot Tower
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
|
6
|
+
and associated documentation files (the "Software"), to deal in the Software without
|
|
7
|
+
restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
|
8
|
+
distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
|
9
|
+
Software is furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or
|
|
12
|
+
substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
|
15
|
+
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
16
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
17
|
+
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
prereg-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: prereg
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Freeze a plan before you run it, and record what changed after
|
|
5
|
+
Author-email: Elliot Tower <elliot@elliottower.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: open science,osf,preregistration,reproducibility
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: pyyaml>=6.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# prereg
|
|
14
|
+
|
|
15
|
+
Freeze a plan before you run it, and record what changed after.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install prereg
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
prereg new V16_reliability_ceilings
|
|
27
|
+
# fill in the plan, commit it
|
|
28
|
+
prereg freeze
|
|
29
|
+
# run the experiment, then log what happened
|
|
30
|
+
prereg log "tolerance now derived from fixtures" --access "no results seen"
|
|
31
|
+
prereg check
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
unchanged V16_reliability_ceilings/PREREG.md
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Commands
|
|
39
|
+
|
|
40
|
+
| Command | What it does |
|
|
41
|
+
|---------|-------------|
|
|
42
|
+
| `prereg new <name>` | Scaffold a plan in OSF's headings |
|
|
43
|
+
| `prereg freeze` | Record the commit and hash |
|
|
44
|
+
| `prereg log <note>` | Append to the log without freezing |
|
|
45
|
+
| `prereg check` | Has the plan changed since the freeze? |
|
|
46
|
+
|
|
47
|
+
## One file, one rule
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
V16_reliability_ceilings/
|
|
51
|
+
PREREG.md the plan, then a line, then an append-only log
|
|
52
|
+
tests/ results/
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Never edit above the line. Only append below it.**
|
|
56
|
+
|
|
57
|
+
`prereg check` enforces it — the freeze records a hash of the plan, and any later edit to it
|
|
58
|
+
fails the check. Appending to the log does not.
|
|
59
|
+
|
|
60
|
+
## The log
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
2026-08-11 frozen at 9894e148e429 nothing run
|
|
64
|
+
2026-08-13 tolerance now from fixtures no results seen
|
|
65
|
+
2026-08-14 ran results not opened
|
|
66
|
+
2026-08-15 C5 failed at k=15: 6.6% vs 5% results seen
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The last column is what distinguishes an amendment from a deviation, so you never have to
|
|
70
|
+
decide which word to use. `nothing run`, `no results seen`, `results not opened`, `results
|
|
71
|
+
seen`. An entry logged before results is an amendment; one logged after is a deviation.
|
|
72
|
+
|
|
73
|
+
## Check output
|
|
74
|
+
|
|
75
|
+
| Exit | Result | Meaning |
|
|
76
|
+
|------|--------|---------|
|
|
77
|
+
| 0 | `unchanged` | The plan says what it said |
|
|
78
|
+
| 1 | `CHANGED` | The plan was edited above the line after freezing |
|
|
79
|
+
| 2 | `not frozen` | No hash recorded — nothing was measured |
|
|
80
|
+
|
|
81
|
+
`not frozen` is not a pass. It is the absence of a check.
|
|
82
|
+
|
|
83
|
+
## The plan uses OSF's headings
|
|
84
|
+
|
|
85
|
+
Verbatim, so the document maps onto an [OSF registration](https://osf.io/prereg/) without being
|
|
86
|
+
rewritten. Two of the twenty-seven do the real work:
|
|
87
|
+
|
|
88
|
+
- **Foreknowledge of data or evidence** — what you have already seen.
|
|
89
|
+
- **Inference criteria** — the decision rule as a commitment, before the number exists.
|
|
90
|
+
|
|
91
|
+
A heading that does not apply is answered `N/A` with a reason, never deleted.
|
|
92
|
+
|
|
93
|
+
## What a freeze is
|
|
94
|
+
|
|
95
|
+
A commit and a hash. The commit is the evidence — it is in history, dated, and not yours to
|
|
96
|
+
revise quietly. The hash is the convenience that lets `prereg check` tell you in a second
|
|
97
|
+
whether the plan still says what it said.
|
|
98
|
+
|
|
99
|
+
Neither proves you did not run the experiment first. Nothing can: a timestamp bounds when
|
|
100
|
+
something existed, never when work began.
|
|
101
|
+
|
|
102
|
+
## Claude Code
|
|
103
|
+
|
|
104
|
+
`plugin/` is a Claude Code plugin that tells Claude when to reach for the CLI.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
/plugin marketplace add elliottower/prereg
|
|
108
|
+
/plugin install prereg@prereg
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
MIT licensed.
|
prereg-0.1.0/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# prereg
|
|
2
|
+
|
|
3
|
+
Freeze a plan before you run it, and record what changed after.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install prereg
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
prereg new V16_reliability_ceilings
|
|
15
|
+
# fill in the plan, commit it
|
|
16
|
+
prereg freeze
|
|
17
|
+
# run the experiment, then log what happened
|
|
18
|
+
prereg log "tolerance now derived from fixtures" --access "no results seen"
|
|
19
|
+
prereg check
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
unchanged V16_reliability_ceilings/PREREG.md
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Commands
|
|
27
|
+
|
|
28
|
+
| Command | What it does |
|
|
29
|
+
|---------|-------------|
|
|
30
|
+
| `prereg new <name>` | Scaffold a plan in OSF's headings |
|
|
31
|
+
| `prereg freeze` | Record the commit and hash |
|
|
32
|
+
| `prereg log <note>` | Append to the log without freezing |
|
|
33
|
+
| `prereg check` | Has the plan changed since the freeze? |
|
|
34
|
+
|
|
35
|
+
## One file, one rule
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
V16_reliability_ceilings/
|
|
39
|
+
PREREG.md the plan, then a line, then an append-only log
|
|
40
|
+
tests/ results/
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Never edit above the line. Only append below it.**
|
|
44
|
+
|
|
45
|
+
`prereg check` enforces it — the freeze records a hash of the plan, and any later edit to it
|
|
46
|
+
fails the check. Appending to the log does not.
|
|
47
|
+
|
|
48
|
+
## The log
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
2026-08-11 frozen at 9894e148e429 nothing run
|
|
52
|
+
2026-08-13 tolerance now from fixtures no results seen
|
|
53
|
+
2026-08-14 ran results not opened
|
|
54
|
+
2026-08-15 C5 failed at k=15: 6.6% vs 5% results seen
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The last column is what distinguishes an amendment from a deviation, so you never have to
|
|
58
|
+
decide which word to use. `nothing run`, `no results seen`, `results not opened`, `results
|
|
59
|
+
seen`. An entry logged before results is an amendment; one logged after is a deviation.
|
|
60
|
+
|
|
61
|
+
## Check output
|
|
62
|
+
|
|
63
|
+
| Exit | Result | Meaning |
|
|
64
|
+
|------|--------|---------|
|
|
65
|
+
| 0 | `unchanged` | The plan says what it said |
|
|
66
|
+
| 1 | `CHANGED` | The plan was edited above the line after freezing |
|
|
67
|
+
| 2 | `not frozen` | No hash recorded — nothing was measured |
|
|
68
|
+
|
|
69
|
+
`not frozen` is not a pass. It is the absence of a check.
|
|
70
|
+
|
|
71
|
+
## The plan uses OSF's headings
|
|
72
|
+
|
|
73
|
+
Verbatim, so the document maps onto an [OSF registration](https://osf.io/prereg/) without being
|
|
74
|
+
rewritten. Two of the twenty-seven do the real work:
|
|
75
|
+
|
|
76
|
+
- **Foreknowledge of data or evidence** — what you have already seen.
|
|
77
|
+
- **Inference criteria** — the decision rule as a commitment, before the number exists.
|
|
78
|
+
|
|
79
|
+
A heading that does not apply is answered `N/A` with a reason, never deleted.
|
|
80
|
+
|
|
81
|
+
## What a freeze is
|
|
82
|
+
|
|
83
|
+
A commit and a hash. The commit is the evidence — it is in history, dated, and not yours to
|
|
84
|
+
revise quietly. The hash is the convenience that lets `prereg check` tell you in a second
|
|
85
|
+
whether the plan still says what it said.
|
|
86
|
+
|
|
87
|
+
Neither proves you did not run the experiment first. Nothing can: a timestamp bounds when
|
|
88
|
+
something existed, never when work began.
|
|
89
|
+
|
|
90
|
+
## Claude Code
|
|
91
|
+
|
|
92
|
+
`plugin/` is a Claude Code plugin that tells Claude when to reach for the CLI.
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
/plugin marketplace add elliottower/prereg
|
|
96
|
+
/plugin install prereg@prereg
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
MIT licensed.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "prereg",
|
|
3
|
+
"description": "Freeze a plan before you run it, and record what changed after",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"author": { "name": "Elliot Tower", "email": "elliot@elliottower.ai" },
|
|
6
|
+
"repository": "https://github.com/elliottower/prereg",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": ["preregistration", "reproducibility", "provenance", "experiments"]
|
|
9
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# The Claude Code plugin
|
|
2
|
+
|
|
3
|
+
Tells Claude the `prereg` CLI exists and when to reach for it. The CLI does the work; this adds
|
|
4
|
+
nothing the CLI cannot do.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
/plugin marketplace add elliottower/prereg
|
|
10
|
+
/plugin install prereg@prereg
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The plugin ships instructions, not binaries. Install the tool too:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv tool install prereg
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
For development against a checkout:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
/plugin marketplace add ~/Documents/GitHub/prereg
|
|
23
|
+
/plugin install prereg@prereg
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## What it changes
|
|
27
|
+
|
|
28
|
+
Claude will freeze a plan before running it rather than after, commit the PREREG.md on its own so
|
|
29
|
+
the freeze is not contaminated by a code change, and log a change to a frozen plan instead of
|
|
30
|
+
editing it quietly. It will also stop writing `**Status:** FROZEN` headers by hand, which produce a
|
|
31
|
+
document that reads as registered and cannot be verified.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: prereg
|
|
3
|
+
description: Freeze an experiment's plan against a commit and a content hash before running it, then log amendments and deviations. Use when writing or freezing a PREREG.md, before launching a run a plan governs, when a frozen plan needs to change, or when asked whether a plan still says what it said. Requires the `prereg` CLI (`uv tool install prereg`).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# prereg
|
|
7
|
+
|
|
8
|
+
A plan, frozen against a commit and a hash, plus an append-only log of what changed after.
|
|
9
|
+
|
|
10
|
+
## The rule that matters
|
|
11
|
+
|
|
12
|
+
**Freeze before you look. Never write the header by hand.**
|
|
13
|
+
|
|
14
|
+
A pre-registration is worth exactly one thing: evidence that the predictions existed before the
|
|
15
|
+
data did. Every way it fails is a version of the plan moving after the numbers were seen — a
|
|
16
|
+
threshold nudged, a hypothesis dropped, a subgroup added. The freeze exists so that movement leaves
|
|
17
|
+
a trace, and a hand-written `**Status:** FROZEN` header leaves none. It produces a document that
|
|
18
|
+
reads as registered and cannot be verified, which is worse than one that never claimed to be.
|
|
19
|
+
|
|
20
|
+
`**Status:**`, `**Plan sha256:**` and `**Frozen:**` are the tool's output. Writing them yourself is
|
|
21
|
+
the one thing this skill exists to prevent.
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
prereg new <name> # scaffold PREREG.md in OSF's headings, plus tests/ and results/
|
|
27
|
+
prereg freeze # write the header, hash the plan, append to the log
|
|
28
|
+
prereg log <note> --access <level>
|
|
29
|
+
prereg check # has anything above the log line changed since the freeze?
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Freezing, in order
|
|
33
|
+
|
|
34
|
+
1. **Write the plan, then get it reviewed.** A plan frozen without review registers the author's
|
|
35
|
+
guesses, not an agreed design.
|
|
36
|
+
2. **Commit the PREREG.md alone.** No code in that commit. A freeze whose commit also carries a
|
|
37
|
+
code change cannot distinguish the registered design from the change made while registering it.
|
|
38
|
+
3. **`prereg freeze`** in the experiment directory. It refuses on a dirty tree, because the freeze
|
|
39
|
+
names a commit.
|
|
40
|
+
4. **Commit the freeze header.** The freeze is only evidence once it is in history.
|
|
41
|
+
5. **Then run.** Not before step 4.
|
|
42
|
+
|
|
43
|
+
## Reading `prereg check`
|
|
44
|
+
|
|
45
|
+
Three results, exhaustive:
|
|
46
|
+
|
|
47
|
+
| exit | | |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| 0 | `unchanged` | the plan says what it said |
|
|
50
|
+
| 1 | `CHANGED` | the plan was edited above the log line after freezing |
|
|
51
|
+
| 2 | `not frozen` | no hash recorded — **nothing was measured** |
|
|
52
|
+
|
|
53
|
+
**`not frozen` is not a pass.** It is the absence of a check, and it reads identically to success
|
|
54
|
+
if you only look at whether the command complained.
|
|
55
|
+
|
|
56
|
+
**`CHANGED` is not fixed by re-freezing.** Re-freezing overwrites the evidence that the plan moved.
|
|
57
|
+
Restore the plan and `prereg log` the change with an honest `--access`.
|
|
58
|
+
|
|
59
|
+
At a repository root with no governing plan, `check` checks every plan below it.
|
|
60
|
+
|
|
61
|
+
## Amendments and deviations
|
|
62
|
+
|
|
63
|
+
`--access` is one of `nothing run`, `no results seen`, `results not opened`, `results seen`. It
|
|
64
|
+
records what was known when the change was made, so the distinction is never a judgment call: an
|
|
65
|
+
entry logged before results is an amendment, one logged after is a deviation. Log the honest level
|
|
66
|
+
even when it is the damaging one — that is the entire function of the field.
|
|
67
|
+
|
|
68
|
+
## When to reach for this
|
|
69
|
+
|
|
70
|
+
- Before launching any run whose result will be reported as confirmatory
|
|
71
|
+
- When a plan is ready to freeze, after review
|
|
72
|
+
- When a frozen plan has to change — log first, never edit silently
|
|
73
|
+
- Before reporting a result a plan governs, to confirm the plan still says what it said
|
|
74
|
+
- In CI, as `prereg check`
|
|
75
|
+
|
|
76
|
+
## Non-obvious behavior
|
|
77
|
+
|
|
78
|
+
- `freeze` is idempotent: the commit, digest and date sit on lines the hash skips, so re-freezing
|
|
79
|
+
an unedited plan reproduces its hash.
|
|
80
|
+
- `--force` re-freezes an already-frozen plan. Use it when the plan legitimately changed and was
|
|
81
|
+
re-committed, never to clear a `CHANGED` warning.
|
|
82
|
+
- Appending below the log line is the allowed edit and does not fail `check`.
|
|
83
|
+
|
|
84
|
+
## What it will not do
|
|
85
|
+
|
|
86
|
+
It cannot prove you did not run the experiment first. Nothing can: a timestamp bounds when
|
|
87
|
+
something existed, never when the work began. It also cannot tell you the plan was any good — a
|
|
88
|
+
frozen bad design is still a bad design, registered.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "prereg"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Freeze a plan before you run it, and record what changed after"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Elliot Tower", email = "elliot@elliottower.ai" }]
|
|
9
|
+
keywords = ["preregistration", "reproducibility", "open science", "osf"]
|
|
10
|
+
dependencies = ["PyYAML>=6.0"]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
prereg = "prereg.cli:main"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["hatchling"]
|
|
17
|
+
build-backend = "hatchling.build"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["src/prereg"]
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Freeze a plan before you run it, and record what changed after.
|
|
2
|
+
|
|
3
|
+
prereg new <name> scaffold the plan, in OSF's headings
|
|
4
|
+
prereg freeze record the commit and hash, append to the log
|
|
5
|
+
prereg log <note> append a line without freezing
|
|
6
|
+
prereg check has anything above the line changed since the freeze?
|
|
7
|
+
|
|
8
|
+
One file per experiment, one rule: never edit above the line, only append below it.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import datetime
|
|
14
|
+
import hashlib
|
|
15
|
+
import pathlib
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from prereg import template
|
|
21
|
+
|
|
22
|
+
PREREG = "PREREG.md"
|
|
23
|
+
MARK = "\n---\n\n## Log\n"
|
|
24
|
+
ACCESS = ["nothing run", "no results seen", "results not opened", "results seen"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def today() -> str:
|
|
28
|
+
return datetime.date.today().isoformat()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def git(*args, cwd=None) -> str:
|
|
32
|
+
r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
|
33
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def find(start: pathlib.Path | None = None) -> pathlib.Path | None:
|
|
37
|
+
"""The PREREG.md governing this directory: here, or the nearest one above."""
|
|
38
|
+
here = (start or pathlib.Path.cwd()).resolve()
|
|
39
|
+
for d in [here, *here.parents]:
|
|
40
|
+
if (d / PREREG).is_file():
|
|
41
|
+
return d / PREREG
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def plan_of(text: str) -> str:
|
|
46
|
+
"""What the freeze hashes: the plan, minus its own status block.
|
|
47
|
+
|
|
48
|
+
The status lines carry the commit, the hash and the freeze date, and they are written into
|
|
49
|
+
the file by `freeze` itself. Including them would mean the hash covered a value derived
|
|
50
|
+
from the hash, so the check could never pass.
|
|
51
|
+
"""
|
|
52
|
+
i = text.find(MARK)
|
|
53
|
+
plan = text if i < 0 else text[:i]
|
|
54
|
+
keep = [ln for ln in plan.splitlines()
|
|
55
|
+
if not ln.startswith(("**Status:**", "**Plan sha256:**", "**Frozen:**"))]
|
|
56
|
+
return "\n".join(keep).strip() + "\n"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sha256_of(s: str) -> str:
|
|
60
|
+
return hashlib.sha256(s.encode()).hexdigest()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def unhashed_content(text: str) -> list[str]:
|
|
64
|
+
"""Parts of the plan the hash would not cover, which `freeze` refuses to register.
|
|
65
|
+
|
|
66
|
+
Two things are skipped when hashing, both for good reasons, and both exploitable if they
|
|
67
|
+
appear where they are not meant to. The log marker ends the hashed region, so a second one
|
|
68
|
+
in the body leaves everything after it editable without `check` noticing. Marker-prefixed
|
|
69
|
+
lines are skipped because `freeze` writes them, so one in the body is editable the same way.
|
|
70
|
+
|
|
71
|
+
Refusing is better than hashing them anyway: changing what the hash covers would invalidate
|
|
72
|
+
every plan already frozen, while refusing only affects plans not yet registered.
|
|
73
|
+
"""
|
|
74
|
+
problems = []
|
|
75
|
+
if text.count(MARK) > 1:
|
|
76
|
+
problems.append(
|
|
77
|
+
"the log marker (`---` then `## Log`) appears more than once. Hashing stops at the "
|
|
78
|
+
"first, so the plan after it would not be covered.")
|
|
79
|
+
i = text.find(MARK)
|
|
80
|
+
plan = text if i < 0 else text[:i]
|
|
81
|
+
m = STATUS_BLOCK.search(plan)
|
|
82
|
+
body = (plan[:m.start()] + plan[m.end():]) if m else plan
|
|
83
|
+
stray = [ln for ln in body.splitlines()
|
|
84
|
+
if ln.startswith(("**Status:**", "**Plan sha256:**", "**Frozen:**"))]
|
|
85
|
+
if stray:
|
|
86
|
+
problems.append(
|
|
87
|
+
"these lines sit outside the status block and are skipped when hashing, so they "
|
|
88
|
+
"could be edited after freezing without `check` noticing:\n "
|
|
89
|
+
+ "\n ".join(stray[:5]))
|
|
90
|
+
return problems
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
STATUS_BLOCK = re.compile(r"^\*\*Status:\*\*.*?(?=\n[ \t]*\n|\Z)", re.S | re.M)
|
|
94
|
+
|
|
95
|
+
# A status line may carry a note after its sentence — "third version; see Log".
|
|
96
|
+
# The note is plan content and has to survive a freeze; the marker and its own
|
|
97
|
+
# value do not.
|
|
98
|
+
STATUS_VALUES = [
|
|
99
|
+
re.compile(r"^\*\*Status:\*\*[ \t]*DRAFT[ \t]*—[ \t]*not frozen\.?"),
|
|
100
|
+
re.compile(r"^\*\*Status:\*\*[ \t]*FROZEN at `[^`]*`\.?"),
|
|
101
|
+
re.compile(r"^\*\*Plan sha256:\*\*[ \t]*`[0-9a-f]*`\.?"),
|
|
102
|
+
re.compile(r"^\*\*Frozen:\*\*[ \t]*\d{4}-\d{2}-\d{2}\.?"),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def status_note(block: str) -> str:
|
|
107
|
+
"""Whatever the status block says beyond the markers' own values."""
|
|
108
|
+
out = []
|
|
109
|
+
for line in block.splitlines():
|
|
110
|
+
for rx in STATUS_VALUES:
|
|
111
|
+
stripped = rx.sub("", line, count=1)
|
|
112
|
+
if stripped != line:
|
|
113
|
+
line = stripped
|
|
114
|
+
break
|
|
115
|
+
if line.strip():
|
|
116
|
+
out.append(line.strip())
|
|
117
|
+
return " ".join(out)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def rewrite_status(text: str, commit: str, digest: str, date: str) -> str:
|
|
121
|
+
"""Replace the whole status block, draft or already frozen.
|
|
122
|
+
|
|
123
|
+
Matching only the literal draft sentence did nothing on a plan that was
|
|
124
|
+
already frozen, so `--force` printed a new hash, wrote none of it, and left
|
|
125
|
+
the plan failing its own check — silently, with a zero exit code. Matching
|
|
126
|
+
the block also stops a note written after `**Status:**` from being glued onto
|
|
127
|
+
the freeze date, which is what a prefix-only replacement did to it.
|
|
128
|
+
"""
|
|
129
|
+
m = STATUS_BLOCK.search(text)
|
|
130
|
+
if m is None:
|
|
131
|
+
return text
|
|
132
|
+
note = status_note(m.group(0))
|
|
133
|
+
block = (f"**Status:** FROZEN at `{commit[:12]}`\n"
|
|
134
|
+
f"**Plan sha256:** `{digest}`\n"
|
|
135
|
+
f"**Frozen:** {date}")
|
|
136
|
+
if note:
|
|
137
|
+
block += f"\n{note}"
|
|
138
|
+
return text[:m.start()] + block + text[m.end():]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def append(path: pathlib.Path, date: str, event: str, access: str) -> None:
|
|
142
|
+
text = path.read_text()
|
|
143
|
+
if MARK not in text:
|
|
144
|
+
text += MARK.rstrip("\n") + "\n\n```\n```\n"
|
|
145
|
+
head, _, tail = text.partition(MARK)
|
|
146
|
+
# Two spaces, not just padding. `{event:<36}` emits nothing extra once the note passes 36
|
|
147
|
+
# characters, and the access level then runs into the note — losing the boundary of the one
|
|
148
|
+
# field that separates an amendment from a deviation.
|
|
149
|
+
line = f"{date} {event:<36} {access}"
|
|
150
|
+
if "```" in tail:
|
|
151
|
+
before, fence, after = tail.rpartition("```")
|
|
152
|
+
tail = before.rstrip("\n") + f"\n{line}\n" + fence + after
|
|
153
|
+
else:
|
|
154
|
+
tail = tail.rstrip("\n") + f"\n{line}\n"
|
|
155
|
+
path.write_text(head + MARK + tail)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_new(a) -> int:
|
|
159
|
+
d = pathlib.Path(a.name)
|
|
160
|
+
if (d / PREREG).exists():
|
|
161
|
+
print(f"{d / PREREG} already exists")
|
|
162
|
+
return 1
|
|
163
|
+
(d / "tests").mkdir(parents=True, exist_ok=True)
|
|
164
|
+
(d / "results").mkdir(exist_ok=True)
|
|
165
|
+
title = a.title or d.name.replace("_", " ").replace("-", " ")
|
|
166
|
+
(d / PREREG).write_text(template.render(title, today()))
|
|
167
|
+
print(f"created {d}/")
|
|
168
|
+
print(f" {PREREG} the plan, in OSF's headings")
|
|
169
|
+
print(f" tests/ results/")
|
|
170
|
+
print("\nfill it in, then `prereg freeze`. Never edit above the log line afterwards.")
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cmd_freeze(a) -> int:
|
|
175
|
+
path = find()
|
|
176
|
+
if path is None:
|
|
177
|
+
print(f"no {PREREG} here or above. `prereg new <name>` makes one.")
|
|
178
|
+
return 2
|
|
179
|
+
text = path.read_text()
|
|
180
|
+
if STATUS_BLOCK.search(text) is None:
|
|
181
|
+
print(f"{path} has no `**Status:**` line, so there is nowhere to record the freeze.")
|
|
182
|
+
print("Add one — `**Status:** DRAFT — not frozen.` — or scaffold with `prereg new`.")
|
|
183
|
+
return 1
|
|
184
|
+
if "**Status:** DRAFT" not in text and not a.force:
|
|
185
|
+
print(f"{path} is already frozen. Use `prereg log` to append, or --force.")
|
|
186
|
+
return 1
|
|
187
|
+
|
|
188
|
+
problems = unhashed_content(text)
|
|
189
|
+
if problems:
|
|
190
|
+
print(f"{path} has content the freeze would not cover:\n")
|
|
191
|
+
for p in problems:
|
|
192
|
+
print(f" - {p}")
|
|
193
|
+
print("\nA freeze that leaves part of the plan editable is worse than none, because it"
|
|
194
|
+
"\nreads as registered. Fix these and freeze again.")
|
|
195
|
+
return 1
|
|
196
|
+
|
|
197
|
+
repo = path.parent
|
|
198
|
+
dirty = git("status", "--porcelain", str(path), cwd=repo)
|
|
199
|
+
if dirty and not a.force:
|
|
200
|
+
print(f"{path} has uncommitted changes. Commit first — the freeze names a commit.")
|
|
201
|
+
return 1
|
|
202
|
+
|
|
203
|
+
commit = git("rev-parse", "HEAD", cwd=repo) or "(not in a git repository)"
|
|
204
|
+
# Normalize the layout first, then hash. Freezing moves any status note onto
|
|
205
|
+
# its own line, and `plan_of` skips marker lines but not that one, so hashing
|
|
206
|
+
# the pre-freeze text would store a digest of a layout the file no longer has
|
|
207
|
+
# and `check` would fail on the freeze itself. Hashing after also makes the
|
|
208
|
+
# freeze idempotent: commit and date sit on skipped lines, so re-freezing an
|
|
209
|
+
# unedited plan reproduces the same digest.
|
|
210
|
+
placeholder = "0" * 64
|
|
211
|
+
text = rewrite_status(text, commit, placeholder, today())
|
|
212
|
+
digest = sha256_of(plan_of(text))
|
|
213
|
+
text = text.replace(f"`{placeholder}`", f"`{digest}`", 1)
|
|
214
|
+
path.write_text(text)
|
|
215
|
+
append(path, today(), f"frozen at {commit[:12]}", "nothing run")
|
|
216
|
+
print(f"frozen {path}")
|
|
217
|
+
print(f" commit {commit[:12]}")
|
|
218
|
+
print(f" sha256 {digest[:16]}… (of everything above the log)")
|
|
219
|
+
print("\nCommit this. The freeze is only evidence once it is in history.")
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def cmd_log(a) -> int:
|
|
224
|
+
path = find()
|
|
225
|
+
if path is None:
|
|
226
|
+
print(f"no {PREREG} here or above.")
|
|
227
|
+
return 2
|
|
228
|
+
if a.access not in ACCESS:
|
|
229
|
+
print(f"access must be one of: {', '.join(ACCESS)}")
|
|
230
|
+
return 1
|
|
231
|
+
# The log is the tamper record, so a note is not free text. One line of it is one entry, and
|
|
232
|
+
# a note carrying a newline writes a second line that reads exactly like an entry somebody
|
|
233
|
+
# made — including a `frozen at ...` one. A fence closes the block early and puts everything
|
|
234
|
+
# after it outside the log.
|
|
235
|
+
if "\n" in a.note or "\r" in a.note:
|
|
236
|
+
print("a note is one line — a newline in it would read as a second log entry.")
|
|
237
|
+
return 1
|
|
238
|
+
if "```" in a.note:
|
|
239
|
+
print("a note cannot contain ``` — it would close the log block early.")
|
|
240
|
+
return 1
|
|
241
|
+
append(path, today(), a.note, a.access)
|
|
242
|
+
print(f"logged: {a.note} ({a.access})")
|
|
243
|
+
if a.access == "results seen":
|
|
244
|
+
print("\nRecorded as a deviation: the results were already known.")
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def check_one(path: pathlib.Path) -> int:
|
|
249
|
+
"""0 unchanged, 1 changed, 2 not frozen."""
|
|
250
|
+
text = path.read_text()
|
|
251
|
+
m = re.search(r"\*\*Plan sha256:\*\* `([0-9a-f]{64})`", text)
|
|
252
|
+
if not m:
|
|
253
|
+
print(f"not frozen {path}")
|
|
254
|
+
return 2
|
|
255
|
+
now = sha256_of(plan_of(text))
|
|
256
|
+
if now == m.group(1):
|
|
257
|
+
print(f"unchanged {path}")
|
|
258
|
+
return 0
|
|
259
|
+
print(f"CHANGED {path}")
|
|
260
|
+
print(f" frozen {m.group(1)[:16]}…")
|
|
261
|
+
print(f" now {now[:16]}…")
|
|
262
|
+
return 1
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def cmd_check(a) -> int:
|
|
266
|
+
"""Check the governing plan, or every plan below when there is none.
|
|
267
|
+
|
|
268
|
+
A repository usually holds one plan per experiment, side by side, so running this at the
|
|
269
|
+
root has to mean "check them all" — otherwise the command is unusable from the one place
|
|
270
|
+
someone would naturally run it.
|
|
271
|
+
"""
|
|
272
|
+
path = find()
|
|
273
|
+
if path is not None:
|
|
274
|
+
rc = check_one(path)
|
|
275
|
+
if rc == 1:
|
|
276
|
+
print("\nThe plan was edited after freezing. Restore it and record the change in the log.")
|
|
277
|
+
elif rc == 2:
|
|
278
|
+
print("\nNothing to check against yet. `prereg freeze` records the hash.")
|
|
279
|
+
return rc
|
|
280
|
+
|
|
281
|
+
found = sorted(pathlib.Path.cwd().rglob(PREREG))
|
|
282
|
+
if not found:
|
|
283
|
+
print(f"no {PREREG} here, above, or below.")
|
|
284
|
+
return 2
|
|
285
|
+
|
|
286
|
+
codes = [check_one(f) for f in found]
|
|
287
|
+
changed = codes.count(1)
|
|
288
|
+
print(f"\n{len(found)} plans: {codes.count(0)} unchanged, {changed} changed, "
|
|
289
|
+
f"{codes.count(2)} not frozen")
|
|
290
|
+
if changed:
|
|
291
|
+
print("A changed plan was edited after freezing. Restore it and record the change.")
|
|
292
|
+
return 1 if changed else 0
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def main() -> int:
|
|
296
|
+
ap = argparse.ArgumentParser(prog="prereg", description=__doc__.split("\n")[0])
|
|
297
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
298
|
+
|
|
299
|
+
n = sub.add_parser("new", help="scaffold a plan")
|
|
300
|
+
n.add_argument("name")
|
|
301
|
+
n.add_argument("--title")
|
|
302
|
+
n.set_defaults(fn=cmd_new)
|
|
303
|
+
|
|
304
|
+
f = sub.add_parser("freeze", help="record the commit and hash")
|
|
305
|
+
f.add_argument("--force", action="store_true")
|
|
306
|
+
f.set_defaults(fn=cmd_freeze)
|
|
307
|
+
|
|
308
|
+
lg = sub.add_parser("log", help="append a line")
|
|
309
|
+
lg.add_argument("note")
|
|
310
|
+
lg.add_argument("--access", default="no results seen",
|
|
311
|
+
help=f"one of: {', '.join(ACCESS)}")
|
|
312
|
+
lg.set_defaults(fn=cmd_log)
|
|
313
|
+
|
|
314
|
+
c = sub.add_parser("check", help="has the plan changed since the freeze?")
|
|
315
|
+
c.set_defaults(fn=cmd_check)
|
|
316
|
+
|
|
317
|
+
a = ap.parse_args()
|
|
318
|
+
if not a.cmd:
|
|
319
|
+
ap.print_help()
|
|
320
|
+
return 0
|
|
321
|
+
return a.fn(a)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
sys.exit(main())
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""The plan, under OSF's question titles.
|
|
2
|
+
|
|
3
|
+
Using OSF's headings verbatim costs nothing and means the document maps onto a registration
|
|
4
|
+
without being rewritten. Two of them do the real work:
|
|
5
|
+
|
|
6
|
+
Foreknowledge of data or evidence forces you to say what you have already seen
|
|
7
|
+
Inference criteria forces the decision rule to be a commitment
|
|
8
|
+
|
|
9
|
+
A heading that does not apply is answered N/A with a reason, never deleted. A deleted heading
|
|
10
|
+
and an inapplicable one look identical in a file and very different to a reader.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
# Verbatim from the OSF Preregistration schema, in order.
|
|
15
|
+
QUESTIONS = [
|
|
16
|
+
("Research questions or hypotheses", "What is being asked, and what would count as an answer."),
|
|
17
|
+
("Foreknowledge of data or evidence",
|
|
18
|
+
"What have you already seen? Pilot runs, exploratory results, anything from a related "
|
|
19
|
+
"study. If nothing, say so."),
|
|
20
|
+
("Explanation of foreknowledge and managing unintended influences",
|
|
21
|
+
"How the answer above does or does not constrain the predictions below."),
|
|
22
|
+
("Study type", "Experimental, observational, meta-analytic."),
|
|
23
|
+
("Intention for causal interpretation", "Or N/A if no causal claim is intended."),
|
|
24
|
+
("Blinding of experimental treatments", ""),
|
|
25
|
+
("Additional blinding during research or analysis", ""),
|
|
26
|
+
("Study design", ""),
|
|
27
|
+
("Randomization", ""),
|
|
28
|
+
("Data collection procedures", ""),
|
|
29
|
+
("Data collection procedures - File upload", ""),
|
|
30
|
+
("Sample size", "And what it can and cannot detect."),
|
|
31
|
+
("Sample size rationale", ""),
|
|
32
|
+
("Starting and stopping rules", ""),
|
|
33
|
+
("Manipulated variables", ""),
|
|
34
|
+
("Measured variables", ""),
|
|
35
|
+
("Measured variables - File upload", ""),
|
|
36
|
+
("Indices", ""),
|
|
37
|
+
("Indices - File upload", ""),
|
|
38
|
+
("Statistical models", ""),
|
|
39
|
+
("Statistical models - File upload", ""),
|
|
40
|
+
("Transformations", ""),
|
|
41
|
+
("Inference criteria",
|
|
42
|
+
"The decision rule, as a commitment, before the number exists. A threshold, not a hope."),
|
|
43
|
+
("Data inclusion and exclusion", ""),
|
|
44
|
+
("Missing data", ""),
|
|
45
|
+
("Other planned analysis", "Anything beyond the above is exploratory and labelled so."),
|
|
46
|
+
("Context and additional information", ""),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
HEADER = """\
|
|
50
|
+
# {title}
|
|
51
|
+
|
|
52
|
+
**Status:** DRAFT — not frozen.
|
|
53
|
+
|
|
54
|
+
Sections use the [OSF Preregistration](https://osf.io/prereg/) question titles verbatim, so
|
|
55
|
+
this maps onto a registration without being rewritten. A question that does not apply is
|
|
56
|
+
answered **N/A** with the reason, never deleted.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
LOG = """\
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Log
|
|
64
|
+
|
|
65
|
+
Append only. Never edit above the line.
|
|
66
|
+
|
|
67
|
+
The last column is what distinguishes an amendment from a deviation, so you do not have to
|
|
68
|
+
decide which word to use: `nothing run`, `no results seen`, `results not opened`, `results seen`.
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
{date} created nothing run
|
|
72
|
+
```
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render(title: str, date: str) -> str:
|
|
77
|
+
parts = [HEADER.format(title=title)]
|
|
78
|
+
for q, hint in QUESTIONS:
|
|
79
|
+
parts.append(f"\n## {q}\n")
|
|
80
|
+
parts.append(f"_{hint}_\n" if hint else "N/A — \n")
|
|
81
|
+
parts.append(LOG.format(date=date))
|
|
82
|
+
return "".join(parts)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""A freeze is only worth anything if editing the plan afterwards is detectable."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from prereg import cli
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(args, cwd):
|
|
13
|
+
return subprocess.run([sys.executable, "-m", "prereg.cli", *args],
|
|
14
|
+
cwd=cwd, capture_output=True, text=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def repo(tmp_path):
|
|
19
|
+
subprocess.run(["git", "init", "-q"], cwd=tmp_path)
|
|
20
|
+
run(["new", "study"], tmp_path)
|
|
21
|
+
subprocess.run(["git", "add", "-A"], cwd=tmp_path)
|
|
22
|
+
subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t",
|
|
23
|
+
"commit", "-q", "-m", "init"], cwd=tmp_path)
|
|
24
|
+
return tmp_path / "study"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_new_uses_the_osf_headings(tmp_path):
|
|
28
|
+
run(["new", "study"], tmp_path)
|
|
29
|
+
text = (tmp_path / "study" / "PREREG.md").read_text()
|
|
30
|
+
for q, _ in cli.template.QUESTIONS:
|
|
31
|
+
assert f"## {q}" in text
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_the_two_headings_that_do_the_work_are_present(tmp_path):
|
|
35
|
+
run(["new", "study"], tmp_path)
|
|
36
|
+
text = (tmp_path / "study" / "PREREG.md").read_text()
|
|
37
|
+
assert "## Foreknowledge of data or evidence" in text
|
|
38
|
+
assert "## Inference criteria" in text
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_check_passes_immediately_after_freeze(repo):
|
|
42
|
+
run(["freeze"], repo)
|
|
43
|
+
assert run(["check"], repo).returncode == 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_editing_the_plan_is_detected(repo):
|
|
47
|
+
run(["freeze"], repo)
|
|
48
|
+
p = repo / "PREREG.md"
|
|
49
|
+
p.write_text(p.read_text().replace("## Randomization", "## Randomisation"))
|
|
50
|
+
r = run(["check"], repo)
|
|
51
|
+
assert r.returncode == 1
|
|
52
|
+
assert "CHANGED" in r.stdout
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_appending_to_the_log_is_not_a_change(repo):
|
|
56
|
+
run(["freeze"], repo)
|
|
57
|
+
run(["log", "tolerance from fixtures", "--access", "no results seen"], repo)
|
|
58
|
+
assert run(["check"], repo).returncode == 0, "appending below the line is the allowed edit"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_freezing_twice_is_refused(repo):
|
|
62
|
+
run(["freeze"], repo)
|
|
63
|
+
assert run(["freeze"], repo).returncode == 1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_freeze_refuses_uncommitted_changes(tmp_path):
|
|
67
|
+
subprocess.run(["git", "init", "-q"], cwd=tmp_path)
|
|
68
|
+
run(["new", "study"], tmp_path)
|
|
69
|
+
r = run(["freeze"], tmp_path / "study")
|
|
70
|
+
assert r.returncode == 1
|
|
71
|
+
assert "Commit first" in r.stdout
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_an_unknown_access_value_is_refused(repo):
|
|
75
|
+
run(["freeze"], repo)
|
|
76
|
+
r = run(["log", "something", "--access", "probably fine"], repo)
|
|
77
|
+
assert r.returncode == 1
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_results_seen_is_called_a_deviation(repo):
|
|
81
|
+
run(["freeze"], repo)
|
|
82
|
+
r = run(["log", "criterion failed", "--access", "results seen"], repo)
|
|
83
|
+
assert "deviation" in r.stdout
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_check_on_an_unfrozen_plan_does_not_report_success(tmp_path):
|
|
87
|
+
run(["new", "study"], tmp_path)
|
|
88
|
+
assert run(["check"], tmp_path / "study").returncode != 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_commands_find_the_plan_from_a_subdirectory(repo):
|
|
92
|
+
run(["freeze"], repo)
|
|
93
|
+
(repo / "results").mkdir(exist_ok=True)
|
|
94
|
+
assert run(["check"], repo / "results").returncode == 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_force_refreeze_rewrites_the_header_it_prints(repo):
|
|
98
|
+
run(["freeze"], repo)
|
|
99
|
+
p = repo / "PREREG.md"
|
|
100
|
+
p.write_text(p.read_text().replace("## Randomization", "## Randomization\n\nBy seed."))
|
|
101
|
+
subprocess.run(["git", "add", "-A"], cwd=repo)
|
|
102
|
+
subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t",
|
|
103
|
+
"commit", "-q", "-m", "edit"], cwd=repo)
|
|
104
|
+
r = run(["freeze", "--force"], repo)
|
|
105
|
+
printed = [w for w in r.stdout.split() if len(w.rstrip("…")) == 16][-1].rstrip("…")
|
|
106
|
+
assert printed in p.read_text(), "freeze printed a digest it did not write"
|
|
107
|
+
assert run(["check"], repo).returncode == 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_status_note_survives_the_freeze_intact(repo):
|
|
111
|
+
p = repo / "PREREG.md"
|
|
112
|
+
p.write_text(p.read_text().replace(
|
|
113
|
+
"**Status:** DRAFT — not frozen.",
|
|
114
|
+
"**Status:** DRAFT — not frozen. Third version; see Log."))
|
|
115
|
+
subprocess.run(["git", "add", "-A"], cwd=repo)
|
|
116
|
+
subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t",
|
|
117
|
+
"commit", "-q", "-m", "note"], cwd=repo)
|
|
118
|
+
run(["freeze"], repo)
|
|
119
|
+
text = p.read_text()
|
|
120
|
+
frozen_line = next(ln for ln in text.splitlines() if ln.startswith("**Frozen:**"))
|
|
121
|
+
assert frozen_line.strip().endswith(cli.today()), \
|
|
122
|
+
f"note was glued onto the freeze date: {frozen_line!r}"
|
|
123
|
+
assert "Third version; see Log." in text
|
|
124
|
+
assert run(["check"], repo).returncode == 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_refreezing_an_unedited_plan_is_idempotent(repo):
|
|
128
|
+
run(["freeze"], repo)
|
|
129
|
+
first = (repo / "PREREG.md").read_text()
|
|
130
|
+
digest = cli.re.search(r"`([0-9a-f]{64})`", first).group(1)
|
|
131
|
+
run(["freeze", "--force"], repo)
|
|
132
|
+
second = (repo / "PREREG.md").read_text()
|
|
133
|
+
assert cli.re.search(r"`([0-9a-f]{64})`", second).group(1) == digest
|
|
134
|
+
assert run(["check"], repo).returncode == 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --- what the hash does not cover ------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def test_freeze_refuses_a_plan_with_no_status_line(repo):
|
|
140
|
+
p = repo / "PREREG.md"
|
|
141
|
+
p.write_text(p.read_text().replace("**Status:** DRAFT — not frozen.", ""))
|
|
142
|
+
subprocess.run(["git", "add", "-A"], cwd=repo)
|
|
143
|
+
subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t",
|
|
144
|
+
"commit", "-q", "-m", "no status"], cwd=repo)
|
|
145
|
+
r = run(["freeze"], repo)
|
|
146
|
+
assert r.returncode != 0, "reported a freeze it did not perform"
|
|
147
|
+
assert "not frozen" not in run(["check"], repo).stdout or r.returncode != 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_a_status_marker_in_the_body_cannot_hide_from_the_hash(repo):
|
|
151
|
+
"""Marker-prefixed lines are skipped when hashing, so one in the body would be editable
|
|
152
|
+
after freezing without `check` noticing."""
|
|
153
|
+
p = repo / "PREREG.md"
|
|
154
|
+
p.write_text(p.read_text().replace(
|
|
155
|
+
"## Randomization",
|
|
156
|
+
"## Randomization\n\n**Frozen:** whatever the author likes\n"))
|
|
157
|
+
subprocess.run(["git", "add", "-A"], cwd=repo)
|
|
158
|
+
subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t",
|
|
159
|
+
"commit", "-q", "-m", "marker in body"], cwd=repo)
|
|
160
|
+
r = run(["freeze"], repo)
|
|
161
|
+
assert r.returncode == 1, "froze a plan whose body line the hash does not cover"
|
|
162
|
+
assert "**Frozen:** whatever the author likes" in r.stdout, \
|
|
163
|
+
"the refusal must name the offending line"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_a_log_marker_in_the_body_cannot_truncate_the_hash(repo):
|
|
167
|
+
"""Hashing stops at the log marker. One in the body would leave the real plan after it
|
|
168
|
+
unhashed and freely editable."""
|
|
169
|
+
p = repo / "PREREG.md"
|
|
170
|
+
p.write_text(p.read_text().replace(
|
|
171
|
+
"## Randomization",
|
|
172
|
+
"## Randomization\n\nSeeds 0-4.\n\n---\n\n## Log\n\n## Sample size\n\nn=300 per arm.\n"))
|
|
173
|
+
subprocess.run(["git", "add", "-A"], cwd=repo)
|
|
174
|
+
subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t",
|
|
175
|
+
"commit", "-q", "-m", "marker in body"], cwd=repo)
|
|
176
|
+
r = run(["freeze"], repo)
|
|
177
|
+
assert r.returncode == 1, "froze a plan whose tail the hash does not cover"
|
|
178
|
+
assert "more than once" in r.stdout
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_line_endings_do_not_change_the_hash(repo):
|
|
182
|
+
run(["freeze"], repo)
|
|
183
|
+
p = repo / "PREREG.md"
|
|
184
|
+
p.write_bytes(p.read_text().replace("\n", "\r\n").encode())
|
|
185
|
+
assert run(["check"], repo).returncode == 0, \
|
|
186
|
+
"a checkout with CRLF endings must not read as a tampered plan"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_trailing_whitespace_alone_is_not_a_change(repo):
|
|
190
|
+
run(["freeze"], repo)
|
|
191
|
+
p = repo / "PREREG.md"
|
|
192
|
+
p.write_text(p.read_text() + "\n\n")
|
|
193
|
+
assert run(["check"], repo).returncode == 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# --- the log is the tamper record, so it is worth attacking -----------------------------------
|
|
197
|
+
|
|
198
|
+
def test_a_log_note_cannot_forge_another_entry(repo):
|
|
199
|
+
run(["freeze"], repo)
|
|
200
|
+
run(["log", "harmless\n2020-01-01 frozen at 000000000000 nothing run",
|
|
201
|
+
"--access", "no results seen"], repo)
|
|
202
|
+
text = (repo / "PREREG.md").read_text()
|
|
203
|
+
assert "2020-01-01 frozen at" not in text, \
|
|
204
|
+
"a newline in a note forged a second log entry"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_a_log_note_cannot_break_out_of_the_fence(repo):
|
|
208
|
+
run(["freeze"], repo)
|
|
209
|
+
run(["log", "see ``` and then some", "--access", "no results seen"], repo)
|
|
210
|
+
text = (repo / "PREREG.md").read_text()
|
|
211
|
+
_, _, tail = text.partition(cli.MARK)
|
|
212
|
+
assert tail.count("```") == 2, f"fence count is {tail.count('```')}, log structure broken"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_freezing_without_a_commit_says_so(tmp_path):
|
|
216
|
+
subprocess.run(["git", "init", "-q"], cwd=tmp_path)
|
|
217
|
+
run(["new", "study"], tmp_path)
|
|
218
|
+
r = run(["freeze"], tmp_path / "study")
|
|
219
|
+
assert "(not in a git repository)" not in (tmp_path / "study" / "PREREG.md").read_text(), \
|
|
220
|
+
"wrote a placeholder where a commit should be"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_check_at_a_root_fails_if_any_plan_below_it_changed(tmp_path):
|
|
224
|
+
subprocess.run(["git", "init", "-q"], cwd=tmp_path)
|
|
225
|
+
for name in ("good", "bad"):
|
|
226
|
+
run(["new", name], tmp_path)
|
|
227
|
+
subprocess.run(["git", "add", "-A"], cwd=tmp_path)
|
|
228
|
+
subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t",
|
|
229
|
+
"commit", "-q", "-m", "init"], cwd=tmp_path)
|
|
230
|
+
run(["freeze"], tmp_path / "good")
|
|
231
|
+
run(["freeze"], tmp_path / "bad")
|
|
232
|
+
p = tmp_path / "bad" / "PREREG.md"
|
|
233
|
+
p.write_text(p.read_text().replace("## Randomization", "## Randomisation"))
|
|
234
|
+
assert run(["check"], tmp_path).returncode != 0, \
|
|
235
|
+
"a root check passed while a plan below it had been edited"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_a_long_note_still_separates_from_its_access_level(repo):
|
|
239
|
+
"""The access level is what distinguishes an amendment from a deviation, so it must never
|
|
240
|
+
run into the note."""
|
|
241
|
+
run(["freeze"], repo)
|
|
242
|
+
note = "restricting the target to {0,1} and {0,1,2} because its value set is integers"
|
|
243
|
+
run(["log", note, "--access", "no results seen"], repo)
|
|
244
|
+
line = next(ln for ln in (repo / "PREREG.md").read_text().splitlines() if note in ln)
|
|
245
|
+
assert line.endswith("no results seen")
|
|
246
|
+
assert not line.endswith(note + "no results seen"), "access level glued to the note"
|
|
247
|
+
assert " no results seen" in line
|