paperlint 2.0.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.
- package/.github/dependabot.yml +72 -0
- package/.github/workflows/ci.yml +297 -0
- package/.github/workflows/dependabot-automerge.yml +70 -0
- package/.github/workflows/pr-title.yml +59 -0
- package/.github/workflows/release.yml +54 -0
- package/CLAUDE.md +598 -0
- package/CONTRIBUTING.md +159 -0
- package/LICENSE +21 -0
- package/README.md +240 -0
- package/action.harness.mjs +287 -0
- package/action.mutations.mjs +162 -0
- package/action.yml +138 -0
- package/bin/rpp.mjs +43 -0
- package/dist/action-ref.d.ts +12 -0
- package/dist/action-ref.d.ts.map +1 -0
- package/dist/action-ref.js +16 -0
- package/dist/action-ref.js.map +1 -0
- package/dist/adapters/banal/failure.d.ts +73 -0
- package/dist/adapters/banal/failure.d.ts.map +1 -0
- package/dist/adapters/banal/failure.js +58 -0
- package/dist/adapters/banal/failure.js.map +1 -0
- package/dist/adapters/banal/index.d.ts +17 -0
- package/dist/adapters/banal/index.d.ts.map +1 -0
- package/dist/adapters/banal/index.js +56 -0
- package/dist/adapters/banal/index.js.map +1 -0
- package/dist/adapters/banal/install.d.ts +26 -0
- package/dist/adapters/banal/install.d.ts.map +1 -0
- package/dist/adapters/banal/install.js +15 -0
- package/dist/adapters/banal/install.js.map +1 -0
- package/dist/adapters/banal/invocation.d.ts +48 -0
- package/dist/adapters/banal/invocation.d.ts.map +1 -0
- package/dist/adapters/banal/invocation.js +43 -0
- package/dist/adapters/banal/invocation.js.map +1 -0
- package/dist/adapters/banal/locate.d.ts +50 -0
- package/dist/adapters/banal/locate.d.ts.map +1 -0
- package/dist/adapters/banal/locate.js +34 -0
- package/dist/adapters/banal/locate.js.map +1 -0
- package/dist/adapters/banal/output.d.ts +27 -0
- package/dist/adapters/banal/output.d.ts.map +1 -0
- package/dist/adapters/banal/output.js +112 -0
- package/dist/adapters/banal/output.js.map +1 -0
- package/dist/adapters/banal/pin.d.ts +19 -0
- package/dist/adapters/banal/pin.d.ts.map +1 -0
- package/dist/adapters/banal/pin.js +15 -0
- package/dist/adapters/banal/pin.js.map +1 -0
- package/dist/adapters/banal/probe.d.ts +12 -0
- package/dist/adapters/banal/probe.d.ts.map +1 -0
- package/dist/adapters/banal/probe.js +27 -0
- package/dist/adapters/banal/probe.js.map +1 -0
- package/dist/adapters/banal/run.d.ts +89 -0
- package/dist/adapters/banal/run.d.ts.map +1 -0
- package/dist/adapters/banal/run.js +104 -0
- package/dist/adapters/banal/run.js.map +1 -0
- package/dist/adapters/banal/settings.d.ts +18 -0
- package/dist/adapters/banal/settings.d.ts.map +1 -0
- package/dist/adapters/banal/settings.js +29 -0
- package/dist/adapters/banal/settings.js.map +1 -0
- package/dist/adapters/banal/xml.d.ts +48 -0
- package/dist/adapters/banal/xml.d.ts.map +1 -0
- package/dist/adapters/banal/xml.js +67 -0
- package/dist/adapters/banal/xml.js.map +1 -0
- package/dist/adapters/curl/download.io.d.ts +14 -0
- package/dist/adapters/curl/download.io.d.ts.map +1 -0
- package/dist/adapters/curl/download.io.js +69 -0
- package/dist/adapters/curl/download.io.js.map +1 -0
- package/dist/adapters/curl/index.d.ts +6 -0
- package/dist/adapters/curl/index.d.ts.map +1 -0
- package/dist/adapters/curl/index.js +6 -0
- package/dist/adapters/curl/index.js.map +1 -0
- package/dist/adapters/memory/index.d.ts +43 -0
- package/dist/adapters/memory/index.d.ts.map +1 -0
- package/dist/adapters/memory/index.js +79 -0
- package/dist/adapters/memory/index.js.map +1 -0
- package/dist/adapters/node/files.io.d.ts +3 -0
- package/dist/adapters/node/files.io.d.ts.map +1 -0
- package/dist/adapters/node/files.io.js +31 -0
- package/dist/adapters/node/files.io.js.map +1 -0
- package/dist/adapters/node/host.io.d.ts +3 -0
- package/dist/adapters/node/host.io.d.ts.map +1 -0
- package/dist/adapters/node/host.io.js +14 -0
- package/dist/adapters/node/host.io.js.map +1 -0
- package/dist/adapters/node/index.d.ts +25 -0
- package/dist/adapters/node/index.d.ts.map +1 -0
- package/dist/adapters/node/index.js +14 -0
- package/dist/adapters/node/index.js.map +1 -0
- package/dist/adapters/node/process.io.d.ts +14 -0
- package/dist/adapters/node/process.io.d.ts.map +1 -0
- package/dist/adapters/node/process.io.js +41 -0
- package/dist/adapters/node/process.io.js.map +1 -0
- package/dist/adapters/node/workspace.io.d.ts +4 -0
- package/dist/adapters/node/workspace.io.d.ts.map +1 -0
- package/dist/adapters/node/workspace.io.js +33 -0
- package/dist/adapters/node/workspace.io.js.map +1 -0
- package/dist/adapters/pdfjs/fill.d.ts +42 -0
- package/dist/adapters/pdfjs/fill.d.ts.map +1 -0
- package/dist/adapters/pdfjs/fill.js +91 -0
- package/dist/adapters/pdfjs/fill.js.map +1 -0
- package/dist/build-engine.d.ts +48 -0
- package/dist/build-engine.d.ts.map +1 -0
- package/dist/build-engine.js +148 -0
- package/dist/build-engine.js.map +1 -0
- package/dist/build.d.ts +163 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +575 -0
- package/dist/build.js.map +1 -0
- package/dist/cli.d.ts +151 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +951 -0
- package/dist/cli.js.map +1 -0
- package/dist/doctor.d.ts +42 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +280 -0
- package/dist/doctor.js.map +1 -0
- package/dist/domain/geometry.d.ts +71 -0
- package/dist/domain/geometry.d.ts.map +1 -0
- package/dist/domain/geometry.js +35 -0
- package/dist/domain/geometry.js.map +1 -0
- package/dist/domain/host.d.ts +16 -0
- package/dist/domain/host.d.ts.map +1 -0
- package/dist/domain/host.js +8 -0
- package/dist/domain/host.js.map +1 -0
- package/dist/domain/page-layout.d.ts +34 -0
- package/dist/domain/page-layout.d.ts.map +1 -0
- package/dist/domain/page-layout.js +8 -0
- package/dist/domain/page-layout.js.map +1 -0
- package/dist/domain/paths.d.ts +5 -0
- package/dist/domain/paths.d.ts.map +1 -0
- package/dist/domain/paths.js +2 -0
- package/dist/domain/paths.js.map +1 -0
- package/dist/domain/result.d.ts +23 -0
- package/dist/domain/result.d.ts.map +1 -0
- package/dist/domain/result.js +10 -0
- package/dist/domain/result.js.map +1 -0
- package/dist/domain/sha256.d.ts +7 -0
- package/dist/domain/sha256.d.ts.map +1 -0
- package/dist/domain/sha256.js +14 -0
- package/dist/domain/sha256.js.map +1 -0
- package/dist/domain/text.d.ts +6 -0
- package/dist/domain/text.d.ts.map +1 -0
- package/dist/domain/text.js +7 -0
- package/dist/domain/text.js.map +1 -0
- package/dist/engine.d.ts +93 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +119 -0
- package/dist/engine.js.map +1 -0
- package/dist/exit-code.d.ts +22 -0
- package/dist/exit-code.d.ts.map +1 -0
- package/dist/exit-code.js +10 -0
- package/dist/exit-code.js.map +1 -0
- package/dist/facts-file.d.ts +96 -0
- package/dist/facts-file.d.ts.map +1 -0
- package/dist/facts-file.js +134 -0
- package/dist/facts-file.js.map +1 -0
- package/dist/hooks-settings.d.ts +141 -0
- package/dist/hooks-settings.d.ts.map +1 -0
- package/dist/hooks-settings.js +306 -0
- package/dist/hooks-settings.js.map +1 -0
- package/dist/init.d.ts +201 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +579 -0
- package/dist/init.js.map +1 -0
- package/dist/latex-log.d.ts +80 -0
- package/dist/latex-log.d.ts.map +1 -0
- package/dist/latex-log.js +187 -0
- package/dist/latex-log.js.map +1 -0
- package/dist/latex-loop.d.ts +129 -0
- package/dist/latex-loop.d.ts.map +1 -0
- package/dist/latex-loop.js +113 -0
- package/dist/latex-loop.js.map +1 -0
- package/dist/link-skills.d.ts +51 -0
- package/dist/link-skills.d.ts.map +1 -0
- package/dist/link-skills.js +199 -0
- package/dist/link-skills.js.map +1 -0
- package/dist/new-paper.d.ts +48 -0
- package/dist/new-paper.d.ts.map +1 -0
- package/dist/new-paper.js +110 -0
- package/dist/new-paper.js.map +1 -0
- package/dist/pdf-facts.d.ts +44 -0
- package/dist/pdf-facts.d.ts.map +1 -0
- package/dist/pdf-facts.js +239 -0
- package/dist/pdf-facts.js.map +1 -0
- package/dist/pdf-geometry.d.ts +170 -0
- package/dist/pdf-geometry.d.ts.map +1 -0
- package/dist/pdf-geometry.js +158 -0
- package/dist/pdf-geometry.js.map +1 -0
- package/dist/ports/download.d.ts +9 -0
- package/dist/ports/download.d.ts.map +1 -0
- package/dist/ports/download.js +2 -0
- package/dist/ports/download.js.map +1 -0
- package/dist/ports/files.d.ts +11 -0
- package/dist/ports/files.d.ts.map +1 -0
- package/dist/ports/files.js +2 -0
- package/dist/ports/files.js.map +1 -0
- package/dist/ports/measure-geometry.d.ts +8 -0
- package/dist/ports/measure-geometry.d.ts.map +1 -0
- package/dist/ports/measure-geometry.js +2 -0
- package/dist/ports/measure-geometry.js.map +1 -0
- package/dist/ports/process.d.ts +45 -0
- package/dist/ports/process.d.ts.map +1 -0
- package/dist/ports/process.js +2 -0
- package/dist/ports/process.js.map +1 -0
- package/dist/ports/tool-installer.d.ts +29 -0
- package/dist/ports/tool-installer.d.ts.map +1 -0
- package/dist/ports/tool-installer.js +2 -0
- package/dist/ports/tool-installer.js.map +1 -0
- package/dist/ports/workspace.d.ts +18 -0
- package/dist/ports/workspace.d.ts.map +1 -0
- package/dist/ports/workspace.js +2 -0
- package/dist/ports/workspace.js.map +1 -0
- package/dist/rules-config.d.ts +34 -0
- package/dist/rules-config.d.ts.map +1 -0
- package/dist/rules-config.js +132 -0
- package/dist/rules-config.js.map +1 -0
- package/dist/structure.d.ts +34 -0
- package/dist/structure.d.ts.map +1 -0
- package/dist/structure.js +149 -0
- package/dist/structure.js.map +1 -0
- package/dist/tex-requirements.d.ts +43 -0
- package/dist/tex-requirements.d.ts.map +1 -0
- package/dist/tex-requirements.js +127 -0
- package/dist/tex-requirements.js.map +1 -0
- package/dist/toolchain.d.ts +159 -0
- package/dist/toolchain.d.ts.map +1 -0
- package/dist/toolchain.js +542 -0
- package/dist/toolchain.js.map +1 -0
- package/dist/types.d.ts +110 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/docs/configuration.md +235 -0
- package/docs/e2e.md +152 -0
- package/docs/incidents.md +59 -0
- package/docs/install.md +170 -0
- package/docs/optional-rules.md +107 -0
- package/docs/package-shape-options.md +262 -0
- package/docs/prior-art/README.md +76 -0
- package/docs/prior-art/blocking-vs-advisory.md +83 -0
- package/docs/prior-art/content-delivery.md +124 -0
- package/docs/prior-art/multi-mode-tools.md +106 -0
- package/docs/prior-art/nondeterministic-checks.md +99 -0
- package/docs/prior-art/package-location.md +422 -0
- package/docs/prior-art/paper-folder-scaffolding.md +538 -0
- package/docs/prior-art/readme-structure.md +69 -0
- package/docs/prior-art/repro/README.md +92 -0
- package/docs/prior-art/repro/claim1-allowedtools.mjs +66 -0
- package/docs/prior-art/repro/claim1-at2.mjs +40 -0
- package/docs/prior-art/repro/claim1-crosschannel.mjs +54 -0
- package/docs/prior-art/repro/claim1-frontmatter.mjs +76 -0
- package/docs/prior-art/repro/claim1-hook-payload-reporter.mjs +10 -0
- package/docs/prior-art/repro/claim1-plugin-frontmatter.mjs +27 -0
- package/docs/prior-art/repro/claim1-plugin-skill.mjs +52 -0
- package/docs/prior-art/repro/claim1-project-skill.mjs +81 -0
- package/docs/prior-art/repro/claim2-marketplace-flat-asclaimed.json +1 -0
- package/docs/prior-art/repro/claim2-marketplace-negative-control.json +1 -0
- package/docs/prior-art/repro/claim2-marketplace-nested-exact.json +9 -0
- package/docs/prior-art/repro/claim2-marketplace-nested-noversion.json +9 -0
- package/docs/prior-art/repro/claim2-marketplace-nested-range.json +1 -0
- package/docs/prior-art/repro/claim3-imports.mjs +50 -0
- package/docs/prior-art/repro/claim4-find-package-json.mjs +8 -0
- package/docs/prior-art/repro/claim4-package-dir.mjs +39 -0
- package/docs/prior-art/repro/claim4-parent-arg.mjs +17 -0
- package/docs/prior-art/repro/claim4-resolve-apis.mjs +21 -0
- package/docs/prior-art/repro/claim4-setup-consumers.mjs +45 -0
- package/docs/prior-art/repro/claim4-yarn-pnp.mjs +70 -0
- package/docs/prior-art/repro/claim5-bin-launch.mjs +39 -0
- package/docs/prior-art/repro/claim5-exports-mutation.mjs +57 -0
- package/docs/prior-art/repro/claim5-resolved-location-and-bin.mjs +33 -0
- package/docs/prior-art/repro/claim6-candidate-ambiguity.mjs +17 -0
- package/docs/prior-art/repro/claim6-doc-path-candidates.mjs +27 -0
- package/docs/prior-art/test-tooling.md +131 -0
- package/docs/rules.md +58 -0
- package/docs/texlive-install-decision.md +230 -0
- package/docs/toolchain.md +152 -0
- package/eslint-rules/doc-fields.harness.mjs +336 -0
- package/eslint-rules/doc-fields.mjs +186 -0
- package/eslint-rules/doc-fields.mutations.mjs +96 -0
- package/eslint-rules/install-path-literals.harness.mjs +121 -0
- package/eslint-rules/install-path-literals.mjs +108 -0
- package/eslint-rules/install-path-literals.mutations.mjs +62 -0
- package/eslint-rules/latex-language.harness.mjs +599 -0
- package/eslint-rules/latex-language.mjs +591 -0
- package/eslint-rules/latex-language.mutations.mjs +196 -0
- package/eslint-rules/paper-research-question.harness.mjs +146 -0
- package/eslint-rules/paper-research-question.mjs +180 -0
- package/eslint-rules/paper-research-question.mutations.mjs +127 -0
- package/eslint-rules/paper-stages.harness.mjs +356 -0
- package/eslint-rules/paper-stages.mjs +455 -0
- package/eslint-rules/paper-stages.mutations.mjs +157 -0
- package/eslint-rules/paper-typography.harness.mjs +291 -0
- package/eslint-rules/paper-typography.mjs +313 -0
- package/eslint-rules/paper-typography.mutations.mjs +131 -0
- package/eslint-rules/papers.harness.mjs +259 -0
- package/eslint-rules/papers.mjs +166 -0
- package/eslint-rules/papers.mutations.mjs +186 -0
- package/eslint-rules/pdf-last-page-balance.harness.mjs +206 -0
- package/eslint-rules/pdf-last-page-balance.mjs +208 -0
- package/eslint-rules/review-findings-cause.harness.mjs +228 -0
- package/eslint-rules/review-findings-cause.mjs +135 -0
- package/eslint-rules/review-findings-cause.mutations.mjs +72 -0
- package/eslint-rules/temp-root-realpath.harness.mjs +176 -0
- package/eslint-rules/temp-root-realpath.mjs +129 -0
- package/eslint-rules/temp-root-realpath.mutations.mjs +99 -0
- package/eslint-rules/tex-build.harness.mjs +753 -0
- package/eslint-rules/tex-build.mjs +322 -0
- package/eslint-rules/tex-build.mutations.mjs +258 -0
- package/eslint.config.mjs +521 -0
- package/fixtures/build-e2e/acmart/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/acmart/paper.tex +11 -0
- package/fixtures/build-e2e/acmart/venue.json +1 -0
- package/fixtures/build-e2e/broken/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/broken/paper.tex +7 -0
- package/fixtures/build-e2e/cite/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/cite/build.sh +5 -0
- package/fixtures/build-e2e/cite/paper.tex +10 -0
- package/fixtures/build-e2e/cite/refs.bib +9 -0
- package/fixtures/build-e2e/empty/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/empty/paper.tex +6 -0
- package/fixtures/build-e2e/fallback/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/fallback/paper.tex +11 -0
- package/fixtures/build-e2e/guards/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/guards/paper.tex +10 -0
- package/fixtures/build-e2e/no-source/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/unbalanced/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/unbalanced/paper.tex +28 -0
- package/fixtures/build-e2e/unbalanced/refs.bib +269 -0
- package/fixtures/install-path-literals/clean.fixture.mjs +3 -0
- package/fixtures/install-path-literals/clean.md +15 -0
- package/fixtures/install-path-literals/defect.fixture.mjs +3 -0
- package/fixtures/install-path-literals/defect.md +14 -0
- package/fixtures/latex-language/clean.tex +50 -0
- package/fixtures/latex-language/defect.tex +52 -0
- package/fixtures/paper-research-question/comment-only/PIPELINE-STATUS.md +9 -0
- package/fixtures/paper-research-question/comment-only/paper.tex +7 -0
- package/fixtures/paper-research-question/declared-not-in-paper/PIPELINE-STATUS.md +10 -0
- package/fixtures/paper-research-question/declared-not-in-paper/paper.tex +6 -0
- package/fixtures/paper-research-question/draft/PIPELINE-STATUS.md +6 -0
- package/fixtures/paper-research-question/draft/paper.tex +2 -0
- package/fixtures/paper-research-question/markdown-no-rq/PIPELINE-STATUS.md +9 -0
- package/fixtures/paper-research-question/markdown-no-rq/paper.md +4 -0
- package/fixtures/paper-research-question/shipped-no-rq/PIPELINE-STATUS.md +12 -0
- package/fixtures/paper-research-question/shipped-no-rq/paper.tex +3 -0
- package/fixtures/paper-research-question/shipped-with-rq/PIPELINE-STATUS.md +10 -0
- package/fixtures/paper-research-question/shipped-with-rq/paper.tex +2 -0
- package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +16 -0
- package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +17 -0
- package/fixtures/paper-stages/nofile/PIPELINE-STATUS.md +8 -0
- package/fixtures/paper-stages/noheader/PIPELINE-STATUS.md +1 -0
- package/fixtures/paper-stages/noheader/versions/2026-07-22-submitted.pdf +0 -0
- package/fixtures/paper-stages/nothing/PIPELINE-STATUS.md +3 -0
- package/fixtures/paper-stages/ok/PIPELINE-STATUS.md +9 -0
- package/fixtures/paper-stages/ok/versions/2026-07-22-submitted.pdf +0 -0
- package/fixtures/paper-stages/stale/PIPELINE-STATUS.md +1 -0
- package/fixtures/paper-stages/stale/versions/2026-07-22-submitted.STALE-WRONG-FILE.pdf +0 -0
- package/fixtures/paper-stages/twice/PIPELINE-STATUS.md +14 -0
- package/fixtures/paper-stages/twice/versions/2026-08-06-submitted.pdf +0 -0
- package/fixtures/paper-stages/twice/versions/2026-10-24-submitted.pdf +0 -0
- package/fixtures/paper-stages/undeclared/PIPELINE-STATUS.md +8 -0
- package/fixtures/paper-stages/undeclared/versions/2026-07-22-submitted.pdf +0 -0
- package/fixtures/paper-stages/undeclared/versions/2026-08-29-camera-ready.pdf +0 -0
- package/fixtures/paper-stages/wrongsize/PIPELINE-STATUS.md +8 -0
- package/fixtures/paper-stages/wrongsize/versions/2026-07-22-submitted.pdf +0 -0
- package/fixtures/paper-typography/clean-paper/paper.tex +29 -0
- package/fixtures/paper-typography/messy-paper/paper.tex +27 -0
- package/fixtures/pdf-facts/README.md +22 -0
- package/fixtures/pdf-facts/corrupt-font.pdf +0 -0
- package/fixtures/pdf-facts/encrypted.pdf +0 -0
- package/fixtures/pdf-facts/hidden-text.pdf +0 -0
- package/fixtures/pdf-facts/hidden-text.tex +28 -0
- package/fixtures/pdf-facts/t3-all.pdf +0 -0
- package/fixtures/pdf-facts/t3-all.tex +8 -0
- package/fixtures/pdf-facts/t3-mixed.pdf +0 -0
- package/fixtures/pdf-facts/t3-mixed.tex +9 -0
- package/fixtures/pdf-facts/ttf.pdf +2240 -1
- package/fixtures/pdf-facts/ttf.tex +6 -0
- package/fixtures/real-markdown-paper/baseline.json +24 -0
- package/fixtures/real-markdown-paper/baseline.mjs +48 -0
- package/fixtures/render-paper/build-clean.sh +25 -0
- package/fixtures/render-paper/build-defect.sh +15 -0
- package/fixtures/review-findings-cause/clean.md +17 -0
- package/fixtures/review-findings-cause/defect.md +14 -0
- package/fixtures/review-findings-cause/old-debt.md +14 -0
- package/fixtures/review-findings-cause/quiet-in-fence.md +16 -0
- package/fixtures/tex-build/clean.tex +21 -0
- package/fixtures/tex-build/defect.tex +24 -0
- package/fixtures/tex-build/frontmatter-clean.tex +25 -0
- package/fixtures/tex-build/frontmatter-defect.tex +23 -0
- package/fixtures/toolchain-mirror/catalog.txt +5 -0
- package/fixtures/toolchain-mirror/install-tl +27 -0
- package/fixtures/toolchain-mirror/release-texlive.txt +3 -0
- package/fixtures/toolchain-mirror/release-year +1 -0
- package/fixtures/toolchain-mirror/stub-kpsewhich +8 -0
- package/fixtures/toolchain-mirror/stub-pdflatex +3 -0
- package/fixtures/toolchain-mirror/stub-tlmgr +44 -0
- package/hooks/hooks.harness.mjs +713 -0
- package/hooks/hooks.mutations.mjs +337 -0
- package/hooks/paper-edit-guard.hook.d.mts +13 -0
- package/hooks/paper-edit-guard.hook.mjs +457 -0
- package/hooks/paper-skills-nudge.hook.mjs +136 -0
- package/hooks/paper-status-gates.hook.mjs +156 -0
- package/hooks/paper-status-gates.sh +91 -0
- package/lib/agent-cli-version.harness.mjs +165 -0
- package/lib/agent-cli-version.mjs +106 -0
- package/lib/agent-cli-version.mutations.mjs +109 -0
- package/lib/markdown.mjs +386 -0
- package/lib/mutation-driver.harness.mjs +227 -0
- package/lib/mutation-driver.mjs +397 -0
- package/lib/mutation-driver.mutations.mjs +68 -0
- package/lib/paper-config.d.mts +34 -0
- package/lib/paper-config.harness.mjs +286 -0
- package/lib/paper-config.mjs +142 -0
- package/lib/paper-config.mutations.mjs +143 -0
- package/lib/skill-checks.mjs +701 -0
- package/lib/skill-corpus.mjs +403 -0
- package/lib/skill-eval-fixture.mjs +63 -0
- package/lib/skill-eval-kit.mjs +257 -0
- package/lib/skill-trigger-cases.harness.mjs +170 -0
- package/lib/skill-trigger-cases.mjs +446 -0
- package/lib/skill-trigger-cases.mutations.mjs +65 -0
- package/lib/trigger-ledger.mjs +215 -0
- package/package.json +97 -0
- package/plugin/.claude-plugin/plugin.json +8 -0
- package/plugin/hooks/hooks.json +30 -0
- package/scripts/check.harness.mjs +177 -0
- package/scripts/check.mjs +239 -0
- package/scripts/check.mutations.mjs +110 -0
- package/scripts/eslint-report-guard.mjs +82 -0
- package/scripts/exclusive.mjs +138 -0
- package/scripts/harness-api.frozen.json +76 -0
- package/scripts/harness-api.test.ts +175 -0
- package/scripts/layer-legacy-frozen.d.mts +28 -0
- package/scripts/layer-legacy-frozen.mjs +152 -0
- package/scripts/layer-legacy-frozen.test.ts +115 -0
- package/scripts/layer-legacy.frozen.json +50 -0
- package/scripts/mutation-batteries-frozen.harness.mjs +204 -0
- package/scripts/mutation-batteries-frozen.mjs +238 -0
- package/scripts/mutation-batteries.frozen.json +117 -0
- package/scripts/release-config.test.ts +90 -0
- package/scripts/rules-are-content-only.harness.mjs +113 -0
- package/scripts/rules-are-content-only.mjs +138 -0
- package/scripts/rules-are-content-only.mutations.mjs +81 -0
- package/scripts/rules-see-files.harness.mjs +115 -0
- package/scripts/rules-see-files.mjs +99 -0
- package/scripts/rules-see-files.mutations.mjs +131 -0
- package/scripts/run-mutations.mjs +100 -0
- package/scripts/semantic-release-plugins.d.ts +16 -0
- package/skills/README.md +15 -0
- package/skills/analyze-sibling-paper/SKILL.md +170 -0
- package/skills/analyze-sibling-paper/SKILL.md.spec.ts +186 -0
- package/skills/analyze-sibling-paper/analyze-sibling-paper.eval.mjs +19 -0
- package/skills/analyze-sibling-paper/analyze-sibling-paper.harness.mjs +23 -0
- package/skills/argument-arc/SKILL.md +177 -0
- package/skills/argument-arc/SKILL.md.spec.ts +192 -0
- package/skills/argument-arc/argument-arc.eval.mjs +19 -0
- package/skills/argument-arc/argument-arc.harness.mjs +23 -0
- package/skills/build-benchmark/SKILL.md +213 -0
- package/skills/build-benchmark/SKILL.md.spec.ts +220 -0
- package/skills/build-benchmark/build-benchmark.eval.mjs +19 -0
- package/skills/build-benchmark/build-benchmark.harness.mjs +23 -0
- package/skills/build-benchmark/references/adversarial-cold-repro.md +68 -0
- package/skills/camera-ready/SKILL.md +148 -0
- package/skills/camera-ready/SKILL.md.spec.ts +164 -0
- package/skills/camera-ready/camera-ready.eval.mjs +19 -0
- package/skills/camera-ready/camera-ready.harness.mjs +23 -0
- package/skills/cold-read-diff/SKILL.md +160 -0
- package/skills/cold-read-diff/SKILL.md.spec.ts +166 -0
- package/skills/cold-read-diff/cold-read-diff.eval.mjs +19 -0
- package/skills/cold-read-diff/cold-read-diff.harness.mjs +23 -0
- package/skills/draft-paper/SKILL.md +152 -0
- package/skills/draft-paper/SKILL.md.spec.ts +169 -0
- package/skills/draft-paper/draft-paper.eval.mjs +19 -0
- package/skills/draft-paper/draft-paper.harness.mjs +23 -0
- package/skills/extend-paper/SKILL.md +99 -0
- package/skills/extend-paper/SKILL.md.spec.ts +116 -0
- package/skills/extend-paper/extend-paper.eval.mjs +19 -0
- package/skills/extend-paper/extend-paper.harness.mjs +23 -0
- package/skills/find-venue/SKILL.md +128 -0
- package/skills/find-venue/SKILL.md.spec.ts +145 -0
- package/skills/find-venue/find-venue.eval.mjs +19 -0
- package/skills/find-venue/find-venue.harness.mjs +23 -0
- package/skills/grade-paper-writing/SKILL.md +436 -0
- package/skills/grade-paper-writing/SKILL.md.spec.ts +453 -0
- package/skills/grade-paper-writing/fixtures/control_gopen.txt +1 -0
- package/skills/grade-paper-writing/fixtures/control_human_paper.txt +1 -0
- package/skills/grade-paper-writing/fixtures/rewrite.txt +1 -0
- package/skills/grade-paper-writing/fixtures/specimen.txt +1 -0
- package/skills/grade-paper-writing/fixtures/structure-checks.md +22 -0
- package/skills/grade-paper-writing/grade-paper-writing.eval.mjs +19 -0
- package/skills/grade-paper-writing/grade-paper-writing.harness.mjs +23 -0
- package/skills/grade-paper-writing/prose-lint.mjs +713 -0
- package/skills/harden-paper/SKILL.md +318 -0
- package/skills/harden-paper/SKILL.md.spec.ts +336 -0
- package/skills/harden-paper/check-numbers.sh +33 -0
- package/skills/harden-paper/check-release-claims.sh +35 -0
- package/skills/harden-paper/fixtures/uncited-assertions-sample.md +43 -0
- package/skills/harden-paper/fixtures/uncited-assertions-sample.tex +77 -0
- package/skills/harden-paper/harden-paper.eval.mjs +19 -0
- package/skills/harden-paper/harden-paper.harness.mjs +23 -0
- package/skills/map-prior-work/SKILL.md +211 -0
- package/skills/map-prior-work/SKILL.md.spec.ts +227 -0
- package/skills/map-prior-work/map-prior-work.eval.mjs +19 -0
- package/skills/map-prior-work/map-prior-work.harness.mjs +23 -0
- package/skills/osf-artifact-upload/SKILL.md +52 -0
- package/skills/osf-artifact-upload/SKILL.md.spec.ts +59 -0
- package/skills/osf-artifact-upload/osf-artifact-upload.eval.mjs +22 -0
- package/skills/osf-artifact-upload/osf-artifact-upload.harness.mjs +103 -0
- package/skills/paper-adversarial-review/SKILL.md +126 -0
- package/skills/paper-adversarial-review/SKILL.md.spec.ts +142 -0
- package/skills/paper-adversarial-review/paper-adversarial-review.eval.mjs +19 -0
- package/skills/paper-adversarial-review/paper-adversarial-review.harness.mjs +23 -0
- package/skills/paper-pipeline/PIPELINE-MAP.md +371 -0
- package/skills/paper-pipeline/SKILL.md +499 -0
- package/skills/paper-pipeline/SKILL.md.spec.ts +517 -0
- package/skills/paper-pipeline/description-language.eval.mjs +347 -0
- package/skills/paper-pipeline/framing-vs-vocabulary.eval.mjs +891 -0
- package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +1254 -0
- package/skills/paper-pipeline/paper-pipeline.eval.mjs +22 -0
- package/skills/paper-pipeline/paper-pipeline.harness.mjs +143 -0
- package/skills/paper-pipeline/pipeline-firing.baseline.json +270 -0
- package/skills/paper-pipeline/pipeline-firing.eval.mjs +664 -0
- package/skills/paper-pipeline/pipeline-language.eval.mjs +672 -0
- package/skills/paper-pipeline/references/acceptance-gate.md +329 -0
- package/skills/paper-pipeline/references/acl-venue-rules.md +142 -0
- package/skills/paper-pipeline/references/anonymization.md +68 -0
- package/skills/paper-pipeline/references/artifact-checklist.md +93 -0
- package/skills/paper-pipeline/references/body-vs-appendix.md +97 -0
- package/skills/paper-pipeline/references/credit-criteria.md +69 -0
- package/skills/paper-pipeline/references/occupancy-2026-08-06-probe/README.md +35 -0
- package/skills/paper-pipeline/references/occupancy-2026-08-06-probe/run_retext.mjs +24 -0
- package/skills/paper-pipeline/references/occupancy-2026-08-06-probe/sentences.txt +11 -0
- package/skills/paper-pipeline/references/occupancy-2026-08-06-probe/test_sentences.py +25 -0
- package/skills/paper-pipeline/references/occupancy-2026-08-06-prose-checkers.md +538 -0
- package/skills/paper-pipeline/references/occupancy-2026-08-06-reproducible-tooling.md +431 -0
- package/skills/paper-pipeline/references/occupancy-2026-08-06-staleness-and-orchestration.md +592 -0
- package/skills/paper-pipeline/references/pipeline-status-template.md +162 -0
- package/skills/paper-pipeline/references/review-ratchet.md +36 -0
- package/skills/paper-pipeline/references/sweep-2026-08-09-ideal-pipeline.md +585 -0
- package/skills/paper-pipeline/references/writing-craft.md +448 -0
- package/skills/paper-pipeline/repro/2026-08-07-description-language-control.log +63 -0
- package/skills/paper-pipeline/repro/2026-08-07-fork-check.log +52 -0
- package/skills/paper-pipeline/repro/2026-08-07-fork-check2.log +33 -0
- package/skills/paper-pipeline/repro/2026-08-07-language-eval-pilot.log +33 -0
- package/skills/paper-pipeline/repro/2026-08-07-language-eval-raw.log +166 -0
- package/skills/paper-pipeline/repro/2026-08-08-framing-vs-vocabulary-oracle.json +338 -0
- package/skills/paper-pipeline/repro/2026-08-08-framing-vs-vocabulary-oracle.log +118 -0
- package/skills/paper-pipeline/repro/2026-08-08-framing-vs-vocabulary-raw.log +245 -0
- package/skills/paper-pipeline/repro/2026-08-08-framing-vs-vocabulary.json +776 -0
- package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-A6-oracle.log +53 -0
- package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-A6-raw.log +89 -0
- package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-oracle.json +450 -0
- package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-oracle.log +136 -0
- package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-raw.log +242 -0
- package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-setupdiff.log +59 -0
- package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation.json +1032 -0
- package/skills/paper-pipeline/repro/2026-08-08-parent-replication-gpw.json +139 -0
- package/skills/paper-pipeline/repro/2026-08-08-parent-replication-gpw.log +98 -0
- package/skills/paper-pipeline/repro/2026-08-08-parent-replication.mjs +92 -0
- package/skills/paper-pipeline/repro/README.md +129 -0
- package/skills/paper-pipeline/repro/analyze-language-eval.py +116 -0
- package/skills/paper-pipeline/scripts/README.md +344 -0
- package/skills/paper-pipeline/scripts/announce.mjs +67 -0
- package/skills/paper-pipeline/scripts/artifact-coverage.harness.mjs +496 -0
- package/skills/paper-pipeline/scripts/artifact-coverage.mjs +397 -0
- package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +218 -0
- package/skills/paper-pipeline/scripts/check-provenance.mjs +184 -0
- package/skills/paper-pipeline/scripts/consumer.d.mts +32 -0
- package/skills/paper-pipeline/scripts/consumer.harness.mjs +562 -0
- package/skills/paper-pipeline/scripts/consumer.mjs +535 -0
- package/skills/paper-pipeline/scripts/consumer.mutations.mjs +190 -0
- package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +457 -0
- package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +656 -0
- package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +54 -0
- package/skills/paper-pipeline/scripts/fixtures/clean/PIPELINE-STATUS.md +51 -0
- package/skills/paper-pipeline/scripts/fixtures/dirty/PIPELINE-STATUS.md +52 -0
- package/skills/paper-pipeline/scripts/fixtures/dirty/paper.md +6 -0
- package/skills/paper-pipeline/scripts/fixtures/real-bib/refs.bib +153 -0
- package/skills/paper-pipeline/scripts/generated-code.harness.mjs +466 -0
- package/skills/paper-pipeline/scripts/generated-code.mjs +338 -0
- package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +254 -0
- package/skills/paper-pipeline/scripts/ledger.mjs +623 -0
- package/skills/paper-pipeline/scripts/ledger.selftest.mjs +286 -0
- package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +389 -0
- package/skills/paper-pipeline/scripts/pipeline-check.mjs +737 -0
- package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +54 -0
- package/skills/paper-pipeline/scripts/pipeline-edges.mjs +169 -0
- package/skills/paper-pipeline/scripts/population-map.harness.mjs +178 -0
- package/skills/paper-pipeline/scripts/population-map.mjs +181 -0
- package/skills/paper-pipeline/scripts/population-map.mutations.mjs +65 -0
- package/skills/paper-pipeline/scripts/population-map.selftest.mjs +122 -0
- package/skills/paper-pipeline/scripts/provenance.harness.mjs +240 -0
- package/skills/paper-pipeline/scripts/provenance.mutations.mjs +59 -0
- package/skills/paper-pipeline/scripts/round-diff.harness.mjs +881 -0
- package/skills/paper-pipeline/scripts/round-diff.mjs +576 -0
- package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +276 -0
- package/skills/paper-pipeline/scripts/run-mechanical.mjs +633 -0
- package/skills/paper-pipeline/scripts/status.mjs +295 -0
- package/skills/paper-status/SKILL.md +183 -0
- package/skills/paper-status/SKILL.md.spec.ts +190 -0
- package/skills/paper-status/paper-status.eval.mjs +22 -0
- package/skills/paper-status/paper-status.harness.mjs +25 -0
- package/skills/pc-panel-review/SKILL.md +263 -0
- package/skills/pc-panel-review/SKILL.md.spec.ts +280 -0
- package/skills/pc-panel-review/pc-panel-review.eval.mjs +19 -0
- package/skills/pc-panel-review/pc-panel-review.harness.mjs +23 -0
- package/skills/plan-paper-timeline/SKILL.md +182 -0
- package/skills/plan-paper-timeline/SKILL.md.spec.ts +200 -0
- package/skills/plan-paper-timeline/fixtures/fake-google-calendar.mjs +239 -0
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +431 -0
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +65 -0
- package/skills/plan-paper-timeline/plan-paper-timeline.eval.mjs +19 -0
- package/skills/plan-paper-timeline/plan-paper-timeline.harness.mjs +23 -0
- package/skills/render-paper/SKILL.md +159 -0
- package/skills/render-paper/SKILL.md.spec.ts +166 -0
- package/skills/render-paper/check-render.sh +419 -0
- package/skills/render-paper/checkers-requirements.txt +55 -0
- package/skills/render-paper/ensure-checkers.sh +69 -0
- package/skills/render-paper/extract-pdf-facts.harness.mjs +166 -0
- package/skills/render-paper/extract-pdf-facts.mjs +144 -0
- package/skills/render-paper/render-paper.eval.mjs +19 -0
- package/skills/render-paper/render-paper.harness.mjs +339 -0
- package/skills/research-ideate/SKILL.md +136 -0
- package/skills/research-ideate/SKILL.md.spec.ts +152 -0
- package/skills/research-ideate/research-ideate.eval.mjs +19 -0
- package/skills/research-ideate/research-ideate.harness.mjs +23 -0
- package/skills/skill-contract.mutations.mjs +179 -0
- package/skills/study-accepted-papers/SKILL.md +206 -0
- package/skills/study-accepted-papers/SKILL.md.spec.ts +223 -0
- package/skills/study-accepted-papers/study-accepted-papers.eval.mjs +19 -0
- package/skills/study-accepted-papers/study-accepted-papers.harness.mjs +23 -0
- package/skills/submit-paper/SKILL.md +182 -0
- package/skills/submit-paper/SKILL.md.spec.ts +199 -0
- package/skills/submit-paper/check-deanon.sh +149 -0
- package/skills/submit-paper/references/publishers/acm.md +92 -0
- package/skills/submit-paper/references/venues/agenticdev.jsonc +108 -0
- package/skills/submit-paper/references/venues/agenticdev.md +139 -0
- package/skills/submit-paper/references/venues/agenticdev.tex +19 -0
- package/skills/submit-paper/references/venues/aisec.jsonc +101 -0
- package/skills/submit-paper/references/venues/aisec.md +105 -0
- package/skills/submit-paper/references/venues/paper-guards.tex +41 -0
- package/skills/submit-paper/references/venues/realm.jsonc +81 -0
- package/skills/submit-paper/references/venues/realm.md +155 -0
- package/skills/submit-paper/references/venues/tex-base.jsonc +50 -0
- package/skills/submit-paper/references/venues/venue-profile.schema.json +74 -0
- package/skills/submit-paper/submit-paper.eval.mjs +19 -0
- package/skills/submit-paper/submit-paper.harness.mjs +23 -0
- package/skills/sweep-design-space/SKILL.md +269 -0
- package/skills/sweep-design-space/SKILL.md.spec.ts +285 -0
- package/skills/sweep-design-space/sweep-design-space.eval.mjs +19 -0
- package/skills/sweep-design-space/sweep-design-space.harness.mjs +23 -0
- package/skills/tighten-paper/SKILL.md +368 -0
- package/skills/tighten-paper/SKILL.md.spec.ts +384 -0
- package/skills/tighten-paper/structure.mjs +371 -0
- package/skills/tighten-paper/tighten-paper.eval.mjs +19 -0
- package/skills/tighten-paper/tighten-paper.harness.mjs +23 -0
- package/skills/verify-citations/SKILL.md +328 -0
- package/skills/verify-citations/SKILL.md.spec.ts +345 -0
- package/skills/verify-citations/scripts/bib-authors.mjs +479 -0
- package/skills/verify-citations/scripts/bib-authors.test.mjs +175 -0
- package/skills/verify-citations/scripts/verify-cites.mjs +1108 -0
- package/skills/verify-citations/scripts/verify-cites.test.mjs +735 -0
- package/skills/verify-citations/verify-citations.eval.mjs +19 -0
- package/skills/verify-citations/verify-citations.harness.mjs +23 -0
- package/src/CLAUDE.md +51 -0
- package/src/action-ref.test.ts +26 -0
- package/src/action-ref.ts +15 -0
- package/src/adapters/banal/failure.test.ts +63 -0
- package/src/adapters/banal/failure.ts +118 -0
- package/src/adapters/banal/index.test.ts +119 -0
- package/src/adapters/banal/index.ts +100 -0
- package/src/adapters/banal/install.test.ts +20 -0
- package/src/adapters/banal/install.ts +41 -0
- package/src/adapters/banal/invocation.test.ts +74 -0
- package/src/adapters/banal/invocation.ts +95 -0
- package/src/adapters/banal/locate.test.ts +52 -0
- package/src/adapters/banal/locate.ts +84 -0
- package/src/adapters/banal/output.test.ts +140 -0
- package/src/adapters/banal/output.ts +141 -0
- package/src/adapters/banal/pin.ts +30 -0
- package/src/adapters/banal/probe.ts +35 -0
- package/src/adapters/banal/run.test.ts +191 -0
- package/src/adapters/banal/run.ts +244 -0
- package/src/adapters/banal/settings.test.ts +31 -0
- package/src/adapters/banal/settings.ts +55 -0
- package/src/adapters/banal/xml.test.ts +111 -0
- package/src/adapters/banal/xml.ts +112 -0
- package/src/adapters/curl/download.io.ts +73 -0
- package/src/adapters/curl/download.test.ts +55 -0
- package/src/adapters/curl/index.ts +5 -0
- package/src/adapters/memory/index.ts +131 -0
- package/src/adapters/node/files.io.ts +39 -0
- package/src/adapters/node/files.test.ts +28 -0
- package/src/adapters/node/host.io.ts +15 -0
- package/src/adapters/node/index.ts +36 -0
- package/src/adapters/node/process.io.ts +49 -0
- package/src/adapters/node/process.test.ts +46 -0
- package/src/adapters/node/workspace.io.ts +40 -0
- package/src/adapters/node/workspace.test.ts +58 -0
- package/src/adapters/pdfjs/fill.test.ts +111 -0
- package/src/adapters/pdfjs/fill.ts +141 -0
- package/src/build-engine.harness.mjs +314 -0
- package/src/build-engine.ts +219 -0
- package/src/build.harness.mjs +631 -0
- package/src/build.mutations.mjs +195 -0
- package/src/build.ts +793 -0
- package/src/cli.harness.mjs +2007 -0
- package/src/cli.mutations.mjs +448 -0
- package/src/cli.ts +1189 -0
- package/src/doctor.harness.mjs +396 -0
- package/src/doctor.mutations.mjs +175 -0
- package/src/doctor.ts +356 -0
- package/src/domain/geometry.ts +108 -0
- package/src/domain/host.ts +23 -0
- package/src/domain/page-layout.ts +32 -0
- package/src/domain/paths.ts +5 -0
- package/src/domain/result.test.ts +26 -0
- package/src/domain/result.ts +29 -0
- package/src/domain/sha256.test.ts +12 -0
- package/src/domain/sha256.ts +21 -0
- package/src/domain/text.ts +11 -0
- package/src/engine.harness.mjs +252 -0
- package/src/engine.ts +176 -0
- package/src/exit-code.test.ts +21 -0
- package/src/exit-code.ts +38 -0
- package/src/facts-file.test.ts +240 -0
- package/src/facts-file.ts +241 -0
- package/src/hooks-settings.harness.mjs +386 -0
- package/src/hooks-settings.mutations.mjs +116 -0
- package/src/hooks-settings.ts +434 -0
- package/src/init.ts +900 -0
- package/src/latex-log.harness.mjs +226 -0
- package/src/latex-log.ts +234 -0
- package/src/latex-loop.harness.mjs +449 -0
- package/src/latex-loop.ts +211 -0
- package/src/link-skills.harness.mjs +273 -0
- package/src/link-skills.mutations.mjs +136 -0
- package/src/link-skills.ts +258 -0
- package/src/new-paper.harness.mjs +216 -0
- package/src/new-paper.mutations.mjs +79 -0
- package/src/new-paper.ts +158 -0
- package/src/pdf-facts.harness.mjs +188 -0
- package/src/pdf-facts.ts +327 -0
- package/src/pdf-geometry.harness.mjs +254 -0
- package/src/pdf-geometry.ts +300 -0
- package/src/ports/download.ts +10 -0
- package/src/ports/files.ts +11 -0
- package/src/ports/measure-geometry.ts +8 -0
- package/src/ports/process.ts +46 -0
- package/src/ports/tool-installer.ts +33 -0
- package/src/ports/workspace.ts +20 -0
- package/src/rules-config.harness.mjs +114 -0
- package/src/rules-config.ts +178 -0
- package/src/structure.harness.mjs +179 -0
- package/src/structure.mutations.mjs +83 -0
- package/src/structure.ts +166 -0
- package/src/tex-requirements.harness.mjs +238 -0
- package/src/tex-requirements.ts +181 -0
- package/src/toolchain.harness.mjs +651 -0
- package/src/toolchain.ts +755 -0
- package/src/types.ts +106 -0
- package/templates/paper/PIPELINE-STATUS.md +72 -0
- package/templates/paper/paper.md +4 -0
- package/templates/paper/paper.tex +8 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,2007 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Both halves for the `paperlint lint` utility, and separately — failures, each
|
|
3
|
+
* of which must be EXPLAINABLE, not just nonzero.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 Two of the defects checked here the utility already had, and both were found by the
|
|
6
|
+
* FIRST run, not by reading:
|
|
7
|
+
* 1. `paperlint --help` used to answer "unknown command `--help`" — argv[0] unconditionally became
|
|
8
|
+
* the command;
|
|
9
|
+
* 2. on an empty set ESLint THROWS `NoFilesFoundError`, and the guard against a false green
|
|
10
|
+
* zero never lived long enough to reach its own check: instead of a message, a stack trace
|
|
11
|
+
* leaked out of the depths of eslint-helpers.
|
|
12
|
+
* Both are pinned down by the assertions below so there is no going back.
|
|
13
|
+
*/
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { recordCheck } from "vigiles";
|
|
16
|
+
import { load as yamlLoad } from "js-yaml";
|
|
17
|
+
import {
|
|
18
|
+
mkdtempSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
existsSync,
|
|
23
|
+
rmSync,
|
|
24
|
+
realpathSync,
|
|
25
|
+
symlinkSync,
|
|
26
|
+
readdirSync,
|
|
27
|
+
} from "node:fs";
|
|
28
|
+
import { spawnSync } from "node:child_process";
|
|
29
|
+
import { tmpdir } from "node:os";
|
|
30
|
+
import { join, dirname } from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
import {
|
|
33
|
+
OLD_PAPERS_DIR_FIELD,
|
|
34
|
+
PAPERS_DIR_FIELD,
|
|
35
|
+
} from "../lib/paper-config.mjs";
|
|
36
|
+
|
|
37
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const {
|
|
39
|
+
run,
|
|
40
|
+
parseArgs,
|
|
41
|
+
buildConfig,
|
|
42
|
+
nextSteps,
|
|
43
|
+
findConfig,
|
|
44
|
+
findDeclaration,
|
|
45
|
+
toPaths,
|
|
46
|
+
runHook,
|
|
47
|
+
} = await import(join(HERE, "cli.ts"));
|
|
48
|
+
const {
|
|
49
|
+
init,
|
|
50
|
+
choosePapers,
|
|
51
|
+
offerWorkflow,
|
|
52
|
+
syncRppJson,
|
|
53
|
+
missingPrograms,
|
|
54
|
+
WORKFLOW_PATH,
|
|
55
|
+
} = await import(join(HERE, "init.ts"));
|
|
56
|
+
const { PROGRAMS } = await import(join(HERE, "doctor.ts"));
|
|
57
|
+
|
|
58
|
+
let n = 0;
|
|
59
|
+
// Counted by vigiles too: `init` now loads `vigiles/claude-code` for its merge, and a harness that
|
|
60
|
+
// loads vigiles without recording a check is reported as having verified nothing.
|
|
61
|
+
const check = (label, cond) => {
|
|
62
|
+
assert.ok(cond, label);
|
|
63
|
+
recordCheck(label);
|
|
64
|
+
n++;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Runs the utility with output captured — quieter and faster than spawning a process. */
|
|
68
|
+
async function cli(args, cwd) {
|
|
69
|
+
// 🔴 THE STREAMS ARE SPLIT, AND THAT IS LOAD-BEARING. As long as the harness dumped log and
|
|
70
|
+
// err into one array, it physically could not see that the `config: …` line was leaking into
|
|
71
|
+
// stdout BEFORE the JSON and breaking any consumer's parser. The defect was not found by a
|
|
72
|
+
// test but by trying to hook a real action to this output — i.e. the test was blind to
|
|
73
|
+
// exactly what it was supposed to catch.
|
|
74
|
+
// `out` stays a merged string for text assertions; `stdout` is what actually goes down a pipe.
|
|
75
|
+
const stdout = [];
|
|
76
|
+
const stderr = [];
|
|
77
|
+
const prev = process.cwd();
|
|
78
|
+
if (cwd) process.chdir(cwd);
|
|
79
|
+
try {
|
|
80
|
+
const code = await run(args, {
|
|
81
|
+
log: (...a) => stdout.push(a.join(" ")),
|
|
82
|
+
err: (...a) => stderr.push(a.join(" ")),
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
code,
|
|
86
|
+
out: [...stdout, ...stderr].join("\n"),
|
|
87
|
+
stdout: stdout.join("\n"),
|
|
88
|
+
stderr: stderr.join("\n"),
|
|
89
|
+
};
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// 🔴 A LEAKED EXCEPTION IS A PROPERTY THAT MUST BE ASSERTED, NOT CAUGHT BY LETTING THE
|
|
92
|
+
// HARNESS CRASH. The mutation that removes the catch around ESLint used to bring the
|
|
93
|
+
// harness down with a stack trace, and the driver — under its own strict rule "killed only
|
|
94
|
+
// by ITS OWN assertion" — refused to count that as a kill and printed "survived". I.e. the
|
|
95
|
+
// real defect looked like a weak test.
|
|
96
|
+
return {
|
|
97
|
+
code: 99,
|
|
98
|
+
out: `THREW: ${e?.message ?? e}`,
|
|
99
|
+
stdout: "",
|
|
100
|
+
stderr: `THREW: ${e?.message ?? e}`,
|
|
101
|
+
};
|
|
102
|
+
} finally {
|
|
103
|
+
process.chdir(prev);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── argument parsing ───────────────────────────────────────────────────────────────────
|
|
108
|
+
check(
|
|
109
|
+
"`--help` as the first argument — is a FLAG, not a command",
|
|
110
|
+
parseArgs(["--help"]).help === true,
|
|
111
|
+
);
|
|
112
|
+
check(
|
|
113
|
+
"and a command is not invented in the process",
|
|
114
|
+
parseArgs(["--help"]).cmd === null,
|
|
115
|
+
);
|
|
116
|
+
check(
|
|
117
|
+
"the path and config are parsed",
|
|
118
|
+
(() => {
|
|
119
|
+
const a = parseArgs(["lint", "papers", "--config", "o.json", "--json"]);
|
|
120
|
+
return (
|
|
121
|
+
a.cmd === "lint" &&
|
|
122
|
+
a.paths[0] === "papers" &&
|
|
123
|
+
a.config === "o.json" &&
|
|
124
|
+
a.json === true
|
|
125
|
+
);
|
|
126
|
+
})(),
|
|
127
|
+
);
|
|
128
|
+
check(
|
|
129
|
+
"the default warning threshold is NEGATIVE — advice does not fail the run",
|
|
130
|
+
parseArgs(["lint"]).maxWarnings === -1,
|
|
131
|
+
);
|
|
132
|
+
check(
|
|
133
|
+
"and it is parsed when named explicitly",
|
|
134
|
+
parseArgs(["lint", "--max-warnings", "0"]).maxWarnings === 0,
|
|
135
|
+
);
|
|
136
|
+
check(
|
|
137
|
+
"the old `--options` spelling keeps working — a flag in someone else's CI is not ours to break",
|
|
138
|
+
parseArgs(["lint", "--options", "o.json"]).config === "o.json",
|
|
139
|
+
);
|
|
140
|
+
check(
|
|
141
|
+
"the papers directory as a string and as a list normalize the same way",
|
|
142
|
+
toPaths("papers")[0] === "papers" &&
|
|
143
|
+
toPaths(["a", "b"]).length === 2 &&
|
|
144
|
+
toPaths(undefined).length === 0 &&
|
|
145
|
+
toPaths(" ").length === 0,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// ── config is assembled, and consumer data gets through ────────────────────────────────────
|
|
149
|
+
{
|
|
150
|
+
const cfg = buildConfig(
|
|
151
|
+
{ typographyDebt: { x: { sectionSign: 3 } }, authorListCommand: "run-me" },
|
|
152
|
+
null,
|
|
153
|
+
);
|
|
154
|
+
// Blocks with `files` — the global `ignores` block below is not a rule block.
|
|
155
|
+
const ruleBlocks = (c) => c.filter((b) => Array.isArray(b.files));
|
|
156
|
+
check(
|
|
157
|
+
"without a LaTeX language the config still gets built — a corpus with no .tex is not a reason to refuse",
|
|
158
|
+
Array.isArray(cfg) && ruleBlocks(cfg).length === 3,
|
|
159
|
+
);
|
|
160
|
+
check(
|
|
161
|
+
"with a LaTeX language a fourth block is added",
|
|
162
|
+
ruleBlocks(buildConfig({}, {})).length === 4,
|
|
163
|
+
);
|
|
164
|
+
check(
|
|
165
|
+
"🔴 the project's paper TEMPLATE directory is ignored — flat config does not skip dot-directories",
|
|
166
|
+
cfg.some(
|
|
167
|
+
(b) =>
|
|
168
|
+
Object.keys(b).length === 1 &&
|
|
169
|
+
Array.isArray(b.ignores) &&
|
|
170
|
+
b.ignores.includes("**/.template/"),
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
const status = ruleBlocks(cfg).find((c) =>
|
|
174
|
+
c.files.some((f) => f.includes("PIPELINE-STATUS")),
|
|
175
|
+
);
|
|
176
|
+
check(
|
|
177
|
+
"the command from options gets through to the rule",
|
|
178
|
+
status.rules["paper/author-list"][1].command === "run-me",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── failures must be EXPLAINABLE ─────────────────────────────────────────────────────────
|
|
183
|
+
{
|
|
184
|
+
const r = await cli(["--help"]);
|
|
185
|
+
check(
|
|
186
|
+
"`--help` prints usage and exits zero",
|
|
187
|
+
r.code === 0 && /npx paperlint lint/.test(r.out),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
// ── ONE DECLARATION, READ BY THE SAME THING THAT WRITES IT ─────────────────────────────
|
|
191
|
+
//
|
|
192
|
+
// 🔴 WITHOUT THIS BLOCK THE REWRITTEN `init` WOULD PRODUCE A BROKEN INSTALL. It writes one
|
|
193
|
+
// declaration — into `package.json`, the file the hooks know how to read (a hook does not
|
|
194
|
+
// import code and cannot walk up the tree; it can only read a path it is able to name). The
|
|
195
|
+
// utility, though, read ONLY `rpp.json`, so right after `paperlint init` its `lint` would say
|
|
196
|
+
// "nothing to lint". I.e. the install command and the check command would be looking at
|
|
197
|
+
// different files — exactly the defect it exists to close, just from the other side.
|
|
198
|
+
{
|
|
199
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "rpp-carrier-")));
|
|
200
|
+
try {
|
|
201
|
+
const paper = join(root, "papers", "p1");
|
|
202
|
+
mkdirSync(join(paper, "versions"), { recursive: true });
|
|
203
|
+
writeFileSync(join(paper, "versions", "s.tex"), "abcd");
|
|
204
|
+
writeFileSync(
|
|
205
|
+
join(paper, "versions", "2026-07-22-submitted.pdf"),
|
|
206
|
+
"x".repeat(100),
|
|
207
|
+
);
|
|
208
|
+
writeFileSync(join(paper, "paper.md"), "# Intro\n\nRQ1: does it hold?\n");
|
|
209
|
+
writeFileSync(
|
|
210
|
+
join(paper, "PIPELINE-STATUS.md"),
|
|
211
|
+
`---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: 100\n source: versions/s.tex\n sourceBytes: 4\nresearchQuestion: "does it hold?"\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`,
|
|
212
|
+
);
|
|
213
|
+
writeFileSync(
|
|
214
|
+
join(root, "package.json"),
|
|
215
|
+
JSON.stringify(
|
|
216
|
+
{
|
|
217
|
+
name: "c",
|
|
218
|
+
version: "1.0.0",
|
|
219
|
+
paperlint: { [PAPERS_DIR_FIELD]: "papers" },
|
|
220
|
+
},
|
|
221
|
+
null,
|
|
222
|
+
2,
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const r = await cli(["lint"], root);
|
|
227
|
+
check(
|
|
228
|
+
"🔴 THE UTILITY READS THE DECLARATION FROM package.json — otherwise `paperlint init` sets up something `paperlint lint` cannot see",
|
|
229
|
+
r.code === 0 && /no findings/.test(r.out),
|
|
230
|
+
);
|
|
231
|
+
check(
|
|
232
|
+
"and NAMES the carrier out loud — swapping settings is never silent",
|
|
233
|
+
/config: package\.json/.test(r.out),
|
|
234
|
+
);
|
|
235
|
+
check(
|
|
236
|
+
"package.json carries no deprecation notice — it is not the one that is deprecated",
|
|
237
|
+
!/is deprecated/.test(r.out),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Guards: settings under the key's old name (before 2.0.0) keep working, and say so.
|
|
241
|
+
writeFileSync(
|
|
242
|
+
join(root, "package.json"),
|
|
243
|
+
JSON.stringify({
|
|
244
|
+
name: "c",
|
|
245
|
+
version: "1.0.0",
|
|
246
|
+
"research-paper-pipeline": { [PAPERS_DIR_FIELD]: "papers" },
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
const legacy = await cli(["lint"], root);
|
|
250
|
+
check(
|
|
251
|
+
"🔴 the OLD key is still read — the same clean run — and a deprecation line names the new one",
|
|
252
|
+
legacy.code === 0 &&
|
|
253
|
+
/no findings/.test(legacy.out) &&
|
|
254
|
+
/"research-paper-pipeline" in package\.json is the old name .* rename it to "paperlint"/.test(
|
|
255
|
+
legacy.out,
|
|
256
|
+
),
|
|
257
|
+
);
|
|
258
|
+
// Guards: both keys, different contents — refused, since there is no telling which is meant.
|
|
259
|
+
writeFileSync(
|
|
260
|
+
join(root, "package.json"),
|
|
261
|
+
JSON.stringify({
|
|
262
|
+
name: "c",
|
|
263
|
+
version: "1.0.0",
|
|
264
|
+
paperlint: { [PAPERS_DIR_FIELD]: "papers" },
|
|
265
|
+
"research-paper-pipeline": { [PAPERS_DIR_FIELD]: "elsewhere" },
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
const both = await cli(["lint"], root);
|
|
269
|
+
check(
|
|
270
|
+
"🔴 both keys with different contents — refused (exit 2), naming both",
|
|
271
|
+
both.code === 2 &&
|
|
272
|
+
/has both "paperlint" and "research-paper-pipeline", and they differ/.test(
|
|
273
|
+
both.out,
|
|
274
|
+
),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// The key is present, the papers-directory field inside it is not: this is not an "empty config" but an
|
|
278
|
+
// unfinished one, and the failure must name the EXACT shape that needs adding.
|
|
279
|
+
writeFileSync(
|
|
280
|
+
join(root, "package.json"),
|
|
281
|
+
JSON.stringify({ name: "c", version: "1.0.0", paperlint: {} }, null, 2),
|
|
282
|
+
);
|
|
283
|
+
const noPapers = await cli(["lint"], root);
|
|
284
|
+
check(
|
|
285
|
+
`a key with no \`${PAPERS_DIR_FIELD}\` — a failure, and the shape is shown INSIDE package.json`,
|
|
286
|
+
noPapers.code === 2 &&
|
|
287
|
+
noPapers.out.includes(`must declare \`${PAPERS_DIR_FIELD}\``) &&
|
|
288
|
+
noPapers.out.includes(
|
|
289
|
+
`"paperlint": { "${PAPERS_DIR_FIELD}": "papers" }`,
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
// The field's OLD name is refused with one clear sentence, not read as a fallback. The
|
|
294
|
+
// new name sits beside it here on purpose: even then the leftover old name is an error.
|
|
295
|
+
writeFileSync(
|
|
296
|
+
join(root, "package.json"),
|
|
297
|
+
JSON.stringify({
|
|
298
|
+
name: "c",
|
|
299
|
+
version: "1.0.0",
|
|
300
|
+
paperlint: {
|
|
301
|
+
[OLD_PAPERS_DIR_FIELD]: "papers",
|
|
302
|
+
[PAPERS_DIR_FIELD]: "papers",
|
|
303
|
+
},
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
const oldName = await cli(["lint"], root);
|
|
307
|
+
check(
|
|
308
|
+
`the old field name "${OLD_PAPERS_DIR_FIELD}" fails with a message that names the new one`,
|
|
309
|
+
oldName.code === 2 &&
|
|
310
|
+
oldName.out.includes(
|
|
311
|
+
`"${OLD_PAPERS_DIR_FIELD}" was renamed to "${PAPERS_DIR_FIELD}" in package.json → "paperlint"`,
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
writeFileSync(
|
|
315
|
+
join(root, "package.json"),
|
|
316
|
+
JSON.stringify({
|
|
317
|
+
name: "c",
|
|
318
|
+
version: "1.0.0",
|
|
319
|
+
paperlint: { [OLD_PAPERS_DIR_FIELD]: "papers" },
|
|
320
|
+
}),
|
|
321
|
+
);
|
|
322
|
+
check(
|
|
323
|
+
"the old field name alone fails the same way — it is never used as the papers directory",
|
|
324
|
+
(await cli(["lint"], root)).code === 2,
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
// 🔴 A package.json WITHOUT the key does not stop the walk upward. Otherwise the search
|
|
328
|
+
// would end at the first project going up the tree — and every project has a package.json —
|
|
329
|
+
// and would never find anything, ever.
|
|
330
|
+
writeFileSync(
|
|
331
|
+
join(root, "package.json"),
|
|
332
|
+
JSON.stringify({ name: "c", version: "1.0.0" }),
|
|
333
|
+
);
|
|
334
|
+
writeFileSync(
|
|
335
|
+
join(root, "rpp.json"),
|
|
336
|
+
JSON.stringify({ [PAPERS_DIR_FIELD]: "papers" }),
|
|
337
|
+
);
|
|
338
|
+
const viaRpp = await cli(["lint"], root);
|
|
339
|
+
check(
|
|
340
|
+
"a keyless package.json does not intercept the search — rpp.json is still found",
|
|
341
|
+
viaRpp.code === 0 && /config: rpp\.json/.test(viaRpp.out),
|
|
342
|
+
);
|
|
343
|
+
check(
|
|
344
|
+
"🔴 but a deprecated carrier is NAMED, not just silently read",
|
|
345
|
+
/rpp\.json is deprecated/.test(viaRpp.out) &&
|
|
346
|
+
/the hooks read only that file/.test(viaRpp.out),
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
// Both carriers side by side: the one everything ELSE reads wins.
|
|
350
|
+
writeFileSync(
|
|
351
|
+
join(root, "package.json"),
|
|
352
|
+
JSON.stringify({
|
|
353
|
+
name: "c",
|
|
354
|
+
version: "1.0.0",
|
|
355
|
+
paperlint: { [PAPERS_DIR_FIELD]: "papers" },
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
check(
|
|
359
|
+
'with both carriers present package.json is chosen — that is what "one declaration" means',
|
|
360
|
+
findDeclaration(root)?.kind === "package.json" &&
|
|
361
|
+
findDeclaration(root)?.path === join(root, "package.json"),
|
|
362
|
+
);
|
|
363
|
+
check(
|
|
364
|
+
'and findConfig still answers the question "which file holds the settings"',
|
|
365
|
+
findConfig(root) === join(root, "package.json"),
|
|
366
|
+
);
|
|
367
|
+
} finally {
|
|
368
|
+
rmSync(root, { recursive: true, force: true });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── `init` — THREE ACTIONS INSTEAD OF NINE ────────────────────────────────────────────────
|
|
373
|
+
//
|
|
374
|
+
// 🔴 THE DEFECT THIS COMMAND WAS REWRITTEN FOR (issue #33, measured 09-18): init wrote
|
|
375
|
+
// `rpp.json` with a GUESSED `"papers": "papers"` and never touched `package.json` — while the
|
|
376
|
+
// three hooks read the papers directory from exactly there. A consumer who did everything by
|
|
377
|
+
// the book got a `paper-edit-guard` guarding a directory that did not exist; from outside that
|
|
378
|
+
// is indistinguishable from a working guard, because silence is its success state.
|
|
379
|
+
//
|
|
380
|
+
// ⚠️ Questions here are NOT ASKED blind: `interactive` is passed explicitly, never read from
|
|
381
|
+
// stdin. A harness stuck at an input prompt is not a red test, it is the absence of any answer
|
|
382
|
+
// at all.
|
|
383
|
+
{
|
|
384
|
+
const workRoot = realpathSync(mkdtempSync(join(tmpdir(), "rpp-init-")));
|
|
385
|
+
const project = (
|
|
386
|
+
name,
|
|
387
|
+
{ pkg = { name: "consumer", version: "1.0.0" }, papers = [] } = {},
|
|
388
|
+
) => {
|
|
389
|
+
const dir = join(workRoot, name);
|
|
390
|
+
mkdirSync(dir, { recursive: true });
|
|
391
|
+
for (const rel of papers) {
|
|
392
|
+
mkdirSync(join(dir, rel, "p1"), { recursive: true });
|
|
393
|
+
writeFileSync(
|
|
394
|
+
join(dir, rel, "p1", "paper.tex"),
|
|
395
|
+
"\\documentclass{article}\n",
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
if (pkg)
|
|
399
|
+
writeFileSync(
|
|
400
|
+
join(dir, "package.json"),
|
|
401
|
+
JSON.stringify(pkg, null, 2) + "\n",
|
|
402
|
+
);
|
|
403
|
+
return dir;
|
|
404
|
+
};
|
|
405
|
+
const say = () => {
|
|
406
|
+
const lines = [];
|
|
407
|
+
return {
|
|
408
|
+
lines,
|
|
409
|
+
log: (...a) => lines.push(a.join(" ")),
|
|
410
|
+
text: () => lines.join("\n"),
|
|
411
|
+
};
|
|
412
|
+
};
|
|
413
|
+
const declared = (dir) =>
|
|
414
|
+
JSON.parse(readFileSync(join(dir, "package.json"), "utf8"))["paperlint"];
|
|
415
|
+
// No check should depend on what is installed ON THIS MACHINE: `command -v` is faked,
|
|
416
|
+
// otherwise "no external programs" would read as a finding about init.
|
|
417
|
+
const haveAll = () => ({ status: 0 });
|
|
418
|
+
const haveNone = () => ({ status: 1 });
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
// ── 1. THE PAPERS DIRECTORY IS MEASURED, NOT GUESSED ────────────────────────────
|
|
422
|
+
{
|
|
423
|
+
const dir = project("detect", { papers: ["writing/drafts"] });
|
|
424
|
+
const out = say();
|
|
425
|
+
const code = await init(dir, {
|
|
426
|
+
log: out.log,
|
|
427
|
+
err: out.log,
|
|
428
|
+
interactive: false,
|
|
429
|
+
run: haveAll,
|
|
430
|
+
});
|
|
431
|
+
check(
|
|
432
|
+
"🔴 THE DECLARATION SHOWS UP IN package.json — the file the hooks read",
|
|
433
|
+
declared(dir) !== undefined &&
|
|
434
|
+
typeof declared(dir)[PAPERS_DIR_FIELD] === "string",
|
|
435
|
+
);
|
|
436
|
+
check(
|
|
437
|
+
"🔴 and its value is MEASURED, not taken from the `papers` default",
|
|
438
|
+
declared(dir)[PAPERS_DIR_FIELD] === "writing/drafts",
|
|
439
|
+
);
|
|
440
|
+
check(
|
|
441
|
+
"and it says HOW it was measured — otherwise a guess reads as a fact",
|
|
442
|
+
/measured: its subdirectories carry/.test(out.text()),
|
|
443
|
+
);
|
|
444
|
+
check(
|
|
445
|
+
"🔴 `rpp.json` IS NO LONGER CREATED — a second declaration is what doctor exists to catch",
|
|
446
|
+
!existsSync(join(dir, "rpp.json")),
|
|
447
|
+
);
|
|
448
|
+
check(
|
|
449
|
+
"init ends with doctor's report: the install vouches for its OWN state",
|
|
450
|
+
/paperlint doctor — what is wired/.test(out.text()),
|
|
451
|
+
);
|
|
452
|
+
check(
|
|
453
|
+
"and exits zero on a consistent install",
|
|
454
|
+
code === 0 && /the same directory/.test(out.text()),
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── 2. A GUESS IS CALLED A GUESS ───────────────────────────────────────────────
|
|
459
|
+
{
|
|
460
|
+
const dir = project("empty");
|
|
461
|
+
const out = say();
|
|
462
|
+
const code = await init(dir, {
|
|
463
|
+
log: out.log,
|
|
464
|
+
err: out.log,
|
|
465
|
+
interactive: false,
|
|
466
|
+
run: haveAll,
|
|
467
|
+
});
|
|
468
|
+
check(
|
|
469
|
+
"nothing to measure — the documented default is taken",
|
|
470
|
+
declared(dir)[PAPERS_DIR_FIELD] === "papers",
|
|
471
|
+
);
|
|
472
|
+
check(
|
|
473
|
+
"🔴 and it is MARKED as a guess, not presented as a measurement",
|
|
474
|
+
/A GUESS/.test(out.text()) &&
|
|
475
|
+
/Nothing here looks like a papers directory/.test(out.text()),
|
|
476
|
+
);
|
|
477
|
+
// Guards: a fresh project with no papers anywhere is not a broken install. The guard has
|
|
478
|
+
// nothing to protect yet, and `init --yes` (an agent, CI) must finish green with a next step.
|
|
479
|
+
check(
|
|
480
|
+
"🔴 no papers anywhere yet — init finishes GREEN and names the next step",
|
|
481
|
+
code === 0 &&
|
|
482
|
+
!/the install is NOT finished/.test(out.text()) &&
|
|
483
|
+
/no papers yet/.test(out.text()) &&
|
|
484
|
+
/new <name>/.test(out.text()),
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── 2½. SETTINGS UNDER THE OLD KEY ARE MOVED TO THE NEW ONE ───────────────────────
|
|
489
|
+
{
|
|
490
|
+
const dir = project("old-key", {
|
|
491
|
+
pkg: {
|
|
492
|
+
name: "consumer",
|
|
493
|
+
"research-paper-pipeline": {
|
|
494
|
+
[PAPERS_DIR_FIELD]: "writing",
|
|
495
|
+
minFindings: 3,
|
|
496
|
+
},
|
|
497
|
+
version: "1.0.0",
|
|
498
|
+
},
|
|
499
|
+
papers: ["writing"],
|
|
500
|
+
});
|
|
501
|
+
const out = say();
|
|
502
|
+
await init(dir, {
|
|
503
|
+
log: out.log,
|
|
504
|
+
err: out.log,
|
|
505
|
+
interactive: false,
|
|
506
|
+
run: haveAll,
|
|
507
|
+
});
|
|
508
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
509
|
+
check(
|
|
510
|
+
"🔴 init moves the old key's settings to the new key, whole, and removes the old key",
|
|
511
|
+
JSON.stringify(pkg.paperlint) ===
|
|
512
|
+
JSON.stringify({ [PAPERS_DIR_FIELD]: "writing", minFindings: 3 }) &&
|
|
513
|
+
!("research-paper-pipeline" in pkg),
|
|
514
|
+
);
|
|
515
|
+
check(
|
|
516
|
+
"in the same position in the file, so the diff is a one-word rename",
|
|
517
|
+
Object.keys(pkg).join() === "name,paperlint,version",
|
|
518
|
+
);
|
|
519
|
+
check(
|
|
520
|
+
"and it says so",
|
|
521
|
+
/moved the settings from "research-paper-pipeline" \(the old key\) to "paperlint"/.test(
|
|
522
|
+
out.text(),
|
|
523
|
+
),
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
{
|
|
527
|
+
const dir = project("both-keys", {
|
|
528
|
+
pkg: {
|
|
529
|
+
name: "consumer",
|
|
530
|
+
paperlint: { [PAPERS_DIR_FIELD]: "writing" },
|
|
531
|
+
"research-paper-pipeline": { [PAPERS_DIR_FIELD]: "other" },
|
|
532
|
+
},
|
|
533
|
+
papers: ["writing"],
|
|
534
|
+
});
|
|
535
|
+
const out = say();
|
|
536
|
+
const code = await init(dir, {
|
|
537
|
+
log: out.log,
|
|
538
|
+
err: out.log,
|
|
539
|
+
interactive: false,
|
|
540
|
+
run: haveAll,
|
|
541
|
+
});
|
|
542
|
+
check(
|
|
543
|
+
"both keys with different contents — init refuses (exit 2) and writes nothing",
|
|
544
|
+
code === 2 &&
|
|
545
|
+
/they differ/.test(out.text()) &&
|
|
546
|
+
"research-paper-pipeline" in
|
|
547
|
+
JSON.parse(readFileSync(join(dir, "package.json"), "utf8")),
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ── 3. SOMEONE ELSE'S VALUE DOES NOT GET OVERWRITTEN ─────────────────────────────
|
|
552
|
+
{
|
|
553
|
+
const dir = project("mine", {
|
|
554
|
+
pkg: {
|
|
555
|
+
name: "c",
|
|
556
|
+
version: "1.0.0",
|
|
557
|
+
paperlint: { [PAPERS_DIR_FIELD]: "mine" },
|
|
558
|
+
},
|
|
559
|
+
papers: ["writing"],
|
|
560
|
+
});
|
|
561
|
+
const before = readFileSync(join(dir, "package.json"), "utf8");
|
|
562
|
+
const out = say();
|
|
563
|
+
await init(dir, {
|
|
564
|
+
log: out.log,
|
|
565
|
+
err: out.log,
|
|
566
|
+
interactive: false,
|
|
567
|
+
run: haveAll,
|
|
568
|
+
});
|
|
569
|
+
check(
|
|
570
|
+
"🔴 an already-declared value stays intact byte for byte — silently replacing a setting is worse than doing nothing",
|
|
571
|
+
readFileSync(join(dir, "package.json"), "utf8") === before,
|
|
572
|
+
);
|
|
573
|
+
check(
|
|
574
|
+
"and it says so out loud, rather than skipping it",
|
|
575
|
+
new RegExp(
|
|
576
|
+
`already declares ${PAPERS_DIR_FIELD} = "mine" — kept, nothing overwritten`,
|
|
577
|
+
).test(out.text()),
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── 4. NOWHERE TO WRITE — A FAILURE WITH A REMEDY ─────────────────────────────
|
|
582
|
+
{
|
|
583
|
+
const dir = project("nopkg", { pkg: null, papers: ["writing"] });
|
|
584
|
+
const out = say();
|
|
585
|
+
const code = await init(dir, {
|
|
586
|
+
log: out.log,
|
|
587
|
+
err: out.log,
|
|
588
|
+
interactive: false,
|
|
589
|
+
run: haveAll,
|
|
590
|
+
});
|
|
591
|
+
check(
|
|
592
|
+
"without package.json init FAILS and carries a remedy, not just a diagnosis",
|
|
593
|
+
code === 2 &&
|
|
594
|
+
/npm init -y/.test(out.text()) &&
|
|
595
|
+
/nowhere to put the declaration/.test(out.text()),
|
|
596
|
+
);
|
|
597
|
+
check(
|
|
598
|
+
"and creates nothing in its place",
|
|
599
|
+
!existsSync(join(dir, "package.json")),
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ── 5. ONLY WHAT CANNOT BE GUESSED IS ASKED, AND ONLY OF A HUMAN ─────────────────
|
|
604
|
+
{
|
|
605
|
+
const dir = project("ci", { papers: ["writing"] });
|
|
606
|
+
const out = say();
|
|
607
|
+
await init(dir, {
|
|
608
|
+
log: out.log,
|
|
609
|
+
err: out.log,
|
|
610
|
+
interactive: false,
|
|
611
|
+
run: haveAll,
|
|
612
|
+
});
|
|
613
|
+
check(
|
|
614
|
+
"🔴 not a terminal — the question is NOT asked, and the default taken is NAMED",
|
|
615
|
+
/stdin is not a terminal, so nothing was asked. Default taken: NO file written/.test(
|
|
616
|
+
out.text(),
|
|
617
|
+
),
|
|
618
|
+
);
|
|
619
|
+
check(
|
|
620
|
+
"and the safe default is the absence of a file",
|
|
621
|
+
!existsSync(join(dir, WORKFLOW_PATH)),
|
|
622
|
+
);
|
|
623
|
+
check(
|
|
624
|
+
"and the CI step is still printed — it just gets pasted in by hand",
|
|
625
|
+
/uses: zernie\/research-paper-pipeline@/.test(out.text()) &&
|
|
626
|
+
/paths: writing/.test(out.text()),
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
{
|
|
630
|
+
// Guards: the printed step carries the tag of the running release, not a placeholder.
|
|
631
|
+
const dir = project("ci-printed-pin", { papers: ["writing"] });
|
|
632
|
+
const out = say();
|
|
633
|
+
await init(dir, {
|
|
634
|
+
log: out.log,
|
|
635
|
+
err: out.log,
|
|
636
|
+
interactive: false,
|
|
637
|
+
run: haveAll,
|
|
638
|
+
version: "1.2.3",
|
|
639
|
+
});
|
|
640
|
+
check(
|
|
641
|
+
"🔴 a released version — the printed step is pinned to its tag, no <commit-sha>",
|
|
642
|
+
/uses: zernie\/research-paper-pipeline@v1\.2\.3\b/.test(out.text()) &&
|
|
643
|
+
!/<commit-sha>/.test(out.text()),
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
{
|
|
647
|
+
const dir = project("ci-yes", { papers: ["writing"] });
|
|
648
|
+
const asked = [];
|
|
649
|
+
const wf = await offerWorkflow(dir, "writing", {
|
|
650
|
+
interactive: true,
|
|
651
|
+
ask: async (q) => {
|
|
652
|
+
asked.push(q);
|
|
653
|
+
return "y";
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
check(
|
|
657
|
+
"saying yes WRITES the workflow",
|
|
658
|
+
wf === "written" && existsSync(join(dir, WORKFLOW_PATH)),
|
|
659
|
+
);
|
|
660
|
+
check(
|
|
661
|
+
"and the workflow carries THE directory that was actually discussed",
|
|
662
|
+
/paths: writing/.test(readFileSync(join(dir, WORKFLOW_PATH), "utf8")),
|
|
663
|
+
);
|
|
664
|
+
check(
|
|
665
|
+
"without a known release the action keeps the obvious placeholder, never a guessed tag",
|
|
666
|
+
/@<commit-sha>/.test(readFileSync(join(dir, WORKFLOW_PATH), "utf8")),
|
|
667
|
+
);
|
|
668
|
+
check("the question is asked exactly once", asked.length === 1);
|
|
669
|
+
const again = await offerWorkflow(dir, "writing", {
|
|
670
|
+
interactive: true,
|
|
671
|
+
ask: async () => "y",
|
|
672
|
+
});
|
|
673
|
+
check(
|
|
674
|
+
"an existing workflow is neither overwritten nor asked about again",
|
|
675
|
+
again === "kept",
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
{
|
|
679
|
+
const dir = project("ci-pinned", { papers: ["writing"] });
|
|
680
|
+
const wf = await offerWorkflow(dir, "writing", {
|
|
681
|
+
interactive: true,
|
|
682
|
+
ask: async () => "y",
|
|
683
|
+
version: "1.2.3",
|
|
684
|
+
});
|
|
685
|
+
const yaml = existsSync(join(dir, WORKFLOW_PATH))
|
|
686
|
+
? readFileSync(join(dir, WORKFLOW_PATH), "utf8")
|
|
687
|
+
: "";
|
|
688
|
+
const uses = (yamlLoad(yaml)?.jobs?.papers?.steps ?? []).map(
|
|
689
|
+
(s) => s.uses,
|
|
690
|
+
);
|
|
691
|
+
check(
|
|
692
|
+
"🔴 a released version — the written workflow is pinned to its tag, no <commit-sha> left",
|
|
693
|
+
wf === "written" &&
|
|
694
|
+
uses.includes("zernie/research-paper-pipeline@v1.2.3") &&
|
|
695
|
+
!yaml.includes("<commit-sha>"),
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
{
|
|
699
|
+
const dir = project("ci-no", { papers: ["writing"] });
|
|
700
|
+
const no = await offerWorkflow(dir, "writing", {
|
|
701
|
+
interactive: true,
|
|
702
|
+
ask: async () => "",
|
|
703
|
+
});
|
|
704
|
+
check(
|
|
705
|
+
"an empty answer is a NO, and no file appears",
|
|
706
|
+
no === "declined" && !existsSync(join(dir, WORKFLOW_PATH)),
|
|
707
|
+
);
|
|
708
|
+
// 🔴 Measured 09-18 on a real pseudo-terminal: `readline.question()` REJECTS with
|
|
709
|
+
// `AbortError: Aborted with Ctrl+D`, and this exception used to escape AFTER the
|
|
710
|
+
// declaration had already been written — i.e. the install both succeeded and looked like
|
|
711
|
+
// a crash.
|
|
712
|
+
// 🔴 THE `.catch` HERE IS LOAD-BEARING, NOT CAUTION. A leaked exception is a PROPERTY that
|
|
713
|
+
// gets asserted; caught by letting the harness crash it reads to the battery's driver as
|
|
714
|
+
// "the mutation survived", i.e. the real defect would look like a hole in the test.
|
|
715
|
+
const aborted = await offerWorkflow(dir, "writing", {
|
|
716
|
+
interactive: true,
|
|
717
|
+
ask: async () => {
|
|
718
|
+
throw new Error("Aborted with Ctrl+D");
|
|
719
|
+
},
|
|
720
|
+
}).catch((e) => `THREW: ${e?.message ?? e}`);
|
|
721
|
+
check(
|
|
722
|
+
"🔴 an interrupted question does NOT crash the command — it means the default",
|
|
723
|
+
aborted === "declined",
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ── 6. SEVERAL CANDIDATES — THE ONLY CASE WHERE A QUESTION IS ASKED ───────────────
|
|
728
|
+
{
|
|
729
|
+
const dir = project("many", { papers: ["alpha", "beta"] });
|
|
730
|
+
const picked = await choosePapers(dir, {
|
|
731
|
+
interactive: true,
|
|
732
|
+
ask: async () => "2",
|
|
733
|
+
});
|
|
734
|
+
check(
|
|
735
|
+
"🔴 the human's answer DECIDES, it does not just decorate the output",
|
|
736
|
+
picked.how === "chosen" && picked.papers === picked.candidates[1],
|
|
737
|
+
);
|
|
738
|
+
const quiet = await choosePapers(dir, { interactive: false });
|
|
739
|
+
check(
|
|
740
|
+
"outside a terminal the first one is taken, and this is NAMED, not passed off as a choice",
|
|
741
|
+
quiet.how === "not-asked" && quiet.papers === quiet.candidates[0],
|
|
742
|
+
);
|
|
743
|
+
const aborted = await choosePapers(dir, {
|
|
744
|
+
interactive: true,
|
|
745
|
+
ask: async () => {
|
|
746
|
+
throw new Error("Aborted with Ctrl+D");
|
|
747
|
+
},
|
|
748
|
+
}).catch((e) => ({
|
|
749
|
+
how: `THREW: ${e?.message ?? e}`,
|
|
750
|
+
papers: null,
|
|
751
|
+
candidates: [],
|
|
752
|
+
}));
|
|
753
|
+
check(
|
|
754
|
+
"an interrupted choice is also a default, not a crash",
|
|
755
|
+
aborted.how === "no-answer" && aborted.papers === aborted.candidates[0],
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ── 7. EXTERNAL TOOLING IS REPORTED, NOT INSTALLED ───────────────────────────────
|
|
760
|
+
//
|
|
761
|
+
// npm's own rule, quoted in the analysis of husky: "The only valid use of install or
|
|
762
|
+
// preinstall scripts is for compilation". An install that can silently fail to happen is
|
|
763
|
+
// worse than an explicit step.
|
|
764
|
+
{
|
|
765
|
+
const dir = project("tools", { papers: ["writing"] });
|
|
766
|
+
const out = say();
|
|
767
|
+
await init(dir, {
|
|
768
|
+
log: out.log,
|
|
769
|
+
err: out.log,
|
|
770
|
+
interactive: false,
|
|
771
|
+
run: haveNone,
|
|
772
|
+
});
|
|
773
|
+
// 🔴 JUDGE ONLY init's OWN REPORT, BEFORE doctor's banner. The first version of these
|
|
774
|
+
// three assertions looked at the WHOLE output — and doctor also prints `✗ pdflatex` and
|
|
775
|
+
// the same install command. The mutation that stripped the remedy OUT of init stayed
|
|
776
|
+
// green: the assertion found a line printed by a different command and reported coverage
|
|
777
|
+
// that did not exist.
|
|
778
|
+
const own = out.text().split("── paperlint doctor")[0];
|
|
779
|
+
check(
|
|
780
|
+
"every absence is NAMED, and the count matches the names",
|
|
781
|
+
/✗ 6 of 6 missing: pdflatex, bibtex/.test(own),
|
|
782
|
+
);
|
|
783
|
+
check(
|
|
784
|
+
"🔴 and it carries the INSTALL COMMAND — a remedy, not just a diagnosis",
|
|
785
|
+
/npx paperlint toolchain/.test(own),
|
|
786
|
+
);
|
|
787
|
+
check(
|
|
788
|
+
"and it says outright that nothing is installed on the user's behalf",
|
|
789
|
+
/Nothing is installed for you/.test(own),
|
|
790
|
+
);
|
|
791
|
+
// One fact, one author: the consequence of each absence is printed by doctor, and printed
|
|
792
|
+
// ONCE.
|
|
793
|
+
check(
|
|
794
|
+
"and init does NOT repeat doctor's table twenty lines above it",
|
|
795
|
+
!/no PDF is produced/.test(own) &&
|
|
796
|
+
/no PDF is produced/.test(
|
|
797
|
+
out.text().split("── paperlint doctor")[1] ?? "",
|
|
798
|
+
),
|
|
799
|
+
);
|
|
800
|
+
check(
|
|
801
|
+
"and asking a system that has everything gives zero absences",
|
|
802
|
+
missingPrograms(haveAll).length === 0,
|
|
803
|
+
);
|
|
804
|
+
check(
|
|
805
|
+
"and an empty system gives all of them — the counter counts the same thing it prints",
|
|
806
|
+
missingPrograms(haveNone).length === PROGRAMS.length,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ── 7-bis. THE SKILLS: WHAT WAS LINKED, AND WHAT WAS LEFT ALONE, BY NAME ────────────
|
|
811
|
+
//
|
|
812
|
+
// The links themselves are `link-skills.harness.mjs`'s subject. Here: init REPORTS them, and
|
|
813
|
+
// a name it refused to take does not turn a working install red. The state is handed in so
|
|
814
|
+
// this block does not depend on a package being installed next to the temp project.
|
|
815
|
+
{
|
|
816
|
+
const dir = project("skills", { papers: ["writing"] });
|
|
817
|
+
const out = say();
|
|
818
|
+
const code = await init(dir, {
|
|
819
|
+
log: out.log,
|
|
820
|
+
err: out.log,
|
|
821
|
+
interactive: false,
|
|
822
|
+
run: haveAll,
|
|
823
|
+
link: (root) => ({
|
|
824
|
+
ok: true,
|
|
825
|
+
home: join(root, ".claude", "skills"),
|
|
826
|
+
example: "../../node_modules/paperlint/skills/alpha",
|
|
827
|
+
links: [
|
|
828
|
+
{ name: "alpha", status: "created" },
|
|
829
|
+
{ name: "beta", status: "present" },
|
|
830
|
+
{ name: "gamma", status: "foreign", reason: "a directory" },
|
|
831
|
+
],
|
|
832
|
+
}),
|
|
833
|
+
});
|
|
834
|
+
const own = out.text().split("── paperlint doctor")[0];
|
|
835
|
+
check(
|
|
836
|
+
"init counts what it did with the skills: created, already there, skipped",
|
|
837
|
+
/3 shipped: 1 linked now, 1 already linked, 1 skipped/.test(own),
|
|
838
|
+
);
|
|
839
|
+
check(
|
|
840
|
+
"🔴 a skipped skill is NAMED with what occupies it — not folded into a count",
|
|
841
|
+
/gamma — a directory/.test(own) &&
|
|
842
|
+
/NOT available in Claude Code/.test(own),
|
|
843
|
+
);
|
|
844
|
+
check(
|
|
845
|
+
"and a name init refused to take does not fail the install",
|
|
846
|
+
code === 0,
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// ── 8. `rpp.json` FOR THOSE WHO ALREADY HAVE ONE ──────────────────────────────────
|
|
851
|
+
{
|
|
852
|
+
const dir = project("legacy", { papers: ["writing"] });
|
|
853
|
+
writeFileSync(
|
|
854
|
+
join(dir, "rpp.json"),
|
|
855
|
+
JSON.stringify({ minFindings: 5 }, null, 2) + "\n",
|
|
856
|
+
);
|
|
857
|
+
const out = say();
|
|
858
|
+
await init(dir, {
|
|
859
|
+
log: out.log,
|
|
860
|
+
err: out.log,
|
|
861
|
+
interactive: false,
|
|
862
|
+
run: haveAll,
|
|
863
|
+
});
|
|
864
|
+
check(
|
|
865
|
+
"an existing rpp.json gets the SAME value, rather than drifting silently",
|
|
866
|
+
JSON.parse(readFileSync(join(dir, "rpp.json"), "utf8"))[
|
|
867
|
+
PAPERS_DIR_FIELD
|
|
868
|
+
] === "writing",
|
|
869
|
+
);
|
|
870
|
+
check(
|
|
871
|
+
"and is named deprecated",
|
|
872
|
+
/rpp\.json was already here/.test(out.text()),
|
|
873
|
+
);
|
|
874
|
+
|
|
875
|
+
const own = join(workRoot, "legacy-own");
|
|
876
|
+
mkdirSync(own, { recursive: true });
|
|
877
|
+
writeFileSync(
|
|
878
|
+
join(own, "package.json"),
|
|
879
|
+
'{"name":"c","version":"1.0.0"}',
|
|
880
|
+
);
|
|
881
|
+
writeFileSync(join(own, "rpp.json"), `{"${PAPERS_DIR_FIELD}":"mine"}`);
|
|
882
|
+
check(
|
|
883
|
+
"and the papers-directory value it already declares stays byte for byte — this too is someone else's value",
|
|
884
|
+
syncRppJson(own, "writing") === "kept" &&
|
|
885
|
+
readFileSync(join(own, "rpp.json"), "utf8") ===
|
|
886
|
+
`{"${PAPERS_DIR_FIELD}":"mine"}`,
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ── 9. THE COMMAND IS WIRED TO `run`, NOT MERELY EXPORTED ────────────────────────
|
|
891
|
+
{
|
|
892
|
+
const dir = project("wired", { papers: ["writing"] });
|
|
893
|
+
const r = await cli(["init", dir]);
|
|
894
|
+
check(
|
|
895
|
+
"`paperlint init` reaches the implementation and declares the measured directory",
|
|
896
|
+
declared(dir)[PAPERS_DIR_FIELD] === "writing" &&
|
|
897
|
+
/paperlint init — each decision/.test(r.out),
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
} finally {
|
|
901
|
+
rmSync(workRoot, { recursive: true, force: true });
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
{
|
|
905
|
+
const r = await cli(["frobnicate"]);
|
|
906
|
+
check(
|
|
907
|
+
"an unknown command is NAMED",
|
|
908
|
+
r.code === 2 && /unknown command `frobnicate`/.test(r.out),
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
{
|
|
912
|
+
// A flag with no value. The defect was found by the compiler while porting to TypeScript:
|
|
913
|
+
// `rest[++i]` past the last argument gives undefined, and `--config` with no value silently
|
|
914
|
+
// meant "no config was set" — i.e. an autodiscovered file instead of the named one.
|
|
915
|
+
const r = await cli(["lint", "--config"]);
|
|
916
|
+
check(
|
|
917
|
+
"a flag with no value — a FAILURE, not a silent default",
|
|
918
|
+
r.code === 2 && /--config needs a value/.test(r.out),
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
{
|
|
922
|
+
// A "." default would give a green run over whatever happens to be lying around — the same
|
|
923
|
+
// contract as the action, and the same reason. An empty directory with no config is exactly
|
|
924
|
+
// that case.
|
|
925
|
+
const bare = realpathSync(mkdtempSync(join(tmpdir(), "rpp-bare-")));
|
|
926
|
+
try {
|
|
927
|
+
const r = await cli(["lint"], bare);
|
|
928
|
+
check(
|
|
929
|
+
"`lint` with NO path AND no config refuses and names BOTH ways out",
|
|
930
|
+
r.code === 2 &&
|
|
931
|
+
/nothing to lint/.test(r.out) &&
|
|
932
|
+
/paperlint init/.test(r.out) &&
|
|
933
|
+
/paperlint lint papers/.test(r.out),
|
|
934
|
+
);
|
|
935
|
+
} finally {
|
|
936
|
+
rmSync(bare, { recursive: true, force: true });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ── on live files: both halves ───────────────────────────────────────────────────────
|
|
941
|
+
{
|
|
942
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "rpp-cli-")));
|
|
943
|
+
try {
|
|
944
|
+
const paper = join(root, "papers", "p1");
|
|
945
|
+
mkdirSync(join(paper, "versions"), { recursive: true });
|
|
946
|
+
writeFileSync(
|
|
947
|
+
join(paper, "versions", "2026-07-22-submitted.pdf"),
|
|
948
|
+
"x".repeat(100),
|
|
949
|
+
);
|
|
950
|
+
const status = (bytes) =>
|
|
951
|
+
`---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: ${bytes}\n source: versions/s.tex\n sourceBytes: 4\nresearchQuestion: "does it hold?"\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`;
|
|
952
|
+
writeFileSync(join(paper, "versions", "s.tex"), "abcd");
|
|
953
|
+
writeFileSync(join(paper, "paper.md"), "# Intro\n\nRQ1: does it hold?\n");
|
|
954
|
+
|
|
955
|
+
writeFileSync(join(paper, "PIPELINE-STATUS.md"), status(100));
|
|
956
|
+
const clean = await cli(["lint", "papers"], root);
|
|
957
|
+
check(
|
|
958
|
+
"on a clean corpus — zero and a legible report",
|
|
959
|
+
clean.code === 0 && /no findings/.test(clean.out),
|
|
960
|
+
);
|
|
961
|
+
|
|
962
|
+
writeFileSync(join(paper, "PIPELINE-STATUS.md"), status(999));
|
|
963
|
+
const dirty = await cli(["lint", "papers"], root);
|
|
964
|
+
check("a planted byte mismatch — a finding and code 1", dirty.code === 1);
|
|
965
|
+
check(
|
|
966
|
+
"and the finding names BOTH numbers",
|
|
967
|
+
/999/.test(dirty.out) && /100/.test(dirty.out),
|
|
968
|
+
);
|
|
969
|
+
|
|
970
|
+
const json = await cli(["lint", "papers", "--json"], root);
|
|
971
|
+
check(
|
|
972
|
+
"`--json` gives back parseable JSON",
|
|
973
|
+
(() => {
|
|
974
|
+
try {
|
|
975
|
+
return Array.isArray(JSON.parse(json.stdout));
|
|
976
|
+
} catch {
|
|
977
|
+
return false;
|
|
978
|
+
}
|
|
979
|
+
})(),
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
// 🔴 The guard against a false green zero: ESLint throws on an empty set, and before the
|
|
983
|
+
// fix a stack trace flew out here instead of an explanation.
|
|
984
|
+
mkdirSync(join(root, "nothing"), { recursive: true });
|
|
985
|
+
const empty = await cli(["lint", "nothing"], root);
|
|
986
|
+
check(
|
|
987
|
+
"the utility does NOT let an exception escape — a failure is declared by the exit code",
|
|
988
|
+
empty.code !== 99,
|
|
989
|
+
);
|
|
990
|
+
check("an empty set — a FAILURE, not a green zero", empty.code === 1);
|
|
991
|
+
check(
|
|
992
|
+
"and the failure explains exactly what was not found",
|
|
993
|
+
/nothing was linted/.test(empty.out) &&
|
|
994
|
+
/not a clean report/.test(empty.out),
|
|
995
|
+
);
|
|
996
|
+
|
|
997
|
+
writeFileSync(join(root, "bad.json"), "{ not json");
|
|
998
|
+
const bad = await cli(["lint", "papers", "--config", "bad.json"], root);
|
|
999
|
+
check(
|
|
1000
|
+
"a broken config is NAMED, not a dropped stack trace",
|
|
1001
|
+
bad.code === 2 && /not valid JSON/.test(bad.out),
|
|
1002
|
+
);
|
|
1003
|
+
|
|
1004
|
+
const noFile = await cli(["lint", "papers", "--config", "nope.json"], root);
|
|
1005
|
+
check(
|
|
1006
|
+
"a missing config is also named",
|
|
1007
|
+
noFile.code === 2 && /config file not found/.test(noFile.out),
|
|
1008
|
+
);
|
|
1009
|
+
|
|
1010
|
+
// `check` stays an alias: someone else's workflow is not broken, but they are told what it
|
|
1011
|
+
// was replaced by.
|
|
1012
|
+
const alias = await cli(["check", "papers"], root);
|
|
1013
|
+
check(
|
|
1014
|
+
"`check` still works and prints what replaced it",
|
|
1015
|
+
alias.code === 1 && /`check` is now `lint`/.test(alias.out),
|
|
1016
|
+
);
|
|
1017
|
+
} finally {
|
|
1018
|
+
rmSync(root, { recursive: true, force: true });
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// ── THE CONFIG FINDS ITSELF, AND THE PAPERS DIRECTORY IS DECLARED IN IT ──────────────────
|
|
1023
|
+
//
|
|
1024
|
+
// 🔴 THE DEFECT THIS BLOCK EXISTS FOR: `paperlint init` wrote `rpp.json`, and `rpp check` only read
|
|
1025
|
+
// it via an explicit `--options`. I.e. the file the utility itself created had no effect on the
|
|
1026
|
+
// run — and there was no way to find that out: zero typography debt looks exactly like a
|
|
1027
|
+
// config that was never found.
|
|
1028
|
+
{
|
|
1029
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "rpp-cfg-")));
|
|
1030
|
+
try {
|
|
1031
|
+
const paper = join(root, "papers", "p1");
|
|
1032
|
+
mkdirSync(join(paper, "versions"), { recursive: true });
|
|
1033
|
+
writeFileSync(join(paper, "versions", "s.tex"), "abcd");
|
|
1034
|
+
writeFileSync(
|
|
1035
|
+
join(paper, "versions", "2026-07-22-submitted.pdf"),
|
|
1036
|
+
"x".repeat(100),
|
|
1037
|
+
);
|
|
1038
|
+
writeFileSync(join(paper, "paper.md"), "# Intro\n\nRQ1: does it hold?\n");
|
|
1039
|
+
const status = (bytes) =>
|
|
1040
|
+
`---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: ${bytes}\n source: versions/s.tex\n sourceBytes: 4\nresearchQuestion: "does it hold?"\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`;
|
|
1041
|
+
writeFileSync(join(paper, "PIPELINE-STATUS.md"), status(100));
|
|
1042
|
+
|
|
1043
|
+
// findConfig — kept separate from the run so the failure is distinguishable
|
|
1044
|
+
writeFileSync(
|
|
1045
|
+
join(root, "rpp.json"),
|
|
1046
|
+
JSON.stringify({ [PAPERS_DIR_FIELD]: "papers" }),
|
|
1047
|
+
);
|
|
1048
|
+
check(
|
|
1049
|
+
"findConfig walks up from a subdirectory and finds the file at the root",
|
|
1050
|
+
findConfig(paper) === join(root, "rpp.json"),
|
|
1051
|
+
);
|
|
1052
|
+
// There can be no tautology here: a neighboring tree must NOT pick up our file. Asserting
|
|
1053
|
+
// `=== null` against a live filesystem is not safe — someone else's rpp.json may sit further
|
|
1054
|
+
// up the chain — so what is asserted is what is actually checkable: our config is not
|
|
1055
|
+
// visible from there.
|
|
1056
|
+
const sibling = realpathSync(mkdtempSync(join(tmpdir(), "rpp-other-")));
|
|
1057
|
+
check(
|
|
1058
|
+
"the config does NOT leak into a neighboring tree — the search goes up, not sideways",
|
|
1059
|
+
findConfig(sibling) !== join(root, "rpp.json"),
|
|
1060
|
+
);
|
|
1061
|
+
rmSync(sibling, { recursive: true, force: true });
|
|
1062
|
+
|
|
1063
|
+
const found = await cli(["lint"], root);
|
|
1064
|
+
check(
|
|
1065
|
+
"the config is FOUND on its own: `lint` with no argument at all runs",
|
|
1066
|
+
found.code === 0 && /no findings/.test(found.out),
|
|
1067
|
+
);
|
|
1068
|
+
check(
|
|
1069
|
+
"and the found file is NAMED out loud — swapping settings silently is not acceptable",
|
|
1070
|
+
/config: rpp\.json/.test(found.out),
|
|
1071
|
+
);
|
|
1072
|
+
|
|
1073
|
+
// 🔴 THE RESOLUTION DISCRIMINATOR. Run FROM the paper's directory: same config, and
|
|
1074
|
+
// `"papers": "papers"` resolved relative to the CURRENT directory would point at
|
|
1075
|
+
// `papers/p1/papers` — which does not exist, and the run would fail with "nothing was
|
|
1076
|
+
// linted" right where the whole corpus is present.
|
|
1077
|
+
const fromSub = await cli(["lint"], paper);
|
|
1078
|
+
check(
|
|
1079
|
+
"the path from the config resolves relative to the CONFIG'S DIRECTORY, not the current one",
|
|
1080
|
+
fromSub.code === 0 && /no findings/.test(fromSub.out),
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
// Consumer data from the found config actually reaches the rules, not just gets read.
|
|
1084
|
+
writeFileSync(join(paper, "PIPELINE-STATUS.md"), status(999));
|
|
1085
|
+
const dirty = await cli(["lint"], root);
|
|
1086
|
+
check(
|
|
1087
|
+
"a found config does not cancel findings — the mismatch is still caught",
|
|
1088
|
+
dirty.code === 1 && /999/.test(dirty.out),
|
|
1089
|
+
);
|
|
1090
|
+
writeFileSync(join(paper, "PIPELINE-STATUS.md"), status(100));
|
|
1091
|
+
|
|
1092
|
+
// An argument OVERRIDES the config: one paper instead of the corpus, with no file edit.
|
|
1093
|
+
mkdirSync(join(root, "elsewhere"), { recursive: true });
|
|
1094
|
+
const override = await cli(["lint", "elsewhere"], root);
|
|
1095
|
+
check(
|
|
1096
|
+
"a command-line argument OVERRIDES the papers directory from the config",
|
|
1097
|
+
override.code === 1 &&
|
|
1098
|
+
/nothing was linted under elsewhere/.test(override.out),
|
|
1099
|
+
);
|
|
1100
|
+
|
|
1101
|
+
// 🔴 THE PAPERS DIRECTORY — A REQUIRED FIELD. A config without it is not an "empty config" but an
|
|
1102
|
+
// unfinished one: silently falling back to "." means running the rules over the whole checkout.
|
|
1103
|
+
writeFileSync(join(root, "rpp.json"), JSON.stringify({ minFindings: 3 }));
|
|
1104
|
+
const noPapers = await cli(["lint"], root);
|
|
1105
|
+
check(
|
|
1106
|
+
"a config WITHOUT the papers-directory field — a failure, and the field is named by name",
|
|
1107
|
+
noPapers.code === 2 &&
|
|
1108
|
+
noPapers.out.includes(`must declare \`${PAPERS_DIR_FIELD}\``) &&
|
|
1109
|
+
/cannot guess/.test(noPapers.out),
|
|
1110
|
+
);
|
|
1111
|
+
check(
|
|
1112
|
+
'an empty string as the papers directory counts as absent, not as the directory ""',
|
|
1113
|
+
(
|
|
1114
|
+
await (async () => {
|
|
1115
|
+
writeFileSync(
|
|
1116
|
+
join(root, "rpp.json"),
|
|
1117
|
+
JSON.stringify({ [PAPERS_DIR_FIELD]: "" }),
|
|
1118
|
+
);
|
|
1119
|
+
return await cli(["lint"], root);
|
|
1120
|
+
})()
|
|
1121
|
+
).code === 2,
|
|
1122
|
+
);
|
|
1123
|
+
} finally {
|
|
1124
|
+
rmSync(root, { recursive: true, force: true });
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// ── THE WARNING THRESHOLD ───────────────────────────────────────────────────────────────
|
|
1129
|
+
//
|
|
1130
|
+
// The action has a `max-warnings` input, and once the action stopped calling eslint directly,
|
|
1131
|
+
// the threshold had to show up here too — otherwise it would have been lost SILENTLY: the run
|
|
1132
|
+
// would stay green, and the consumer's setting would stop meaning anything.
|
|
1133
|
+
{
|
|
1134
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "rpp-warn-")));
|
|
1135
|
+
try {
|
|
1136
|
+
const paper = join(root, "papers", "p1");
|
|
1137
|
+
mkdirSync(join(paper, "versions"), { recursive: true });
|
|
1138
|
+
writeFileSync(join(paper, "versions", "s.tex"), "abcd");
|
|
1139
|
+
writeFileSync(
|
|
1140
|
+
join(paper, "versions", "2026-07-22-submitted.pdf"),
|
|
1141
|
+
"x".repeat(100),
|
|
1142
|
+
);
|
|
1143
|
+
// Three § signs — the `paper/typography` rule, warn level and only warn.
|
|
1144
|
+
writeFileSync(
|
|
1145
|
+
join(paper, "paper.md"),
|
|
1146
|
+
"# Intro\n\nRQ1: does it hold?\n\nSee \u00a7 5 and \u00a7 6 and \u00a7 7.\n",
|
|
1147
|
+
);
|
|
1148
|
+
writeFileSync(
|
|
1149
|
+
join(paper, "PIPELINE-STATUS.md"),
|
|
1150
|
+
`---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: 100\n source: versions/s.tex\n sourceBytes: 4\nresearchQuestion: "does it hold?"\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`,
|
|
1151
|
+
);
|
|
1152
|
+
writeFileSync(
|
|
1153
|
+
join(root, "rpp.json"),
|
|
1154
|
+
JSON.stringify({ [PAPERS_DIR_FIELD]: "papers" }),
|
|
1155
|
+
);
|
|
1156
|
+
|
|
1157
|
+
const lax = await cli(["lint"], root);
|
|
1158
|
+
check(
|
|
1159
|
+
"a warning WITH NO threshold does not fail the run — otherwise a gate on advice would be muted entirely",
|
|
1160
|
+
lax.code === 0,
|
|
1161
|
+
);
|
|
1162
|
+
const strict = await cli(["lint", "--max-warnings", "0"], root);
|
|
1163
|
+
check(
|
|
1164
|
+
"and with a threshold of 0 it fails, and it's the very same warning",
|
|
1165
|
+
strict.code === 1,
|
|
1166
|
+
);
|
|
1167
|
+
check(
|
|
1168
|
+
'and the failure names the NUMBER and the THRESHOLD, not just "too many"',
|
|
1169
|
+
/1 warning\(s\) exceed the --max-warnings limit of 0/.test(strict.out),
|
|
1170
|
+
);
|
|
1171
|
+
const generous = await cli(["lint", "--max-warnings", "5"], root);
|
|
1172
|
+
check(
|
|
1173
|
+
"a threshold ABOVE the finding count stays quiet — the check is about the threshold, not about a warning existing",
|
|
1174
|
+
generous.code === 0,
|
|
1175
|
+
);
|
|
1176
|
+
} finally {
|
|
1177
|
+
rmSync(root, { recursive: true, force: true });
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// ── STRUCTURE GETS THROUGH TO THE COMMAND ──────────────────────────────────────────────
|
|
1182
|
+
//
|
|
1183
|
+
// `structure.mjs` is checked separately and in full (`structure.harness.mjs`). Here — exactly
|
|
1184
|
+
// one fact that harness cannot know: that the module is WIRED IN. A correct module forgotten in
|
|
1185
|
+
// `run()` gives zero findings and looks like a clean corpus.
|
|
1186
|
+
{
|
|
1187
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "rpp-wired-")));
|
|
1188
|
+
try {
|
|
1189
|
+
const paper = join(root, "papers", "orphan");
|
|
1190
|
+
mkdirSync(paper, { recursive: true });
|
|
1191
|
+
// The marker is present, the scorecard is not: NOT ONE pipeline rule runs over this directory.
|
|
1192
|
+
writeFileSync(join(paper, "paper.tex"), "\\documentclass{article}\n");
|
|
1193
|
+
writeFileSync(
|
|
1194
|
+
join(root, "rpp.json"),
|
|
1195
|
+
JSON.stringify({ [PAPERS_DIR_FIELD]: "papers" }),
|
|
1196
|
+
);
|
|
1197
|
+
|
|
1198
|
+
const r = await cli(["lint"], root);
|
|
1199
|
+
check(
|
|
1200
|
+
"a directory with no scorecard is NOT a green zero: the command exits one",
|
|
1201
|
+
r.code === 1,
|
|
1202
|
+
);
|
|
1203
|
+
check(
|
|
1204
|
+
"and the finding is printed before the ESLint report, with its consequence",
|
|
1205
|
+
/missing `PIPELINE-STATUS\.md`/.test(r.out) && /is checked/.test(r.out),
|
|
1206
|
+
);
|
|
1207
|
+
check(
|
|
1208
|
+
'and it does NOT print "no findings" over something that was found',
|
|
1209
|
+
!/no findings/.test(r.out),
|
|
1210
|
+
);
|
|
1211
|
+
|
|
1212
|
+
const j = await cli(["lint", "--json"], root);
|
|
1213
|
+
// 🔴 The defect the streams were split for: a `config: …` line in stdout before the array
|
|
1214
|
+
// breaks `| jq` for a consumer. Both halves — stdout is clean, and the line is NOT LOST.
|
|
1215
|
+
check(
|
|
1216
|
+
"`--json`: stdout is clean JSON, not one incidental line before it",
|
|
1217
|
+
(() => {
|
|
1218
|
+
try {
|
|
1219
|
+
JSON.parse(j.stdout);
|
|
1220
|
+
return true;
|
|
1221
|
+
} catch {
|
|
1222
|
+
return false;
|
|
1223
|
+
}
|
|
1224
|
+
})(),
|
|
1225
|
+
);
|
|
1226
|
+
check(
|
|
1227
|
+
"and the line about the found config is not lost — it went to stderr",
|
|
1228
|
+
/config: rpp\.json/.test(j.stderr) && !/config:/.test(j.stdout),
|
|
1229
|
+
);
|
|
1230
|
+
check(
|
|
1231
|
+
"`--json` gives back ONE array, where the missing-file finding sits next to the rule findings",
|
|
1232
|
+
(() => {
|
|
1233
|
+
try {
|
|
1234
|
+
const parsed = JSON.parse(j.stdout);
|
|
1235
|
+
return parsed.some((x) =>
|
|
1236
|
+
x.messages?.some((m) => m.ruleId === "structure/required-file"),
|
|
1237
|
+
);
|
|
1238
|
+
} catch {
|
|
1239
|
+
return false;
|
|
1240
|
+
}
|
|
1241
|
+
})(),
|
|
1242
|
+
);
|
|
1243
|
+
|
|
1244
|
+
// The paired half RIGHT HERE: the scorecard is added — the check falls silent.
|
|
1245
|
+
writeFileSync(
|
|
1246
|
+
join(paper, "PIPELINE-STATUS.md"),
|
|
1247
|
+
"---\nstages: []\n---\n# S\n",
|
|
1248
|
+
);
|
|
1249
|
+
const after = await cli(["lint"], root);
|
|
1250
|
+
check(
|
|
1251
|
+
"the scorecard was added — the structural complaint is gone",
|
|
1252
|
+
!/missing `PIPELINE-STATUS\.md`/.test(after.out),
|
|
1253
|
+
);
|
|
1254
|
+
} finally {
|
|
1255
|
+
rmSync(root, { recursive: true, force: true });
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// ── THE PAPERS DO NOT HAVE TO LIVE UNDER THE CURRENT DIRECTORY (#48) ─────────────────────
|
|
1260
|
+
//
|
|
1261
|
+
// 🔴 ESLint IGNORES EVERY FILE OUTSIDE ITS `cwd`. With `cwd` left at the default, `paperlint lint
|
|
1262
|
+
// /some/other/papers` threw `all-matched-files-ignored` and leaked a raw stack trace, while the
|
|
1263
|
+
// same tree linted fine from inside. The property is not "does not crash" — a catch would give
|
|
1264
|
+
// that — but "the SAME findings wherever the command is typed", so the assertion compares the
|
|
1265
|
+
// two runs finding by finding, ignoring only the path each run prints.
|
|
1266
|
+
//
|
|
1267
|
+
// Second half: the typography debt is keyed by the paper's path FROM THE CONFIG'S DIRECTORY
|
|
1268
|
+
// (`"papers/p"`). Keys are computed against ESLint's `cwd`, so a run started from a
|
|
1269
|
+
// subdirectory, or with `--config` from elsewhere, silently stopped honouring the debt.
|
|
1270
|
+
{
|
|
1271
|
+
const findings = (r) => {
|
|
1272
|
+
try {
|
|
1273
|
+
return JSON.parse(r.stdout)
|
|
1274
|
+
.flatMap((x) => x.messages.map((m) => `${m.ruleId}: ${m.message}`))
|
|
1275
|
+
.sort();
|
|
1276
|
+
} catch {
|
|
1277
|
+
return [`UNPARSABLE: ${r.out}`];
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
const tree = realpathSync(mkdtempSync(join(tmpdir(), "rpp-outside-")));
|
|
1281
|
+
const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), "rpp-elsewhere-")));
|
|
1282
|
+
try {
|
|
1283
|
+
const paper = join(tree, "papers", "p");
|
|
1284
|
+
mkdirSync(paper, { recursive: true });
|
|
1285
|
+
// Two findings of different kinds: a structural one (no scorecard) and a rule one (`§`).
|
|
1286
|
+
writeFileSync(join(paper, "paper.md"), "# T\n\nSee § 3 and §4.\n");
|
|
1287
|
+
|
|
1288
|
+
const inside = await cli(["lint", "--json", "papers"], tree);
|
|
1289
|
+
const outside = await cli(
|
|
1290
|
+
["lint", "--json", join(tree, "papers")],
|
|
1291
|
+
elsewhere,
|
|
1292
|
+
);
|
|
1293
|
+
check(
|
|
1294
|
+
"linting a tree OUTSIDE the current directory does not throw",
|
|
1295
|
+
outside.code !== 99 &&
|
|
1296
|
+
!/all-matched-files-ignored|THREW/.test(outside.out),
|
|
1297
|
+
);
|
|
1298
|
+
check(
|
|
1299
|
+
"the inside run is the reference: it has both a structure and a rule finding",
|
|
1300
|
+
findings(inside).some((f) => f.startsWith("structure/required-file")) &&
|
|
1301
|
+
findings(inside).some((f) => f.startsWith("paper/typography")),
|
|
1302
|
+
);
|
|
1303
|
+
check(
|
|
1304
|
+
"and from outside it reports the SAME findings with the SAME exit code",
|
|
1305
|
+
JSON.stringify(findings(outside)) === JSON.stringify(findings(inside)) &&
|
|
1306
|
+
outside.code === inside.code,
|
|
1307
|
+
);
|
|
1308
|
+
|
|
1309
|
+
// The debt, keyed from the config's directory, covers the two `§`.
|
|
1310
|
+
writeFileSync(
|
|
1311
|
+
join(tree, "package.json"),
|
|
1312
|
+
JSON.stringify({
|
|
1313
|
+
name: "x",
|
|
1314
|
+
paperlint: {
|
|
1315
|
+
[PAPERS_DIR_FIELD]: "papers",
|
|
1316
|
+
typographyDebt: { "papers/p": { sectionSign: 2 } },
|
|
1317
|
+
},
|
|
1318
|
+
}),
|
|
1319
|
+
);
|
|
1320
|
+
const debtHonoured = (r) =>
|
|
1321
|
+
r.code !== 99 &&
|
|
1322
|
+
!findings(r).some((f) => f.startsWith("paper/typography"));
|
|
1323
|
+
check(
|
|
1324
|
+
"from the config's own directory the declared debt silences the `§` finding",
|
|
1325
|
+
debtHonoured(await cli(["lint", "--json"], tree)),
|
|
1326
|
+
);
|
|
1327
|
+
check(
|
|
1328
|
+
"from a SUBDIRECTORY (config found by walking up) the same debt still applies",
|
|
1329
|
+
debtHonoured(await cli(["lint", "--json"], join(tree, "papers"))),
|
|
1330
|
+
);
|
|
1331
|
+
check(
|
|
1332
|
+
"and with `--config` from an unrelated directory it applies too",
|
|
1333
|
+
debtHonoured(
|
|
1334
|
+
await cli(
|
|
1335
|
+
["lint", "--json", "--config", join(tree, "package.json")],
|
|
1336
|
+
elsewhere,
|
|
1337
|
+
),
|
|
1338
|
+
),
|
|
1339
|
+
);
|
|
1340
|
+
// A RELATIVE `--config` stays relative in `configPath`, while every paper path is absolute:
|
|
1341
|
+
// without resolving it first, the common root of `.` and `/tmp/…` is `/`, the debt keys
|
|
1342
|
+
// no longer match, and the declared debt comes back as new warnings (Codex on #45).
|
|
1343
|
+
check(
|
|
1344
|
+
"and with a RELATIVE `--config package.json` it applies too",
|
|
1345
|
+
debtHonoured(
|
|
1346
|
+
await cli(["lint", "--json", "--config", "package.json"], tree),
|
|
1347
|
+
),
|
|
1348
|
+
);
|
|
1349
|
+
} finally {
|
|
1350
|
+
rmSync(tree, { recursive: true, force: true });
|
|
1351
|
+
rmSync(elsewhere, { recursive: true, force: true });
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// ── `paperlint hook` — THE RUNTIME RESOLVES FROM THE PACKAGE, NOT FROM THE PROJECT ROOT ────────
|
|
1356
|
+
//
|
|
1357
|
+
// 🔴 The measurement that gave rise to this command: one tarball, two package managers.
|
|
1358
|
+
// npm: node_modules/vigiles/dist/cli.js EXISTS
|
|
1359
|
+
// pnpm: node_modules/vigiles/dist/cli.js DOES NOT
|
|
1360
|
+
// The old wiring addressed the runtime from the project root and did not resolve under pnpm,
|
|
1361
|
+
// and `|| exit 2` on PreToolUse(Bash) turned that into a block on ANY command. The end-to-end
|
|
1362
|
+
// half (both installs, real processes) lives in `test/e2e/install.mjs`; here — the verdicts.
|
|
1363
|
+
{
|
|
1364
|
+
const calls = [];
|
|
1365
|
+
const fake = (code) => (bin, args, opts) => {
|
|
1366
|
+
calls.push({ bin, args, opts });
|
|
1367
|
+
return { status: code };
|
|
1368
|
+
};
|
|
1369
|
+
let said = "";
|
|
1370
|
+
const err = (...a) => {
|
|
1371
|
+
said += a.join(" ") + "\n";
|
|
1372
|
+
};
|
|
1373
|
+
|
|
1374
|
+
said = "";
|
|
1375
|
+
check(
|
|
1376
|
+
"no name — a failure, and the right invocation is suggested",
|
|
1377
|
+
runHook(undefined, { err }) === 2 &&
|
|
1378
|
+
/paperlint hook paper-edit-guard/.test(said),
|
|
1379
|
+
);
|
|
1380
|
+
|
|
1381
|
+
said = "";
|
|
1382
|
+
check(
|
|
1383
|
+
"an unknown hook is NAMED together with the path it was looked for at",
|
|
1384
|
+
runHook("no-such-hook", { err }) === 2 &&
|
|
1385
|
+
/unknown hook `no-such-hook`/.test(said) &&
|
|
1386
|
+
/no-such-hook\.hook\.mjs/.test(said),
|
|
1387
|
+
);
|
|
1388
|
+
|
|
1389
|
+
// 🔴 THE MAIN ASSERTION. A runtime that cannot be found HAS NO RIGHT to return 2: on
|
|
1390
|
+
// PreToolUse that blocks any Bash command, including the one that would fix it. It must say
|
|
1391
|
+
// so LOUDLY and let it through. Silent degradation is worse than an explicit one, but blocking
|
|
1392
|
+
// everything is worse than both.
|
|
1393
|
+
said = "";
|
|
1394
|
+
calls.length = 0;
|
|
1395
|
+
const brokenResolve = () => {
|
|
1396
|
+
throw new Error("Cannot find module 'vigiles/dist/cli.js'");
|
|
1397
|
+
};
|
|
1398
|
+
const code = runHook("paper-edit-guard", {
|
|
1399
|
+
err,
|
|
1400
|
+
run: fake(0),
|
|
1401
|
+
resolve: brokenResolve,
|
|
1402
|
+
});
|
|
1403
|
+
check(
|
|
1404
|
+
"the runtime does not resolve — work is NOT blocked (code 0, not 2)",
|
|
1405
|
+
code === 0,
|
|
1406
|
+
);
|
|
1407
|
+
check(
|
|
1408
|
+
"and the complaint is LOUD: the hook is named, the effect is named, the remedy is named",
|
|
1409
|
+
/paper-edit-guard/.test(said) &&
|
|
1410
|
+
/is NOT running/.test(said) &&
|
|
1411
|
+
/Reinstall this package/.test(said),
|
|
1412
|
+
);
|
|
1413
|
+
check("and the runtime was NOT started in the process", calls.length === 0);
|
|
1414
|
+
|
|
1415
|
+
// The hook's real verdict passes through — otherwise the guard stops being a guard.
|
|
1416
|
+
calls.length = 0;
|
|
1417
|
+
check(
|
|
1418
|
+
"the hook's verdict passes STRAIGHT THROUGH: 2 stays 2",
|
|
1419
|
+
runHook("paper-edit-guard", { err, run: fake(2) }) === 2,
|
|
1420
|
+
);
|
|
1421
|
+
check(
|
|
1422
|
+
"and EXACTLY the runtime with this hook's program is run",
|
|
1423
|
+
calls.length === 1 &&
|
|
1424
|
+
calls[0].args[1] === "hook-runtime" &&
|
|
1425
|
+
calls[0].args[2] === "run-program" &&
|
|
1426
|
+
/paper-edit-guard\.hook\.mjs$/.test(calls[0].args[3]),
|
|
1427
|
+
);
|
|
1428
|
+
check(
|
|
1429
|
+
"zero stays zero",
|
|
1430
|
+
runHook("paper-skills-nudge", { err, run: fake(0) }) === 0,
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// ── RUNNING THROUGH A SYMLINK — the only way a consumer ever calls the utility ───────
|
|
1435
|
+
//
|
|
1436
|
+
// 🔴 npm puts a SYMLINK in `node_modules/.bin/`. The first version compared `import.meta.url`
|
|
1437
|
+
// against `file://${process.argv[1]}`: for a symlink those two paths are DIFFERENT, the
|
|
1438
|
+
// condition is false, and the utility silently exited zero. A direct `node bin/rpp.mjs` worked
|
|
1439
|
+
// fine, meanwhile — i.e. the defect was invisible in exactly the way it is normally checked.
|
|
1440
|
+
{
|
|
1441
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "rpp-link-")));
|
|
1442
|
+
try {
|
|
1443
|
+
const link = join(root, "rpp-shim");
|
|
1444
|
+
symlinkSync(join(HERE, "..", "bin", "rpp.mjs"), link);
|
|
1445
|
+
const paper = join(root, "papers", "p");
|
|
1446
|
+
mkdirSync(join(paper, "versions"), { recursive: true });
|
|
1447
|
+
writeFileSync(
|
|
1448
|
+
join(paper, "PIPELINE-STATUS.md"),
|
|
1449
|
+
"---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/a.pdf\n bytes: 1\n---\n# S\n",
|
|
1450
|
+
);
|
|
1451
|
+
const r = spawnSync(process.execPath, [link, "lint", "papers"], {
|
|
1452
|
+
cwd: root,
|
|
1453
|
+
encoding: "utf8",
|
|
1454
|
+
});
|
|
1455
|
+
const out = (r.stdout ?? "") + (r.stderr ?? "");
|
|
1456
|
+
check(
|
|
1457
|
+
"through a SYMLINK the utility works, rather than silently exiting zero",
|
|
1458
|
+
r.status === 1,
|
|
1459
|
+
);
|
|
1460
|
+
check("and prints findings", /paper\/stages/.test(out));
|
|
1461
|
+
} finally {
|
|
1462
|
+
rmSync(root, { recursive: true, force: true });
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
console.log(
|
|
1467
|
+
`✓ ${String(n)} assertions passed — paperlint lint, one command instead of hand-rolled config`,
|
|
1468
|
+
);
|
|
1469
|
+
|
|
1470
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1471
|
+
// What is left after init is typed in THE SAME TERMINAL. The two `/plugin` lines are gone: the
|
|
1472
|
+
// hooks are written into `.claude/settings.json` by init itself (hooks-settings.harness.mjs).
|
|
1473
|
+
|
|
1474
|
+
{
|
|
1475
|
+
const steps = nextSteps("papers");
|
|
1476
|
+
check(
|
|
1477
|
+
"🔴 no /plugin line is left — nothing is typed into another program any more",
|
|
1478
|
+
!steps.includes("/plugin"),
|
|
1479
|
+
);
|
|
1480
|
+
check("and asks for NO manual install", !/npm i -D vigiles/.test(steps));
|
|
1481
|
+
check(
|
|
1482
|
+
"it names the two commands that ARE left: new and lint",
|
|
1483
|
+
/npx paperlint new <name>/.test(steps) && /npx paperlint lint/.test(steps),
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// ── the mode decision: a human is at the other end only when EVERY signal says so ──────────
|
|
1488
|
+
{
|
|
1489
|
+
const { interactivity } = await import(join(HERE, "init.ts"));
|
|
1490
|
+
const tty = { stdinTTY: true, stdoutTTY: true, env: {}, yes: false };
|
|
1491
|
+
check(
|
|
1492
|
+
"a terminal on stdin AND stdout, no CI, no --yes → interactive",
|
|
1493
|
+
interactivity(tty).interactive === true,
|
|
1494
|
+
);
|
|
1495
|
+
for (const [label, over, why] of [
|
|
1496
|
+
["--yes", { yes: true }, "--yes was given"],
|
|
1497
|
+
["CI set", { env: { CI: "true" } }, "CI is set"],
|
|
1498
|
+
["stdin piped", { stdinTTY: false }, "stdin is not a terminal"],
|
|
1499
|
+
[
|
|
1500
|
+
"🔴 stdout piped — an agent reading the output would never see the question",
|
|
1501
|
+
{ stdoutTTY: false },
|
|
1502
|
+
"stdout is not a terminal",
|
|
1503
|
+
],
|
|
1504
|
+
]) {
|
|
1505
|
+
const m = interactivity({ ...tty, ...over });
|
|
1506
|
+
check(
|
|
1507
|
+
`${label} → NOT interactive, and the reason is named`,
|
|
1508
|
+
m.interactive === false && m.why === why,
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
check(
|
|
1512
|
+
"an empty CI variable is not CI",
|
|
1513
|
+
interactivity({ ...tty, env: { CI: "" } }).interactive === true,
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// ── the new flags parse, and none of them becomes the directory argument ─────────────────
|
|
1518
|
+
{
|
|
1519
|
+
const a = parseArgs([
|
|
1520
|
+
"init",
|
|
1521
|
+
"--yes",
|
|
1522
|
+
"--no-hooks",
|
|
1523
|
+
"--paper",
|
|
1524
|
+
"demo",
|
|
1525
|
+
"--format",
|
|
1526
|
+
"md",
|
|
1527
|
+
]);
|
|
1528
|
+
check(
|
|
1529
|
+
"init flags parse into fields, not into paths",
|
|
1530
|
+
a.yes &&
|
|
1531
|
+
a.noHooks &&
|
|
1532
|
+
a.paper === "demo" &&
|
|
1533
|
+
a.format === "md" &&
|
|
1534
|
+
a.paths.length === 0,
|
|
1535
|
+
);
|
|
1536
|
+
check("-y is --yes", parseArgs(["init", "-y"]).yes === true);
|
|
1537
|
+
check(
|
|
1538
|
+
"`--paper` with its value taken away is a refusal, not a default",
|
|
1539
|
+
parseArgs(["init", "--paper"]).missingValue === "--paper",
|
|
1540
|
+
);
|
|
1541
|
+
const r = await cli(["init", "--hooks=local"]);
|
|
1542
|
+
check(
|
|
1543
|
+
"🔴 --hooks=local is NOT implemented, and it is refused by name rather than read as a path",
|
|
1544
|
+
r.code === 2 && /--hooks=local is not implemented/.test(r.out),
|
|
1545
|
+
);
|
|
1546
|
+
const f = await cli(["init", "--format", "pdf"]);
|
|
1547
|
+
check(
|
|
1548
|
+
"an unknown --format is refused before anything is written",
|
|
1549
|
+
f.code === 2 && /--format must be one of tex, md/.test(f.out),
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// ── init wires the hooks, and a human can say no ─────────────────────────────────────────
|
|
1554
|
+
{
|
|
1555
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "rpp-init-hooks-")));
|
|
1556
|
+
const mk = (name) => {
|
|
1557
|
+
const dir = join(root, name);
|
|
1558
|
+
mkdirSync(join(dir, "papers", "p1"), { recursive: true });
|
|
1559
|
+
writeFileSync(join(dir, "papers", "p1", "paper.md"), "# P\n");
|
|
1560
|
+
writeFileSync(
|
|
1561
|
+
join(dir, "papers", "p1", "PIPELINE-STATUS.md"),
|
|
1562
|
+
"---\n---\n",
|
|
1563
|
+
);
|
|
1564
|
+
writeFileSync(
|
|
1565
|
+
join(dir, "package.json"),
|
|
1566
|
+
'{"name":"c","version":"1.0.0"}\n',
|
|
1567
|
+
);
|
|
1568
|
+
return dir;
|
|
1569
|
+
};
|
|
1570
|
+
const settings = (dir) => join(dir, ".claude", "settings.json");
|
|
1571
|
+
const quiet = { log: () => {}, err: () => {}, run: () => ({ status: 0 }) };
|
|
1572
|
+
try {
|
|
1573
|
+
{
|
|
1574
|
+
const dir = mk("default-yes");
|
|
1575
|
+
const lines = [];
|
|
1576
|
+
await init(dir, {
|
|
1577
|
+
...quiet,
|
|
1578
|
+
log: (...a) => lines.push(a.join(" ")),
|
|
1579
|
+
interactive: false,
|
|
1580
|
+
});
|
|
1581
|
+
const text = lines.join("\n");
|
|
1582
|
+
check(
|
|
1583
|
+
"🔴 without a human the hooks default to YES — settings.json is written",
|
|
1584
|
+
existsSync(settings(dir)) &&
|
|
1585
|
+
/hook paper-edit-guard/.test(readFileSync(settings(dir), "utf8")),
|
|
1586
|
+
);
|
|
1587
|
+
check(
|
|
1588
|
+
"and the default names the flag that changes it",
|
|
1589
|
+
/default taken: YES — stdin is not a terminal, so nothing was asked\. `--no-hooks` skips this/.test(
|
|
1590
|
+
text,
|
|
1591
|
+
),
|
|
1592
|
+
);
|
|
1593
|
+
check(
|
|
1594
|
+
"🔴 and says that the commands cannot run in a fresh clone before `npm install`",
|
|
1595
|
+
/in a fresh clone they cannot run until `npm install` has run there/.test(
|
|
1596
|
+
text,
|
|
1597
|
+
),
|
|
1598
|
+
);
|
|
1599
|
+
check(
|
|
1600
|
+
"the /plugin lines are gone from init's output too",
|
|
1601
|
+
!/\/plugin (marketplace|install)/.test(text),
|
|
1602
|
+
);
|
|
1603
|
+
}
|
|
1604
|
+
{
|
|
1605
|
+
const dir = mk("no-hooks");
|
|
1606
|
+
await init(dir, { ...quiet, interactive: false, hooks: false });
|
|
1607
|
+
check("--no-hooks writes nothing", !existsSync(settings(dir)));
|
|
1608
|
+
}
|
|
1609
|
+
{
|
|
1610
|
+
const dir = mk("asked-no");
|
|
1611
|
+
const asked = [];
|
|
1612
|
+
await init(dir, {
|
|
1613
|
+
...quiet,
|
|
1614
|
+
interactive: true,
|
|
1615
|
+
ask: async (q) => {
|
|
1616
|
+
asked.push(q);
|
|
1617
|
+
return /\[Y\/n\]/.test(q) ? "n" : "";
|
|
1618
|
+
},
|
|
1619
|
+
});
|
|
1620
|
+
check(
|
|
1621
|
+
"a human is asked [Y/n] for the hooks",
|
|
1622
|
+
asked.some((q) => /wire the paper hooks .*\[Y\/n\]/.test(q)),
|
|
1623
|
+
);
|
|
1624
|
+
check("and a no is respected", !existsSync(settings(dir)));
|
|
1625
|
+
}
|
|
1626
|
+
{
|
|
1627
|
+
const dir = mk("asked-enter");
|
|
1628
|
+
await init(dir, {
|
|
1629
|
+
...quiet,
|
|
1630
|
+
interactive: true,
|
|
1631
|
+
ask: async () => "",
|
|
1632
|
+
});
|
|
1633
|
+
check(
|
|
1634
|
+
"an empty answer is the stated default, YES",
|
|
1635
|
+
existsSync(settings(dir)),
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
} finally {
|
|
1639
|
+
rmSync(root, { recursive: true, force: true });
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// ── init offers a first paper only where there is none, and only to a human ─────────────
|
|
1644
|
+
{
|
|
1645
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "rpp-init-paper-")));
|
|
1646
|
+
const bare = (name) => {
|
|
1647
|
+
const dir = join(root, name);
|
|
1648
|
+
mkdirSync(dir, { recursive: true });
|
|
1649
|
+
writeFileSync(
|
|
1650
|
+
join(dir, "package.json"),
|
|
1651
|
+
'{"name":"c","version":"1.0.0"}\n',
|
|
1652
|
+
);
|
|
1653
|
+
return dir;
|
|
1654
|
+
};
|
|
1655
|
+
const base = { err: () => {}, run: () => ({ status: 0 }), hooks: false };
|
|
1656
|
+
try {
|
|
1657
|
+
{
|
|
1658
|
+
const dir = bare("human");
|
|
1659
|
+
const asked = [];
|
|
1660
|
+
const made = [];
|
|
1661
|
+
await init(dir, {
|
|
1662
|
+
...base,
|
|
1663
|
+
log: () => {},
|
|
1664
|
+
interactive: true,
|
|
1665
|
+
ask: async (q) => {
|
|
1666
|
+
asked.push(q);
|
|
1667
|
+
if (/create a first paper/.test(q)) return "first";
|
|
1668
|
+
if (/format:/.test(q)) return "md";
|
|
1669
|
+
return "";
|
|
1670
|
+
},
|
|
1671
|
+
createPaper: async (papersRoot, name, format) => {
|
|
1672
|
+
made.push({ papersRoot, name, format });
|
|
1673
|
+
return 0;
|
|
1674
|
+
},
|
|
1675
|
+
});
|
|
1676
|
+
check(
|
|
1677
|
+
"a human with no paper is offered one, and the answer and format reach `paperlint new`'s routine",
|
|
1678
|
+
made.length === 1 &&
|
|
1679
|
+
made[0].name === "first" &&
|
|
1680
|
+
made[0].format === "md" &&
|
|
1681
|
+
made[0].papersRoot === join(dir, "papers"),
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
{
|
|
1685
|
+
const dir = bare("agent");
|
|
1686
|
+
const made = [];
|
|
1687
|
+
const lines = [];
|
|
1688
|
+
await init(dir, {
|
|
1689
|
+
...base,
|
|
1690
|
+
log: (...a) => lines.push(a.join(" ")),
|
|
1691
|
+
interactive: false,
|
|
1692
|
+
createPaper: async (...a) => (made.push(a), 0),
|
|
1693
|
+
});
|
|
1694
|
+
check(
|
|
1695
|
+
"🔴 without a human nothing is created, and the flag that would is named",
|
|
1696
|
+
made.length === 0 &&
|
|
1697
|
+
/`--paper <name>` creates one/.test(lines.join("\n")),
|
|
1698
|
+
);
|
|
1699
|
+
}
|
|
1700
|
+
{
|
|
1701
|
+
const dir = bare("flag");
|
|
1702
|
+
const made = [];
|
|
1703
|
+
await init(dir, {
|
|
1704
|
+
...base,
|
|
1705
|
+
log: () => {},
|
|
1706
|
+
interactive: false,
|
|
1707
|
+
paper: "given",
|
|
1708
|
+
createPaper: async (papersRoot, name, format) => (
|
|
1709
|
+
made.push({ name, format }),
|
|
1710
|
+
0
|
|
1711
|
+
),
|
|
1712
|
+
});
|
|
1713
|
+
check(
|
|
1714
|
+
"--paper creates it without a human, in the default format",
|
|
1715
|
+
made.length === 1 &&
|
|
1716
|
+
made[0].name === "given" &&
|
|
1717
|
+
made[0].format === "tex",
|
|
1718
|
+
);
|
|
1719
|
+
}
|
|
1720
|
+
{
|
|
1721
|
+
const dir = bare("has-one");
|
|
1722
|
+
mkdirSync(join(dir, "papers", "p1"), { recursive: true });
|
|
1723
|
+
writeFileSync(join(dir, "papers", "p1", "paper.md"), "# P\n");
|
|
1724
|
+
const asked = [];
|
|
1725
|
+
await init(dir, {
|
|
1726
|
+
...base,
|
|
1727
|
+
log: () => {},
|
|
1728
|
+
interactive: true,
|
|
1729
|
+
ask: async (q) => (asked.push(q), ""),
|
|
1730
|
+
createPaper: async () => 0,
|
|
1731
|
+
});
|
|
1732
|
+
check(
|
|
1733
|
+
"a papers directory that already holds a paper is not offered another",
|
|
1734
|
+
!asked.some((q) => /create a first paper/.test(q)),
|
|
1735
|
+
);
|
|
1736
|
+
}
|
|
1737
|
+
{
|
|
1738
|
+
// Codex on #55: a DECLARED papersDir with no paper in it yet is kept by the declaration
|
|
1739
|
+
// step, and every later step must use the kept value, not the directory init guessed.
|
|
1740
|
+
const dir = bare("declared");
|
|
1741
|
+
writeFileSync(
|
|
1742
|
+
join(dir, "package.json"),
|
|
1743
|
+
`{"name":"c","version":"1.0.0","paperlint":{"${PAPERS_DIR_FIELD}":"writing"}}\n`,
|
|
1744
|
+
);
|
|
1745
|
+
const made = [];
|
|
1746
|
+
await init(dir, {
|
|
1747
|
+
...base,
|
|
1748
|
+
log: () => {},
|
|
1749
|
+
interactive: false,
|
|
1750
|
+
paper: "first",
|
|
1751
|
+
createPaper: async (papersRoot, name) => (
|
|
1752
|
+
made.push({ papersRoot, name }),
|
|
1753
|
+
0
|
|
1754
|
+
),
|
|
1755
|
+
});
|
|
1756
|
+
check(
|
|
1757
|
+
"🔴 with papersDir already declared, --paper creates the paper IN the declared directory, not the guess",
|
|
1758
|
+
made.length === 1 && made[0].papersRoot === join(dir, "writing"),
|
|
1759
|
+
);
|
|
1760
|
+
}
|
|
1761
|
+
{
|
|
1762
|
+
// Codex on #55: an explicit --paper that is refused or whose lint fails must not end in
|
|
1763
|
+
// the doctor's exit code, which can be 0 — automation would read "done".
|
|
1764
|
+
// The doctor's own exit code is not injectable and is non-zero in a bare temp project, so
|
|
1765
|
+
// "non-zero" would pass on the old code too. The lint failure therefore returns a code the
|
|
1766
|
+
// doctor never produces (7), and the invalid name is compared against a baseline run.
|
|
1767
|
+
const baseline = await init(bare("baseline"), {
|
|
1768
|
+
...base,
|
|
1769
|
+
log: () => {},
|
|
1770
|
+
interactive: false,
|
|
1771
|
+
paper: "fine",
|
|
1772
|
+
createPaper: async () => 0,
|
|
1773
|
+
});
|
|
1774
|
+
const failed = await init(bare("lint-fails"), {
|
|
1775
|
+
...base,
|
|
1776
|
+
log: () => {},
|
|
1777
|
+
interactive: false,
|
|
1778
|
+
paper: "ok",
|
|
1779
|
+
createPaper: async () => 7,
|
|
1780
|
+
});
|
|
1781
|
+
check(
|
|
1782
|
+
"🔴 `init --paper <name>` whose lint fails exits with THAT code, not the doctor's",
|
|
1783
|
+
failed === 7,
|
|
1784
|
+
);
|
|
1785
|
+
const refused = await init(bare("refused"), {
|
|
1786
|
+
...base,
|
|
1787
|
+
log: () => {},
|
|
1788
|
+
interactive: false,
|
|
1789
|
+
paper: "Bad",
|
|
1790
|
+
createPaper: async () => 0,
|
|
1791
|
+
});
|
|
1792
|
+
check(
|
|
1793
|
+
`🔴 \`init --paper <invalid name>\` exits 2 (the same project with a valid name exits ${String(baseline)})`,
|
|
1794
|
+
refused === 2,
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1797
|
+
} finally {
|
|
1798
|
+
rmSync(root, { recursive: true, force: true });
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// ── `paperlint lint` does not sweep the project's paper TEMPLATE as a paper ────────────────────
|
|
1803
|
+
{
|
|
1804
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "rpp-template-")));
|
|
1805
|
+
try {
|
|
1806
|
+
writeFileSync(
|
|
1807
|
+
join(root, "package.json"),
|
|
1808
|
+
`{"paperlint":{"${PAPERS_DIR_FIELD}":"papers"}}\n`,
|
|
1809
|
+
);
|
|
1810
|
+
mkdirSync(join(root, "papers", "p1"), { recursive: true });
|
|
1811
|
+
writeFileSync(join(root, "papers", "p1", "paper.md"), "# P\n");
|
|
1812
|
+
writeFileSync(
|
|
1813
|
+
join(root, "papers", "p1", "PIPELINE-STATUS.md"),
|
|
1814
|
+
"---\n---\n",
|
|
1815
|
+
);
|
|
1816
|
+
// A template that WOULD fail if linted: a stage whose pdf does not exist.
|
|
1817
|
+
mkdirSync(join(root, "papers", ".template"), { recursive: true });
|
|
1818
|
+
writeFileSync(
|
|
1819
|
+
join(root, "papers", ".template", "PIPELINE-STATUS.md"),
|
|
1820
|
+
"---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/none.pdf\n bytes: 1\n---\n",
|
|
1821
|
+
);
|
|
1822
|
+
const r = await cli(["lint"], root);
|
|
1823
|
+
check(
|
|
1824
|
+
"🔴 a defective <papers>/.template/ does not fail the run — it is not a paper",
|
|
1825
|
+
r.code === 0 && !/\.template/.test(r.out),
|
|
1826
|
+
);
|
|
1827
|
+
// The other half: the same file in a real paper folder IS linted and fails.
|
|
1828
|
+
mkdirSync(join(root, "papers", "p2"), { recursive: true });
|
|
1829
|
+
writeFileSync(
|
|
1830
|
+
join(root, "papers", "p2", "PIPELINE-STATUS.md"),
|
|
1831
|
+
readFileSync(join(root, "papers", ".template", "PIPELINE-STATUS.md")),
|
|
1832
|
+
);
|
|
1833
|
+
writeFileSync(join(root, "papers", "p2", "paper.md"), "# P\n");
|
|
1834
|
+
const r2 = await cli(["lint"], root);
|
|
1835
|
+
check(
|
|
1836
|
+
"and the same scorecard in a real paper folder fails — the ignore is scoped, not a blind spot",
|
|
1837
|
+
r2.code === 1 && /paper\/stages/.test(r2.out),
|
|
1838
|
+
);
|
|
1839
|
+
} finally {
|
|
1840
|
+
rmSync(root, { recursive: true, force: true });
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// ── `paperlint new` through the CLI: the papers directory comes from the one declaration ────────
|
|
1845
|
+
{
|
|
1846
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "rpp-new-cli-")));
|
|
1847
|
+
try {
|
|
1848
|
+
const none = await cli(["new", "demo"], root);
|
|
1849
|
+
check(
|
|
1850
|
+
"without a declaration there is nowhere to put a paper — refused, with the remedy",
|
|
1851
|
+
none.code === 2 && /Run `npx paperlint init` first/.test(none.out),
|
|
1852
|
+
);
|
|
1853
|
+
writeFileSync(
|
|
1854
|
+
join(root, "package.json"),
|
|
1855
|
+
`{"paperlint":{"${PAPERS_DIR_FIELD}":"writing"}}\n`,
|
|
1856
|
+
);
|
|
1857
|
+
const r = await cli(["new", "demo"], root);
|
|
1858
|
+
check(
|
|
1859
|
+
"🔴 `paperlint new demo` creates writing/demo/ and its lint is the verdict — exit 0, no findings",
|
|
1860
|
+
r.code === 0 &&
|
|
1861
|
+
existsSync(join(root, "writing", "demo", "PIPELINE-STATUS.md")) &&
|
|
1862
|
+
existsSync(join(root, "writing", "demo", "paper.tex")) &&
|
|
1863
|
+
/no findings/.test(r.out),
|
|
1864
|
+
);
|
|
1865
|
+
const again = await cli(["new", "demo", "--format", "md"], root);
|
|
1866
|
+
check(
|
|
1867
|
+
"a second run on the same folder adds nothing — the tex source already stands",
|
|
1868
|
+
again.code === 0 &&
|
|
1869
|
+
!existsSync(join(root, "writing", "demo", "paper.md")) &&
|
|
1870
|
+
/kept — never overwritten/.test(again.out),
|
|
1871
|
+
);
|
|
1872
|
+
const bad = await cli(["new", "Demo"], root);
|
|
1873
|
+
check("an invalid name is refused with exit 2", bad.code === 2);
|
|
1874
|
+
// Compared by exact name, not existsSync: on a case-insensitive filesystem (macOS default)
|
|
1875
|
+
// `writing/Demo` "exists" because `writing/demo` does, so existsSync cannot tell them apart.
|
|
1876
|
+
check(
|
|
1877
|
+
"a refused name creates nothing — writing/ still holds only demo",
|
|
1878
|
+
JSON.stringify(readdirSync(join(root, "writing")).sort()) ===
|
|
1879
|
+
JSON.stringify(["demo"]),
|
|
1880
|
+
);
|
|
1881
|
+
const two = await cli(["new", "a", "b"], root);
|
|
1882
|
+
check("one paper at a time", two.code === 2);
|
|
1883
|
+
} finally {
|
|
1884
|
+
rmSync(root, { recursive: true, force: true });
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// ── the settings are parsed at the boundary: unknown keys, `rules` blocks ─────────────────
|
|
1889
|
+
{
|
|
1890
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "rpp-settings-")));
|
|
1891
|
+
try {
|
|
1892
|
+
const dir = join(root, "papers", "p");
|
|
1893
|
+
mkdirSync(dir, { recursive: true });
|
|
1894
|
+
writeFileSync(
|
|
1895
|
+
join(dir, "PIPELINE-STATUS.md"),
|
|
1896
|
+
"# Status\n\nStage: draft\n",
|
|
1897
|
+
);
|
|
1898
|
+
writeFileSync(
|
|
1899
|
+
join(dir, "paper.tex"),
|
|
1900
|
+
"\\documentclass{article}\n\\begin{document}x\\end{document}\n",
|
|
1901
|
+
);
|
|
1902
|
+
const settings = (rpp) =>
|
|
1903
|
+
writeFileSync(
|
|
1904
|
+
join(root, "package.json"),
|
|
1905
|
+
JSON.stringify({
|
|
1906
|
+
name: "c",
|
|
1907
|
+
version: "1.0.0",
|
|
1908
|
+
paperlint: { [PAPERS_DIR_FIELD]: "papers", ...rpp },
|
|
1909
|
+
}),
|
|
1910
|
+
);
|
|
1911
|
+
const balanceOn = (files) => ({
|
|
1912
|
+
rules: [{ files, rules: { "pdf/last-page-balance": "error" } }],
|
|
1913
|
+
});
|
|
1914
|
+
|
|
1915
|
+
settings({ typographyDept: {} });
|
|
1916
|
+
const typo = await cli(["lint"], root);
|
|
1917
|
+
check(
|
|
1918
|
+
"🔴 an unknown key is REFUSED by name — a typo must not read as 'not set'",
|
|
1919
|
+
typo.code === 2 &&
|
|
1920
|
+
typo.out.includes(
|
|
1921
|
+
'package.json → "paperlint": unknown key "typographyDept"',
|
|
1922
|
+
),
|
|
1923
|
+
);
|
|
1924
|
+
settings({
|
|
1925
|
+
ledger: "x.jsonl",
|
|
1926
|
+
citeChecks: "c",
|
|
1927
|
+
timezone: "UTC",
|
|
1928
|
+
contactEmail: "a@b.c",
|
|
1929
|
+
triggerCases: "t.mjs",
|
|
1930
|
+
scripts: "s",
|
|
1931
|
+
});
|
|
1932
|
+
check(
|
|
1933
|
+
"the keys the skill scripts read are known keys, not typos",
|
|
1934
|
+
(await cli(["lint"], root)).code !== 2,
|
|
1935
|
+
);
|
|
1936
|
+
settings({ rules: { "pdf/last-page-balance": "error" } });
|
|
1937
|
+
check(
|
|
1938
|
+
"`rules` that is not a list is refused, naming the key",
|
|
1939
|
+
/\.rules must be a list of blocks/.test((await cli(["lint"], root)).out),
|
|
1940
|
+
);
|
|
1941
|
+
settings({
|
|
1942
|
+
rules: [{ files: ["papers/**"], rules: { "pdf/nope": "error" } }],
|
|
1943
|
+
});
|
|
1944
|
+
const unknownRule = await cli(["lint"], root);
|
|
1945
|
+
check(
|
|
1946
|
+
"a rule paperlint does not ship is refused, naming the block and the rule",
|
|
1947
|
+
unknownRule.code === 2 &&
|
|
1948
|
+
unknownRule.out.includes(
|
|
1949
|
+
'.rules[0].rules: "pdf/nope" is not a rule paperlint ships',
|
|
1950
|
+
),
|
|
1951
|
+
);
|
|
1952
|
+
settings({
|
|
1953
|
+
rules: [
|
|
1954
|
+
{ files: ["papers/**"], rules: { "pdf/last-page-balance": "fatal" } },
|
|
1955
|
+
],
|
|
1956
|
+
});
|
|
1957
|
+
check(
|
|
1958
|
+
"a bad severity is refused, naming the rule",
|
|
1959
|
+
/rules\["pdf\/last-page-balance"\]: "fatal" is not a severity/.test(
|
|
1960
|
+
(await cli(["lint"], root)).out,
|
|
1961
|
+
),
|
|
1962
|
+
);
|
|
1963
|
+
settings({
|
|
1964
|
+
rules: [{ files: ["papers/**"], plugins: {}, rules: {} }],
|
|
1965
|
+
});
|
|
1966
|
+
check(
|
|
1967
|
+
"a block key other than files/ignores/rules is refused",
|
|
1968
|
+
/rules\[0\]: unknown key "plugins"/.test((await cli(["lint"], root)).out),
|
|
1969
|
+
);
|
|
1970
|
+
|
|
1971
|
+
settings(balanceOn(["papers/p/**"]));
|
|
1972
|
+
const on = await cli(["lint"], root);
|
|
1973
|
+
check(
|
|
1974
|
+
"🔴 an optional rule turned on by a `rules` block RUNS — here it asks for the build's facts",
|
|
1975
|
+
on.code === 1 &&
|
|
1976
|
+
/pdf\/last-page-balance/.test(on.out) &&
|
|
1977
|
+
/paperlint build/.test(on.out),
|
|
1978
|
+
);
|
|
1979
|
+
const fromInside = await cli(["lint"], dir);
|
|
1980
|
+
check(
|
|
1981
|
+
"`files` is relative to the file holding the settings, not to where lint runs",
|
|
1982
|
+
fromInside.code === 1 && /pdf\/last-page-balance/.test(fromInside.out),
|
|
1983
|
+
);
|
|
1984
|
+
settings(balanceOn(["paperz/**"]));
|
|
1985
|
+
const silent = await cli(["lint"], root);
|
|
1986
|
+
check(
|
|
1987
|
+
"🔴 an optional rule turned on for a glob that reaches no paper.tex FAILS the run — not a green zero",
|
|
1988
|
+
silent.code === 1 &&
|
|
1989
|
+
silent.out.includes(
|
|
1990
|
+
'pdf/last-page-balance is turned on in "rules", but no linted paper.tex gets it',
|
|
1991
|
+
),
|
|
1992
|
+
);
|
|
1993
|
+
settings({
|
|
1994
|
+
rules: [{ files: ["papers/**"], rules: { "paper/typography": "off" } }],
|
|
1995
|
+
});
|
|
1996
|
+
check(
|
|
1997
|
+
"a built-in rule's severity can be changed the same way",
|
|
1998
|
+
(await cli(["lint"], root)).code === 0,
|
|
1999
|
+
);
|
|
2000
|
+
} finally {
|
|
2001
|
+
rmSync(root, { recursive: true, force: true });
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
console.log(
|
|
2006
|
+
`✓ ${String(n)} assertions in total, including init's hooks and paperlint new`,
|
|
2007
|
+
);
|