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,1108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* verify-cites.mjs — deterministic, cacheable citation-existence gate.
|
|
4
|
+
*
|
|
5
|
+
* Provenance: design ported (reimplemented fresh — no source copied) from
|
|
6
|
+
* github.com/Imbad0202/academic-research-skills (CC BY-NC): the 4-DB lookup +
|
|
7
|
+
* narrowed-false reducer + DOI-misdirection. CVE / PoC-commit-SHA pinning design
|
|
8
|
+
* from github.com/euzun/security-paper-writing (MIT). Poach backlog for later
|
|
9
|
+
* rounds: papers/research/oss-skill-mining-imbad0202.md.
|
|
10
|
+
*
|
|
11
|
+
* Turns the LLM cite-check (SKILL.md, prose) into a reproducible artifact: for
|
|
12
|
+
* every citation it hits four real academic databases (Crossref, OpenAlex,
|
|
13
|
+
* Semantic Scholar, arXiv), DOI/arXiv-id first then a title search, and reduces
|
|
14
|
+
* the evidence to one of three verdicts:
|
|
15
|
+
*
|
|
16
|
+
* true — found, metadata matches (a resolver confirmed it)
|
|
17
|
+
* false — FABRICATION: a provided DOI/arXiv-id/CVE resolves to nothing,
|
|
18
|
+
* OR a DOI resolves to an UNRELATED real paper (DOI misdirection)
|
|
19
|
+
* unresolvable — could not find it by title, but no positive disproof.
|
|
20
|
+
* Explicitly NOT fabrication (a legit unindexed / regional /
|
|
21
|
+
* pre-digital paper looks exactly like this).
|
|
22
|
+
*
|
|
23
|
+
* The "narrowed-false" rule is the whole point: a title you simply can't find is
|
|
24
|
+
* NOT evidence of fabrication → unresolvable. Only a resolvable *identifier* that
|
|
25
|
+
* provably fails, or a DOI that lands on the wrong paper, is fabrication → false.
|
|
26
|
+
*
|
|
27
|
+
* Design ported (fresh original code, no source copied) from the OSS mining note
|
|
28
|
+
* papers/research/oss-skill-mining-imbad0202.md (STEAL #1), plus the
|
|
29
|
+
* CVE / PoC-SHA pinning discipline from oss-skill-mining-euzun.md (§4a).
|
|
30
|
+
*
|
|
31
|
+
* Architecture: the PURE reducer (reduceVerdict / classifyResolver / checkNvd /
|
|
32
|
+
* checkCommit + the string helpers) is separated from the live HTTP layer, so the
|
|
33
|
+
* verdict logic is unit-testable offline with injected fixtures — see
|
|
34
|
+
* verify-cites.test.mjs. The network layer only shapes raw API responses into the
|
|
35
|
+
* normalized evidence the pure functions consume.
|
|
36
|
+
*
|
|
37
|
+
* Usage:
|
|
38
|
+
* node verify-cites.mjs cites.json # JSON array of citation objects
|
|
39
|
+
* node verify-cites.mjs refs.bib # best-effort .bib extraction
|
|
40
|
+
* cat cites.json | node verify-cites.mjs - # from stdin
|
|
41
|
+
* node verify-cites.mjs cites.json --offline # no network (everything degrades)
|
|
42
|
+
*
|
|
43
|
+
* Citation object: { id, doi?, arxiv?, title?, authors?, year?, cve?, commit? }
|
|
44
|
+
* Output: JSON array of { id, verdict, reason, matched_db, matched_title?, flags? }
|
|
45
|
+
* + a summary line on stderr. Exit 1 iff any verdict is `false`.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
49
|
+
import { fileURLToPath } from "node:url";
|
|
50
|
+
import { dirname, join, extname } from "node:path";
|
|
51
|
+
import { createHash } from "node:crypto";
|
|
52
|
+
import { consumerContactEmail } from "../../paper-pipeline/scripts/consumer.mjs";
|
|
53
|
+
|
|
54
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
const CACHE_PATH = join(__dirname, ".cite-cache.json");
|
|
56
|
+
// 🔴 THE ADDRESS IS THE CONSUMER'S, NOT OURS. Crossref's "polite pool" keys off this `mailto:`:
|
|
57
|
+
// it decides who gets the faster tier and, more to the point, WHOM THEY WARN before blocking.
|
|
58
|
+
// Hard-coded, it pointed at one person for every user of this package — so the warnings would
|
|
59
|
+
// reach someone who cannot act on them while the actual caller heard nothing. Undeclared, we
|
|
60
|
+
// send no `mailto:` and land in the public pool: slower, never wrong.
|
|
61
|
+
const CONTACT = consumerContactEmail();
|
|
62
|
+
const USER_AGENT = `verify-cites/1.0 (citation gate${CONTACT ? `; mailto:${CONTACT}` : ""})`;
|
|
63
|
+
const TIMEOUT_MS = 15000;
|
|
64
|
+
const TITLE_THRESHOLD = 0.7; // Levenshtein-normalized similarity
|
|
65
|
+
const YEAR_TOLERANCE = 1; // ±1
|
|
66
|
+
|
|
67
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
68
|
+
// PURE LAYER — no I/O. Everything below is unit-testable with plain fixtures.
|
|
69
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/** Classic Levenshtein edit distance (own implementation, two-row DP). */
|
|
72
|
+
export function levenshtein(a, b) {
|
|
73
|
+
a = a ?? "";
|
|
74
|
+
b = b ?? "";
|
|
75
|
+
if (a === b) return 0;
|
|
76
|
+
if (a.length === 0) return b.length;
|
|
77
|
+
if (b.length === 0) return a.length;
|
|
78
|
+
let prev = new Array(b.length + 1);
|
|
79
|
+
let cur = new Array(b.length + 1);
|
|
80
|
+
for (let j = 0; j <= b.length; j++) prev[j] = j;
|
|
81
|
+
for (let i = 1; i <= a.length; i++) {
|
|
82
|
+
cur[0] = i;
|
|
83
|
+
const ca = a.charCodeAt(i - 1);
|
|
84
|
+
for (let j = 1; j <= b.length; j++) {
|
|
85
|
+
const cost = ca === b.charCodeAt(j - 1) ? 0 : 1;
|
|
86
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
|
|
87
|
+
}
|
|
88
|
+
[prev, cur] = [cur, prev];
|
|
89
|
+
}
|
|
90
|
+
return prev[b.length];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Normalize a title for comparison: lowercase, strip diacritics + punctuation,
|
|
95
|
+
* collapse whitespace. PRESERVES unicode letters/numbers (\p{L}\p{N}) so that
|
|
96
|
+
* Cyrillic / CJK / Greek titles keep their content — an ASCII-only normalizer
|
|
97
|
+
* collapses every non-Latin title to "" and then scores two DIFFERENT non-Latin
|
|
98
|
+
* titles as identical (S1: a fabricated non-Latin cite would pass as `true`).
|
|
99
|
+
* A title that has NO letters/digits at all (pure punctuation/emoji) still
|
|
100
|
+
* normalizes to "" — callers must treat "" as INCOMPARABLE, never as a match.
|
|
101
|
+
*/
|
|
102
|
+
export function normalizeTitle(t) {
|
|
103
|
+
return (t ?? "")
|
|
104
|
+
.toLowerCase()
|
|
105
|
+
.normalize("NFKD")
|
|
106
|
+
.replace(/\p{M}+/gu, "") // strip combining marks (diacritics)
|
|
107
|
+
.replace(/[^\p{L}\p{N}\s]/gu, " ") // keep unicode letters/numbers, drop punct
|
|
108
|
+
.replace(/\s+/g, " ")
|
|
109
|
+
.trim();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Normalized similarity in [0,1]: 1 - dist/maxLen over normalized titles.
|
|
114
|
+
* If EITHER side normalizes to "" (incomparable) → 0, NEVER 1 (two empties are
|
|
115
|
+
* not "identical", they are un-judgeable — see normalizeTitle / S1).
|
|
116
|
+
*/
|
|
117
|
+
export function titleSimilarity(a, b) {
|
|
118
|
+
const na = normalizeTitle(a);
|
|
119
|
+
const nb = normalizeTitle(b);
|
|
120
|
+
if (!na || !nb) return 0;
|
|
121
|
+
const maxLen = Math.max(na.length, nb.length);
|
|
122
|
+
return 1 - levenshtein(na, nb) / maxLen;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Two titles "match" iff normalized similarity ≥ threshold (default 0.70). */
|
|
126
|
+
export function titlesMatch(a, b, threshold = TITLE_THRESHOLD) {
|
|
127
|
+
return titleSimilarity(a, b) >= threshold;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Normalize a DOI: strip a `doi:` / resolver-URL prefix and any TRAILING sentence
|
|
131
|
+
* punctuation a .bib or prose extractor swallowed. A trailing `.` makes doi.org
|
|
132
|
+
* return responseCode 100, so an unnormalized real DOI reads as fabrication (the
|
|
133
|
+
* gate's own parser must never manufacture a false `false`). */
|
|
134
|
+
export function normalizeDoi(doi) {
|
|
135
|
+
if (!doi) return doi;
|
|
136
|
+
return String(doi)
|
|
137
|
+
.trim()
|
|
138
|
+
.replace(/^doi:\s*/i, "")
|
|
139
|
+
.replace(/^https?:\/\/(dx\.)?doi\.org\//i, "")
|
|
140
|
+
.replace(/[.,;:]+$/, "")
|
|
141
|
+
.trim();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Normalize an arXiv id: strip an `arXiv:` prefix + surrounding punctuation, so a
|
|
145
|
+
* bib-styled `arXiv:1706.03762` reaches the API as a bare id (else the arXiv API
|
|
146
|
+
* returns an entry-less feed → authoritative not-found → a wrong `false`). */
|
|
147
|
+
export function normalizeArxiv(arxiv) {
|
|
148
|
+
if (!arxiv) return arxiv;
|
|
149
|
+
return String(arxiv)
|
|
150
|
+
.trim()
|
|
151
|
+
.replace(/^arxiv:\s*/i, "")
|
|
152
|
+
.replace(/[.,;:]+$/, "")
|
|
153
|
+
.trim();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Shallow copy of a citation with doi/arxiv identifiers normalized. Applied at the
|
|
157
|
+
* verify boundary so BOTH JSON input and .bib-parsed input are clean before any
|
|
158
|
+
* network / authority lookup. */
|
|
159
|
+
export function normalizeIdentifiers(citation) {
|
|
160
|
+
const c = { ...citation };
|
|
161
|
+
if (c.doi) c.doi = normalizeDoi(c.doi);
|
|
162
|
+
if (c.arxiv) c.arxiv = normalizeArxiv(c.arxiv);
|
|
163
|
+
return c;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Short function words that carry no discriminative signal for token-overlap.
|
|
167
|
+
const STOP_TOKENS = new Set([
|
|
168
|
+
"the",
|
|
169
|
+
"a",
|
|
170
|
+
"an",
|
|
171
|
+
"of",
|
|
172
|
+
"and",
|
|
173
|
+
"for",
|
|
174
|
+
"to",
|
|
175
|
+
"in",
|
|
176
|
+
"on",
|
|
177
|
+
"is",
|
|
178
|
+
"are",
|
|
179
|
+
"with",
|
|
180
|
+
"via",
|
|
181
|
+
"using",
|
|
182
|
+
"from",
|
|
183
|
+
"by",
|
|
184
|
+
"at",
|
|
185
|
+
"as",
|
|
186
|
+
"or",
|
|
187
|
+
"be",
|
|
188
|
+
"we",
|
|
189
|
+
"our",
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
/** Content tokens of a normalized title (drop stopwords + 1-char noise). */
|
|
193
|
+
function contentTokens(norm) {
|
|
194
|
+
return norm.split(" ").filter((w) => w.length >= 2 && !STOP_TOKENS.has(w));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Does `base` spell the initials of some run of consecutive `words`?
|
|
198
|
+
* ("cnn" ↔ ["convolutional","neural","networks"]). Handles acronym ↔ expansion. */
|
|
199
|
+
function isAcronymOf(base, words) {
|
|
200
|
+
if (base.length < 2) return false;
|
|
201
|
+
for (let i = 0; i < words.length; i++) {
|
|
202
|
+
let acr = "";
|
|
203
|
+
for (let j = i; j < words.length && acr.length < base.length; j++) {
|
|
204
|
+
acr += words[j][0] || "";
|
|
205
|
+
}
|
|
206
|
+
if (acr === base) return true;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Count tokens of the SMALLER set present in the LARGER set — directly, as a
|
|
212
|
+
* depluralized form, or as an acronym expansion — for a containment ratio. */
|
|
213
|
+
function tokenOverlap(small, large) {
|
|
214
|
+
const setL = new Set(large);
|
|
215
|
+
let count = 0;
|
|
216
|
+
for (const t of small) {
|
|
217
|
+
const base = t.replace(/s$/, "");
|
|
218
|
+
if (setL.has(t) || setL.has(base) || isAcronymOf(base, large)) count++;
|
|
219
|
+
}
|
|
220
|
+
return count;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* titleRelation — the ROBUST title comparison for identifier verification.
|
|
225
|
+
* Returns 'match' | 'different' | 'incomparable'. Beyond raw Levenshtein it adds
|
|
226
|
+
* token-set containment so a split subtitle, an abbreviation/acronym, or a
|
|
227
|
+
* preprint→published drift does NOT read as an unrelated paper (M2/M3). If either
|
|
228
|
+
* title is empty after normalization (e.g. non-Latin vs Latin) it is INCOMPARABLE
|
|
229
|
+
* — we cannot assert the titles are unrelated, so we never say 'different'.
|
|
230
|
+
*/
|
|
231
|
+
/** Which alphabets/scripts appear in a normalized string (digits are neutral). */
|
|
232
|
+
function scriptsOf(s) {
|
|
233
|
+
const set = new Set();
|
|
234
|
+
if (/[a-z]/.test(s)) set.add("latin");
|
|
235
|
+
if (/[Ѐ-ӿ]/.test(s)) set.add("cyrillic");
|
|
236
|
+
if (/[-ヿ㐀-鿿가-]/.test(s)) set.add("cjk");
|
|
237
|
+
if (/[Ͱ-Ͽ]/.test(s)) set.add("greek");
|
|
238
|
+
if (/[-ۿ]/.test(s)) set.add("arabic");
|
|
239
|
+
return set;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function titleRelation(a, b) {
|
|
243
|
+
const na = normalizeTitle(a);
|
|
244
|
+
const nb = normalizeTitle(b);
|
|
245
|
+
if (!na || !nb) return "incomparable";
|
|
246
|
+
// Cross-script titles (e.g. Cyrillic claimed vs Latin record) are NOT char-
|
|
247
|
+
// comparable: a non-Latin work's DOI often records a translated/transliterated
|
|
248
|
+
// title, so a character mismatch is NOT evidence of an unrelated paper (M3). If
|
|
249
|
+
// the two share no script, we cannot assert 'different' → incomparable.
|
|
250
|
+
const sa = scriptsOf(na);
|
|
251
|
+
const sb = scriptsOf(nb);
|
|
252
|
+
if (sa.size && sb.size && ![...sa].some((x) => sb.has(x)))
|
|
253
|
+
return "incomparable";
|
|
254
|
+
const sim = 1 - levenshtein(na, nb) / Math.max(na.length, nb.length);
|
|
255
|
+
if (sim >= TITLE_THRESHOLD) return "match";
|
|
256
|
+
const A = contentTokens(na);
|
|
257
|
+
const B = contentTokens(nb);
|
|
258
|
+
if (A.length && B.length) {
|
|
259
|
+
const [small, large] = A.length <= B.length ? [A, B] : [B, A];
|
|
260
|
+
const contain = tokenOverlap(small, large) / small.length;
|
|
261
|
+
if (contain >= 0.6) return "match"; // subtitle split / abbrev / drift
|
|
262
|
+
}
|
|
263
|
+
return "different";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Candidate title strings for a resolved record — the plain title and, when the
|
|
267
|
+
* record splits a subtitle into its own field (Crossref), the concatenation. */
|
|
268
|
+
export function candidateTitles(record) {
|
|
269
|
+
const t = (record.title || "").trim();
|
|
270
|
+
const out = [t];
|
|
271
|
+
if (record.subtitle) {
|
|
272
|
+
const sub = String(record.subtitle).trim();
|
|
273
|
+
out.push(`${t} ${sub}`.trim(), `${t}: ${sub}`.trim());
|
|
274
|
+
}
|
|
275
|
+
return out.filter(Boolean);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Best relation between a claimed title and any candidate rendering of a record.
|
|
279
|
+
* 'match' if any candidate matches; 'incomparable' if EVERY comparison was
|
|
280
|
+
* incomparable (claimed title un-normalizable); otherwise 'different'. */
|
|
281
|
+
export function bestTitleRelation(claimed, candidates) {
|
|
282
|
+
let sawComparable = false;
|
|
283
|
+
for (const cand of candidates) {
|
|
284
|
+
const rel = titleRelation(claimed, cand);
|
|
285
|
+
if (rel === "match") return "match";
|
|
286
|
+
if (rel !== "incomparable") sawComparable = true;
|
|
287
|
+
}
|
|
288
|
+
return sawComparable ? "different" : "incomparable";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Year match: within ±tolerance. Missing on either side → not disqualifying. */
|
|
292
|
+
export function yearMatch(claimed, found, tolerance = YEAR_TOLERANCE) {
|
|
293
|
+
const c = toYear(claimed);
|
|
294
|
+
const f = toYear(found);
|
|
295
|
+
if (c == null || f == null) return true;
|
|
296
|
+
return Math.abs(c - f) <= tolerance;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function toYear(y) {
|
|
300
|
+
if (y == null) return null;
|
|
301
|
+
const n = typeof y === "number" ? y : parseInt(String(y).slice(0, 4), 10);
|
|
302
|
+
return Number.isFinite(n) ? n : null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* classifyResolver — PURE. Turn one resolver's normalized response into a
|
|
307
|
+
* per-resolver evidence status. This is the heart of the narrowed-false logic.
|
|
308
|
+
*
|
|
309
|
+
* response shape (produced by the network layer OR by a test fixture):
|
|
310
|
+
* {
|
|
311
|
+
* db: 'crossref' | 'openalex' | 'semantic_scholar' | 'arxiv',
|
|
312
|
+
* transport:'ok' | 'error', // 'error' = network/timeout/rate-limit
|
|
313
|
+
* query: 'doi' | 'arxiv' | 'title',
|
|
314
|
+
* record: { title, year } | null, // for id lookups: resolved record or null
|
|
315
|
+
* records: [{ title, year }] | null, // for title searches: hits (may be [])
|
|
316
|
+
* }
|
|
317
|
+
*
|
|
318
|
+
* returns one of:
|
|
319
|
+
* { db, status:'matched', matchedTitle, matchedYear }
|
|
320
|
+
* { db, status:'doi_mismatch', foundTitle } // DOI landed on unrelated paper
|
|
321
|
+
* { db, status:'id_unmatched', query } // provided id resolved to nothing
|
|
322
|
+
* { db, status:'title_miss' } // title search found no ≥0.70 hit
|
|
323
|
+
* { db, status:'unreachable' } // transport failure — NOT disproof
|
|
324
|
+
*/
|
|
325
|
+
export function classifyResolver(citation, response) {
|
|
326
|
+
const db = response.db;
|
|
327
|
+
if (response.transport === "error") return { db, status: "unreachable" };
|
|
328
|
+
|
|
329
|
+
if (response.query === "doi" || response.query === "arxiv") {
|
|
330
|
+
// A provided identifier. If it resolves to nothing at THIS resolver → an
|
|
331
|
+
// id-miss. Whether that is fabrication is decided in reduceVerdict: a content
|
|
332
|
+
// registry (Crossref/OpenAlex/S2) is NOT the DOI authority (M1), so its DOI
|
|
333
|
+
// id-miss is `authoritative:false`; only the arXiv API is authoritative for an
|
|
334
|
+
// arXiv id, so an arXiv-db arXiv id-miss is `authoritative:true`.
|
|
335
|
+
if (response.record == null) {
|
|
336
|
+
return {
|
|
337
|
+
db,
|
|
338
|
+
status: "id_unmatched",
|
|
339
|
+
query: response.query,
|
|
340
|
+
authoritative: response.query === "arxiv" ? db === "arxiv" : false,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
// It resolved to a real record. If we have a claimed title, it must not be a
|
|
344
|
+
// CONFIDENTLY-DIFFERENT paper. titleRelation returns 'different' ONLY for two
|
|
345
|
+
// comparable-yet-unrelated titles; a split subtitle / abbreviation / drift →
|
|
346
|
+
// 'match', and a non-Latin-vs-Latin (or otherwise un-normalizable) pair →
|
|
347
|
+
// 'incomparable' → we do NOT assert misdirection (M2/M3).
|
|
348
|
+
if (citation.title) {
|
|
349
|
+
const rel = bestTitleRelation(
|
|
350
|
+
citation.title,
|
|
351
|
+
candidateTitles(response.record),
|
|
352
|
+
);
|
|
353
|
+
if (rel === "different") {
|
|
354
|
+
return {
|
|
355
|
+
db,
|
|
356
|
+
status: "doi_mismatch",
|
|
357
|
+
foundTitle: response.record.title,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
db,
|
|
363
|
+
status: "matched",
|
|
364
|
+
matchedTitle: response.record.title,
|
|
365
|
+
matchedYear: response.record.year,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Title search: accept iff a hit is a robust title MATCH (not merely ≥0.70 raw
|
|
370
|
+
// Levenshtein) AND year ±1. titleRelation treats an un-normalizable claimed
|
|
371
|
+
// title as 'incomparable' → no match (we can't confirm existence from a title
|
|
372
|
+
// we can't compare), never a false positive.
|
|
373
|
+
const hits = response.records || [];
|
|
374
|
+
for (const rec of hits) {
|
|
375
|
+
if (
|
|
376
|
+
titleRelation(citation.title, rec.title) === "match" &&
|
|
377
|
+
yearMatch(citation.year, rec.year)
|
|
378
|
+
) {
|
|
379
|
+
return {
|
|
380
|
+
db,
|
|
381
|
+
status: "matched",
|
|
382
|
+
matchedTitle: rec.title,
|
|
383
|
+
matchedYear: rec.year,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// Nothing found by title. Coverage gap — NOT fabrication.
|
|
388
|
+
return { db, status: "title_miss" };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* classifyDoiAuthority — PURE. The DOI *authority* (doi.org handle system) is the
|
|
393
|
+
* only source that can positively DISPROVE a DOI's existence. A content-registry
|
|
394
|
+
* 404 (Crossref/OpenAlex/S2 having no metadata) is NOT disproof — DataCite/Zenodo
|
|
395
|
+
* DOIs and freshly-minted 2026 DOIs resolve at doi.org while carrying no registry
|
|
396
|
+
* metadata (M1). Maps the doi.org `/api/handles/{doi}` response to evidence:
|
|
397
|
+
* response shape: { transport:'ok'|'error', responseCode?: number }
|
|
398
|
+
* responseCode 1 → the handle exists → authority_present
|
|
399
|
+
* responseCode 100 → the handle does NOT exist → authority_absent (disproof)
|
|
400
|
+
* anything else / transport error → unreachable (no disproof)
|
|
401
|
+
*/
|
|
402
|
+
export function classifyDoiAuthority(response) {
|
|
403
|
+
const db = "doi_authority";
|
|
404
|
+
if (!response || response.transport === "error")
|
|
405
|
+
return { db, status: "unreachable" };
|
|
406
|
+
if (response.responseCode === 1) return { db, status: "authority_present" };
|
|
407
|
+
if (response.responseCode === 100) return { db, status: "authority_absent" };
|
|
408
|
+
return { db, status: "unreachable" }; // unknown code — cannot disprove
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* checkNvd — PURE. CVE existence via a normalized NVD response, folded into the
|
|
413
|
+
* same evidence shape as a resolver so the reducer treats it uniformly.
|
|
414
|
+
* nvd shape: { transport:'ok'|'error', found: boolean }
|
|
415
|
+
* found === false on a well-formed CVE id = the id resolves to nothing = false.
|
|
416
|
+
*/
|
|
417
|
+
export function checkNvd(citation, nvd) {
|
|
418
|
+
const db = "nvd";
|
|
419
|
+
if (!citation.cve) return null;
|
|
420
|
+
if (!nvd || nvd.transport === "error") return { db, status: "unreachable" };
|
|
421
|
+
if (nvd.found) {
|
|
422
|
+
return { db, status: "matched", matchedTitle: citation.cve };
|
|
423
|
+
}
|
|
424
|
+
// NVD authoritatively has no such CVE → id-keyed miss → fabrication evidence.
|
|
425
|
+
return { db, status: "id_unmatched", query: "cve" };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Well-formed CVE id? CVE-YYYY-NNNN(+). */
|
|
429
|
+
export function isValidCveId(cve) {
|
|
430
|
+
return /^CVE-\d{4}-\d{4,}$/i.test((cve ?? "").trim());
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* checkCommit — PURE. PoC / exploit references must pin a FULL 40-char git SHA
|
|
435
|
+
* (branches and tags move; SHAs don't). Returns advisory flags — a short or
|
|
436
|
+
* malformed SHA is a hygiene flag, NOT a fabrication verdict.
|
|
437
|
+
*/
|
|
438
|
+
export function checkCommit(citation) {
|
|
439
|
+
const flags = [];
|
|
440
|
+
if (citation.commit == null) return flags;
|
|
441
|
+
const sha = String(citation.commit).trim();
|
|
442
|
+
if (!/^[0-9a-f]+$/i.test(sha)) {
|
|
443
|
+
flags.push(`commit "${sha}" is not a hex git SHA — pin a full 40-char SHA`);
|
|
444
|
+
} else if (sha.length < 40) {
|
|
445
|
+
flags.push(
|
|
446
|
+
`commit "${sha}" is a short SHA (${sha.length}/40) — pin the full 40-char SHA (short hashes collide / are ambiguous)`,
|
|
447
|
+
);
|
|
448
|
+
} else if (sha.length > 40) {
|
|
449
|
+
// A hex string LONGER than 40 chars is not a valid SHA-1 git object id — do
|
|
450
|
+
// not wave it through just because it is hex (S3).
|
|
451
|
+
flags.push(
|
|
452
|
+
`commit "${sha}" is ${sha.length} hex chars (>40) — not a valid git SHA; pin the exact full 40-char SHA`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
return flags;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* reduceVerdict — PURE. The narrowed-false reducer. Given a citation and the
|
|
460
|
+
* list of per-resolver/NVD evidence (from classifyResolver / checkNvd), plus the
|
|
461
|
+
* commit flags, produce the final verdict.
|
|
462
|
+
*
|
|
463
|
+
* true iff ANY evidence is `matched` (a match always WINS)
|
|
464
|
+
* false iff NOT matched AND ≥1 evidence is `id_unmatched` or `doi_mismatch`
|
|
465
|
+
* (a resolvable id that fails, or a DOI on the wrong paper)
|
|
466
|
+
* unresolvable otherwise (only `title_miss` and/or `unreachable` — no disproof)
|
|
467
|
+
*/
|
|
468
|
+
export function reduceVerdict(citation, evidence, commitFlags = []) {
|
|
469
|
+
const ev = evidence.filter(Boolean);
|
|
470
|
+
const flags = [...commitFlags];
|
|
471
|
+
const out = (verdict, reason, matched_db = null, extra = {}) => ({
|
|
472
|
+
id: citation.id,
|
|
473
|
+
verdict,
|
|
474
|
+
reason,
|
|
475
|
+
matched_db,
|
|
476
|
+
...extra,
|
|
477
|
+
...(flags.length ? { flags } : {}),
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// A match ALWAYS wins.
|
|
481
|
+
const matched = ev.find((e) => e.status === "matched");
|
|
482
|
+
if (matched) {
|
|
483
|
+
return out(
|
|
484
|
+
"true",
|
|
485
|
+
`confirmed by ${matched.db}${matched.matchedTitle ? ` — "${matched.matchedTitle}"` : ""}`,
|
|
486
|
+
matched.db,
|
|
487
|
+
{ matched_title: matched.matchedTitle },
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// A DOI/arXiv id that resolves to a CONFIDENTLY-DIFFERENT paper → misdirection.
|
|
492
|
+
const mismatch = ev.find((e) => e.status === "doi_mismatch");
|
|
493
|
+
if (mismatch) {
|
|
494
|
+
return out(
|
|
495
|
+
"false",
|
|
496
|
+
`DOI_MISMATCH: identifier resolves via ${mismatch.db} to an UNRELATED paper "${mismatch.foundTitle}" (claimed "${citation.title}") — DOI misdirection`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ── Authority-gated fabrication (M1) ──────────────────────────────────────
|
|
501
|
+
// A content-registry 404 is NOT positive disproof of a DOI. Fabrication may be
|
|
502
|
+
// asserted ONLY from an AUTHORITATIVE not-found:
|
|
503
|
+
// • DOI → doi.org handle system says responseCode 100 (authority_absent)
|
|
504
|
+
// • arXiv → the reachable arXiv API itself (not S2) definitively missed it
|
|
505
|
+
// • CVE → NVD authoritatively has no such id
|
|
506
|
+
const authorityAbsent = ev.find((e) => e.status === "authority_absent");
|
|
507
|
+
const authorityPresent = ev.find((e) => e.status === "authority_present");
|
|
508
|
+
const doiIdMiss = ev.find(
|
|
509
|
+
(e) => e.status === "id_unmatched" && e.query === "doi",
|
|
510
|
+
);
|
|
511
|
+
const arxivAuthMiss = ev.find(
|
|
512
|
+
(e) =>
|
|
513
|
+
e.status === "id_unmatched" && e.query === "arxiv" && e.authoritative,
|
|
514
|
+
);
|
|
515
|
+
const arxivAnyMiss = ev.find(
|
|
516
|
+
(e) => e.status === "id_unmatched" && e.query === "arxiv",
|
|
517
|
+
);
|
|
518
|
+
const cveMiss = ev.find(
|
|
519
|
+
(e) => e.status === "id_unmatched" && e.query === "cve",
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
if (authorityAbsent) {
|
|
523
|
+
return out(
|
|
524
|
+
"false",
|
|
525
|
+
`RESOLVED_TO_NOTHING: DOI ${citation.doi} does not resolve at the DOI authority (doi.org handle responseCode 100 = not found) — fabrication evidence`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
if (arxivAuthMiss) {
|
|
529
|
+
return out(
|
|
530
|
+
"false",
|
|
531
|
+
`RESOLVED_TO_NOTHING: arXiv id ${citation.arxiv} returns a definitive not-found from the reachable arXiv API — fabrication evidence`,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
if (cveMiss) {
|
|
535
|
+
return out(
|
|
536
|
+
"false",
|
|
537
|
+
`RESOLVED_TO_NOTHING: the provided CVE ${citation.cve} resolves to no record (checked ${cveMiss.db}) — fabrication evidence`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ── No positive disproof → unresolvable, with the most informative reason ──
|
|
542
|
+
const reachable = ev.some((e) => e.status !== "unreachable");
|
|
543
|
+
let reason;
|
|
544
|
+
if (doiIdMiss && authorityPresent) {
|
|
545
|
+
reason = `DOI ${citation.doi} resolves at doi.org but no content registry (Crossref/OpenAlex/Semantic Scholar) has metadata for it — common for DataCite/Zenodo and freshly-minted DOIs; the work EXISTS, its metadata is just unindexed → unresolvable, NOT fabrication`;
|
|
546
|
+
} else if (doiIdMiss) {
|
|
547
|
+
reason = `DOI ${citation.doi} not found in any content registry, and the DOI authority (doi.org) was not reachable to confirm non-existence — cannot assert fabrication → unresolvable, never false`;
|
|
548
|
+
} else if (arxivAnyMiss) {
|
|
549
|
+
reason = `arXiv id ${citation.arxiv} missed only by non-authoritative sources (the reachable arXiv API did not definitively reject it) — cannot assert fabrication → unresolvable`;
|
|
550
|
+
} else if (reachable) {
|
|
551
|
+
reason =
|
|
552
|
+
"not found by title in any database (no resolvable id to disprove) — could be a legit unindexed/regional/pre-digital work, NOT fabrication";
|
|
553
|
+
} else {
|
|
554
|
+
reason =
|
|
555
|
+
"all databases unreachable (network/rate-limit) — degraded to unresolvable, never to false";
|
|
556
|
+
}
|
|
557
|
+
return out("unresolvable", reason);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
561
|
+
// .bib PARSER — small, best-effort. Extracts @article/@inproceedings/... entries
|
|
562
|
+
// and (loosely) \bibitem blocks. Not a full BibTeX grammar; enough to seed cites.
|
|
563
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* field(body, name) — extract a BibTeX field value from an entry body.
|
|
567
|
+
* (a) anchors `name` so `title` does NOT also match `booktitle` (S4a);
|
|
568
|
+
* (b) reads BRACE-delimited values with balanced-brace scanning, so a protected
|
|
569
|
+
* acronym `{Evaluating {LLM}-based ...}` is captured whole, not truncated at
|
|
570
|
+
* the inner `}` (S4b);
|
|
571
|
+
* also handles "quoted" and bare (numeric) values.
|
|
572
|
+
*/
|
|
573
|
+
function bibField(body, name) {
|
|
574
|
+
const re = new RegExp(`(?<![a-zA-Z])${name}\\s*=\\s*`, "i");
|
|
575
|
+
const fm = re.exec(body);
|
|
576
|
+
if (!fm) return undefined;
|
|
577
|
+
let i = fm.index + fm[0].length;
|
|
578
|
+
const ch = body[i];
|
|
579
|
+
if (ch === "{") {
|
|
580
|
+
let depth = 0;
|
|
581
|
+
let j = i;
|
|
582
|
+
for (; j < body.length; j++) {
|
|
583
|
+
if (body[j] === "{") depth++;
|
|
584
|
+
else if (body[j] === "}") {
|
|
585
|
+
depth--;
|
|
586
|
+
if (depth === 0) break;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return body
|
|
590
|
+
.slice(i + 1, j)
|
|
591
|
+
.replace(/\s+/g, " ")
|
|
592
|
+
.trim();
|
|
593
|
+
}
|
|
594
|
+
if (ch === '"') {
|
|
595
|
+
let j = i + 1;
|
|
596
|
+
while (j < body.length && body[j] !== '"') j++;
|
|
597
|
+
return body
|
|
598
|
+
.slice(i + 1, j)
|
|
599
|
+
.replace(/\s+/g, " ")
|
|
600
|
+
.trim();
|
|
601
|
+
}
|
|
602
|
+
// bare value (e.g. year = 2017,) — up to the next comma / newline / close brace
|
|
603
|
+
let j = i;
|
|
604
|
+
while (j < body.length && !/[,\n}]/.test(body[j])) j++;
|
|
605
|
+
const v = body.slice(i, j).replace(/\s+/g, " ").trim();
|
|
606
|
+
return v || undefined;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export function parseBib(text) {
|
|
610
|
+
const cites = [];
|
|
611
|
+
// @type{key, — then read the entry body with BALANCED-BRACE scanning so that
|
|
612
|
+
// one-line entries and indented / same-line closing braces (`\n }` or `...}`)
|
|
613
|
+
// are captured, not dropped by a `\n}`-at-column-0 requirement (S4c).
|
|
614
|
+
const headRe = /@(\w+)\s*\{\s*([^,\s}]+)\s*,/g;
|
|
615
|
+
let m;
|
|
616
|
+
while ((m = headRe.exec(text)) !== null) {
|
|
617
|
+
const type = m[1].toLowerCase();
|
|
618
|
+
if (type === "comment" || type === "string" || type === "preamble")
|
|
619
|
+
continue;
|
|
620
|
+
const key = m[2].trim();
|
|
621
|
+
// Balanced scan from just after the key's comma to the entry's closing brace.
|
|
622
|
+
let depth = 1; // the entry's opening `{` was already consumed by headRe
|
|
623
|
+
let j = headRe.lastIndex;
|
|
624
|
+
for (; j < text.length && depth > 0; j++) {
|
|
625
|
+
if (text[j] === "{") depth++;
|
|
626
|
+
else if (text[j] === "}") depth--;
|
|
627
|
+
}
|
|
628
|
+
const body = text.slice(headRe.lastIndex, j - 1);
|
|
629
|
+
headRe.lastIndex = j; // resume scanning AFTER this entry
|
|
630
|
+
|
|
631
|
+
const c = { id: key };
|
|
632
|
+
const doi = bibField(body, "doi");
|
|
633
|
+
const title = bibField(body, "title");
|
|
634
|
+
const year = bibField(body, "year");
|
|
635
|
+
const author = bibField(body, "author");
|
|
636
|
+
const eprint = bibField(body, "eprint");
|
|
637
|
+
const archive =
|
|
638
|
+
bibField(body, "archiveprefix") || bibField(body, "eprinttype");
|
|
639
|
+
if (doi) c.doi = normalizeDoi(doi);
|
|
640
|
+
if (title) c.title = title.replace(/[{}]/g, "");
|
|
641
|
+
if (year) c.year = year;
|
|
642
|
+
if (author) c.authors = author;
|
|
643
|
+
if (eprint && (!archive || /arxiv/i.test(archive)))
|
|
644
|
+
c.arxiv = normalizeArxiv(eprint);
|
|
645
|
+
cites.push(c);
|
|
646
|
+
}
|
|
647
|
+
if (cites.length > 0) return cites;
|
|
648
|
+
|
|
649
|
+
// Fallback: \bibitem — grab arXiv ids / DOIs out of each block, best-effort.
|
|
650
|
+
const itemRe =
|
|
651
|
+
/\\bibitem(?:\[[^\]]*\])?\{([^}]+)\}([\s\S]*?)(?=\\bibitem|\\end\{thebibliography\}|$)/g;
|
|
652
|
+
while ((m = itemRe.exec(text)) !== null) {
|
|
653
|
+
const key = m[1].trim();
|
|
654
|
+
const block = m[2];
|
|
655
|
+
const c = { id: key };
|
|
656
|
+
const doiM = block.match(/10\.\d{4,9}\/[-._;()/:A-Z0-9]+/i);
|
|
657
|
+
const arxivM = block.match(/arXiv:\s*(\d{4}\.\d{4,5})/i);
|
|
658
|
+
const yearM = block.match(/\b(19|20)\d{2}\b/);
|
|
659
|
+
if (doiM) c.doi = normalizeDoi(doiM[0]);
|
|
660
|
+
if (arxivM) c.arxiv = normalizeArxiv(arxivM[1]);
|
|
661
|
+
if (yearM) c.year = yearM[0];
|
|
662
|
+
// crude title: longest quoted or {\em ...} run
|
|
663
|
+
const titleM =
|
|
664
|
+
block.match(/``([^']{6,}?)''/) ||
|
|
665
|
+
block.match(/\\emph\{([^}]{6,}?)\}/) ||
|
|
666
|
+
block.match(/\{\\em\s+([^}]{6,}?)\}/);
|
|
667
|
+
if (titleM) c.title = titleM[1].replace(/\s+/g, " ").trim();
|
|
668
|
+
cites.push(c);
|
|
669
|
+
}
|
|
670
|
+
return cites;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
674
|
+
// NETWORK LAYER — impure. Fetches the 4 APIs + NVD, shapes raw → normalized
|
|
675
|
+
// evidence, then hands off to the pure functions above. Cached to disk.
|
|
676
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
677
|
+
|
|
678
|
+
function loadCache() {
|
|
679
|
+
try {
|
|
680
|
+
if (existsSync(CACHE_PATH))
|
|
681
|
+
return JSON.parse(readFileSync(CACHE_PATH, "utf8"));
|
|
682
|
+
} catch {
|
|
683
|
+
/* corrupt cache → start fresh */
|
|
684
|
+
}
|
|
685
|
+
return {};
|
|
686
|
+
}
|
|
687
|
+
function saveCache(cache) {
|
|
688
|
+
try {
|
|
689
|
+
writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2));
|
|
690
|
+
} catch {
|
|
691
|
+
/* best-effort; a cache write failure must never fail the run */
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function titleHash(t) {
|
|
696
|
+
return createHash("sha1")
|
|
697
|
+
.update(normalizeTitle(t))
|
|
698
|
+
.digest("hex")
|
|
699
|
+
.slice(0, 16);
|
|
700
|
+
}
|
|
701
|
+
function cacheKey(kind, val) {
|
|
702
|
+
return `${kind}:${val}`;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function httpGet(url, { json = true } = {}) {
|
|
706
|
+
const ctrl = new AbortController();
|
|
707
|
+
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
708
|
+
try {
|
|
709
|
+
const res = await fetch(url, {
|
|
710
|
+
headers: {
|
|
711
|
+
"User-Agent": USER_AGENT,
|
|
712
|
+
Accept: json ? "application/json" : "*/*",
|
|
713
|
+
},
|
|
714
|
+
signal: ctrl.signal,
|
|
715
|
+
});
|
|
716
|
+
if (res.status === 429 || res.status >= 500) {
|
|
717
|
+
return { ok: false, reason: `http ${res.status}` }; // rate-limit / outage
|
|
718
|
+
}
|
|
719
|
+
if (res.status === 404) return { ok: true, notFound: true };
|
|
720
|
+
if (!res.ok) return { ok: false, reason: `http ${res.status}` };
|
|
721
|
+
const data = json ? await res.json() : await res.text();
|
|
722
|
+
return { ok: true, data };
|
|
723
|
+
} catch (e) {
|
|
724
|
+
return {
|
|
725
|
+
ok: false,
|
|
726
|
+
reason: e.name === "AbortError" ? "timeout" : String(e),
|
|
727
|
+
};
|
|
728
|
+
} finally {
|
|
729
|
+
clearTimeout(timer);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Each *Resolver returns a normalized response (the shape classifyResolver eats).
|
|
734
|
+
// A transport failure → { transport:'error' }: degrade to unresolvable, NEVER false.
|
|
735
|
+
|
|
736
|
+
async function crossrefResolve(citation) {
|
|
737
|
+
const db = "crossref";
|
|
738
|
+
if (citation.doi) {
|
|
739
|
+
const r = await httpGet(
|
|
740
|
+
`https://api.crossref.org/works/${encodeURIComponent(citation.doi)}`,
|
|
741
|
+
);
|
|
742
|
+
if (!r.ok) return { db, transport: "error" };
|
|
743
|
+
if (r.notFound) return { db, transport: "ok", query: "doi", record: null };
|
|
744
|
+
const w = r.data?.message;
|
|
745
|
+
return {
|
|
746
|
+
db,
|
|
747
|
+
transport: "ok",
|
|
748
|
+
query: "doi",
|
|
749
|
+
// Crossref splits a subtitle into its own field — carry it so the title
|
|
750
|
+
// comparison can try title AND title+subtitle (M2).
|
|
751
|
+
record: w
|
|
752
|
+
? {
|
|
753
|
+
title: (w.title || [])[0] || "",
|
|
754
|
+
subtitle: (w.subtitle || [])[0] || "",
|
|
755
|
+
year: crYear(w),
|
|
756
|
+
}
|
|
757
|
+
: null,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
if (citation.title) {
|
|
761
|
+
const url = `https://api.crossref.org/works?query.bibliographic=${encodeURIComponent(
|
|
762
|
+
citation.title,
|
|
763
|
+
)}&rows=5`;
|
|
764
|
+
const r = await httpGet(url);
|
|
765
|
+
if (!r.ok) return { db, transport: "error" };
|
|
766
|
+
const items = r.data?.message?.items || [];
|
|
767
|
+
return {
|
|
768
|
+
db,
|
|
769
|
+
transport: "ok",
|
|
770
|
+
query: "title",
|
|
771
|
+
records: items.map((w) => ({
|
|
772
|
+
title: (w.title || [])[0] || "",
|
|
773
|
+
year: crYear(w),
|
|
774
|
+
})),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
return null;
|
|
778
|
+
}
|
|
779
|
+
function crYear(w) {
|
|
780
|
+
return (
|
|
781
|
+
w?.issued?.["date-parts"]?.[0]?.[0] ??
|
|
782
|
+
w?.published?.["date-parts"]?.[0]?.[0] ??
|
|
783
|
+
null
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async function openalexResolve(citation) {
|
|
788
|
+
const db = "openalex";
|
|
789
|
+
if (citation.doi) {
|
|
790
|
+
const r = await httpGet(
|
|
791
|
+
`https://api.openalex.org/works/doi:${encodeURIComponent(citation.doi)}`,
|
|
792
|
+
);
|
|
793
|
+
if (!r.ok) return { db, transport: "error" };
|
|
794
|
+
if (r.notFound) return { db, transport: "ok", query: "doi", record: null };
|
|
795
|
+
const w = r.data;
|
|
796
|
+
return {
|
|
797
|
+
db,
|
|
798
|
+
transport: "ok",
|
|
799
|
+
query: "doi",
|
|
800
|
+
record: w?.id
|
|
801
|
+
? { title: w.display_name || "", year: w.publication_year }
|
|
802
|
+
: null,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
if (citation.title) {
|
|
806
|
+
const r = await httpGet(
|
|
807
|
+
`https://api.openalex.org/works?search=${encodeURIComponent(citation.title)}&per-page=5`,
|
|
808
|
+
);
|
|
809
|
+
if (!r.ok) return { db, transport: "error" };
|
|
810
|
+
const items = r.data?.results || [];
|
|
811
|
+
return {
|
|
812
|
+
db,
|
|
813
|
+
transport: "ok",
|
|
814
|
+
query: "title",
|
|
815
|
+
records: items.map((w) => ({
|
|
816
|
+
title: w.display_name || "",
|
|
817
|
+
year: w.publication_year,
|
|
818
|
+
})),
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function semanticScholarResolve(citation) {
|
|
825
|
+
const db = "semantic_scholar";
|
|
826
|
+
const base = "https://api.semanticscholar.org/graph/v1/paper";
|
|
827
|
+
if (citation.doi || citation.arxiv) {
|
|
828
|
+
const id = citation.doi ? `DOI:${citation.doi}` : `arXiv:${citation.arxiv}`;
|
|
829
|
+
const r = await httpGet(
|
|
830
|
+
`${base}/${encodeURIComponent(id)}?fields=title,year`,
|
|
831
|
+
);
|
|
832
|
+
if (!r.ok) return { db, transport: "error" };
|
|
833
|
+
if (r.notFound)
|
|
834
|
+
return {
|
|
835
|
+
db,
|
|
836
|
+
transport: "ok",
|
|
837
|
+
query: citation.doi ? "doi" : "arxiv",
|
|
838
|
+
record: null,
|
|
839
|
+
};
|
|
840
|
+
const w = r.data;
|
|
841
|
+
return {
|
|
842
|
+
db,
|
|
843
|
+
transport: "ok",
|
|
844
|
+
query: citation.doi ? "doi" : "arxiv",
|
|
845
|
+
record: w?.title != null ? { title: w.title || "", year: w.year } : null,
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
if (citation.title) {
|
|
849
|
+
const r = await httpGet(
|
|
850
|
+
`${base}/search?query=${encodeURIComponent(citation.title)}&fields=title,year&limit=5`,
|
|
851
|
+
);
|
|
852
|
+
if (!r.ok) return { db, transport: "error" };
|
|
853
|
+
const items = r.data?.data || [];
|
|
854
|
+
return {
|
|
855
|
+
db,
|
|
856
|
+
transport: "ok",
|
|
857
|
+
query: "title",
|
|
858
|
+
records: items.map((w) => ({ title: w.title || "", year: w.year })),
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async function arxivResolve(citation) {
|
|
865
|
+
const db = "arxiv";
|
|
866
|
+
// arXiv only makes sense for an arXiv id or a title search (no DOI endpoint).
|
|
867
|
+
if (citation.arxiv) {
|
|
868
|
+
const r = await httpGet(
|
|
869
|
+
`https://export.arxiv.org/api/query?id_list=${encodeURIComponent(citation.arxiv)}&max_results=1`,
|
|
870
|
+
{ json: false },
|
|
871
|
+
);
|
|
872
|
+
if (!r.ok) return { db, transport: "error" };
|
|
873
|
+
const entry = parseArxivFeed(r.data);
|
|
874
|
+
return { db, transport: "ok", query: "arxiv", record: entry[0] ?? null };
|
|
875
|
+
}
|
|
876
|
+
if (citation.title && !citation.doi) {
|
|
877
|
+
const r = await httpGet(
|
|
878
|
+
`https://export.arxiv.org/api/query?search_query=ti:${encodeURIComponent(
|
|
879
|
+
'"' + citation.title + '"',
|
|
880
|
+
)}&max_results=5`,
|
|
881
|
+
{ json: false },
|
|
882
|
+
);
|
|
883
|
+
if (!r.ok) return { db, transport: "error" };
|
|
884
|
+
return {
|
|
885
|
+
db,
|
|
886
|
+
transport: "ok",
|
|
887
|
+
query: "title",
|
|
888
|
+
records: parseArxivFeed(r.data),
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
export function parseArxivFeed(xml) {
|
|
894
|
+
const out = [];
|
|
895
|
+
if (!xml) return out;
|
|
896
|
+
const entries = xml.split(/<entry>/).slice(1);
|
|
897
|
+
for (const e of entries) {
|
|
898
|
+
const tM = e.match(/<title>([\s\S]*?)<\/title>/);
|
|
899
|
+
const pM = e.match(/<published>(\d{4})/);
|
|
900
|
+
const idM = e.match(/<id>([\s\S]*?)<\/id>/);
|
|
901
|
+
if (!tM) continue;
|
|
902
|
+
const title = tM[1].replace(/\s+/g, " ").trim();
|
|
903
|
+
// S2: arXiv answers a bad/unknown id with HTTP 200 and a SENTINEL entry titled
|
|
904
|
+
// "Error" whose <id> is the arxiv.org/api/errors URL. That is NOT a real
|
|
905
|
+
// "unrelated paper" — skip it so the id reads as not-found, not a mismatch.
|
|
906
|
+
const isErrorSentinel =
|
|
907
|
+
title === "Error" && /arxiv\.org\/api\/errors/i.test(idM ? idM[1] : "");
|
|
908
|
+
if (isErrorSentinel) continue;
|
|
909
|
+
out.push({ title, year: pM ? +pM[1] : null });
|
|
910
|
+
}
|
|
911
|
+
return out;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* doiAuthorityCheck — hit the DOI *authority* (doi.org handle system). This is the
|
|
916
|
+
* only source that can positively disprove a DOI (M1). A content-registry 404 just
|
|
917
|
+
* means "no metadata here"; doi.org responseCode 100 means "this DOI does not
|
|
918
|
+
* exist". Returns { transport, responseCode } for classifyDoiAuthority.
|
|
919
|
+
*/
|
|
920
|
+
async function doiAuthorityCheck(citation) {
|
|
921
|
+
if (!citation.doi) return null;
|
|
922
|
+
// Keep the DOI's own slashes as path separators; encode the rest.
|
|
923
|
+
const path = encodeURIComponent(citation.doi.trim()).replace(/%2F/gi, "/");
|
|
924
|
+
const r = await httpGet(`https://doi.org/api/handles/${path}`);
|
|
925
|
+
if (r.notFound) return { transport: "ok", responseCode: 100 }; // 404 = not found
|
|
926
|
+
if (!r.ok) return { transport: "error" };
|
|
927
|
+
const code = r.data?.responseCode;
|
|
928
|
+
return {
|
|
929
|
+
transport: "ok",
|
|
930
|
+
responseCode: typeof code === "number" ? code : undefined,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async function nvdCheck(citation) {
|
|
935
|
+
if (!citation.cve) return null;
|
|
936
|
+
if (!isValidCveId(citation.cve)) {
|
|
937
|
+
// Malformed CVE id — treat as a provided-id-that-cannot-resolve (fabrication).
|
|
938
|
+
return { transport: "ok", found: false };
|
|
939
|
+
}
|
|
940
|
+
const r = await httpGet(
|
|
941
|
+
`https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=${encodeURIComponent(
|
|
942
|
+
citation.cve.toUpperCase(),
|
|
943
|
+
)}`,
|
|
944
|
+
);
|
|
945
|
+
if (!r.ok) return { transport: "error" };
|
|
946
|
+
if (r.notFound) return { transport: "ok", found: false };
|
|
947
|
+
const total = r.data?.totalResults ?? (r.data?.vulnerabilities || []).length;
|
|
948
|
+
return { transport: "ok", found: total > 0 };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Verify ONE citation live (with caching). Returns the reduceVerdict result.
|
|
953
|
+
*
|
|
954
|
+
* 🔴 CONTRACT: CALL SEQUENTIALLY. The shared `cache` is read BEFORE the `await` and written AFTER
|
|
955
|
+
* it (three places below), so two concurrent calls sharing the same `cache` will both miss the
|
|
956
|
+
* write and both fall through to the network. This doesn't corrupt the data — the resolvers are
|
|
957
|
+
* deterministic, and the second call writes an equivalent value — but it's extra requests to
|
|
958
|
+
* CrossRef / OpenAlex / Semantic Scholar / arXiv / NVD, i.e. a direct route to a 429, which is
|
|
959
|
+
* exactly why the neighboring `bib-authors.mjs` has 900ms pauses.
|
|
960
|
+
*
|
|
961
|
+
* Today the contract IS HONORED: the only caller is `main()` in this same file (a sequential
|
|
962
|
+
* `for … of` with `await`), so the interleaving `require-atomic-updates` warns about does not
|
|
963
|
+
* exist. The rule is deliberately left at `warn` for that reason: it judges the SHAPE, not the
|
|
964
|
+
* fact of it. But the function IS EXPORTED and takes a shared cache — exactly what gets
|
|
965
|
+
* parallelized via `Promise.all` — so the warning is left visible rather than suppressed. The real
|
|
966
|
+
* fix would be caching UNRESOLVED promises in the cache (in-flight deduplication) rather than
|
|
967
|
+
* values; that's incompatible with the current JSON-based `saveCache()` and so hasn't been done
|
|
968
|
+
* (measured 2026-08-28).
|
|
969
|
+
*/
|
|
970
|
+
export async function verifyCitationLive(
|
|
971
|
+
citation,
|
|
972
|
+
{ cache = {}, offline = false } = {},
|
|
973
|
+
) {
|
|
974
|
+
citation = normalizeIdentifiers(citation); // clean doi:/arXiv: prefixes + trailing punct first
|
|
975
|
+
const commitFlags = checkCommit(citation);
|
|
976
|
+
|
|
977
|
+
if (offline) {
|
|
978
|
+
// No network: everything degrades to unresolvable (matches narrowed-false).
|
|
979
|
+
return reduceVerdict(citation, [], commitFlags);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const evidence = [];
|
|
983
|
+
|
|
984
|
+
// Paper resolvers (skip when the citation is CVE/commit-only).
|
|
985
|
+
if (citation.doi || citation.arxiv || citation.title) {
|
|
986
|
+
const resolvers = [
|
|
987
|
+
{ name: "crossref", fn: crossrefResolve },
|
|
988
|
+
{ name: "openalex", fn: openalexResolve },
|
|
989
|
+
{ name: "semantic_scholar", fn: semanticScholarResolve },
|
|
990
|
+
{ name: "arxiv", fn: arxivResolve },
|
|
991
|
+
];
|
|
992
|
+
for (const { name, fn } of resolvers) {
|
|
993
|
+
const ck = cacheKey(
|
|
994
|
+
name,
|
|
995
|
+
citation.doi
|
|
996
|
+
? `doi:${citation.doi}`
|
|
997
|
+
: citation.arxiv
|
|
998
|
+
? `arxiv:${citation.arxiv}`
|
|
999
|
+
: `title:${titleHash(citation.title)}`,
|
|
1000
|
+
);
|
|
1001
|
+
let resp;
|
|
1002
|
+
if (cache[ck]) {
|
|
1003
|
+
resp = cache[ck];
|
|
1004
|
+
} else {
|
|
1005
|
+
resp = await fn(citation);
|
|
1006
|
+
if (resp && resp.transport === "ok") cache[ck] = resp; // only cache successes
|
|
1007
|
+
}
|
|
1008
|
+
if (resp) evidence.push(classifyResolver(citation, resp));
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// DOI authority (doi.org) — the ONLY source that can disprove a DOI (M1).
|
|
1012
|
+
// Always consulted when a DOI is present so a registry-404 never becomes
|
|
1013
|
+
// `false` without doi.org confirming responseCode 100.
|
|
1014
|
+
if (citation.doi) {
|
|
1015
|
+
const ck = cacheKey("doi_authority", citation.doi);
|
|
1016
|
+
let auth = cache[ck];
|
|
1017
|
+
if (!auth) {
|
|
1018
|
+
auth = await doiAuthorityCheck(citation);
|
|
1019
|
+
// Cache only POSITIVE existence; never cache authority-absent (100), so a
|
|
1020
|
+
// freshly-minted DOI checked pre-propagation isn't pinned to `false` on re-run.
|
|
1021
|
+
if (auth && auth.transport === "ok" && auth.responseCode !== 100)
|
|
1022
|
+
cache[ck] = auth;
|
|
1023
|
+
}
|
|
1024
|
+
if (auth) evidence.push(classifyDoiAuthority(auth));
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// CVE via NVD.
|
|
1029
|
+
if (citation.cve) {
|
|
1030
|
+
const ck = cacheKey("nvd", citation.cve.toUpperCase());
|
|
1031
|
+
let nvd = cache[ck];
|
|
1032
|
+
if (!nvd) {
|
|
1033
|
+
nvd = await nvdCheck(citation);
|
|
1034
|
+
if (nvd && nvd.transport === "ok") cache[ck] = nvd;
|
|
1035
|
+
}
|
|
1036
|
+
const e = checkNvd(citation, nvd);
|
|
1037
|
+
if (e) evidence.push(e);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return reduceVerdict(citation, evidence, commitFlags);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1044
|
+
// CLI
|
|
1045
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1046
|
+
|
|
1047
|
+
function loadInput(argv) {
|
|
1048
|
+
const path = argv.find((a) => !a.startsWith("-"));
|
|
1049
|
+
const raw =
|
|
1050
|
+
path && path !== "-" ? readFileSync(path, "utf8") : readFileSync(0, "utf8"); // stdin
|
|
1051
|
+
if (path && extname(path).toLowerCase() === ".bib") return parseBib(raw);
|
|
1052
|
+
const parsed = JSON.parse(raw);
|
|
1053
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async function main() {
|
|
1057
|
+
const argv = process.argv.slice(2);
|
|
1058
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
1059
|
+
process.stderr.write(
|
|
1060
|
+
"usage: verify-cites.mjs <cites.json | refs.bib | -> [--offline]\n",
|
|
1061
|
+
);
|
|
1062
|
+
process.exit(0);
|
|
1063
|
+
}
|
|
1064
|
+
const offline = argv.includes("--offline");
|
|
1065
|
+
|
|
1066
|
+
let cites;
|
|
1067
|
+
try {
|
|
1068
|
+
cites = loadInput(argv);
|
|
1069
|
+
} catch (e) {
|
|
1070
|
+
process.stderr.write(`error: could not read input — ${e.message}\n`);
|
|
1071
|
+
process.exit(2);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const cache = loadCache();
|
|
1075
|
+
const results = [];
|
|
1076
|
+
for (const c of cites) {
|
|
1077
|
+
if (!c || !c.id) {
|
|
1078
|
+
results.push({
|
|
1079
|
+
id: c?.id ?? null,
|
|
1080
|
+
verdict: "unresolvable",
|
|
1081
|
+
reason: "citation has no id",
|
|
1082
|
+
matched_db: null,
|
|
1083
|
+
});
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
results.push(await verifyCitationLive(c, { cache, offline }));
|
|
1087
|
+
}
|
|
1088
|
+
saveCache(cache);
|
|
1089
|
+
|
|
1090
|
+
process.stdout.write(JSON.stringify(results, null, 2) + "\n");
|
|
1091
|
+
|
|
1092
|
+
const n = results.length;
|
|
1093
|
+
const f = results.filter((r) => r.verdict === "false").length;
|
|
1094
|
+
const u = results.filter((r) => r.verdict === "unresolvable").length;
|
|
1095
|
+
const t = results.filter((r) => r.verdict === "true").length;
|
|
1096
|
+
const flagged = results.filter((r) => r.flags && r.flags.length).length;
|
|
1097
|
+
process.stderr.write(
|
|
1098
|
+
`\nverify-cites: ${n} citation(s) — ${t} true, ${f} false (fabrication), ${u} unresolvable` +
|
|
1099
|
+
(flagged ? `, ${flagged} with hygiene flag(s)` : "") +
|
|
1100
|
+
`\n${f > 0 ? "FAIL — fabrication(s) found (exit 1)" : "PASS — no fabrication (unresolvable is advisory)"}\n`,
|
|
1101
|
+
);
|
|
1102
|
+
process.exit(f > 0 ? 1 : 0);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Run as CLI only when invoked directly (not when imported by the test).
|
|
1106
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
1107
|
+
main();
|
|
1108
|
+
}
|