paperlint 3.1.0 → 3.1.2
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/workflows/ci.yml +28 -41
- package/.github/workflows/dependabot-automerge.yml +1 -1
- package/CLAUDE.md +21 -105
- package/CONTRIBUTING.md +23 -25
- package/README.md +11 -7
- package/bin/paperlint.test.mjs +45 -0
- package/dist/adapters/node/files.io.d.ts +2 -0
- package/dist/adapters/node/files.io.d.ts.map +1 -1
- package/dist/adapters/node/files.io.js +3 -2
- package/dist/adapters/node/files.io.js.map +1 -1
- package/dist/adapters/pdfjs/fill.d.ts +15 -4
- package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
- package/dist/adapters/pdfjs/fill.js +31 -21
- package/dist/adapters/pdfjs/fill.js.map +1 -1
- package/dist/adapters/references/clock.io.d.ts +5 -0
- package/dist/adapters/references/clock.io.d.ts.map +1 -0
- package/dist/adapters/references/clock.io.js +7 -0
- package/dist/adapters/references/clock.io.js.map +1 -0
- package/dist/adapters/references/index.d.ts +24 -2
- package/dist/adapters/references/index.d.ts.map +1 -1
- package/dist/adapters/references/index.js +131 -12
- package/dist/adapters/references/index.js.map +1 -1
- package/dist/adapters/references/pool.d.ts +8 -0
- package/dist/adapters/references/pool.d.ts.map +1 -0
- package/dist/adapters/references/pool.js +19 -0
- package/dist/adapters/references/pool.js.map +1 -0
- package/dist/build.d.ts +14 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +17 -11
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +27 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +71 -55
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts +27 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +4 -3
- package/dist/doctor.js.map +1 -1
- package/dist/domain/lookup-cache.d.ts +75 -0
- package/dist/domain/lookup-cache.d.ts.map +1 -0
- package/dist/domain/lookup-cache.js +137 -0
- package/dist/domain/lookup-cache.js.map +1 -0
- package/dist/domain/result.d.ts +0 -2
- package/dist/domain/result.d.ts.map +1 -1
- package/dist/domain/result.js +0 -4
- package/dist/domain/result.js.map +1 -1
- package/dist/hooks-settings.d.ts +8 -5
- package/dist/hooks-settings.d.ts.map +1 -1
- package/dist/hooks-settings.js +32 -32
- package/dist/hooks-settings.js.map +1 -1
- package/dist/init.d.ts +8 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +3 -3
- package/dist/init.js.map +1 -1
- package/dist/latex-log.d.ts +0 -2
- package/dist/latex-log.d.ts.map +1 -1
- package/dist/latex-log.js +7 -13
- package/dist/latex-log.js.map +1 -1
- package/dist/link-skills.d.ts +15 -2
- package/dist/link-skills.d.ts.map +1 -1
- package/dist/link-skills.js +36 -20
- package/dist/link-skills.js.map +1 -1
- package/dist/paper-files.d.ts.map +1 -1
- package/dist/paper-files.js +6 -4
- package/dist/paper-files.js.map +1 -1
- package/dist/pdf-facts.d.ts +11 -0
- package/dist/pdf-facts.d.ts.map +1 -1
- package/dist/pdf-facts.js +16 -18
- package/dist/pdf-facts.js.map +1 -1
- package/dist/pdf-geometry.js +1 -1
- package/dist/pdf-geometry.js.map +1 -1
- package/dist/ports/check-references.d.ts +12 -2
- package/dist/ports/check-references.d.ts.map +1 -1
- package/dist/ports/check-references.js +0 -5
- package/dist/ports/check-references.js.map +1 -1
- package/dist/presets.d.ts +2 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +3 -1
- package/dist/presets.js.map +1 -1
- package/dist/references.d.ts +11 -3
- package/dist/references.d.ts.map +1 -1
- package/dist/references.js +63 -15
- package/dist/references.js.map +1 -1
- package/dist/tex-requirements.d.ts +8 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +6 -5
- package/dist/tex-requirements.js.map +1 -1
- package/dist/toolchain.d.ts +3 -2
- package/dist/toolchain.d.ts.map +1 -1
- package/dist/toolchain.js +4 -5
- package/dist/toolchain.js.map +1 -1
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +11 -9
- package/dist/venue-rules.js.map +1 -1
- package/docs/prior-art/README.md +11 -10
- package/docs/prior-art/citation-checkers.md +174 -0
- package/docs/prior-art/test-tooling.md +7 -30
- package/docs/references.md +126 -0
- package/docs/testing.md +118 -0
- package/docs/toolchain.md +6 -4
- package/eslint-rules/install-path-literals.harness.mjs +17 -1
- package/eslint-rules/latex-language.harness.mjs +3 -3
- package/eslint-rules/latex-language.mjs +12 -9
- package/eslint-rules/latex-language.test.mjs +200 -0
- package/eslint-rules/paper-research-question.harness.mjs +73 -7
- package/eslint-rules/paper-stages.harness.mjs +9 -14
- package/eslint-rules/paper-stages.test.mjs +135 -0
- package/eslint-rules/paper-typography.mjs +9 -2
- package/eslint-rules/paper-typography.test.mjs +146 -1
- package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
- package/eslint-rules/review-frontmatter.mjs +2 -1
- package/eslint-rules/review-frontmatter.test.mjs +37 -1
- package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
- package/eslint-rules/tex-build.mjs +13 -13
- package/eslint.config.mjs +6 -2
- package/hooks/hooks.harness.mjs +6 -11
- package/hooks/paper-edit-guard.hook.mjs +2 -2
- package/hooks/paper-edit-guard.test.mjs +61 -0
- package/hooks/paper-skills-nudge.hook.mjs +2 -2
- package/hooks/paper-skills-nudge.test.mjs +38 -0
- package/hooks/paper-status-gates.test.mjs +42 -0
- package/lib/agent-cli-version.harness.mjs +7 -0
- package/lib/check.d.mts +17 -0
- package/lib/check.mjs +50 -0
- package/lib/check.test.ts +67 -0
- package/lib/markdown.mjs +16 -69
- package/lib/markdown.test.mjs +246 -0
- package/lib/paper-config.harness.mjs +28 -9
- package/lib/paper-config.mjs +7 -2
- package/lib/skill-checks.mjs +18 -46
- package/lib/skill-checks.test.mjs +297 -0
- package/lib/skill-corpus.mjs +11 -13
- package/lib/skill-corpus.test.mjs +54 -0
- package/lib/skill-trigger-cases.harness.mjs +57 -4
- package/package.json +6 -5
- package/scripts/check.harness.mjs +4 -10
- package/scripts/check.mjs +45 -42
- package/scripts/check.test.mjs +104 -0
- package/scripts/coverage-config.test.ts +406 -0
- package/scripts/eslint-report-guard.test.mjs +48 -0
- package/scripts/harness-api.frozen.json +0 -2
- package/scripts/layer-legacy-frozen.d.mts +9 -1
- package/scripts/layer-legacy-frozen.mjs +28 -11
- package/scripts/layer-legacy-frozen.test.ts +143 -1
- package/scripts/rules-are-content-only.harness.mjs +3 -11
- package/scripts/rules-are-content-only.mjs +29 -19
- package/scripts/rules-are-content-only.test.mjs +61 -0
- package/scripts/rules-see-files.mjs +11 -9
- package/scripts/rules-see-files.test.mjs +71 -0
- package/scripts/test.mjs +44 -0
- package/scripts/test.test.mjs +52 -0
- package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
- package/skills/grade-paper-writing/prose-lint.mjs +31 -12
- package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
- package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
- package/skills/paper-pipeline/references/artifact-checklist.md +7 -7
- package/skills/paper-pipeline/scripts/README.md +13 -132
- package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
- package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
- package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
- package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
- package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
- package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
- package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
- package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
- package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
- package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
- package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
- package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
- package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
- package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
- package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
- package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
- package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
- package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
- package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
- package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
- package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
- package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
- package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
- package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
- package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
- package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
- package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
- package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
- package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
- package/skills/paper-pipeline/scripts/status.mjs +2 -2
- package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
- package/skills/render-paper/checkers-requirements.txt +30 -19
- package/skills/render-paper/ensure-checkers.sh +20 -4
- package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
- package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
- package/skills/tighten-paper/structure.test.mjs +116 -0
- package/skills/verify-citations/SKILL.md +30 -14
- package/skills/verify-citations/SKILL.md.spec.ts +29 -13
- package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +253 -0
- package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
- package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
- package/skills/verify-citations/scripts/bib-authors.mjs +6 -6
- package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
- package/skills/verify-citations/scripts/verify-cites.mjs +360 -215
- package/skills/verify-citations/scripts/verify-cites.net.test.mjs +588 -0
- package/skills/verify-citations/scripts/verify-cites.test.mjs +151 -5
- package/src/adapters/banal/failure.test.ts +70 -1
- package/src/adapters/banal/index.test.ts +16 -0
- package/src/adapters/banal/output.test.ts +5 -0
- package/src/adapters/curl/download.test.ts +43 -1
- package/src/adapters/memory/index.test.ts +25 -0
- package/src/adapters/node/files.io.ts +3 -2
- package/src/adapters/node/files.test.ts +13 -1
- package/src/adapters/node/process.test.ts +38 -0
- package/src/adapters/pdfjs/fill.test.ts +34 -0
- package/src/adapters/pdfjs/fill.ts +44 -25
- package/src/adapters/references/clock.io.ts +8 -0
- package/src/adapters/references/index.test.ts +560 -0
- package/src/adapters/references/index.ts +203 -19
- package/src/adapters/references/pool.test.ts +31 -0
- package/src/adapters/references/pool.ts +24 -0
- package/src/build-engine.harness.mjs +3 -7
- package/src/build-engine.test.ts +137 -0
- package/src/build.harness.mjs +5 -9
- package/src/build.test.ts +362 -0
- package/src/build.ts +23 -12
- package/src/cli.harness.mjs +10 -17
- package/src/cli.test.ts +584 -0
- package/src/cli.ts +112 -66
- package/src/doctor.harness.mjs +3 -8
- package/src/doctor.test.ts +64 -0
- package/src/doctor.ts +12 -4
- package/src/domain/lookup-cache.test.ts +178 -0
- package/src/domain/lookup-cache.ts +202 -0
- package/src/domain/result.test.ts +4 -0
- package/src/domain/result.ts +0 -5
- package/src/engine.harness.mjs +27 -7
- package/src/facts-file.test.ts +110 -0
- package/src/hooks-settings.harness.mjs +43 -13
- package/src/hooks-settings.ts +41 -35
- package/src/init.test.ts +308 -5
- package/src/init.ts +3 -3
- package/src/latex-log.harness.mjs +32 -7
- package/src/latex-log.ts +6 -13
- package/src/latex-loop.harness.mjs +8 -7
- package/src/link-skills.harness.mjs +3 -8
- package/src/link-skills.test.ts +105 -0
- package/src/link-skills.ts +75 -21
- package/src/new-paper.harness.mjs +3 -7
- package/src/new-paper.test.ts +31 -1
- package/src/paper-files.test.ts +12 -0
- package/src/paper-files.ts +9 -5
- package/src/paper-settings.test.ts +20 -0
- package/src/pdf-facts.harness.mjs +3 -6
- package/src/pdf-facts.test.ts +144 -0
- package/src/pdf-facts.ts +17 -19
- package/src/pdf-geometry.harness.mjs +3 -7
- package/src/pdf-geometry.ts +1 -1
- package/src/ports/check-references.ts +16 -2
- package/src/presets.test.ts +2 -0
- package/src/presets.ts +7 -1
- package/src/references.test.ts +257 -10
- package/src/references.ts +93 -17
- package/src/rules-config.harness.mjs +10 -8
- package/src/structure.harness.mjs +3 -7
- package/src/tex-requirements.harness.mjs +34 -7
- package/src/tex-requirements.ts +12 -6
- package/src/toolchain.harness.mjs +3 -7
- package/src/toolchain.test.ts +280 -0
- package/src/toolchain.ts +8 -7
- package/src/venue-rules.test.ts +60 -0
- package/src/venue-rules.ts +13 -11
- package/action.mutations.mjs +0 -162
- package/eslint-rules/install-path-literals.mutations.mjs +0 -62
- package/eslint-rules/latex-language.mutations.mjs +0 -196
- package/eslint-rules/paper-research-question.mutations.mjs +0 -127
- package/eslint-rules/paper-stages.mutations.mjs +0 -81
- package/eslint-rules/papers.mutations.mjs +0 -186
- package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
- package/eslint-rules/tex-build.mutations.mjs +0 -258
- package/hooks/hooks.mutations.mjs +0 -337
- package/lib/agent-cli-version.mutations.mjs +0 -109
- package/lib/mutation-driver.harness.mjs +0 -227
- package/lib/mutation-driver.mjs +0 -397
- package/lib/mutation-driver.mutations.mjs +0 -68
- package/lib/paper-config.mutations.mjs +0 -143
- package/lib/skill-trigger-cases.mutations.mjs +0 -65
- package/scripts/check.mutations.mjs +0 -110
- package/scripts/exclusive.mjs +0 -138
- package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
- package/scripts/mutation-batteries-frozen.mjs +0 -238
- package/scripts/mutation-batteries.frozen.json +0 -108
- package/scripts/rules-are-content-only.mutations.mjs +0 -81
- package/scripts/rules-see-files.mutations.mjs +0 -131
- package/scripts/run-mutations.mjs +0 -100
- package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
- package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
- package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
- package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
- package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
- package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
- package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
- package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
- package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
- package/skills/skill-contract.mutations.mjs +0 -179
- package/src/build.mutations.mjs +0 -196
- package/src/cli.mutations.mjs +0 -414
- package/src/doctor.mutations.mjs +0 -175
- package/src/hooks-settings.mutations.mjs +0 -99
- package/src/link-skills.mutations.mjs +0 -140
- package/src/new-paper.mutations.mjs +0 -65
- package/src/structure.mutations.mjs +0 -83
|
@@ -298,9 +298,9 @@ assert.equal(
|
|
|
298
298
|
|
|
299
299
|
// 🔴 RELATIVE, AND `/`-SEPARATED, ASSERTED BEFORE THE EQUALITIES BELOW — and the order is a
|
|
300
300
|
// finding, not a preference. `assert` aborts at the first failure, so with this line placed
|
|
301
|
-
// after them a resolver that returned an absolute path died on "must be returned as written"
|
|
302
|
-
//
|
|
303
|
-
// nothing showed it can fail. The narrow property goes first; the equalities then cover the rest.
|
|
301
|
+
// after them a resolver that returned an absolute path died on "must be returned as written" —
|
|
302
|
+
// a RED result, but for a different case than this one, i.e. this assertion was never reached
|
|
303
|
+
// and nothing showed it can fail. The narrow property goes first; the equalities then cover the rest.
|
|
304
304
|
// The value is compared against text a human typed inside a SKILL.md, so an absolute path makes
|
|
305
305
|
// every `startsWith` false — not an error, an empty loop body.
|
|
306
306
|
assert.equal(
|
|
@@ -192,27 +192,32 @@ export function consumerSkillsDir(opts) {
|
|
|
192
192
|
* in the port and the TypeScript writer imports it, as `src/cli.ts` already does for `isMain`.
|
|
193
193
|
*
|
|
194
194
|
* @param dir the directory to list. A missing directory throws `ENOENT` from `readdirSync`.
|
|
195
|
+
* @param fs the four calls it makes; a test passes a fake to stage what a real disk only does
|
|
196
|
+
* in a race (an entry gone between the listing and the stat).
|
|
195
197
|
* @returns the skill names, sorted.
|
|
196
198
|
*/
|
|
197
|
-
export function installedSkills(
|
|
199
|
+
export function installedSkills(
|
|
200
|
+
dir,
|
|
201
|
+
fs = { readdirSync, statSync, existsSync, readlinkSync },
|
|
202
|
+
) {
|
|
198
203
|
const names = [];
|
|
199
204
|
const dangling = [];
|
|
200
|
-
for (const name of readdirSync(dir)) {
|
|
205
|
+
for (const name of fs.readdirSync(dir)) {
|
|
201
206
|
const entry = join(dir, name);
|
|
202
207
|
let st;
|
|
203
208
|
try {
|
|
204
|
-
st = statSync(entry); // FOLLOWS the link — the question is where the entry LEADS
|
|
209
|
+
st = fs.statSync(entry); // FOLLOWS the link — the question is where the entry LEADS
|
|
205
210
|
} catch (e) {
|
|
206
211
|
// The entry was listed a moment ago, so a failed stat is a link that leads nowhere
|
|
207
212
|
// (ENOENT) or in a circle (ELOOP) — not an absence.
|
|
208
213
|
dangling.push({
|
|
209
214
|
name,
|
|
210
|
-
target: readlinkOr(entry),
|
|
215
|
+
target: readlinkOr(entry, fs.readlinkSync),
|
|
211
216
|
cause: e.code ?? "error",
|
|
212
217
|
});
|
|
213
218
|
continue;
|
|
214
219
|
}
|
|
215
|
-
if (st.isDirectory() && existsSync(join(entry, "SKILL.md")))
|
|
220
|
+
if (st.isDirectory() && fs.existsSync(join(entry, "SKILL.md")))
|
|
216
221
|
names.push(name);
|
|
217
222
|
}
|
|
218
223
|
if (dangling.length) {
|
|
@@ -233,11 +238,11 @@ export function installedSkills(dir) {
|
|
|
233
238
|
}
|
|
234
239
|
|
|
235
240
|
/** Where a link points, for the message — or a placeholder when it is not a link at all. */
|
|
236
|
-
function readlinkOr(entry) {
|
|
241
|
+
function readlinkOr(entry, readlink) {
|
|
237
242
|
try {
|
|
238
|
-
return
|
|
243
|
+
return readlink(entry);
|
|
239
244
|
} catch {
|
|
240
|
-
return "(not a link)";
|
|
245
|
+
return "(not a link)"; // a plain entry gone between the listing and the stat
|
|
241
246
|
}
|
|
242
247
|
}
|
|
243
248
|
|
|
@@ -295,23 +300,6 @@ export function ledgerPath(
|
|
|
295
300
|
return join(hereDir, "runs.jsonl");
|
|
296
301
|
}
|
|
297
302
|
|
|
298
|
-
/**
|
|
299
|
-
* `ledgerPath()` plus the assurance that the directory holding it exists.
|
|
300
|
-
*
|
|
301
|
-
* Split out rather than folded in, because the two failures are different: an undeclared ledger
|
|
302
|
-
* is a setup mistake the message above can fix, while a declared-but-missing directory may be
|
|
303
|
-
* the very thing the caller is about to create.
|
|
304
|
-
*/
|
|
305
|
-
export function ledgerPathExisting(hereDir, opts) {
|
|
306
|
-
const p = ledgerPath(hereDir, opts);
|
|
307
|
-
if (!existsSync(p))
|
|
308
|
-
throw new Error(
|
|
309
|
-
`${CONFIG_KEY}: the ledger "${p}" does not exist. Create it (an empty file is a valid ` +
|
|
310
|
-
`empty ledger) or fix the "ledger" declaration in ${CONFIG_FILE}.`,
|
|
311
|
-
);
|
|
312
|
-
return p;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
303
|
// ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
316
304
|
// THE FOURTH CARRIER — WHERE A SKILL'S PROSE NAMES THESE SCRIPTS
|
|
317
305
|
//
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* consumer.mjs carriers the harness does not reach: isMain without a real entry point, a dangling
|
|
3
|
+
* skill link, the ledger that must exist, the declared time zone, and the contact address.
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { afterEach, test } from "vitest";
|
|
9
|
+
import { useTempDir, writeTree } from "../../../test/support.mjs";
|
|
10
|
+
import {
|
|
11
|
+
consumerContactEmail,
|
|
12
|
+
consumerTimezone,
|
|
13
|
+
installedSkills,
|
|
14
|
+
isMain,
|
|
15
|
+
} from "./consumer.mjs";
|
|
16
|
+
|
|
17
|
+
const root = useTempDir("consumer-");
|
|
18
|
+
const argv1 = process.argv[1];
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
process.argv[1] = argv1;
|
|
21
|
+
});
|
|
22
|
+
/** A consumer root whose paperlint.json holds `settings`. */
|
|
23
|
+
const consumer = (name, settings) =>
|
|
24
|
+
writeTree(join(root, name), {
|
|
25
|
+
"paperlint.json": JSON.stringify(settings),
|
|
26
|
+
});
|
|
27
|
+
const at = (dir) => ({ env: {}, cwd: dir });
|
|
28
|
+
|
|
29
|
+
test("isMain: no entry point is never main; an entry that is not on disk is compared as written", () => {
|
|
30
|
+
process.argv[1] = undefined;
|
|
31
|
+
assert.equal(isMain("file:///x.mjs"), false);
|
|
32
|
+
process.argv[1] = "/no/such/entry.mjs";
|
|
33
|
+
assert.equal(isMain("file:///no/such/entry.mjs"), true);
|
|
34
|
+
assert.equal(isMain("file:///other.mjs"), false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("installedSkills: a link that leads nowhere is refused by name, with the errno", () => {
|
|
38
|
+
const dir = join(root, "skills");
|
|
39
|
+
mkdirSync(join(dir, "ok"), { recursive: true });
|
|
40
|
+
writeFileSync(join(dir, "ok", "SKILL.md"), "# ok\n");
|
|
41
|
+
symlinkSync(join(root, "gone"), join(dir, "dead"));
|
|
42
|
+
assert.throws(() => installedSkills(dir), {
|
|
43
|
+
code: "DANGLING_SKILL_LINK",
|
|
44
|
+
dangling: [{ name: "dead", target: join(root, "gone"), cause: "ENOENT" }],
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("installedSkills: an entry gone between the listing and the stat is refused as '(not a link)'; an error without a code says 'error'", () => {
|
|
49
|
+
const fake = (statError) => ({
|
|
50
|
+
readdirSync: () => ["gone"],
|
|
51
|
+
statSync: () => {
|
|
52
|
+
throw statError;
|
|
53
|
+
},
|
|
54
|
+
existsSync: () => false,
|
|
55
|
+
readlinkSync: () => {
|
|
56
|
+
throw Object.assign(new Error("EINVAL"), { code: "EINVAL" });
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
assert.throws(
|
|
60
|
+
() =>
|
|
61
|
+
installedSkills(
|
|
62
|
+
"/s",
|
|
63
|
+
fake(Object.assign(new Error("ENOENT"), { code: "ENOENT" })),
|
|
64
|
+
),
|
|
65
|
+
{ dangling: [{ name: "gone", target: "(not a link)", cause: "ENOENT" }] },
|
|
66
|
+
);
|
|
67
|
+
assert.throws(() => installedSkills("/s", fake(new Error("odd"))), {
|
|
68
|
+
dangling: [{ name: "gone", target: "(not a link)", cause: "error" }],
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("consumerTimezone: UTC by default, the declared zone, and loud refusals of null and a typo", () => {
|
|
73
|
+
assert.equal(consumerTimezone(at(join(root, "nothing-declared"))), "UTC");
|
|
74
|
+
assert.equal(
|
|
75
|
+
consumerTimezone(at(consumer("tz", { timezone: "Europe/Berlin" }))),
|
|
76
|
+
"Europe/Berlin",
|
|
77
|
+
);
|
|
78
|
+
assert.throws(
|
|
79
|
+
() => consumerTimezone(at(consumer("tz-null", { timezone: null }))),
|
|
80
|
+
new TypeError(
|
|
81
|
+
'paperlint: "timezone" must be a non-empty IANA zone string, got null',
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
assert.throws(
|
|
85
|
+
() =>
|
|
86
|
+
consumerTimezone(at(consumer("tz-typo", { timezone: "Europe/Berlinn" }))),
|
|
87
|
+
{
|
|
88
|
+
name: "RangeError",
|
|
89
|
+
message:
|
|
90
|
+
/^paperlint: "timezone" is "Europe\/Berlinn", which is not an IANA time zone/,
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("consumerContactEmail: absent or null is the public pool; a non-address is refused", () => {
|
|
96
|
+
assert.equal(
|
|
97
|
+
consumerContactEmail(at(consumer("mail-null", { contactEmail: null }))),
|
|
98
|
+
null,
|
|
99
|
+
);
|
|
100
|
+
assert.equal(
|
|
101
|
+
consumerContactEmail(
|
|
102
|
+
at(consumer("mail", { contactEmail: "me@example.org" })),
|
|
103
|
+
),
|
|
104
|
+
"me@example.org",
|
|
105
|
+
);
|
|
106
|
+
assert.throws(
|
|
107
|
+
() =>
|
|
108
|
+
consumerContactEmail(at(consumer("mail-bad", { contactEmail: "me" }))),
|
|
109
|
+
new TypeError(
|
|
110
|
+
'paperlint: "contactEmail" must be an email address, got "me". Remove the key entirely to use the public pool.',
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
@@ -212,7 +212,7 @@ function dropEtAl(authors) {
|
|
|
212
212
|
return { authors: out.filter(Boolean), truncated: true };
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
const yearIn = (s) => (/\b(19|20)\d{2}\b/.exec(s
|
|
215
|
+
const yearIn = (s) => (/\b(19|20)\d{2}\b/.exec(s) ?? [null])[0];
|
|
216
216
|
|
|
217
217
|
// ── parsing: .bib WITH A REAL PARSER ─────────────────────────────────────────
|
|
218
218
|
|
|
@@ -254,10 +254,13 @@ const joinName = (a) =>
|
|
|
254
254
|
* the same, and "the bibliography was not checked" reads as "the bibliography is fine". That is why
|
|
255
255
|
* the message names the command, not the fact.
|
|
256
256
|
*/
|
|
257
|
-
export async function parseBib(
|
|
257
|
+
export async function parseBib(
|
|
258
|
+
text,
|
|
259
|
+
{ load = () => import("@retorquere/bibtex-parser") } = {},
|
|
260
|
+
) {
|
|
258
261
|
let parse;
|
|
259
262
|
try {
|
|
260
|
-
({ parse } = await
|
|
263
|
+
({ parse } = await load());
|
|
261
264
|
} catch (e) {
|
|
262
265
|
if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e;
|
|
263
266
|
throw new Error(
|
|
@@ -289,6 +292,9 @@ export async function parseBib(text) {
|
|
|
289
292
|
.filter((a) => !isOthers(a))
|
|
290
293
|
.map(joinName)
|
|
291
294
|
.filter(Boolean);
|
|
295
|
+
// A field is a string in today's parser output (list fields such as `keywords` are arrays,
|
|
296
|
+
// and none of those is read); an array or a number from another parser version is joined or
|
|
297
|
+
// printed rather than dropped.
|
|
292
298
|
const str = (v) =>
|
|
293
299
|
Array.isArray(v)
|
|
294
300
|
? v.join(" ")
|
|
@@ -477,7 +483,6 @@ export function loadCache(path) {
|
|
|
477
483
|
}
|
|
478
484
|
|
|
479
485
|
function saveCache(path, cache) {
|
|
480
|
-
if (!path) return;
|
|
481
486
|
mkdirSync(dirname(path), { recursive: true });
|
|
482
487
|
writeFileSync(path, `${JSON.stringify(cache, null, 1)}\n`);
|
|
483
488
|
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extract-ref-facts.mjs beyond its harness: the edges of each reader (markdown list, .bib, CrossRef,
|
|
3
|
+
* arXiv, cache) and the command itself — sources, flags, the network loop (with `fetch` stubbed in
|
|
4
|
+
* the child process), and the refusals.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { test } from "vitest";
|
|
11
|
+
import { runNode, useTempDir, writeTree } from "../../../test/support.mjs";
|
|
12
|
+
import {
|
|
13
|
+
buildFacts,
|
|
14
|
+
extractIds,
|
|
15
|
+
loadCache,
|
|
16
|
+
loadEntries,
|
|
17
|
+
parseBib,
|
|
18
|
+
parseMarkdownRefs,
|
|
19
|
+
primaryOf,
|
|
20
|
+
readArxiv,
|
|
21
|
+
readCrossref,
|
|
22
|
+
recordFrom,
|
|
23
|
+
resolveSource,
|
|
24
|
+
} from "./extract-ref-facts.mjs";
|
|
25
|
+
|
|
26
|
+
const SCRIPT = join(
|
|
27
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
28
|
+
"extract-ref-facts.mjs",
|
|
29
|
+
);
|
|
30
|
+
const root = useTempDir("extract-ref-facts-");
|
|
31
|
+
|
|
32
|
+
test("markdown: the list ends at the file's end or a second References heading; lines continue an entry", () => {
|
|
33
|
+
assert.deepEqual(parseMarkdownRefs("# P\n\n## References"), []);
|
|
34
|
+
const refs = parseMarkdownRefs(
|
|
35
|
+
[
|
|
36
|
+
"## References",
|
|
37
|
+
"",
|
|
38
|
+
"1. A. Author, B. Author. *A Title.* Venue,",
|
|
39
|
+
" continued 2021.",
|
|
40
|
+
"2. *Untitled Authors.* 2019 note",
|
|
41
|
+
"3. C. Writer 2018. *Late Year.* Venue",
|
|
42
|
+
"4. No title here at all",
|
|
43
|
+
"",
|
|
44
|
+
"## References",
|
|
45
|
+
"5. Never read. *X.* 2000",
|
|
46
|
+
].join("\n"),
|
|
47
|
+
);
|
|
48
|
+
assert.deepEqual(
|
|
49
|
+
refs.map(({ n, line, authors, title, year, venue_text }) => ({
|
|
50
|
+
n,
|
|
51
|
+
line,
|
|
52
|
+
authors,
|
|
53
|
+
title,
|
|
54
|
+
year,
|
|
55
|
+
venue_text,
|
|
56
|
+
})),
|
|
57
|
+
[
|
|
58
|
+
{
|
|
59
|
+
n: 1,
|
|
60
|
+
line: 3,
|
|
61
|
+
authors: ["A. Author", "B. Author"],
|
|
62
|
+
title: "A Title",
|
|
63
|
+
year: "2021",
|
|
64
|
+
venue_text: "Venue, continued 2021.",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
n: 2,
|
|
68
|
+
line: 5,
|
|
69
|
+
authors: [],
|
|
70
|
+
title: "Untitled Authors",
|
|
71
|
+
year: "2019",
|
|
72
|
+
venue_text: "2019 note",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
n: 3,
|
|
76
|
+
line: 6,
|
|
77
|
+
authors: ["C. Writer 2018"],
|
|
78
|
+
title: "Late Year",
|
|
79
|
+
year: "2018",
|
|
80
|
+
venue_text: "Venue",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
n: 4,
|
|
84
|
+
line: 7,
|
|
85
|
+
authors: [],
|
|
86
|
+
title: null,
|
|
87
|
+
year: null,
|
|
88
|
+
venue_text: "No title here at all",
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test(".bib: a key the text spells with spaces has no line; a missing title or year is null, not empty", async () => {
|
|
95
|
+
const [entry] = await parseBib(
|
|
96
|
+
"@misc{ spaced ,\n note={see arXiv:2101.00001}\n}\n",
|
|
97
|
+
);
|
|
98
|
+
assert.deepEqual(
|
|
99
|
+
{
|
|
100
|
+
line: entry.line,
|
|
101
|
+
title: entry.title,
|
|
102
|
+
year: entry.year,
|
|
103
|
+
authors: entry.authors,
|
|
104
|
+
raw: entry.raw,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
line: 0,
|
|
108
|
+
title: null,
|
|
109
|
+
year: null,
|
|
110
|
+
authors: [],
|
|
111
|
+
raw: "see arXiv:2101.00001",
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test(".bib from a parser that returns no fields, or list and number fields: joined and printed, not dropped", async () => {
|
|
117
|
+
const load = async () => ({
|
|
118
|
+
parse: () => ({
|
|
119
|
+
entries: [
|
|
120
|
+
{ key: "bare" },
|
|
121
|
+
{
|
|
122
|
+
key: "odd",
|
|
123
|
+
fields: {
|
|
124
|
+
title: ["Split", "Title"],
|
|
125
|
+
year: 2021,
|
|
126
|
+
note: null,
|
|
127
|
+
author: [{ firstName: "Ada", lastName: "Lovelace" }],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
const [bare, odd] = await parseBib("@misc{bare,}\n@misc{odd,}\n", { load });
|
|
134
|
+
assert.deepEqual(
|
|
135
|
+
[
|
|
136
|
+
bare,
|
|
137
|
+
{ title: odd.title, year: odd.year, authors: odd.authors, raw: odd.raw },
|
|
138
|
+
],
|
|
139
|
+
[
|
|
140
|
+
{
|
|
141
|
+
n: 1,
|
|
142
|
+
line: 1,
|
|
143
|
+
key: "bare",
|
|
144
|
+
raw: "",
|
|
145
|
+
authors: [],
|
|
146
|
+
truncated: false,
|
|
147
|
+
title: null,
|
|
148
|
+
year: null,
|
|
149
|
+
venue_text: "",
|
|
150
|
+
doi_field: null,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
title: "Split Title",
|
|
154
|
+
year: "2021",
|
|
155
|
+
authors: ["Ada Lovelace"],
|
|
156
|
+
raw: "Ada Lovelace Split Title 2021",
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("identifiers: a declared DOI field comes first; an entry without text fields has none", () => {
|
|
163
|
+
const ids = extractIds({
|
|
164
|
+
doi_field: "10.1111/field",
|
|
165
|
+
raw: "doi:10.2222/text.",
|
|
166
|
+
});
|
|
167
|
+
assert.deepEqual(ids, { arxiv: [], doi: ["10.1111/field", "10.2222/text"] });
|
|
168
|
+
assert.equal(primaryOf(ids), "doi:10.1111/field");
|
|
169
|
+
const none = extractIds({});
|
|
170
|
+
assert.deepEqual(none, { arxiv: [], doi: [] });
|
|
171
|
+
assert.equal(primaryOf(none), null);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("CrossRef: every fallback — no message, the date sources, scalar title/subtitle/venue, nameless authors", () => {
|
|
175
|
+
assert.equal(readCrossref("{}"), null);
|
|
176
|
+
const rec = (message) => readCrossref(JSON.stringify({ message }));
|
|
177
|
+
assert.deepEqual(
|
|
178
|
+
rec({
|
|
179
|
+
title: "Scalar",
|
|
180
|
+
subtitle: "Sub",
|
|
181
|
+
author: [{ family: "F" }, { name: "Org" }, {}],
|
|
182
|
+
"container-title": "Journal",
|
|
183
|
+
"published-print": { "date-parts": [[2019]] },
|
|
184
|
+
}),
|
|
185
|
+
{
|
|
186
|
+
title: "Scalar: Sub",
|
|
187
|
+
authors: ["F", "Org", ""],
|
|
188
|
+
year: "2019",
|
|
189
|
+
venue: "Journal",
|
|
190
|
+
journalRef: null,
|
|
191
|
+
doi: null,
|
|
192
|
+
error: false,
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
assert.equal(
|
|
196
|
+
rec({ title: ["T"], "published-online": { "date-parts": [[2018]] } }).year,
|
|
197
|
+
"2018",
|
|
198
|
+
);
|
|
199
|
+
assert.deepEqual(rec({ title: ["T"] }), {
|
|
200
|
+
title: "T",
|
|
201
|
+
authors: [],
|
|
202
|
+
year: null,
|
|
203
|
+
venue: undefined,
|
|
204
|
+
journalRef: null,
|
|
205
|
+
doi: null,
|
|
206
|
+
error: false,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("arXiv: an entry with no published date and no title", () => {
|
|
211
|
+
assert.deepEqual(readArxiv("<feed><entry><id>x</id></entry></feed>"), {
|
|
212
|
+
title: null,
|
|
213
|
+
authors: [],
|
|
214
|
+
year: null,
|
|
215
|
+
venue: null,
|
|
216
|
+
journalRef: null,
|
|
217
|
+
doi: null,
|
|
218
|
+
error: false,
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a record: a hand-written cache entry with no status, an undated response, and a 200 naming no work", () => {
|
|
223
|
+
assert.deepEqual(recordFrom("arxiv:2101.00001", {}), {
|
|
224
|
+
cached: true,
|
|
225
|
+
registry: "arxiv",
|
|
226
|
+
httpStatus: null,
|
|
227
|
+
fetched: null,
|
|
228
|
+
});
|
|
229
|
+
assert.deepEqual(
|
|
230
|
+
recordFrom("doi:10.1234/x", { httpStatus: 200, body: "{}" }),
|
|
231
|
+
{
|
|
232
|
+
cached: true,
|
|
233
|
+
registry: "crossref",
|
|
234
|
+
httpStatus: 200,
|
|
235
|
+
fetched: null,
|
|
236
|
+
found: false,
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("cache: no path and a corrupt file both read as empty", () => {
|
|
242
|
+
const corrupt = join(root, "corrupt.json");
|
|
243
|
+
writeFileSync(corrupt, "{ nope");
|
|
244
|
+
assert.deepEqual([loadCache(undefined), loadCache(corrupt)], [{}, {}]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("sources: a file names itself; a .bib is read as BibTeX; facts without a cache say so", async () => {
|
|
248
|
+
const bib = join(root, "one.bib");
|
|
249
|
+
writeFileSync(bib, "@misc{k, title={T}, year={2020}}\n");
|
|
250
|
+
assert.equal(resolveSource(bib), bib);
|
|
251
|
+
const entries = await loadEntries(bib);
|
|
252
|
+
const facts = buildFacts({
|
|
253
|
+
source: bib,
|
|
254
|
+
text: "",
|
|
255
|
+
entries,
|
|
256
|
+
cache: {},
|
|
257
|
+
cachePath: null,
|
|
258
|
+
});
|
|
259
|
+
assert.deepEqual([facts.source_kind, facts.cache], ["bibtex", null]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ── the command ─────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
const CROSSREF_OK = JSON.stringify({
|
|
265
|
+
message: {
|
|
266
|
+
title: ["Found"],
|
|
267
|
+
author: [{ family: "A" }],
|
|
268
|
+
issued: { "date-parts": [[2020]] },
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
writeTree(root, {
|
|
272
|
+
"paper/paper.md":
|
|
273
|
+
"## References\n\n1. A. A. *Found.* doi:10.1234/ok\n2. B. B. *Lost.* doi:10.1234/down\n",
|
|
274
|
+
"empty/paper.md": "# Nothing cited\n",
|
|
275
|
+
// `fetch` in the child: 10.1234/down fails at the transport, anything else is a CrossRef hit.
|
|
276
|
+
"fetch.mjs":
|
|
277
|
+
`globalThis.fetch = async (url) => {\n` +
|
|
278
|
+
` if (url.includes("down")) throw new Error("network down");\n` +
|
|
279
|
+
` return { status: 200, text: async () => ${JSON.stringify(CROSSREF_OK)} };\n` +
|
|
280
|
+
`};\n`,
|
|
281
|
+
// The optional .bib parser, uninstalled — and broken in some other way.
|
|
282
|
+
"no-parser.mjs":
|
|
283
|
+
"import { register } from 'node:module';\n" +
|
|
284
|
+
"register('data:text/javascript,' + encodeURIComponent(\"export async function resolve(s, c, next) { if (s === '@retorquere/bibtex-parser') throw Object.assign(new Error('gone'), { code: 'ERR_MODULE_NOT_FOUND' }); return next(s, c); }\"));\n",
|
|
285
|
+
"bad-parser.mjs":
|
|
286
|
+
"import { register } from 'node:module';\n" +
|
|
287
|
+
"register('data:text/javascript,' + encodeURIComponent(\"export async function resolve(s, c, next) { if (s === '@retorquere/bibtex-parser') throw Object.assign(new Error('broken install'), { code: 'EACCES' }); return next(s, c); }\"));\n",
|
|
288
|
+
"bib/refs.bib": "@misc{k, title={T}, note={doi:10.1234/ok}}\n",
|
|
289
|
+
});
|
|
290
|
+
const cli = (args, { cwd = root, nodeArgs = [] } = {}) =>
|
|
291
|
+
runNode(SCRIPT, args, {
|
|
292
|
+
cwd,
|
|
293
|
+
nodeArgs,
|
|
294
|
+
env: { CLAUDE_PROJECT_DIR: root },
|
|
295
|
+
});
|
|
296
|
+
const FETCH = ["--import", join(root, "fetch.mjs")];
|
|
297
|
+
|
|
298
|
+
test("online: hits are fetched and cached, a transport failure is named and stays missing", () => {
|
|
299
|
+
const r = cli(["paper"], { nodeArgs: FETCH });
|
|
300
|
+
assert.deepEqual(
|
|
301
|
+
{
|
|
302
|
+
status: r.status,
|
|
303
|
+
stdout: r.stdout,
|
|
304
|
+
stderr: r.stderr,
|
|
305
|
+
cached: Object.keys(
|
|
306
|
+
JSON.parse(readFileSync(join(root, "paper/repro/refs-cache.json"))),
|
|
307
|
+
),
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
status: 0,
|
|
311
|
+
stdout:
|
|
312
|
+
"📚 paper.md → paper/_build/refs.facts.json (2 entries, 2 identifiers, 1 resolved, +1 fetched)\n",
|
|
313
|
+
stderr: " … doi:10.1234/down: network down\n",
|
|
314
|
+
cached: ["doi:10.1234/ok"],
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("online again: cached keys are not refetched; --quiet hides the failure; --refresh refetches", () => {
|
|
320
|
+
const again = cli(["paper", "--quiet"], { nodeArgs: FETCH });
|
|
321
|
+
assert.deepEqual(again, {
|
|
322
|
+
status: 0,
|
|
323
|
+
stdout:
|
|
324
|
+
"📚 paper.md → paper/_build/refs.facts.json (2 entries, 2 identifiers, 1 resolved, all from cache)\n",
|
|
325
|
+
stderr: "",
|
|
326
|
+
});
|
|
327
|
+
const refresh = cli(["paper", "--refresh", "--quiet"], { nodeArgs: FETCH });
|
|
328
|
+
assert.match(refresh.stdout, /1 resolved, \+1 fetched\)\n$/);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("the paper file itself as the target, with --cache, --out and --offline", () => {
|
|
332
|
+
const r = cli([
|
|
333
|
+
join(root, "paper/paper.md"),
|
|
334
|
+
"--offline",
|
|
335
|
+
`--cache=${join(root, "paper/repro/refs-cache.json")}`,
|
|
336
|
+
`--out=${join(root, "out/facts.json")}`,
|
|
337
|
+
]);
|
|
338
|
+
assert.deepEqual(r, {
|
|
339
|
+
status: 0,
|
|
340
|
+
stdout:
|
|
341
|
+
"📚 paper.md → out/facts.json (2 entries, 2 identifiers, 1 resolved, offline)\n",
|
|
342
|
+
stderr: "",
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("no target: the current directory", () => {
|
|
347
|
+
const r = cli(["--offline"], { cwd: join(root, "paper") });
|
|
348
|
+
assert.equal(r.status, 0, r.stderr);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("refusals: nowhere to read from, and zero entries (not written as a clean bibliography)", () => {
|
|
352
|
+
assert.deepEqual(cli([join(root, "nope"), "--offline"]), {
|
|
353
|
+
status: 1,
|
|
354
|
+
stdout: "",
|
|
355
|
+
stderr: `🛑 no paper.md, draft.md, refs.bib, build/custom.bib under ${join(root, "nope")} — nowhere to take a bibliography from.\n`,
|
|
356
|
+
});
|
|
357
|
+
assert.deepEqual(cli(["empty", "--offline"]), {
|
|
358
|
+
status: 1,
|
|
359
|
+
stdout: "",
|
|
360
|
+
stderr:
|
|
361
|
+
"🛑 parsed 0 entries out of empty/paper.md. Silence here would look like a clean bibliography.\n",
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test(".bib without its optional parser: the error names the install; any other failure is not masked", () => {
|
|
366
|
+
const missing = cli(["bib", "--offline"], {
|
|
367
|
+
nodeArgs: ["--import", join(root, "no-parser.mjs")],
|
|
368
|
+
});
|
|
369
|
+
assert.equal(missing.status, 1);
|
|
370
|
+
assert.match(
|
|
371
|
+
missing.stderr,
|
|
372
|
+
/parsing \.bib requires @retorquere\/bibtex-parser[\s\S]*Install: {2}npm i -D @retorquere\/bibtex-parser/,
|
|
373
|
+
);
|
|
374
|
+
const broken = cli(["bib", "--offline"], {
|
|
375
|
+
nodeArgs: ["--import", join(root, "bad-parser.mjs")],
|
|
376
|
+
});
|
|
377
|
+
assert.equal(broken.status, 1);
|
|
378
|
+
assert.match(broken.stderr, /broken install/);
|
|
379
|
+
assert.doesNotMatch(broken.stderr, /npm i -D/);
|
|
380
|
+
});
|