paperlint 10.3.0 → 10.4.0

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.
Files changed (139) hide show
  1. package/.github/workflows/ci.yml +15 -4
  2. package/README.md +1 -0
  3. package/dist/adapters/git/index.d.ts +10 -0
  4. package/dist/adapters/git/index.d.ts.map +1 -0
  5. package/dist/adapters/git/index.js +151 -0
  6. package/dist/adapters/git/index.js.map +1 -0
  7. package/dist/adapters/memory/index.d.ts +6 -0
  8. package/dist/adapters/memory/index.d.ts.map +1 -1
  9. package/dist/adapters/memory/index.js +8 -0
  10. package/dist/adapters/memory/index.js.map +1 -1
  11. package/dist/adapters/node/index.d.ts +1 -0
  12. package/dist/adapters/node/index.d.ts.map +1 -1
  13. package/dist/adapters/node/index.js +1 -0
  14. package/dist/adapters/node/index.js.map +1 -1
  15. package/dist/adapters/node/list-entries.io.d.ts +3 -0
  16. package/dist/adapters/node/list-entries.io.d.ts.map +1 -0
  17. package/dist/adapters/node/list-entries.io.js +24 -0
  18. package/dist/adapters/node/list-entries.io.js.map +1 -0
  19. package/dist/adapters/study-registry/index.d.ts +70 -10
  20. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  21. package/dist/adapters/study-registry/index.js +84 -10
  22. package/dist/adapters/study-registry/index.js.map +1 -1
  23. package/dist/cli.d.ts.map +1 -1
  24. package/dist/cli.js +33 -3
  25. package/dist/cli.js.map +1 -1
  26. package/dist/domain/decide-first.d.ts +100 -0
  27. package/dist/domain/decide-first.d.ts.map +1 -0
  28. package/dist/domain/decide-first.js +102 -0
  29. package/dist/domain/decide-first.js.map +1 -0
  30. package/dist/domain/index-columns.d.ts +45 -0
  31. package/dist/domain/index-columns.d.ts.map +1 -0
  32. package/dist/domain/index-columns.js +62 -0
  33. package/dist/domain/index-columns.js.map +1 -0
  34. package/dist/domain/study-mode.d.ts +37 -0
  35. package/dist/domain/study-mode.d.ts.map +1 -0
  36. package/dist/domain/study-mode.js +45 -0
  37. package/dist/domain/study-mode.js.map +1 -0
  38. package/dist/domain/study-registry.d.ts +57 -10
  39. package/dist/domain/study-registry.d.ts.map +1 -1
  40. package/dist/domain/study-registry.js +106 -30
  41. package/dist/domain/study-registry.js.map +1 -1
  42. package/dist/outputs-before-decision-rule.d.ts +17 -0
  43. package/dist/outputs-before-decision-rule.d.ts.map +1 -0
  44. package/dist/outputs-before-decision-rule.js +90 -0
  45. package/dist/outputs-before-decision-rule.js.map +1 -0
  46. package/dist/ports/git-history.d.ts +44 -0
  47. package/dist/ports/git-history.d.ts.map +1 -0
  48. package/dist/ports/git-history.js +2 -0
  49. package/dist/ports/git-history.js.map +1 -0
  50. package/dist/ports/list-entries.d.ts +17 -0
  51. package/dist/ports/list-entries.d.ts.map +1 -0
  52. package/dist/ports/list-entries.js +2 -0
  53. package/dist/ports/list-entries.js.map +1 -0
  54. package/dist/ports/study-registry-codec.d.ts +6 -3
  55. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  56. package/dist/studies.d.ts +4 -0
  57. package/dist/studies.d.ts.map +1 -1
  58. package/dist/studies.js +50 -22
  59. package/dist/studies.js.map +1 -1
  60. package/dist/study-gate.d.ts +14 -0
  61. package/dist/study-gate.d.ts.map +1 -0
  62. package/dist/study-gate.js +95 -0
  63. package/dist/study-gate.js.map +1 -0
  64. package/dist/study-index-input.d.ts +5 -0
  65. package/dist/study-index-input.d.ts.map +1 -0
  66. package/dist/study-index-input.js +13 -0
  67. package/dist/study-index-input.js.map +1 -0
  68. package/dist/study-modes.d.ts +45 -0
  69. package/dist/study-modes.d.ts.map +1 -0
  70. package/dist/study-modes.js +70 -0
  71. package/dist/study-modes.js.map +1 -0
  72. package/dist/study-registry-rule.d.ts +17 -1
  73. package/dist/study-registry-rule.d.ts.map +1 -1
  74. package/dist/study-registry-rule.js +34 -13
  75. package/dist/study-registry-rule.js.map +1 -1
  76. package/dist/study-run.d.ts +19 -0
  77. package/dist/study-run.d.ts.map +1 -0
  78. package/dist/study-run.js +9 -0
  79. package/dist/study-run.js.map +1 -0
  80. package/docs/design/study-modes.md +247 -0
  81. package/docs/design/study-registry.md +6 -0
  82. package/docs/e2e.md +8 -4
  83. package/docs/layout.md +22 -19
  84. package/docs/rules/repro/outputs-before-decision.md +119 -0
  85. package/docs/rules/repro/study-registered.md +47 -14
  86. package/docs/rules.md +54 -53
  87. package/docs/testing.md +1 -0
  88. package/package.json +2 -1
  89. package/scripts/check.ts +6 -0
  90. package/skills/camera-ready/SKILL.md +6 -1
  91. package/skills/camera-ready/SKILL.md.spec.ts +5 -0
  92. package/skills/draft-paper/SKILL.md +11 -3
  93. package/skills/draft-paper/SKILL.md.spec.ts +10 -2
  94. package/skills/harden-paper/SKILL.md +12 -2
  95. package/skills/harden-paper/SKILL.md.spec.ts +11 -1
  96. package/skills/osf-artifact-upload/SKILL.md +7 -1
  97. package/skills/osf-artifact-upload/SKILL.md.spec.ts +6 -0
  98. package/skills/pc-panel-review/SKILL.md +10 -3
  99. package/skills/pc-panel-review/SKILL.md.spec.ts +9 -2
  100. package/skills/tighten-paper/SKILL.md +5 -1
  101. package/skills/tighten-paper/SKILL.md.spec.ts +4 -0
  102. package/src/adapters/git/history.test.ts +121 -0
  103. package/src/adapters/git/index.test.ts +268 -0
  104. package/src/adapters/git/index.ts +227 -0
  105. package/src/adapters/memory/index.test.ts +15 -1
  106. package/src/adapters/memory/index.ts +13 -0
  107. package/src/adapters/node/index.ts +1 -0
  108. package/src/adapters/node/list-entries.io.ts +26 -0
  109. package/src/adapters/node/list-entries.test.ts +43 -0
  110. package/src/adapters/study-registry/index.test.ts +12 -9
  111. package/src/adapters/study-registry/index.ts +130 -24
  112. package/src/adapters/study-registry/mode.test.ts +162 -0
  113. package/src/cli.ts +47 -3
  114. package/src/domain/decide-first.test.ts +258 -0
  115. package/src/domain/decide-first.ts +243 -0
  116. package/src/domain/index-columns.test.ts +216 -0
  117. package/src/domain/index-columns.ts +105 -0
  118. package/src/domain/study-measurements.test.ts +9 -5
  119. package/src/domain/study-mode.test.ts +55 -0
  120. package/src/domain/study-mode.ts +78 -0
  121. package/src/domain/study-registry.test.ts +38 -31
  122. package/src/domain/study-registry.ts +170 -31
  123. package/src/new-paper.test.ts +5 -1
  124. package/src/outputs-before-decision-rule.test.ts +206 -0
  125. package/src/outputs-before-decision-rule.ts +124 -0
  126. package/src/ports/git-history.ts +44 -0
  127. package/src/ports/list-entries.ts +18 -0
  128. package/src/ports/study-registry-codec.ts +10 -3
  129. package/src/rule-cases.test.ts +78 -3
  130. package/src/studies-index-modes.test.ts +129 -0
  131. package/src/studies.test.ts +35 -11
  132. package/src/studies.ts +88 -27
  133. package/src/study-gate.test.ts +316 -0
  134. package/src/study-gate.ts +161 -0
  135. package/src/study-index-input.ts +23 -0
  136. package/src/study-modes.ts +120 -0
  137. package/src/study-registry-rule.test.ts +198 -11
  138. package/src/study-registry-rule.ts +51 -14
  139. package/src/study-run.ts +21 -0
@@ -4,7 +4,7 @@ description: Use when the numbers exist and it's time to WRITE the paper — "dr
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Agent, Skill]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:c8d25c21dc01a742 compiled from skills/draft-paper/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:a68aebea0917e4a6 compiled from skills/draft-paper/SKILL.md.spec.ts -->
8
8
 
9
9
  # draft-paper — prose that survives review and gets cited
10
10
 
@@ -33,9 +33,17 @@ number in the body stays in the body**.
33
33
  study, and — for security — the explicit threat model (attacker goals/capabilities/knowledge). A
34
34
  fuzzy threat model is the fastest reject at a security venue.
35
35
  4. **Method** — the reusable core. How the measurement/benchmark is *constructed* so someone else can
36
- run it on a different target. This is the part that gets cited; give it the most care.
36
+ run it on a different target. This is the part that gets cited; give it the most care. **It states
37
+ each study's mode from the index, never from memory:** run `npx paperlint studies index <paper-dir>`, then read
38
+ each reported study's `mode`, `decision` and `first output` columns in `repro/README.md` and
39
+ write one sentence per study — decided before its run, with the decision's commit and date (and
40
+ its first output's, which came after); an exploratory study named exploratory, with its reason.
41
+ A study whose `decision` reads `not committed` was not decided before its run: say so.
37
42
  5. **Results** — the numbers from `build-benchmark`, each tied to a claim. Report ranges/CIs, not
38
- rounded maxes. One figure or table carries the paper — make it legible standalone.
43
+ rounded maxes. One figure or table carries the paper — make it legible standalone. A number from
44
+ an exploratory study says "exploratory" where it is reported; "pre-registered" or "decided before"
45
+ only for a study the index shows as `decide_before_run` with its decision committed before its
46
+ first output.
39
47
  6. **Threats to validity** — construct / internal / external, honestly (see below). This section is a
40
48
  trust signal, not a confession.
41
49
  7. **Related work** — position against the *nearest* neighbor explicitly ("X measures A; we measure B
@@ -39,9 +39,17 @@ number in the body stays in the body**.
39
39
  study, and — for security — the explicit threat model (attacker goals/capabilities/knowledge). A
40
40
  fuzzy threat model is the fastest reject at a security venue.
41
41
  4. **Method** — the reusable core. How the measurement/benchmark is *constructed* so someone else can
42
- run it on a different target. This is the part that gets cited; give it the most care.
42
+ run it on a different target. This is the part that gets cited; give it the most care. **It states
43
+ each study's mode from the index, never from memory:** run \`npx paperlint studies index <paper-dir>\`, then read
44
+ each reported study's \`mode\`, \`decision\` and \`first output\` columns in \`repro/README.md\` and
45
+ write one sentence per study — decided before its run, with the decision's commit and date (and
46
+ its first output's, which came after); an exploratory study named exploratory, with its reason.
47
+ A study whose \`decision\` reads \`not committed\` was not decided before its run: say so.
43
48
  5. **Results** — the numbers from \`build-benchmark\`, each tied to a claim. Report ranges/CIs, not
44
- rounded maxes. One figure or table carries the paper — make it legible standalone.
49
+ rounded maxes. One figure or table carries the paper — make it legible standalone. A number from
50
+ an exploratory study says "exploratory" where it is reported; "pre-registered" or "decided before"
51
+ only for a study the index shows as \`decide_before_run\` with its decision committed before its
52
+ first output.
45
53
  6. **Threats to validity** — construct / internal / external, honestly (see below). This section is a
46
54
  trust signal, not a confession.
47
55
  7. **Related work** — position against the *nearest* neighbor explicitly ("X measures A; we measure B
@@ -4,7 +4,7 @@ description: Use when asking "is this paper actually ready to submit?" / "what's
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, WebSearch, WebFetch, Agent, Skill]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:35f1ec023504e0ce compiled from skills/harden-paper/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:85001dd768358bb5 compiled from skills/harden-paper/SKILL.md.spec.ts -->
8
8
 
9
9
  # harden-paper — the multi-axis pre-submit gate
10
10
 
@@ -61,8 +61,18 @@ middle sags" — wasting the polish on possibly-cut sections.
61
61
  there with no pointer reaching it, the body is overclaiming and "it's in the appendix" is not
62
62
  a defence — reviewers are not required to read appendices, and ARR says so in as many words.
63
63
  Rule and the sorting test: `paper-pipeline/references/body-vs-appendix.md`.
64
+ - 🔴 **A confirmatory claim on a study that was not confirmatory.** Every claim the paper presents
65
+ as tested — a prediction, a hypothesis confirmed, a headline effect — must rest on a study whose
66
+ `mode` is `decide_before_run` and whose `decision` commit precedes its `first output`, read
67
+ from the index in `repro/README.md` after `npx paperlint studies index <paper-dir>` (the plans and git, not
68
+ the prose). A confirmatory claim on an exploratory study, on one whose decision reads `not
69
+ committed`, or on one whose first output is not after its decision, is a **soundness** finding,
70
+ not a wording one: re-label the result exploratory, or rest the claim on a study decided first.
71
+ Flag every "pre-registered", "preregistered", "registered in advance" or "decided before" the
72
+ table does not support, and check the Methods sentence names each study's mode as the table does.
64
73
  Mechanical leg: `tex/claim-provenance` (axis 7) reports every sentence that states a number and
65
- does not say whose it is.
74
+ does not say whose it is; `repro/outputs-before-decision` every output that entered history
75
+ before its decision.
66
76
 
67
77
  3. **Novelty vs the nearest neighbor** — the scoop check. Search for the paper that *already did your
68
78
  thing* (`verify-citations` covers metadata; here, actively hunt the closest empirical/method twin).
@@ -78,8 +78,18 @@ middle sags" — wasting the polish on possibly-cut sections.
78
78
  there with no pointer reaching it, the body is overclaiming and "it's in the appendix" is not
79
79
  a defence — reviewers are not required to read appendices, and ARR says so in as many words.
80
80
  Rule and the sorting test: \`paper-pipeline/references/body-vs-appendix.md\`.
81
+ - 🔴 **A confirmatory claim on a study that was not confirmatory.** Every claim the paper presents
82
+ as tested — a prediction, a hypothesis confirmed, a headline effect — must rest on a study whose
83
+ \`mode\` is \`decide_before_run\` and whose \`decision\` commit precedes its \`first output\`, read
84
+ from the index in \`repro/README.md\` after \`npx paperlint studies index <paper-dir>\` (the plans and git, not
85
+ the prose). A confirmatory claim on an exploratory study, on one whose decision reads \`not
86
+ committed\`, or on one whose first output is not after its decision, is a **soundness** finding,
87
+ not a wording one: re-label the result exploratory, or rest the claim on a study decided first.
88
+ Flag every "pre-registered", "preregistered", "registered in advance" or "decided before" the
89
+ table does not support, and check the Methods sentence names each study's mode as the table does.
81
90
  Mechanical leg: \`tex/claim-provenance\` (axis 7) reports every sentence that states a number and
82
- does not say whose it is.
91
+ does not say whose it is; \`repro/outputs-before-decision\` every output that entered history
92
+ before its decision.
83
93
 
84
94
  3. **Novelty vs the nearest neighbor** — the scoop check. Search for the paper that *already did your
85
95
  thing* (\`verify-citations\` covers metadata; here, actively hunt the closest empirical/method twin).
@@ -4,7 +4,7 @@ description: Upload or replace a paper's reproduction artifact file on an OSF pr
4
4
  allowed-tools: [Read, Write, Grep, Glob, Bash]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:334c907233abc97c compiled from skills/osf-artifact-upload/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:953fdd419dbc789c compiled from skills/osf-artifact-upload/SKILL.md.spec.ts -->
8
8
 
9
9
  # osf-artifact-upload — push/replace a file on an OSF project via API
10
10
 
@@ -45,6 +45,12 @@ curl -g -s --retry 3 --cacert $CA -H "$AUTH" -X PUT --data-binary @"$ZIP" "${UP}
45
45
  Verification that actually holds: **OSF-stored md5 == `md5sum $ZIP`**. The upload response also returns the
46
46
  md5; matching it to the local file is proof even when the download-back call flakes.
47
47
 
48
+ ## What the artifact ships about each study
49
+ Before zipping, the artifact carries, for every study the paper reports: its `plan.json` (its mode)
50
+ and its decision file; and `repro/README.md` with the index regenerated (`npx paperlint studies index <paper-dir>`)
51
+ — each study's mode, decision commit and first output — with the command that re-checks the order
52
+ (`npx paperlint lint` on a full clone). A zip without them asks a reviewer to take the modes on trust.
53
+
48
54
  ## After
49
55
  - Confirm the paper's Availability `\url{}` still points at the same node (it does — same guid).
50
56
  - Tell the user to **revoke the token** (osf.io/settings/tokens → Delete).
@@ -51,6 +51,12 @@ curl -g -s --retry 3 --cacert $CA -H "$AUTH" -X PUT --data-binary @"$ZIP" "\${UP
51
51
  Verification that actually holds: **OSF-stored md5 == \`md5sum $ZIP\`**. The upload response also returns the
52
52
  md5; matching it to the local file is proof even when the download-back call flakes.
53
53
 
54
+ ## What the artifact ships about each study
55
+ Before zipping, the artifact carries, for every study the paper reports: its \`plan.json\` (its mode)
56
+ and its decision file; and \`repro/README.md\` with the index regenerated (\`npx paperlint studies index <paper-dir>\`)
57
+ — each study's mode, decision commit and first output — with the command that re-checks the order
58
+ (\`npx paperlint lint\` on a full clone). A zip without them asks a reviewer to take the modes on trust.
59
+
54
60
  ## After
55
61
  - Confirm the paper's Availability \`\\url{}\` still points at the same node (it does — same guid).
56
62
  - Tell the user to **revoke the token** (osf.io/settings/tokens → Delete).
@@ -4,7 +4,7 @@ description: Use when asking "what would the program committee decide?" / "simul
4
4
  allowed-tools: [Read, Write, Grep, Glob, Bash, WebSearch, WebFetch, Agent, Skill]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:7e5fb04d1cfa0548 compiled from skills/pc-panel-review/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:d92a744ec1cc1b05 compiled from skills/pc-panel-review/SKILL.md.spec.ts -->
8
8
 
9
9
  # pc-panel-review — model the whole PC, not one reviewer
10
10
 
@@ -61,11 +61,18 @@ prose-only read can.
61
61
  and calls the paper a blog post — the one reviewer a panel of careful full readers never
62
62
  predicts. Give it the three sections as text and the venue's name, nothing else.
63
63
  - **Methods/stats skeptic** — sample size, construct validity, multiple comparisons, overclaims,
64
- whether the CIs support the claims.
64
+ whether the CIs support the claims, and **whether every confirmatory claim rests on a study
65
+ decided before its run**. Run `npx paperlint studies index <paper-dir>` and give this reviewer the index in
66
+ `repro/README.md` (each study's `mode`, `decision` and `first output`) beside the paper: a
67
+ claim of a tested prediction on an exploratory study, on one whose decision reads `not
68
+ committed`, or on one whose first output is not after its decision is a soundness finding, and
69
+ so is a "pre-registered" or "decided before" the table does not support; the reviewer names it.
65
70
  - **Domain practitioner** — relevance to the CFP, novelty vs the nearest prior work, does the
66
71
  finding change behavior, generalizability, fairness/tone toward anyone named.
67
72
  - **Artifact / reproducibility reviewer (MANDATORY if an artifact is attached)** — *actually
68
- `cd` into the artifact and run it*; verify every headline number reproduces; read the code for
73
+ `cd` into the artifact and run it*; verify every headline number reproduces; re-run the order
74
+ check the paper's decided-before claims rest on (`npx paperlint lint` on a full clone); read the
75
+ code for
69
76
  circularity (echoing stored fields vs recomputing); grep all files (incl. data + compiled
70
77
  caches) for anonymization leaks; scope the badge (Available / Functional / Reproduced).
71
78
  - **Security/ethics reviewer** (security venues) — threat model soundness, responsible disclosure,
@@ -77,11 +77,18 @@ prose-only read can.
77
77
  and calls the paper a blog post — the one reviewer a panel of careful full readers never
78
78
  predicts. Give it the three sections as text and the venue's name, nothing else.
79
79
  - **Methods/stats skeptic** — sample size, construct validity, multiple comparisons, overclaims,
80
- whether the CIs support the claims.
80
+ whether the CIs support the claims, and **whether every confirmatory claim rests on a study
81
+ decided before its run**. Run \`npx paperlint studies index <paper-dir>\` and give this reviewer the index in
82
+ \`repro/README.md\` (each study's \`mode\`, \`decision\` and \`first output\`) beside the paper: a
83
+ claim of a tested prediction on an exploratory study, on one whose decision reads \`not
84
+ committed\`, or on one whose first output is not after its decision is a soundness finding, and
85
+ so is a "pre-registered" or "decided before" the table does not support; the reviewer names it.
81
86
  - **Domain practitioner** — relevance to the CFP, novelty vs the nearest prior work, does the
82
87
  finding change behavior, generalizability, fairness/tone toward anyone named.
83
88
  - **Artifact / reproducibility reviewer (MANDATORY if an artifact is attached)** — *actually
84
- \`cd\` into the artifact and run it*; verify every headline number reproduces; read the code for
89
+ \`cd\` into the artifact and run it*; verify every headline number reproduces; re-run the order
90
+ check the paper's decided-before claims rest on (\`npx paperlint lint\` on a full clone); read the
91
+ code for
85
92
  circularity (echoing stored fields vs recomputing); grep all files (incl. data + compiled
86
93
  caches) for anonymization leaks; scope the badge (Available / Functional / Reproduced).
87
94
  - **Security/ethics reviewer** (security venues) — threat model soundness, responsible disclosure,
@@ -4,7 +4,7 @@ description: "Use when a paper \"feels long / bloated / overly complex / hard to
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Agent]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:c57d6da372b28acf compiled from skills/tighten-paper/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:ccf39ff26a2146e9 compiled from skills/tighten-paper/SKILL.md.spec.ts -->
8
8
 
9
9
  # tighten-paper — the developmental edit
10
10
 
@@ -149,6 +149,10 @@ called it "fat for no reason" after a pass that cut almost nothing and left it 1
149
149
  measurement paper's fat HIDES as explanation of real numbers, which is exactly why "it's all numbers" lets it
150
150
  survive a lazy pass (GateBench 2026-07-25: a pass cut only 5% hiding behind this; the author correctly pushed
151
151
  back that whole sections were over-explained, not just number-dense).
152
+ - **The study-mode sentence is never cut for length.** The Methods sentence that says a study was
153
+ decided before its run (with its decision's commit and date, from the index in `repro/README.md`)
154
+ or is exploratory — and the word "exploratory" beside an exploratory number — is the claim's
155
+ warrant, not its explanation: cut around it, never it.
152
156
  - **"Fold to the artifact" is the PRIMARY length lever for a measurement/benchmark paper — not a last resort.**
153
157
  FIRST verify the artifact exists and recomputes the numbers (it usually does for these papers). Then per-case
154
158
  breakdowns, secondary analyses, exhaustive robustness sub-results, ablation walkthroughs, and every "we also
@@ -155,6 +155,10 @@ called it "fat for no reason" after a pass that cut almost nothing and left it 1
155
155
  measurement paper's fat HIDES as explanation of real numbers, which is exactly why "it's all numbers" lets it
156
156
  survive a lazy pass (GateBench 2026-07-25: a pass cut only 5% hiding behind this; the author correctly pushed
157
157
  back that whole sections were over-explained, not just number-dense).
158
+ - **The study-mode sentence is never cut for length.** The Methods sentence that says a study was
159
+ decided before its run (with its decision's commit and date, from the index in \`repro/README.md\`)
160
+ or is exploratory — and the word "exploratory" beside an exploratory number — is the claim's
161
+ warrant, not its explanation: cut around it, never it.
158
162
  - **"Fold to the artifact" is the PRIMARY length lever for a measurement/benchmark paper — not a last resort.**
159
163
  FIRST verify the artifact exists and recomputes the numbers (it usually does for these papers). Then per-case
160
164
  breakdowns, secondary analyses, exhaustive robustness sub-results, ablation walkthroughs, and every "we also
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The git ports over a REAL repository in a scratch directory: the committed state of a decision,
3
+ * and the order of every output's first commit against the decision's — after, the same commit, or
4
+ * not after — a deleted output included, and a shallow clone named as such.
5
+ */
6
+ import { writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { expect, it } from "vitest";
9
+ import { absolutePath } from "../../domain/paths.ts";
10
+ import { spawnProcess } from "../node/index.ts";
11
+ import { commit, git, initRepo } from "../../../test/git-repo.ts";
12
+ import { useTempDir } from "../../../test/support.ts";
13
+ import { gitHistory } from "./index.ts";
14
+
15
+ const root = useTempDir("git-history-");
16
+ const ports = gitHistory(spawnProcess(), { PATH: String(process.env.PATH) });
17
+ const T1 = "2026-10-16T10:00:00+05:00";
18
+ const T2 = "2026-10-16T11:00:00+05:00";
19
+ const T3 = "2026-10-16T12:00:00+05:00";
20
+
21
+ it("a decision committed, then changed, then never committed: three states", () => {
22
+ const repo = join(root, "committed");
23
+ initRepo(repo);
24
+ const sha = commit(repo, { "p/decision.md": "exclusions: none\n" }, T1);
25
+ const paper = absolutePath(join(repo, "p"));
26
+ const file = absolutePath(join(repo, "p/decision.md"));
27
+ // Guards: the commit and its date as git records them.
28
+ expect(ports.committed(paper, file)).toEqual({
29
+ kind: "committed",
30
+ commit: sha,
31
+ date: T1,
32
+ });
33
+ writeFileSync(file, "exclusions: run 7\n");
34
+ // Guards: an edit after the commit is not the recorded decision.
35
+ expect(ports.committed(paper, file)).toEqual({
36
+ kind: "changed",
37
+ commit: sha,
38
+ });
39
+ writeFileSync(join(repo, "p/new.md"), "x\n");
40
+ expect(ports.committed(paper, absolutePath(join(repo, "p/new.md")))).toEqual({
41
+ kind: "not-committed",
42
+ });
43
+ });
44
+
45
+ it("outputs after the decision, in its commit, before it, and deleted since: each with its order", () => {
46
+ const repo = join(root, "order");
47
+ initRepo(repo);
48
+ const early = commit(repo, { "p/repro/S/early.csv": "1\n" }, T1);
49
+ const decided = commit(
50
+ repo,
51
+ { "p/repro/S/decision.md": "d\n", "p/repro/S/same.csv": "2\n" },
52
+ T2,
53
+ );
54
+ const later = commit(
55
+ repo,
56
+ { "p/repro/S/later.csv": "3\n", "p/repro/S/early.csv": null },
57
+ T3,
58
+ );
59
+ // Guards: the order is read from ancestry; a deleted early output is still evidence.
60
+ expect(
61
+ ports.order(
62
+ absolutePath(join(repo, "p/repro/S")),
63
+ absolutePath(join(repo, "p/repro/S/decision.md")),
64
+ ),
65
+ ).toEqual({
66
+ kind: "read",
67
+ decision: { commit: decided, date: T2 },
68
+ decisionDeleted: false,
69
+ files: [
70
+ { path: "early.csv", commit: early, date: T1, order: "not-after" },
71
+ { path: "decision.md", commit: decided, date: T2, order: "same" },
72
+ { path: "same.csv", commit: decided, date: T2, order: "same" },
73
+ { path: "later.csv", commit: later, date: T3, order: "after" },
74
+ ],
75
+ });
76
+ });
77
+
78
+ it("a shallow clone is named, not judged", () => {
79
+ const repo = join(root, "deep");
80
+ initRepo(repo);
81
+ commit(repo, { "p/repro/S/decision.md": "d\n" }, T1);
82
+ commit(repo, { "p/repro/S/out.csv": "1\n" }, T2);
83
+ const shallow = join(root, "shallow");
84
+ git(root, ["clone", "-q", "--depth", "1", `file://${repo}`, shallow]);
85
+ // Guards: a CI checkout of depth 1 says so instead of reading every file as born in one commit.
86
+ expect(
87
+ ports.order(
88
+ absolutePath(join(shallow, "p/repro/S")),
89
+ absolutePath(join(shallow, "p/repro/S/decision.md")),
90
+ ),
91
+ ).toEqual({ kind: "shallow" });
92
+ });
93
+
94
+ it("outside any repository, git's own refusal is the reason", () => {
95
+ const r = ports.order(absolutePath("/"), absolutePath("/decision.md"));
96
+ // Guards: no repository is "unknown", never an empty history.
97
+ expect(r.kind).toBe("unknown");
98
+ });
99
+
100
+ it("a decision committed and deleted in a later commit is not in HEAD: never `committed`", () => {
101
+ const repo = join(root, "deleted");
102
+ initRepo(repo);
103
+ const sha = commit(repo, { "p/repro/S/decision.md": "d\n" }, T1);
104
+ const gone = commit(repo, { "p/repro/S/decision.md": null }, T2);
105
+ const paper = absolutePath(join(repo, "p"));
106
+ const file = absolutePath(join(repo, "p/repro/S/decision.md"));
107
+ // Guards: `git log -1` names the deletion and the tree is clean, yet there is no decision to gate on;
108
+ // the order still reads the decision's first commit, and says it is gone.
109
+ expect([
110
+ ports.committed(paper, file),
111
+ ports.order(absolutePath(join(repo, "p/repro/S")), file),
112
+ ]).toEqual([
113
+ { kind: "deleted", commit: gone },
114
+ {
115
+ kind: "read",
116
+ decision: { commit: sha, date: T1 },
117
+ decisionDeleted: true,
118
+ files: [{ path: "decision.md", commit: sha, date: T1, order: "same" }],
119
+ },
120
+ ]);
121
+ });
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The git ports over a scripted `RunProcess`: the commands they run and each answer git can give —
3
+ * including each way git can fail to give one. The same ports over a real repository are in
4
+ * `history.test.ts` beside this file.
5
+ */
6
+ import { expect, it } from "vitest";
7
+ import { exitedWith, scriptedProcess } from "../memory/index.ts";
8
+ import { absolutePath } from "../../domain/paths.ts";
9
+ import type { Command, ProcessExit } from "../../ports/process.ts";
10
+ import { firstCommitsOf, gitHistory } from "./index.ts";
11
+
12
+ const PAPER = absolutePath("/repo/papers/p");
13
+ const FILE = absolutePath("/repo/papers/p/repro/S/decision.md");
14
+ const STUDY = absolutePath("/repo/papers/p/repro/S");
15
+ const D = "d".repeat(40);
16
+ const A = "a".repeat(40);
17
+ const B = "b".repeat(40);
18
+ const DATE = "2026-10-16T15:23:00+05:00";
19
+
20
+ /** HEAD's tree holds the decision, unless a test says otherwise. */
21
+ const IN_HEAD = exitedWith("repro/S/decision.md\n");
22
+
23
+ /**
24
+ * The ports over git answering by its subcommand (`args[2]`, after `-c core.quotepath=false`); the
25
+ * decision in HEAD's tree unless `ls-tree` is answered.
26
+ */
27
+ const over = (answers: Readonly<Record<string, ProcessExit>>) => {
28
+ const all: Readonly<Record<string, ProcessExit>> = {
29
+ "ls-tree": IN_HEAD,
30
+ ...answers,
31
+ };
32
+ const run = scriptedProcess(
33
+ (c: Command) => all[String(c.args[2])] ?? exitedWith(""),
34
+ );
35
+ return { git: gitHistory(run, { PATH: "/usr/bin" }), calls: run.calls };
36
+ };
37
+
38
+ it("committed: the last commit and its date when the file is as committed — asked from the paper", () => {
39
+ const { git, calls } = over({ log: exitedWith(`${D}\t${DATE}\n`) });
40
+ // Guards: the answer, and that git runs in the paper's folder with the file's absolute path.
41
+ expect(git.committed(PAPER, FILE)).toEqual({
42
+ kind: "committed",
43
+ commit: D,
44
+ date: DATE,
45
+ });
46
+ expect(calls.map((c) => [c.args, c.cwd])).toEqual([
47
+ [
48
+ [
49
+ "-c",
50
+ "core.quotepath=false",
51
+ "log",
52
+ "-1",
53
+ "--format=%H%x09%cI",
54
+ "--",
55
+ FILE,
56
+ ],
57
+ PAPER,
58
+ ],
59
+ [
60
+ [
61
+ "-c",
62
+ "core.quotepath=false",
63
+ "ls-tree",
64
+ "--name-only",
65
+ "HEAD",
66
+ "--",
67
+ FILE,
68
+ ],
69
+ PAPER,
70
+ ],
71
+ [
72
+ ["-c", "core.quotepath=false", "status", "--porcelain=v1", "--", FILE],
73
+ PAPER,
74
+ ],
75
+ ]);
76
+ });
77
+
78
+ it("committed: a decision whose last commit deleted it is not in HEAD — deleted, never committed", () => {
79
+ // Guards: `log -1` names the deleting commit and the status is clean; only HEAD's tree tells.
80
+ expect([
81
+ over({
82
+ log: exitedWith(`${D}\t${DATE}\n`),
83
+ "ls-tree": exitedWith(""),
84
+ }).git.committed(PAPER, FILE),
85
+ over({
86
+ log: exitedWith(`${D}\t${DATE}\n`),
87
+ "ls-tree": { kind: "spawn-failed", message: "EIO" },
88
+ }).git.committed(PAPER, FILE),
89
+ ]).toEqual([
90
+ { kind: "deleted", commit: D },
91
+ { kind: "unknown", why: "EIO" },
92
+ ]);
93
+ });
94
+
95
+ it("committed: changed since its commit, never committed, or git unable to say at either step", () => {
96
+ // Guards: an edited decision is not the recorded one; a file only on disk records nothing.
97
+ expect([
98
+ over({
99
+ log: exitedWith(`${D}\t${DATE}\n`),
100
+ status: exitedWith(" M decision.md\n"),
101
+ }).git.committed(PAPER, FILE),
102
+ over({ log: exitedWith("") }).git.committed(PAPER, FILE),
103
+ over({ log: { kind: "not-found", file: "git" } }).git.committed(
104
+ PAPER,
105
+ FILE,
106
+ ),
107
+ over({
108
+ log: exitedWith(`${D}\t${DATE}\n`),
109
+ status: { kind: "spawn-failed", message: "EACCES" },
110
+ }).git.committed(PAPER, FILE),
111
+ ]).toEqual([
112
+ { kind: "changed", commit: D },
113
+ { kind: "not-committed" },
114
+ { kind: "unknown", why: "git is not installed" },
115
+ { kind: "unknown", why: "EACCES" },
116
+ ]);
117
+ });
118
+
119
+ it("every way git can fail is a reason, never an answer", () => {
120
+ const ask = (x: ProcessExit) =>
121
+ over({ "rev-parse": x }).git.order(STUDY, FILE);
122
+ // Guards: no git, no repository, a hang, a kill — each named, none read as "no commits".
123
+ expect([
124
+ ask(exitedWith("", 128)),
125
+ ask({
126
+ kind: "exited",
127
+ status: 128,
128
+ stdout: "",
129
+ stderr: "fatal: not a git repository\n",
130
+ }),
131
+ ask({ kind: "timed-out", afterMs: 1, stdout: "", stderr: "" }),
132
+ ask({ kind: "signalled", signal: "SIGTERM", stdout: "", stderr: "" }),
133
+ ]).toEqual([
134
+ { kind: "unknown", why: "git exited 128" },
135
+ { kind: "unknown", why: "fatal: not a git repository" },
136
+ { kind: "unknown", why: "git did not finish (timed-out)" },
137
+ { kind: "unknown", why: "git did not finish (signalled)" },
138
+ ]);
139
+ });
140
+
141
+ it("order: a shallow clone is said, not judged", () => {
142
+ // Guards: in a shallow clone every file starts at the clone — no order can be read.
143
+ expect(
144
+ over({ "rev-parse": exitedWith("true\n") }).git.order(STUDY, FILE),
145
+ ).toEqual({ kind: "shallow" });
146
+ });
147
+
148
+ it("order: each file's first commit against the decision's — after, the same, not after", () => {
149
+ const log = `\x01${D}\t${DATE}\nx.csv\n\x01${A}\t${DATE}\na.csv\nx.csv\n\x01${B}\t${DATE}\nb.csv\n`;
150
+ // Guards: the decision's own commit is `same`; the others are ordered by ancestry.
151
+ const result = gitHistory(
152
+ scriptedProcess((c) => {
153
+ const sub = String(c.args[2]);
154
+ if (sub === "rev-parse") return exitedWith("false\n");
155
+ if (sub === "ls-tree") return IN_HEAD;
156
+ if (sub === "log" && c.args.includes("--reverse")) return exitedWith(log);
157
+ if (sub === "log") return exitedWith(`${A}\t${DATE}\n${D}\t${DATE}\n`);
158
+ return exitedWith("", String(c.args[5]) === A ? 0 : 1);
159
+ }),
160
+ {},
161
+ ).order(STUDY, FILE);
162
+ expect(result).toEqual({
163
+ kind: "read",
164
+ decision: { commit: D, date: DATE },
165
+ decisionDeleted: false,
166
+ files: [
167
+ { path: "x.csv", commit: D, date: DATE, order: "same" },
168
+ { path: "a.csv", commit: A, date: DATE, order: "after" },
169
+ { path: "b.csv", commit: B, date: DATE, order: "not-after" },
170
+ ],
171
+ });
172
+ });
173
+
174
+ it("order: no decision commit makes every file not-after; a failed ancestry question is a reason", () => {
175
+ const log = `\x01${A}\t${DATE}\na.csv\n`;
176
+ const answer = (mergeBase: ProcessExit, decisionLog: string) =>
177
+ gitHistory(
178
+ scriptedProcess((c) => {
179
+ const sub = String(c.args[2]);
180
+ if (sub === "rev-parse") return exitedWith("false\n");
181
+ if (sub === "ls-tree") return IN_HEAD;
182
+ if (sub === "log" && c.args.includes("--reverse"))
183
+ return exitedWith(log);
184
+ if (sub === "log") return exitedWith(decisionLog);
185
+ return mergeBase;
186
+ }),
187
+ {},
188
+ ).order(STUDY, FILE);
189
+ // Guards: an uncommitted decision orders nothing; git failing mid-way is not "not after".
190
+ expect(answer(exitedWith(""), "")).toEqual({
191
+ kind: "read",
192
+ decision: null,
193
+ decisionDeleted: false,
194
+ files: [{ path: "a.csv", commit: A, date: DATE, order: "not-after" }],
195
+ });
196
+ expect(
197
+ answer(
198
+ {
199
+ kind: "exited",
200
+ status: 128,
201
+ stdout: "",
202
+ stderr: "fatal: bad object\n",
203
+ },
204
+ `${D}\t${DATE}\n`,
205
+ ),
206
+ ).toEqual({ kind: "unknown", why: "fatal: bad object" });
207
+ });
208
+
209
+ it("order: git failing on the decision's log or the folder's log is a reason", () => {
210
+ const fail = {
211
+ kind: "exited",
212
+ status: 128,
213
+ stdout: "",
214
+ stderr: "fatal: x\n",
215
+ } as const;
216
+ const answer = (decision: ProcessExit, folder: ProcessExit) =>
217
+ gitHistory(
218
+ scriptedProcess((c) => {
219
+ const sub = String(c.args[2]);
220
+ if (sub === "rev-parse") return exitedWith("false\n");
221
+ return c.args.includes("--reverse") ? folder : decision;
222
+ }),
223
+ {},
224
+ ).order(STUDY, FILE);
225
+ // Guards: each step's failure surfaces.
226
+ expect([answer(fail, exitedWith("")), answer(exitedWith(""), fail)]).toEqual([
227
+ { kind: "unknown", why: "fatal: x" },
228
+ { kind: "unknown", why: "fatal: x" },
229
+ ]);
230
+ });
231
+
232
+ it("order: a decision not in HEAD is said deleted; git failing on HEAD's tree is a reason", () => {
233
+ const answer = (lsTree: ProcessExit) =>
234
+ gitHistory(
235
+ scriptedProcess((c) => {
236
+ const sub = String(c.args[2]);
237
+ if (sub === "rev-parse") return exitedWith("false\n");
238
+ if (sub === "ls-tree") return lsTree;
239
+ if (c.args.includes("--reverse"))
240
+ return exitedWith(`\x01${D}\t${DATE}\ndecision.md\n`);
241
+ return exitedWith(`${D}\t${DATE}\n`);
242
+ }),
243
+ {},
244
+ ).order(STUDY, FILE);
245
+ // Guards: the decision's first commit is still read, and its absence from HEAD is carried with it.
246
+ expect([
247
+ answer(exitedWith("")),
248
+ answer({ kind: "spawn-failed", message: "EIO" }),
249
+ ]).toEqual([
250
+ {
251
+ kind: "read",
252
+ decision: { commit: D, date: DATE },
253
+ decisionDeleted: true,
254
+ files: [{ path: "decision.md", commit: D, date: DATE, order: "same" }],
255
+ },
256
+ { kind: "unknown", why: "EIO" },
257
+ ]);
258
+ });
259
+
260
+ it("firstCommitsOf keeps each path's first commit and skips blank lines", () => {
261
+ // Guards: the reverse log is read oldest first; a later commit of a path does not move it.
262
+ expect([
263
+ ...firstCommitsOf(`\x01${A}\t${DATE}\na\n\n\x01${B}\t${DATE}\na\nb\n`),
264
+ ]).toEqual([
265
+ ["a", { commit: A, date: DATE }],
266
+ ["b", { commit: B, date: DATE }],
267
+ ]);
268
+ });