paperlint 3.0.0 → 3.1.1

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 (319) hide show
  1. package/.github/workflows/ci.yml +28 -41
  2. package/.github/workflows/dependabot-automerge.yml +1 -1
  3. package/CLAUDE.md +51 -56
  4. package/CONTRIBUTING.md +30 -18
  5. package/README.md +179 -180
  6. package/bin/paperlint.test.mjs +45 -0
  7. package/dist/adapters/node/files.io.d.ts +2 -0
  8. package/dist/adapters/node/files.io.d.ts.map +1 -1
  9. package/dist/adapters/node/files.io.js +3 -2
  10. package/dist/adapters/node/files.io.js.map +1 -1
  11. package/dist/adapters/pdfjs/fill.d.ts +15 -4
  12. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  13. package/dist/adapters/pdfjs/fill.js +31 -21
  14. package/dist/adapters/pdfjs/fill.js.map +1 -1
  15. package/dist/adapters/references/index.d.ts.map +1 -1
  16. package/dist/adapters/references/index.js +14 -5
  17. package/dist/adapters/references/index.js.map +1 -1
  18. package/dist/adapters/references/pool.d.ts +8 -0
  19. package/dist/adapters/references/pool.d.ts.map +1 -0
  20. package/dist/adapters/references/pool.js +19 -0
  21. package/dist/adapters/references/pool.js.map +1 -0
  22. package/dist/build-engine.d.ts.map +1 -1
  23. package/dist/build-engine.js +6 -2
  24. package/dist/build-engine.js.map +1 -1
  25. package/dist/build.d.ts +10 -2
  26. package/dist/build.d.ts.map +1 -1
  27. package/dist/build.js +29 -11
  28. package/dist/build.js.map +1 -1
  29. package/dist/cli.d.ts +93 -6
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +296 -80
  32. package/dist/cli.js.map +1 -1
  33. package/dist/doctor.d.ts +27 -1
  34. package/dist/doctor.d.ts.map +1 -1
  35. package/dist/doctor.js +4 -3
  36. package/dist/doctor.js.map +1 -1
  37. package/dist/domain/result.d.ts +0 -2
  38. package/dist/domain/result.d.ts.map +1 -1
  39. package/dist/domain/result.js +0 -4
  40. package/dist/domain/result.js.map +1 -1
  41. package/dist/engine.d.ts +9 -0
  42. package/dist/engine.d.ts.map +1 -1
  43. package/dist/engine.js +27 -0
  44. package/dist/engine.js.map +1 -1
  45. package/dist/hooks-settings.d.ts +8 -5
  46. package/dist/hooks-settings.d.ts.map +1 -1
  47. package/dist/hooks-settings.js +32 -32
  48. package/dist/hooks-settings.js.map +1 -1
  49. package/dist/init.d.ts +8 -0
  50. package/dist/init.d.ts.map +1 -1
  51. package/dist/init.js +4 -4
  52. package/dist/init.js.map +1 -1
  53. package/dist/latex-log.d.ts +0 -2
  54. package/dist/latex-log.d.ts.map +1 -1
  55. package/dist/latex-log.js +7 -13
  56. package/dist/latex-log.js.map +1 -1
  57. package/dist/link-skills.d.ts +15 -2
  58. package/dist/link-skills.d.ts.map +1 -1
  59. package/dist/link-skills.js +36 -20
  60. package/dist/link-skills.js.map +1 -1
  61. package/dist/new-paper.d.ts +8 -1
  62. package/dist/new-paper.d.ts.map +1 -1
  63. package/dist/new-paper.js +50 -15
  64. package/dist/new-paper.js.map +1 -1
  65. package/dist/paper-files.d.ts +30 -8
  66. package/dist/paper-files.d.ts.map +1 -1
  67. package/dist/paper-files.js +59 -30
  68. package/dist/paper-files.js.map +1 -1
  69. package/dist/pdf-facts.d.ts +11 -0
  70. package/dist/pdf-facts.d.ts.map +1 -1
  71. package/dist/pdf-facts.js +16 -18
  72. package/dist/pdf-facts.js.map +1 -1
  73. package/dist/pdf-geometry.js +1 -1
  74. package/dist/pdf-geometry.js.map +1 -1
  75. package/dist/presets.d.ts +2 -0
  76. package/dist/presets.d.ts.map +1 -1
  77. package/dist/presets.js +3 -1
  78. package/dist/presets.js.map +1 -1
  79. package/dist/references.js +2 -2
  80. package/dist/references.js.map +1 -1
  81. package/dist/structure.js +1 -1
  82. package/dist/structure.js.map +1 -1
  83. package/dist/tex-requirements.d.ts +8 -0
  84. package/dist/tex-requirements.d.ts.map +1 -1
  85. package/dist/tex-requirements.js +6 -5
  86. package/dist/tex-requirements.js.map +1 -1
  87. package/dist/toolchain.d.ts +8 -3
  88. package/dist/toolchain.d.ts.map +1 -1
  89. package/dist/toolchain.js +26 -10
  90. package/dist/toolchain.js.map +1 -1
  91. package/dist/types.d.ts +6 -0
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/venue-rules.d.ts.map +1 -1
  94. package/dist/venue-rules.js +12 -10
  95. package/dist/venue-rules.js.map +1 -1
  96. package/docs/configuration.md +48 -4
  97. package/docs/prior-art/README.md +1 -1
  98. package/docs/prior-art/test-tooling.md +7 -30
  99. package/docs/rules.md +29 -25
  100. package/docs/skills.md +80 -0
  101. package/eslint-rules/install-path-literals.harness.mjs +17 -1
  102. package/eslint-rules/latex-language.harness.mjs +3 -3
  103. package/eslint-rules/latex-language.mjs +12 -9
  104. package/eslint-rules/latex-language.test.mjs +200 -0
  105. package/eslint-rules/paper-research-question.harness.mjs +73 -7
  106. package/eslint-rules/paper-stages.harness.mjs +9 -14
  107. package/eslint-rules/paper-stages.test.mjs +135 -0
  108. package/eslint-rules/paper-typography.mjs +56 -9
  109. package/eslint-rules/paper-typography.test.mjs +186 -1
  110. package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
  111. package/eslint-rules/review-frontmatter.mjs +2 -1
  112. package/eslint-rules/review-frontmatter.schema.json +1 -1
  113. package/eslint-rules/review-frontmatter.test.mjs +37 -1
  114. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  115. package/eslint-rules/tex-build.mjs +13 -13
  116. package/eslint.config.mjs +6 -2
  117. package/fixtures/toolchain-mirror/catalog.txt +1 -0
  118. package/fixtures/toolchain-mirror/install-tl +4 -1
  119. package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
  120. package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
  121. package/hooks/hooks.harness.mjs +6 -11
  122. package/hooks/paper-edit-guard.hook.mjs +2 -2
  123. package/hooks/paper-edit-guard.test.mjs +61 -0
  124. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  125. package/hooks/paper-skills-nudge.test.mjs +38 -0
  126. package/hooks/paper-status-gates.test.mjs +42 -0
  127. package/lib/agent-cli-version.harness.mjs +7 -0
  128. package/lib/check.d.mts +17 -0
  129. package/lib/check.mjs +50 -0
  130. package/lib/check.test.ts +67 -0
  131. package/lib/markdown.mjs +16 -69
  132. package/lib/markdown.test.mjs +246 -0
  133. package/lib/paper-config.harness.mjs +28 -9
  134. package/lib/paper-config.mjs +7 -2
  135. package/lib/skill-checks.mjs +18 -46
  136. package/lib/skill-checks.test.mjs +297 -0
  137. package/lib/skill-corpus.mjs +11 -13
  138. package/lib/skill-corpus.test.mjs +54 -0
  139. package/lib/skill-trigger-cases.harness.mjs +57 -4
  140. package/package.json +6 -5
  141. package/scripts/check.harness.mjs +4 -10
  142. package/scripts/check.mjs +45 -42
  143. package/scripts/check.test.mjs +104 -0
  144. package/scripts/coverage-config.test.ts +406 -0
  145. package/scripts/eslint-report-guard.test.mjs +48 -0
  146. package/scripts/harness-api.frozen.json +0 -2
  147. package/scripts/layer-legacy-frozen.d.mts +9 -1
  148. package/scripts/layer-legacy-frozen.mjs +28 -11
  149. package/scripts/layer-legacy-frozen.test.ts +143 -1
  150. package/scripts/rules-are-content-only.harness.mjs +3 -11
  151. package/scripts/rules-are-content-only.mjs +29 -19
  152. package/scripts/rules-are-content-only.test.mjs +61 -0
  153. package/scripts/rules-see-files.mjs +11 -9
  154. package/scripts/rules-see-files.test.mjs +71 -0
  155. package/scripts/test.mjs +44 -0
  156. package/scripts/test.test.mjs +52 -0
  157. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  158. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  159. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  160. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  161. package/skills/paper-pipeline/scripts/README.md +13 -132
  162. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  163. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  164. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  165. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  166. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  167. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  168. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  169. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  170. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  171. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  172. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  173. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  174. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  175. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  176. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
  177. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  178. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  179. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  180. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  181. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  182. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  183. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  184. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  185. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  186. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  187. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  188. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  189. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  190. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  191. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  192. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  193. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  194. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  195. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  196. package/skills/tighten-paper/structure.test.mjs +116 -0
  197. package/skills/verify-citations/SKILL.md +5 -5
  198. package/skills/verify-citations/SKILL.md.spec.ts +4 -4
  199. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +235 -0
  200. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  201. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  202. package/skills/verify-citations/scripts/bib-authors.mjs +2 -1
  203. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  204. package/skills/verify-citations/scripts/verify-cites.mjs +96 -96
  205. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +287 -0
  206. package/skills/verify-citations/scripts/verify-cites.test.mjs +149 -5
  207. package/src/adapters/banal/failure.test.ts +70 -1
  208. package/src/adapters/banal/index.test.ts +16 -0
  209. package/src/adapters/banal/output.test.ts +5 -0
  210. package/src/adapters/curl/download.test.ts +43 -1
  211. package/src/adapters/memory/index.test.ts +25 -0
  212. package/src/adapters/node/files.io.ts +3 -2
  213. package/src/adapters/node/files.test.ts +13 -1
  214. package/src/adapters/node/process.test.ts +38 -0
  215. package/src/adapters/pdfjs/fill.test.ts +34 -0
  216. package/src/adapters/pdfjs/fill.ts +44 -25
  217. package/src/adapters/references/index.test.ts +228 -0
  218. package/src/adapters/references/index.ts +21 -6
  219. package/src/adapters/references/pool.test.ts +31 -0
  220. package/src/adapters/references/pool.ts +24 -0
  221. package/src/build-engine.harness.mjs +3 -7
  222. package/src/build-engine.test.ts +137 -0
  223. package/src/build-engine.ts +6 -1
  224. package/src/build.harness.mjs +51 -11
  225. package/src/build.test.ts +362 -0
  226. package/src/build.ts +40 -14
  227. package/src/cli.harness.mjs +12 -19
  228. package/src/cli.test.ts +584 -0
  229. package/src/cli.ts +409 -94
  230. package/src/doctor.harness.mjs +3 -8
  231. package/src/doctor.test.ts +64 -0
  232. package/src/doctor.ts +12 -4
  233. package/src/domain/result.test.ts +4 -0
  234. package/src/domain/result.ts +0 -5
  235. package/src/engine.harness.mjs +77 -7
  236. package/src/engine.ts +35 -0
  237. package/src/facts-file.test.ts +110 -0
  238. package/src/hooks-settings.harness.mjs +43 -13
  239. package/src/hooks-settings.ts +41 -35
  240. package/src/init.test.ts +308 -5
  241. package/src/init.ts +4 -4
  242. package/src/latex-log.harness.mjs +32 -7
  243. package/src/latex-log.ts +6 -13
  244. package/src/latex-loop.harness.mjs +8 -7
  245. package/src/link-skills.harness.mjs +3 -8
  246. package/src/link-skills.test.ts +105 -0
  247. package/src/link-skills.ts +75 -21
  248. package/src/linted-set.test.ts +118 -0
  249. package/src/new-paper.harness.mjs +5 -7
  250. package/src/new-paper.test.ts +203 -2
  251. package/src/new-paper.ts +84 -17
  252. package/src/paper-files.test.ts +122 -5
  253. package/src/paper-files.ts +99 -39
  254. package/src/paper-settings-commands.test.ts +41 -5
  255. package/src/paper-settings.test.ts +20 -0
  256. package/src/parse-args.test.ts +48 -0
  257. package/src/pdf-facts.harness.mjs +3 -6
  258. package/src/pdf-facts.test.ts +144 -0
  259. package/src/pdf-facts.ts +17 -19
  260. package/src/pdf-geometry.harness.mjs +3 -7
  261. package/src/pdf-geometry.ts +1 -1
  262. package/src/presets.test.ts +2 -0
  263. package/src/presets.ts +7 -1
  264. package/src/references.test.ts +97 -0
  265. package/src/references.ts +2 -2
  266. package/src/rules-config.harness.mjs +10 -8
  267. package/src/rules-off.test.ts +111 -0
  268. package/src/structure.harness.mjs +5 -9
  269. package/src/structure.ts +1 -1
  270. package/src/tex-requirements.harness.mjs +34 -7
  271. package/src/tex-requirements.ts +12 -6
  272. package/src/toolchain.harness.mjs +44 -13
  273. package/src/toolchain.test.ts +280 -0
  274. package/src/toolchain.ts +37 -12
  275. package/src/types.ts +6 -0
  276. package/src/venue-rules.test.ts +63 -0
  277. package/src/venue-rules.ts +14 -12
  278. package/templates/paper/paperlint.json +1 -1
  279. package/action.mutations.mjs +0 -162
  280. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  281. package/eslint-rules/latex-language.mutations.mjs +0 -196
  282. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  283. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  284. package/eslint-rules/papers.mutations.mjs +0 -186
  285. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  286. package/eslint-rules/tex-build.mutations.mjs +0 -258
  287. package/hooks/hooks.mutations.mjs +0 -337
  288. package/lib/agent-cli-version.mutations.mjs +0 -109
  289. package/lib/mutation-driver.harness.mjs +0 -227
  290. package/lib/mutation-driver.mjs +0 -397
  291. package/lib/mutation-driver.mutations.mjs +0 -68
  292. package/lib/paper-config.mutations.mjs +0 -143
  293. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  294. package/scripts/check.mutations.mjs +0 -110
  295. package/scripts/exclusive.mjs +0 -138
  296. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  297. package/scripts/mutation-batteries-frozen.mjs +0 -238
  298. package/scripts/mutation-batteries.frozen.json +0 -108
  299. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  300. package/scripts/rules-see-files.mutations.mjs +0 -131
  301. package/scripts/run-mutations.mjs +0 -100
  302. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  303. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  304. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  305. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  306. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  307. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  308. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  309. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  310. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  311. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  312. package/skills/skill-contract.mutations.mjs +0 -179
  313. package/src/build.mutations.mjs +0 -196
  314. package/src/cli.mutations.mjs +0 -414
  315. package/src/doctor.mutations.mjs +0 -175
  316. package/src/hooks-settings.mutations.mjs +0 -99
  317. package/src/link-skills.mutations.mjs +0 -140
  318. package/src/new-paper.mutations.mjs +0 -79
  319. package/src/structure.mutations.mjs +0 -83
@@ -0,0 +1,474 @@
1
+ /**
2
+ * pipeline-check.mjs as a command — the banner every edit to a paper reprints (where the paper
3
+ * stands and the next move), the finding list under it, the warning for a scorecard that does not
4
+ * parse (a misspelled current template vs the retired single-table format), and the inputs from
5
+ * OUTSIDE the scorecard: the text's date (git or mtime), `reviews/`, and credentials in the env.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { execFileSync } from "node:child_process";
9
+ import { utimesSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { test } from "vitest";
13
+ import { runNode, useTempDir, writeTree } from "../../../test/support.mjs";
14
+ import { check, newestSourceDate, parseStatus } from "./pipeline-check.mjs";
15
+ import { readdirSync } from "node:fs";
16
+ import { scorecard } from "./fixtures/scorecard.mjs";
17
+
18
+ const SCRIPT = join(
19
+ dirname(fileURLToPath(import.meta.url)),
20
+ "pipeline-check.mjs",
21
+ );
22
+ const root = useTempDir("pipeline-check-cli-");
23
+ const TODAY = "2026-09-01";
24
+ const at = (iso) => new Date(`${iso}T12:00:00Z`);
25
+ // The temp paper sits outside any git repository, so the text's date is its mtime: pinned before
26
+ // every date in the scorecard fixture, so a gate is stale only when a case makes it so.
27
+ const TEXT_DATE = "2026-08-01";
28
+
29
+ /** A paper directory: `files` go in as written, `mtimes` pin `{ path: iso }` after writing. */
30
+ const paper = (name, card, { files = {}, mtimes = {} } = {}) => {
31
+ const dir = join(root, name);
32
+ writeTree(dir, {
33
+ ...(card === null ? {} : { "PIPELINE-STATUS.md": card }),
34
+ "paper.md": "## 1. Heading\n\nProse.\n",
35
+ ...files,
36
+ });
37
+ for (const [rel, iso] of Object.entries({
38
+ "paper.md": TEXT_DATE,
39
+ ...mtimes,
40
+ }))
41
+ utimesSync(join(dir, rel), at(iso), at(iso));
42
+ return dir;
43
+ };
44
+ const clean = (r) => ({
45
+ ...r,
46
+ stdout: r.stdout.replaceAll(root, "<root>"),
47
+ stderr: r.stderr.replaceAll(root, "<root>"),
48
+ });
49
+ const NO_TOKENS = { OSF_TOKEN: "", GITHUB_TOKEN: "" };
50
+ const run = (name, args = [], env = {}) =>
51
+ clean(
52
+ runNode(SCRIPT, [join(root, name), `--today=${TODAY}`, ...args], {
53
+ env: { ...NO_TOKENS, ...env },
54
+ }),
55
+ );
56
+ const json = (name, env) => {
57
+ const r = run(name, ["--json"], env);
58
+ assert.equal(r.status, 0, r.stderr);
59
+ return JSON.parse(r.stdout);
60
+ };
61
+ const card = (over = {}) => scorecard(over, TODAY);
62
+ const NEXT_CLEAN =
63
+ " ➡️ NEXT: no mechanical blocker — run paper-status for the measured picture (build page count, stale gates, owner-split) before deciding it is done";
64
+ const PAGE_NOTE =
65
+ " (page count comes from repro/build-submission.sh, never from this line)";
66
+
67
+ test("no scorecard: nothing to say, exit 0", () => {
68
+ paper("none", null);
69
+ assert.deepEqual(run("none"), { status: 0, stdout: "", stderr: "" });
70
+ });
71
+
72
+ test("a clean scorecard: the banner, with the deadline counted down and no finding", () => {
73
+ paper("clean", card({ deadline: "2026-09-11" }));
74
+ assert.deepEqual(run("clean"), {
75
+ status: 0,
76
+ stderr: "",
77
+ stdout: [
78
+ "📊 clean — 2026-09-11 (T−10d) · 0 open finding(s)",
79
+ " verdict row: Submit-ready — every gate green, artifact reproduces clean, nothing blocking.",
80
+ NEXT_CLEAN,
81
+ PAGE_NOTE,
82
+ "",
83
+ ].join("\n"),
84
+ });
85
+ });
86
+
87
+ test("findings: a passed deadline counts up, a missing verdict says so, every finding is listed", () => {
88
+ paper(
89
+ "late",
90
+ card({
91
+ deadline: "2026-08-20",
92
+ access: { status: "☐" },
93
+ cites: { date: "2026-07-15" },
94
+ }).replace(/^\*\*Readiness verdict:\*\*.*$/m, ""),
95
+ );
96
+ const stale =
97
+ "cites (skill) last ran 2026-07-15, the text changed 2026-08-01 — this ☑ is stale, not green";
98
+ const access =
99
+ "access (can you physically submit?) is ☐ with -12 day(s) to the deadline — profile moderation runs up to two weeks and there is no expedite route without an institutional email";
100
+ assert.deepEqual(run("late"), {
101
+ status: 0,
102
+ stderr: "",
103
+ stdout: [
104
+ "📊 late — 2026-08-20 (T+12d) · 2 open finding(s)",
105
+ " verdict row: (none)",
106
+ ` ➡️ NEXT: ${stale}`,
107
+ PAGE_NOTE,
108
+ "⚠️ pipeline-check — 2 finding(s) in <root>/late/PIPELINE-STATUS.md:",
109
+ ` [stale-continuous] ${stale}`,
110
+ ` [submit-access] ${access}`,
111
+ "",
112
+ ].join("\n"),
113
+ });
114
+ });
115
+
116
+ test("no deadline in the header: the banner says so, and open access is flagged without a count", () => {
117
+ paper(
118
+ "undated",
119
+ card({ access: { status: "" } }).replace(/ · Deadline: \S+/, ""),
120
+ );
121
+ const r = run("undated");
122
+ assert.equal(r.status, 0);
123
+ assert.match(
124
+ r.stdout,
125
+ /^📊 undated — deadline not in the header · 1 open finding\(s\)$/m,
126
+ );
127
+ assert.match(
128
+ r.stdout,
129
+ /\[submit-access\] access \(can you physically submit\?\) is blank — turn it green in the first week, not the last$/m,
130
+ );
131
+ });
132
+
133
+ test("open access far from the deadline is not a finding yet", () => {
134
+ paper("early", card({ access: { status: "◐" } }));
135
+ assert.deepEqual(json("early"), []);
136
+ });
137
+
138
+ test("open access inside the moderation window is flagged even when the box is blank", () => {
139
+ paper("blank-near", card({ deadline: "2026-09-11", access: { status: "" } }));
140
+ assert.deepEqual(
141
+ json("blank-near").map((f) => f.msg),
142
+ [
143
+ "access (can you physically submit?) is blank with 10 day(s) to the deadline — profile moderation runs up to two weeks and there is no expedite route without an institutional email",
144
+ ],
145
+ );
146
+ });
147
+
148
+ test("a continuous check that is not ☑, or ☑ without a date, is not called stale here", () => {
149
+ // The undated ☑ is `pipeline/undated-continuous`'s finding, not this checker's.
150
+ paper(
151
+ "not-done",
152
+ card({ cites: { date: "" }, render: { status: "◐", date: "2020-01-01" } }),
153
+ );
154
+ assert.deepEqual(json("not-done"), []);
155
+ });
156
+
157
+ test("check() on its own: no sections and no directory is no finding", () => {
158
+ assert.deepEqual(check({ sections: {}, header: "" }, {}), []);
159
+ });
160
+
161
+ test("--json prints the findings and nothing else", () => {
162
+ paper("js", card({ render: { date: "2026-07-20" } }));
163
+ assert.deepEqual(json("js"), [
164
+ {
165
+ kind: "stale-continuous",
166
+ msg: "render (skill) last ran 2026-07-20, the text changed 2026-08-01 — this ☑ is stale, not green",
167
+ },
168
+ ]);
169
+ });
170
+
171
+ test("without arguments it reads the current directory and today's date", () => {
172
+ const dir = paper("cwd", card());
173
+ const r = clean(runNode(SCRIPT, ["--json"], { cwd: dir, env: NO_TOKENS }));
174
+ assert.deepEqual(r, { status: 0, stdout: "[]\n", stderr: "" });
175
+ });
176
+
177
+ test("a misspelled current template: the warning names the sections and headings it did find", () => {
178
+ paper("typo", card().replace("### LOOP", "### LOPO"));
179
+ const r = run("typo");
180
+ assert.equal(r.status, 0);
181
+ assert.equal(r.stdout, "");
182
+ assert.equal(
183
+ r.stderr,
184
+ [
185
+ "⚠️ <root>/typo/PIPELINE-STATUS.md: expected the four sections SETUP/LOOP/CONTINUOUS/GATES, found [SETUP, CONTINUOUS, GATES].",
186
+ ' Parsed 4 table(s), 4 of them with an id column; headings: "PIPELINE-STATUS — harness fixture", "SETUP", "LOPO", "CONTINUOUS", "GATES".',
187
+ " The template is a format contract — see paper-pipeline/references/pipeline-status-template.md.",
188
+ " Until it parses, these checks are silent, which is the failure mode they exist to prevent.",
189
+ "",
190
+ ].join("\n"),
191
+ );
192
+ });
193
+
194
+ test("the retired single-table format is named as such, not as a typo", () => {
195
+ paper(
196
+ "legacy",
197
+ "Deadline: 2026-08-06\n\n| # | Gate | Status |\n|---|------|--------|\n| 1 | cites | ☑ |\n",
198
+ );
199
+ const r = run("legacy");
200
+ assert.equal(r.status, 0);
201
+ assert.match(r.stderr, /found \[none\]\.\n/);
202
+ assert.match(
203
+ r.stderr,
204
+ /Parsed 1 table\(s\), 0 of them with an id column; headings: none\.\n/,
205
+ );
206
+ assert.match(
207
+ r.stderr,
208
+ /this is the pre-2026-08 single-table format \(first column `#`, a row number\)/,
209
+ );
210
+ });
211
+
212
+ test("a scorecard with no table at all is not called legacy", () => {
213
+ paper("prose", "# Status\n\nNothing tabulated yet.\n");
214
+ const r = run("prose");
215
+ assert.match(
216
+ r.stderr,
217
+ /Parsed 0 table\(s\), 0 of them with an id column; headings: "Status"\.\n/,
218
+ );
219
+ assert.doesNotMatch(r.stderr, /single-table format/);
220
+ });
221
+
222
+ test("reviews/: a worst section named twice is a repeat finding; no cold read is a finding", () => {
223
+ paper("reviews", card(), {
224
+ files: {
225
+ "reviews/2026-08-10-writing.md": "WORST-SECTION: Abstract\n",
226
+ "reviews/2026-08-12-grade-paper-writing.md":
227
+ " WORST-SECTION: abstract \n",
228
+ // Unmarked: skipped rather than guessed at, so it neither makes nor breaks the repeat.
229
+ "reviews/2026-08-14-writing.md": "no marker here\n",
230
+ "reviews/2026-08-15-writing.txt": "WORST-SECTION: intro\n",
231
+ },
232
+ });
233
+ assert.deepEqual(
234
+ json("reviews").map((f) => f.kind),
235
+ ["repeat-finding", "no-cold-read"],
236
+ );
237
+ });
238
+
239
+ test("reviews/: two different worst sections are not a repeat", () => {
240
+ paper("mixed", card(), {
241
+ files: {
242
+ "reviews/a-writing.md": "WORST-SECTION: abstract\n",
243
+ "reviews/b-writing.md": "WORST-SECTION: intro\n",
244
+ "reviews/c-coldread.md": "read\n",
245
+ },
246
+ mtimes: { "reviews/c-coldread.md": "2026-08-20" },
247
+ });
248
+ assert.deepEqual(json("mixed"), []);
249
+ });
250
+
251
+ test("reviews/ as a file, and a report path that is a directory, are unreadable — not a crash", () => {
252
+ paper("reviews-file", card(), { files: { reviews: "not a folder\n" } });
253
+ assert.deepEqual(
254
+ json("reviews-file").map((f) => f.kind),
255
+ ["no-cold-read"],
256
+ );
257
+ paper("reviews-dir", card(), {
258
+ files: {
259
+ "reviews/a-writing.md/inner.txt": "x",
260
+ "reviews/b-writing.md": "WORST-SECTION: abstract\n",
261
+ "reviews/c-coldread.md": "read\n",
262
+ },
263
+ mtimes: { "reviews/c-coldread.md": "2026-08-20" },
264
+ });
265
+ assert.deepEqual(json("reviews-dir"), []);
266
+ });
267
+
268
+ test("reviews/: a cold read older than the text is stale, under either spelling", () => {
269
+ paper("stale-cr", card(), {
270
+ files: { "reviews/2026-07-01-cold-read.md": "read\n" },
271
+ mtimes: { "reviews/2026-07-01-cold-read.md": "2026-07-01" },
272
+ });
273
+ assert.deepEqual(json("stale-cr"), [
274
+ {
275
+ kind: "stale-cold-read",
276
+ msg: "the cold read ran 2026-07-01, the text changed 2026-08-01 — it describes prose that no longer exists",
277
+ },
278
+ ]);
279
+ });
280
+
281
+ test("a credential in the env un-parks the blocker it clears — unless the row already shows it done", () => {
282
+ paper(
283
+ "creds",
284
+ card({
285
+ harden: { status: "◐", result: "github release pending" },
286
+ panel: { status: "◐", result: "artifact hosted at osf.io/abc12" },
287
+ }),
288
+ );
289
+ assert.deepEqual(json("creds", { GITHUB_TOKEN: "t", OSF_TOKEN: "t" }), [
290
+ {
291
+ kind: "credential-available",
292
+ msg: "harden (repository work) is parked as open, but GITHUB_TOKEN is set in this environment — do it over the API yourself. Do not report this as waiting on the human.",
293
+ },
294
+ ]);
295
+ assert.deepEqual(json("creds"), []);
296
+ });
297
+
298
+ test("the text's date ignores dot-dirs, node_modules, repro, build outputs and anything past depth 3", () => {
299
+ const late = "2026-09-20"; // after every row date: walked, any of these would make the gates stale
300
+ const files = {
301
+ ".cache/paper.md": "x",
302
+ // eslint-disable-next-line port/js-install-path -- a fixture directory the walk must skip, not an install location
303
+ "node_modules/pkg/paper.md": "x",
304
+ "repro/main.tex": "x",
305
+ "build/main.tex": "x",
306
+ "a/b/c/d/deep.tex": "x",
307
+ };
308
+ paper("walk", card(), {
309
+ files,
310
+ mtimes: Object.fromEntries(Object.keys(files).map((f) => [f, late])),
311
+ });
312
+ assert.deepEqual(json("walk"), []);
313
+ });
314
+
315
+ test("a paper with no source text has no date, so nothing can be stale", () => {
316
+ const dir = paper("nosrc", card({ cites: { date: "2020-01-01" } }));
317
+ execFileSync("rm", [join(dir, "paper.md")]);
318
+ assert.deepEqual(json("nosrc"), []);
319
+ });
320
+
321
+ test("inside git: a committed file is dated by its commit, a dirty or ignored one by its mtime", () => {
322
+ // The paper is its own repository and the command runs from elsewhere (this package's repo):
323
+ // git has to be asked from where the file lives, or every paper outside the cwd's repository
324
+ // silently falls back to mtimes.
325
+ const dir = paper("repo", card({ cites: { date: "2026-08-20" } }), {
326
+ files: { "sec.tex": "a", "draft.md": "b", ".gitignore": "draft.md\n" },
327
+ mtimes: { "sec.tex": "2026-08-25", "draft.md": "2026-08-22" },
328
+ });
329
+ const git = (...a) =>
330
+ execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...a], {
331
+ cwd: dir,
332
+ env: {
333
+ ...process.env,
334
+ GIT_COMMITTER_DATE: "2026-08-02T12:00:00Z",
335
+ GIT_AUTHOR_DATE: "2026-08-02T12:00:00Z",
336
+ },
337
+ stdio: "ignore",
338
+ });
339
+ git("init", "-q");
340
+ git("add", "paper.md", ".gitignore", "PIPELINE-STATUS.md");
341
+ git("commit", "-qm", "init");
342
+ // A checkout rewrites mtimes; the commit date is what the text's date is.
343
+ utimesSync(join(dir, "paper.md"), at("2026-09-20"), at("2026-09-20"));
344
+ const stale = (changed) => [
345
+ `cites (skill) last ran 2026-08-20, the text changed ${changed} — this ☑ is stale, not green`,
346
+ ];
347
+ // sec.tex is untracked, i.e. dirty: its mtime (08-25) is the newest date.
348
+ assert.deepEqual(
349
+ json("repo").map((f) => f.msg),
350
+ stale("2026-08-25"),
351
+ );
352
+ execFileSync("rm", [join(dir, "sec.tex")]);
353
+ // paper.md by its commit (08-02, not its 09-20 mtime); draft.md is ignored — no status, no
354
+ // log — so by its mtime (08-22), which is now the newest.
355
+ assert.deepEqual(
356
+ json("repo").map((f) => f.msg),
357
+ stale("2026-08-22"),
358
+ );
359
+ });
360
+
361
+ test("parseStatus: a heading with no Latin word is no section; a short or id-less row is skipped", () => {
362
+ const parsed = parseStatus(
363
+ [
364
+ "## Итоги",
365
+ "| id | Work | Skill | Status |",
366
+ "|----|------|-------|--------|",
367
+ "| x | only | three |",
368
+ "| ** | a | b | ☑ |",
369
+ "",
370
+ "## SETUP",
371
+ "| id | Work | Skill | Status |",
372
+ "|----|------|-------|--------|",
373
+ "| idea | w | s | nothing recognisable |",
374
+ ].join("\n"),
375
+ );
376
+ assert.deepEqual(parsed.sections, {
377
+ SETUP: [
378
+ {
379
+ id: "idea",
380
+ name: "w",
381
+ skill: "s",
382
+ status: "",
383
+ date: null,
384
+ raw: "idea | w | s | nothing recognisable",
385
+ section: "SETUP",
386
+ heading: "SETUP",
387
+ },
388
+ ],
389
+ });
390
+ assert.deepEqual(parsed.shape, {
391
+ tables: 2,
392
+ scorecardTables: 2,
393
+ headings: ["Итоги", "SETUP"],
394
+ });
395
+ });
396
+
397
+ test("newestSourceDate: a subdirectory that cannot be listed is skipped, the rest still dated", () => {
398
+ const dir = paper("unlistable", card(), {
399
+ files: { "locked/late.tex": "x" },
400
+ mtimes: { "locked/late.tex": "2026-09-20" },
401
+ });
402
+ const readdir = (d) => {
403
+ if (d.endsWith("locked"))
404
+ throw Object.assign(new Error("EACCES: permission denied"), {
405
+ code: "EACCES",
406
+ });
407
+ return readdirSync(d, { withFileTypes: true });
408
+ };
409
+ assert.deepEqual(
410
+ [newestSourceDate(dir, { readdir }), newestSourceDate(dir)],
411
+ [TEXT_DATE, "2026-09-20"],
412
+ );
413
+ });
414
+
415
+ test("parseStatus reads a token stream that breaks markdown-it's shape without throwing", () => {
416
+ const T = (type, content) =>
417
+ content === undefined ? { type } : { type, content };
418
+ const tokens = [
419
+ // a heading whose inline token is missing
420
+ T("heading_open"),
421
+ T("paragraph_open"),
422
+ T("heading_open"),
423
+ T("inline", "SETUP"),
424
+ // a table with no thead, a cell before any row, a row closed that was never opened
425
+ T("table_open"),
426
+ T("td_open"),
427
+ T("inline", "stray"),
428
+ T("tr_close"),
429
+ T("table_close"),
430
+ // a scorecard table: a header cell with no inline, an empty row, then one real row — and no
431
+ // table_close at all, so the table runs to the end of the stream
432
+ T("table_open"),
433
+ T("thead_open"),
434
+ T("tr_open"),
435
+ T("th_open"),
436
+ T("inline", "id"),
437
+ T("th_open"),
438
+ T("th_close"),
439
+ T("tr_close"),
440
+ T("thead_close"),
441
+ T("tr_open"),
442
+ T("tr_close"),
443
+ T("tr_open"),
444
+ ...["idea", "w", "s", "☑"].flatMap((c) => [T("td_open"), T("inline", c)]),
445
+ T("tr_close"),
446
+ ];
447
+ const parsed = parseStatus("ignored", { parse: () => tokens });
448
+ assert.deepEqual(parsed.shape, {
449
+ tables: 2,
450
+ scorecardTables: 1,
451
+ headings: ["", "SETUP"],
452
+ });
453
+ assert.deepEqual(
454
+ parsed.sections.SETUP.map((r) => [r.id, r.name, r.skill, r.status]),
455
+ [["idea", "w", "s", "☑"]],
456
+ );
457
+ });
458
+
459
+ test("check(): rows built by another caller without their raw text are read as empty", () => {
460
+ const saved = process.env.GITHUB_TOKEN;
461
+ process.env.GITHUB_TOKEN = "t";
462
+ try {
463
+ assert.deepEqual(
464
+ check(
465
+ { sections: { GATES: [{ id: "g", status: "◐" }] }, header: "" },
466
+ {},
467
+ ),
468
+ [],
469
+ );
470
+ } finally {
471
+ if (saved === undefined) delete process.env.GITHUB_TOKEN;
472
+ else process.env.GITHUB_TOKEN = saved;
473
+ }
474
+ });
@@ -40,6 +40,7 @@ import { tmpdir } from "node:os";
40
40
  import { join, dirname } from "node:path";
41
41
  import { fileURLToPath } from "node:url";
42
42
  import { consumerRoot } from "./consumer.mjs";
43
+ import { scorecard as makeScorecard } from "./fixtures/scorecard.mjs";
43
44
  const HERE = dirname(fileURLToPath(import.meta.url));
44
45
 
45
46
  const ROOT = consumerRoot();
@@ -50,74 +51,12 @@ const tmp = realpathSync(mkdtempSync(join(tmpdir(), "pipecheck-harness-")));
50
51
  // has something real to compare against. Every clean row therefore carries today's date: hard-coding
51
52
  // a date would make this suite start failing on its own the following morning.
52
53
  const TODAY = new Date().toISOString().slice(0, 10);
53
- const FAR = "2027-12-31";
54
54
 
55
55
  const ENV = { ...process.env };
56
56
  delete ENV.OSF_TOKEN;
57
57
  delete ENV.GITHUB_TOKEN;
58
58
 
59
- /**
60
- * A scorecard with the four sections the template requires, all green. `over` replaces individual
61
- * cells: `{ cites: { date: "2020-01-01" } }`, `{ verdict: "…" }`, `{ deadline: "2026-08-18" }`,
62
- * `{ tail: "…" }` for anything appended after the last section, `{ preamble: "…" }` for anything
63
- * inserted BEFORE the first heading (the only way to plant a row under no heading at all).
64
- */
65
- function scorecard(over = {}) {
66
- const r = (id, status = "☑", date = TODAY) => ({
67
- id,
68
- status,
69
- date,
70
- ...(over[id] ?? {}),
71
- });
72
- const row = (o, ...mid) =>
73
- `| ${o.id} | work | ${o.skill ?? "skill"} | ${mid.length ? mid.join(" | ") + " | " : ""}${o.status} | ${o.date} | ${o.result ?? "result"} | — |`;
74
- // The GATES `Requires` cell, canonical by default so the baseline is silent — `{ structure: {
75
- // requires: "render" } }` plants a dropped edge. `harden` deliberately declares four of its five
76
- // canonical inputs: the fifth (`priordelta`) has no row in this fixture, and the clean case asserts that the checker stays
77
- // quiet about an edge there is nowhere to point at.
78
- const req = (id, dflt) => over[id]?.requires ?? dflt;
79
- const verdict =
80
- over.verdict ??
81
- "Submit-ready — every gate green, artifact reproduces clean, nothing blocking.";
82
- return [
83
- "# PIPELINE-STATUS — harness fixture",
84
- `Venue: Fixture Workshop · Deadline: ${over.deadline ?? FAR} · Blind: double · State: drafting`,
85
- "",
86
- `**Readiness verdict:** ${verdict}`,
87
- "",
88
- over.preamble ?? "",
89
- "",
90
- "### SETUP",
91
- "| id | Work | Skill | Status | Date | Result | Open |",
92
- "|----|------|-------|--------|------|--------|------|",
93
- row(r("idea")),
94
- row(r("access")),
95
- row(r("frame")),
96
- "",
97
- "### LOOP",
98
- "| id | Work | Skill | Status | Date | Result | Open |",
99
- "|----|------|-------|--------|------|--------|------|",
100
- row(r("study")),
101
- row(r("draft")),
102
- row(r("arc")),
103
- "",
104
- "### CONTINUOUS",
105
- "| id | Trigger | Skill | Status | Date | Result | Open |",
106
- "|----|---------|-------|--------|------|--------|------|",
107
- row(r("cites")),
108
- row(r("render")),
109
- "",
110
- "### GATES",
111
- "| id | Gate | Skill | Requires | Status | Date | Result | Open |",
112
- "|----|------|-------|----------|--------|------|--------|------|",
113
- row(r("structure"), req("structure", "render, arc")),
114
- row(r("writing"), req("writing", "draft, arc, structure")),
115
- row(r("panel"), req("panel", "structure, writing")),
116
- row(r("harden"), req("harden", "panel, structure, writing, cites")),
117
- "",
118
- over.tail ?? "",
119
- ].join("\n");
120
- }
59
+ const scorecard = (over = {}) => makeScorecard(over, TODAY);
121
60
 
122
61
  /** Write a fixture and return the findings pipeline-check reports, verbatim. */
123
62
  function findings(name, over = {}, today = TODAY) {
@@ -106,7 +106,7 @@
106
106
  * not an id — and is therefore not adopted; see the diagnostic in `main`.
107
107
  */
108
108
  import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
109
- import { join, resolve } from "node:path";
109
+ import { dirname, join, resolve } from "node:path";
110
110
  import { execFileSync } from "node:child_process";
111
111
  import MarkdownIt from "markdown-it";
112
112
  import {
@@ -238,7 +238,7 @@ function readTable(toks) {
238
238
  * read is still skipped (`continue`), just silently — the linter speaks about it, and to the
239
239
  * precision of a line.
240
240
  */
241
- export function parseStatus(text) {
241
+ export function parseStatus(text, { parse = (t) => md.parse(t, {}) } = {}) {
242
242
  const sections = {};
243
243
  let verdict = "";
244
244
  let header = "";
@@ -249,7 +249,7 @@ export function parseStatus(text) {
249
249
  let section = null;
250
250
  let heading = "(before the first heading)";
251
251
 
252
- const tokens = md.parse(currentPart(text), {});
252
+ const tokens = parse(currentPart(text));
253
253
  for (let i = 0; i < tokens.length; i++) {
254
254
  const t = tokens[i];
255
255
 
@@ -291,8 +291,9 @@ export function parseStatus(text) {
291
291
  const joined = c.join(" | ");
292
292
  const row = {
293
293
  id,
294
- name: c[1] ?? "",
295
- skill: c[2] ?? "",
294
+ // `c.length >= 4` above, and every cell is a string (readTable), so both exist.
295
+ name: c[1],
296
+ skill: c[2],
296
297
  // The `Requires` column is no longer read here: all three checks that ate it
297
298
  // (`gate-missing-input` · `gate-stale-input` · `unknown-input`) and the check of the
298
299
  // declaration itself (`undeclared-input`) moved into `eslint-rules/pipeline-status.mjs`.
@@ -349,15 +350,18 @@ const GENERATED_DIRS = new Set([
349
350
  "artifact-anon",
350
351
  ]);
351
352
 
352
- function newestSourceDate(dir) {
353
+ export function newestSourceDate(
354
+ dir,
355
+ { readdir = (d) => readdirSync(d, { withFileTypes: true }) } = {},
356
+ ) {
353
357
  const sources = [];
354
358
  const walk = (d, depth) => {
355
359
  if (depth > 3) return;
356
360
  let entries;
357
361
  try {
358
- entries = readdirSync(d, { withFileTypes: true });
362
+ entries = readdir(d);
359
363
  } catch {
360
- return;
364
+ return; // a directory that cannot be listed (EACCES) holds no source this walk can date
361
365
  }
362
366
  for (const e of entries) {
363
367
  if (
@@ -378,11 +382,23 @@ function newestSourceDate(dir) {
378
382
  let newest = "";
379
383
  for (const p of sources) {
380
384
  let when; // no initializer: both branches below assign it (2026-08-28)
385
+ // Asked from the file's own directory: from the caller's cwd, a paper in another repository
386
+ // (or the cwd outside any) made git throw, and every date silently became an mtime.
387
+ const QUIET_GIT = {
388
+ cwd: dirname(p),
389
+ encoding: "utf8",
390
+ stdio: ["ignore", "pipe", "ignore"],
391
+ };
381
392
  try {
382
393
  // Dirty file: the edit is real and uncommitted, so mtime is the honest answer.
383
- const dirty = execFileSync("git", ["status", "--porcelain", "--", p], {
384
- encoding: "utf8",
385
- }).trim();
394
+ // stderr is not the reader's: outside a repository git prints `fatal: … is outside
395
+ // repository` and throws, and the catch below already answers with the mtime. Inherited,
396
+ // that line reached the person's terminal under a banner that was otherwise correct.
397
+ const dirty = execFileSync(
398
+ "git",
399
+ ["status", "--porcelain", "--", p],
400
+ QUIET_GIT,
401
+ ).trim();
386
402
  // 🔴 EMPTY OUTPUT FROM `git log` IS NOT A DATE. The command exits 0 and prints NOTHING when
387
403
  // a path has no history: a shallow clone (`actions/checkout` defaults to `fetch-depth: 1`), a
388
404
  // file outside git, a file inside `node_modules`. Previously that empty value went on as
@@ -398,16 +414,18 @@ function newestSourceDate(dir) {
398
414
  // 34784079821).
399
415
  const logged = dirty
400
416
  ? ""
401
- : execFileSync("git", ["log", "-1", "--format=%cs", "--", p], {
402
- encoding: "utf8",
403
- }).trim();
417
+ : execFileSync(
418
+ "git",
419
+ ["log", "-1", "--format=%cs", "--", p],
420
+ QUIET_GIT,
421
+ ).trim();
404
422
  when = logged || new Date(statSync(p).mtimeMs).toISOString().slice(0, 10);
405
423
  } catch {
406
424
  when = new Date(statSync(p).mtimeMs).toISOString().slice(0, 10); // no git here: fall back
407
425
  }
408
426
  if (when > newest) newest = when;
409
427
  }
410
- return newest || null;
428
+ return newest; // non-empty: every source above got a date
411
429
  }
412
430
 
413
431
  // ── checks ───────────────────────────────────────────────────────────────────
@@ -556,12 +574,10 @@ export function check({ sections, header }, { sourceDate, today, dir }) {
556
574
  // `done` is checked against the row TEXT, not the row's status box: a multi-axis gate like
557
575
  // harden stays ⚠ for reasons that have nothing to do with this credential, and firing on its
558
576
  // status would mean nagging about an upload that already happened.
559
- const row = [...all.values()].find(
560
- (r) =>
561
- what.re.test(r.raw ?? "") &&
562
- !DONE.has(r.status) &&
563
- !what.done.test(r.raw ?? ""),
564
- );
577
+ const row = [...all.values()].find((r) => {
578
+ const text = r.raw ?? ""; // a row built by another caller may carry no raw text
579
+ return what.re.test(text) && !DONE.has(r.status) && !what.done.test(text);
580
+ });
565
581
  if (row) {
566
582
  add(
567
583
  "credential-available",