paperlint 3.0.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +28 -41
- package/.github/workflows/dependabot-automerge.yml +1 -1
- package/CLAUDE.md +51 -56
- package/CONTRIBUTING.md +30 -18
- package/README.md +179 -180
- 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/index.d.ts.map +1 -1
- package/dist/adapters/references/index.js +14 -5
- 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-engine.d.ts.map +1 -1
- package/dist/build-engine.js +6 -2
- package/dist/build-engine.js.map +1 -1
- package/dist/build.d.ts +10 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +29 -11
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +93 -6
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +296 -80
- 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/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/engine.d.ts +9 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +27 -0
- package/dist/engine.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 +4 -4
- 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/new-paper.d.ts +8 -1
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +50 -15
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-files.d.ts +30 -8
- package/dist/paper-files.d.ts.map +1 -1
- package/dist/paper-files.js +59 -30
- 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/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.js +2 -2
- package/dist/references.js.map +1 -1
- package/dist/structure.js +1 -1
- package/dist/structure.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 +8 -3
- package/dist/toolchain.d.ts.map +1 -1
- package/dist/toolchain.js +26 -10
- package/dist/toolchain.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +12 -10
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +48 -4
- package/docs/prior-art/README.md +1 -1
- package/docs/prior-art/test-tooling.md +7 -30
- package/docs/rules.md +29 -25
- package/docs/skills.md +80 -0
- 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 +56 -9
- package/eslint-rules/paper-typography.test.mjs +186 -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.schema.json +1 -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/fixtures/toolchain-mirror/catalog.txt +1 -0
- package/fixtures/toolchain-mirror/install-tl +4 -1
- package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
- package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
- 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/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/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 +5 -5
- package/skills/verify-citations/SKILL.md.spec.ts +4 -4
- package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +235 -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 +2 -1
- package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
- package/skills/verify-citations/scripts/verify-cites.mjs +96 -96
- package/skills/verify-citations/scripts/verify-cites.net.test.mjs +287 -0
- package/skills/verify-citations/scripts/verify-cites.test.mjs +149 -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/index.test.ts +228 -0
- package/src/adapters/references/index.ts +21 -6
- 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-engine.ts +6 -1
- package/src/build.harness.mjs +51 -11
- package/src/build.test.ts +362 -0
- package/src/build.ts +40 -14
- package/src/cli.harness.mjs +12 -19
- package/src/cli.test.ts +584 -0
- package/src/cli.ts +409 -94
- package/src/doctor.harness.mjs +3 -8
- package/src/doctor.test.ts +64 -0
- package/src/doctor.ts +12 -4
- package/src/domain/result.test.ts +4 -0
- package/src/domain/result.ts +0 -5
- package/src/engine.harness.mjs +77 -7
- package/src/engine.ts +35 -0
- 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 +4 -4
- 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/linted-set.test.ts +118 -0
- package/src/new-paper.harness.mjs +5 -7
- package/src/new-paper.test.ts +203 -2
- package/src/new-paper.ts +84 -17
- package/src/paper-files.test.ts +122 -5
- package/src/paper-files.ts +99 -39
- package/src/paper-settings-commands.test.ts +41 -5
- package/src/paper-settings.test.ts +20 -0
- package/src/parse-args.test.ts +48 -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/presets.test.ts +2 -0
- package/src/presets.ts +7 -1
- package/src/references.test.ts +97 -0
- package/src/references.ts +2 -2
- package/src/rules-config.harness.mjs +10 -8
- package/src/rules-off.test.ts +111 -0
- package/src/structure.harness.mjs +5 -9
- package/src/structure.ts +1 -1
- package/src/tex-requirements.harness.mjs +34 -7
- package/src/tex-requirements.ts +12 -6
- package/src/toolchain.harness.mjs +44 -13
- package/src/toolchain.test.ts +280 -0
- package/src/toolchain.ts +37 -12
- package/src/types.ts +6 -0
- package/src/venue-rules.test.ts +63 -0
- package/src/venue-rules.ts +14 -12
- package/templates/paper/paperlint.json +1 -1
- 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 -79
- package/src/structure.mutations.mjs +0 -83
|
@@ -45,8 +45,7 @@
|
|
|
45
45
|
* from the neighbouring `CLAUDE.md` exactly as `pdf/profile` reads a venue profile, and the address
|
|
46
46
|
* of the finding is the `## Abstract` line in the paper itself. Parity was taken BEFORE the deletion
|
|
47
47
|
* on `compile-rules-2026/paper.md`: 306 words against a ceiling of 305, one finding in both places,
|
|
48
|
-
* the same four numbers. The test
|
|
49
|
-
* `eslint-rules/paper-craft.{harness,mutations}.mjs`.
|
|
48
|
+
* the same four numbers. The test is `eslint-rules/paper-craft.harness.mjs`.
|
|
50
49
|
*
|
|
51
50
|
* WHAT IS LEFT AND WHY — TWO. The paragraph below was written when there were three, and is kept as
|
|
52
51
|
* a record: they read a SECOND ENTITY on the filesystem, while an ESLint rule sees one linted file:
|
|
@@ -313,11 +312,9 @@ const NOMINAL = /\b\w{4,}(tion|sion|ment|ance|ence|ity|ness)s?\b/gi;
|
|
|
313
312
|
|
|
314
313
|
// JARGON — moved into `eslint-rules/paper-craft.mjs` (the `unexplained-jargon` rule, 2026-08-26).
|
|
315
314
|
|
|
315
|
+
/** Matches of every pattern in the lexicon `pats`, summed. Every caller passes a lexicon array. */
|
|
316
316
|
const count = (t, pats) =>
|
|
317
|
-
|
|
318
|
-
(n, p) => n + (t.match(p) || []).length,
|
|
319
|
-
0,
|
|
320
|
-
);
|
|
317
|
+
pats.reduce((n, p) => n + (t.match(p) || []).length, 0);
|
|
321
318
|
// 🔴 Next to this lived `listHits(t, pats)` — the same walk, but returning THE MATCHES THEMSELVES
|
|
322
319
|
// rather than their count. Nobody called it (`no-unused-vars`, 2026-08-28), and that is not a
|
|
323
320
|
// trifle: the paragraph below explains that a metric was ignored precisely because it could not NAME
|
|
@@ -368,14 +365,17 @@ function splitSentences(t) {
|
|
|
368
365
|
}
|
|
369
366
|
const words = (t) => (t.match(/[A-Za-z0-9%.'’-]+/g) || []).length;
|
|
370
367
|
|
|
371
|
-
|
|
368
|
+
/**
|
|
369
|
+
* The metrics of `text`, whose sentences the caller split and found non-empty — the same array is
|
|
370
|
+
* passed in rather than split again, so the check the caller made is the one the division relies on.
|
|
371
|
+
*/
|
|
372
|
+
function analyse(text, sents) {
|
|
372
373
|
const W = words(text),
|
|
373
374
|
per1k = (n) => +((n / W) * 1000).toFixed(1);
|
|
374
|
-
const sents = splitSentences(text);
|
|
375
375
|
const lens = sents.map(words);
|
|
376
|
-
const mean = lens.reduce((a, b) => a + b, 0) /
|
|
376
|
+
const mean = lens.reduce((a, b) => a + b, 0) / lens.length;
|
|
377
377
|
const sd = Math.sqrt(
|
|
378
|
-
lens.reduce((a, b) => a + (b - mean) ** 2, 0) /
|
|
378
|
+
lens.reduce((a, b) => a + (b - mean) ** 2, 0) / lens.length,
|
|
379
379
|
);
|
|
380
380
|
|
|
381
381
|
const cats = {
|
|
@@ -652,7 +652,21 @@ for (const f of args.filter((a) => !a.startsWith("--"))) {
|
|
|
652
652
|
const raw = fs.readFileSync(f, "utf8");
|
|
653
653
|
const isPdfText = f.endsWith(".txt");
|
|
654
654
|
const prep = isPdfText ? pdfTextOnly : bodyOnly;
|
|
655
|
-
|
|
655
|
+
// 🔴 NOTHING LEFT IS NOT "CLEAN". A body that strips to no words used to be analysed anyway: every
|
|
656
|
+
// metric is a fraction of the word count, so the report printed NaN, FLAGged three metrics and
|
|
657
|
+
// exited 0. Refused like `.tex` above — no number is better than a meaningless one.
|
|
658
|
+
// A body of headings only has words but no sentence (headings are struck out before splitting),
|
|
659
|
+
// and a zero sentence count divides just the same, so the test is on sentences.
|
|
660
|
+
const text = prep(raw);
|
|
661
|
+
const sentences = splitSentences(text);
|
|
662
|
+
if (sentences.length === 0) {
|
|
663
|
+
console.error(
|
|
664
|
+
`prose-lint: ${f} has no prose to measure — after the frontmatter, comments, code ` +
|
|
665
|
+
"blocks, tables and the reference section are stripped, no word is left.",
|
|
666
|
+
);
|
|
667
|
+
process.exit(2);
|
|
668
|
+
}
|
|
669
|
+
const r = analyse(text, sentences);
|
|
656
670
|
if (flagsOnly) {
|
|
657
671
|
const lines = [];
|
|
658
672
|
// Citation density in a paragraph and a pile-up of jargon in a sentence moved on 2026-08-26 into
|
|
@@ -678,7 +692,12 @@ for (const f of args.filter((a) => !a.startsWith("--"))) {
|
|
|
678
692
|
// `hedge-density`, `discourse-subject`, `undefined-coinage`.
|
|
679
693
|
if (lines.length) {
|
|
680
694
|
flagged += lines.length;
|
|
681
|
-
|
|
695
|
+
// The header STATES the count. run-mechanical.mjs prefers a stated "— N finding(s)" over
|
|
696
|
+
// counting output lines, and without it recorded header + findings + footer: one flagged
|
|
697
|
+
// caption became three findings in the ledger.
|
|
698
|
+
console.error(
|
|
699
|
+
`✍️ prose-lint — ${f.split("/").pop()} — ${lines.length} finding(s):`,
|
|
700
|
+
);
|
|
682
701
|
lines.forEach((l) => console.error(l));
|
|
683
702
|
console.error(
|
|
684
703
|
" run `node .claude/skills/grade-paper-writing/prose-lint.mjs <file>` for the sentences",
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prose-lint.mjs as a process — the way the skill and `run-mechanical.mjs` run it. Every mode, on
|
|
3
|
+
* fixtures written here, with the WHOLE output compared: the report is the product, so a changed
|
|
4
|
+
* number or a dropped line is a changed result.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { expect, test } from "vitest";
|
|
11
|
+
import { runNode, useTempDir, writeTree } from "../../test/support.mjs";
|
|
12
|
+
|
|
13
|
+
const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "prose-lint.mjs");
|
|
14
|
+
const root = useTempDir("prose-lint-");
|
|
15
|
+
|
|
16
|
+
const LONG_CAPTION_SENTENCE = Array.from(
|
|
17
|
+
{ length: 45 },
|
|
18
|
+
(_, i) => `word${i}`,
|
|
19
|
+
).join(" ");
|
|
20
|
+
const PAPER = `---
|
|
21
|
+
title: A title the linter must not read as a sentence
|
|
22
|
+
---
|
|
23
|
+
# The paper
|
|
24
|
+
|
|
25
|
+
<!-- TIGHTEN: a working note, not prose -->
|
|
26
|
+
|
|
27
|
+
## Introduction
|
|
28
|
+
|
|
29
|
+
In this paper we present a linter. It might possibly help, but this is not a proof. Clearly the
|
|
30
|
+
first result matters (Section 3). Prior work agrees [1, 2]. The speedup was 40% on average across the
|
|
31
|
+
full suite of papers, which suggests the method might hold generally.
|
|
32
|
+
|
|
33
|
+
- The first item is short.
|
|
34
|
+
- The second item is not long either.
|
|
35
|
+
|
|
36
|
+
\`\`\`
|
|
37
|
+
# a heading inside a code block is code
|
|
38
|
+
\`\`\`
|
|
39
|
+
|
|
40
|
+
| a | table |
|
|
41
|
+
|---|---|
|
|
42
|
+
| is | data |
|
|
43
|
+
|
|
44
|
+
### Method
|
|
45
|
+
|
|
46
|
+
We believe that rather than guessing, we measure. Instead of prose, the numbers speak.
|
|
47
|
+
|
|
48
|
+
## References
|
|
49
|
+
|
|
50
|
+
[1] A reference that is not prose.
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
writeTree(root, {
|
|
54
|
+
"p/paper.md": PAPER,
|
|
55
|
+
"p/figures/fig1.tex": `\\begin{figure}\\caption{\\textbf{Big.} ${LONG_CAPTION_SENTENCE}. ${"More words here. ".repeat(20)}}\\end{figure}`,
|
|
56
|
+
// Empty once LaTeX commands and braces are stripped: skipped, not counted as a zero-word caption.
|
|
57
|
+
"p/figures/fig2.tex": "\\caption{\\centering}",
|
|
58
|
+
// A lone "#": no word at all, and no sentence either.
|
|
59
|
+
"p/figures/fig4.tex": "\\caption{{#}}",
|
|
60
|
+
"p/figures/notes.txt": "not a figure",
|
|
61
|
+
// Reads as a markdown heading once LaTeX is stripped ("# Big"), so it splits into NO sentence:
|
|
62
|
+
// the caption scan must not flag it and must not crash.
|
|
63
|
+
"p/figures/fig3.tex": "\\caption{{#} Big}",
|
|
64
|
+
"clean/paper.md": "## Intro\n\nShort and plain. Nothing else.\n",
|
|
65
|
+
"page.txt":
|
|
66
|
+
"A rendered page. It was hyphen-\nated across a line.\n\n12\n\fNext page text here.\nReferences\n[1] cut here.\n",
|
|
67
|
+
"paper.tex": "\\documentclass{article}",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("no arguments: the usage line, exit 0", () => {
|
|
71
|
+
assert.deepEqual(runNode(SCRIPT), {
|
|
72
|
+
status: 0,
|
|
73
|
+
stdout: "",
|
|
74
|
+
stderr: "usage: node prose-lint.mjs <file.md|file.txt> [more files]\n",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a .tex file is refused with exit 2, and nothing is measured", () => {
|
|
79
|
+
const r = runNode(SCRIPT, [join(root, "paper.tex")]);
|
|
80
|
+
assert.deepEqual(
|
|
81
|
+
{ status: r.status, stdout: r.stdout },
|
|
82
|
+
{ status: 2, stdout: "" },
|
|
83
|
+
);
|
|
84
|
+
expect(r.stderr.replaceAll(root, "<root>")).toMatchSnapshot();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("the full report over markdown and rendered text", () => {
|
|
88
|
+
const r = runNode(SCRIPT, [
|
|
89
|
+
join(root, "p", "paper.md"),
|
|
90
|
+
join(root, "page.txt"),
|
|
91
|
+
]);
|
|
92
|
+
assert.deepEqual(
|
|
93
|
+
{ status: r.status, stderr: r.stderr },
|
|
94
|
+
{ status: 0, stderr: "" },
|
|
95
|
+
);
|
|
96
|
+
expect(r.stdout).toMatchSnapshot();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("--headings lists every heading from level 2 to 4, in order", () => {
|
|
100
|
+
const r = runNode(SCRIPT, ["--headings", join(root, "p", "paper.md")]);
|
|
101
|
+
assert.deepEqual(
|
|
102
|
+
{ status: r.status, stderr: r.stderr },
|
|
103
|
+
{ status: 0, stderr: "" },
|
|
104
|
+
);
|
|
105
|
+
expect(r.stdout).toMatchSnapshot();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("--flags-only: an over-long caption flags and exits 1", () => {
|
|
109
|
+
const r = runNode(SCRIPT, ["--flags-only", join(root, "p", "paper.md")]);
|
|
110
|
+
assert.deepEqual(
|
|
111
|
+
{ status: r.status, stdout: r.stdout },
|
|
112
|
+
{ status: 1, stdout: "" },
|
|
113
|
+
);
|
|
114
|
+
expect(r.stderr).toMatchSnapshot();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("--flags-only: a paper with no figures directory is clean, exit 0 and silent", () => {
|
|
118
|
+
assert.deepEqual(
|
|
119
|
+
runNode(SCRIPT, ["--flags-only", join(root, "clean", "paper.md")]),
|
|
120
|
+
{
|
|
121
|
+
status: 0,
|
|
122
|
+
stdout: "",
|
|
123
|
+
stderr: "",
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a file with no prose left to measure is refused with exit 2, not reported as NaN", () => {
|
|
129
|
+
writeTree(root, {
|
|
130
|
+
"empty/paper.md":
|
|
131
|
+
"---\ntitle: only frontmatter\n---\n<!-- and a note -->\n",
|
|
132
|
+
});
|
|
133
|
+
const file = join(root, "empty", "paper.md");
|
|
134
|
+
assert.deepEqual(runNode(SCRIPT, [file]), {
|
|
135
|
+
status: 2,
|
|
136
|
+
stdout: "",
|
|
137
|
+
stderr:
|
|
138
|
+
`prose-lint: ${file} has no prose to measure — after the frontmatter, comments, code ` +
|
|
139
|
+
"blocks, tables and the reference section are stripped, no word is left.\n",
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a body of headings only has no sentence to measure, and is refused the same way", () => {
|
|
144
|
+
writeTree(root, { "headings/paper.md": "## Intro\n\n### Method\n" });
|
|
145
|
+
const file = join(root, "headings", "paper.md");
|
|
146
|
+
assert.deepEqual(runNode(SCRIPT, [file]), {
|
|
147
|
+
status: 2,
|
|
148
|
+
stdout: "",
|
|
149
|
+
stderr:
|
|
150
|
+
`prose-lint: ${file} has no prose to measure — after the frontmatter, comments, code ` +
|
|
151
|
+
"blocks, tables and the reference section are stripped, no word is left.\n",
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -752,9 +752,9 @@ function buildArm(a) {
|
|
|
752
752
|
// WRITING is the opposite case, and a parser is the wrong tool: `yaml.dump` would
|
|
753
753
|
// reformat the whole block, so the fixture would differ from the real file in ways
|
|
754
754
|
// the ablation is not measuring. The line is READ OUT of the file instead of
|
|
755
|
-
// assumed
|
|
756
|
-
//
|
|
757
|
-
//
|
|
755
|
+
// assumed, and a missing line is an error rather than a silent no-op, because an
|
|
756
|
+
// edit that fails to apply would otherwise score as a pass over a fixture nobody
|
|
757
|
+
// changed.
|
|
758
758
|
const setLine = (text, key, value) => {
|
|
759
759
|
const line = new RegExp(`^${key}:.*\\n`, "m").exec(text); // kb-lint:markdown-regex-ok — editing a LINE, not parsing
|
|
760
760
|
if (!line)
|
|
@@ -10,7 +10,7 @@ each mechanical gate and check the gate says no (see **Harnesses**, added 2026-0
|
|
|
10
10
|
> here, and **their tests, which until that day lay in another top-level directory**. That is,
|
|
11
11
|
> colocation was missing precisely where we preach it: checker in the skill, its harness — outside.
|
|
12
12
|
>
|
|
13
|
-
> What remained without an owner (shared library of 24 skills, `markdown.mjs
|
|
13
|
+
> What remained without an owner (shared library of 24 skills, `markdown.mjs`) —
|
|
14
14
|
> in [`.claude/lib/`](../../../lib/README.md), also there the breakdown of four forms of reference, from which
|
|
15
15
|
> path-based search sees only one.
|
|
16
16
|
|
|
@@ -153,7 +153,7 @@ finished. Neither row present means it did not run, whatever any status file cla
|
|
|
153
153
|
- `EXPECTED_GATES` in `status.mjs` is the table. A check that records rows and is absent there
|
|
154
154
|
writes rows nobody sees. Add a new skill AND its mechanical checks; `unlistedGates()` catches the
|
|
155
155
|
omission after the fact, and `pipeline-corpus.harness.mjs` catches it before.
|
|
156
|
-
- `ledger.
|
|
156
|
+
- `ledger.test.mjs` and every harness redirect `PIPELINE_LEDGER` to a temp file **before**
|
|
157
157
|
importing `ledger.mjs`. Save-and-restore is not isolation; it lost that race on its first day.
|
|
158
158
|
|
|
159
159
|
## Harnesses (2026-08-07)
|
|
@@ -184,133 +184,14 @@ These two are the only ones aimed at the **skills** rather than the scripts they
|
|
|
184
184
|
is what `vigiles audit`'s `Tested` metric actually counts — "34 surfaces with no vigiles test/eval"
|
|
185
185
|
was never about `structure.mjs`. It also fails differently: a script with a bad path crashes, a
|
|
186
186
|
**skill** with a bad path is read by a model that quietly does something adjacent and reports success.
|
|
187
|
-
Its non-vacuity proof is
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
> **2026-09-
|
|
193
|
-
> `*.mutations.mjs
|
|
194
|
-
>
|
|
195
|
-
> which may only shrink. The history below is kept as a record, not as an instruction.
|
|
196
|
-
|
|
197
|
-
Nine harnesses now carry a mutations file of their own — `delivered-pdf.mutations.mjs` (25 rows), `bound-numbers.mutations.mjs` (12 rows),
|
|
198
|
-
`round-diff.mutations.mjs` (28 rows), `ledger.mutations.mjs` (28 rows, covering the 2026-08-10
|
|
199
|
-
refactor across both `gates.harness.mjs` and `skill-checks.mjs`), `artifact-coverage.mutations.mjs`
|
|
200
|
-
(22 rows), `generated-code.mutations.mjs` (22 rows), `textidote.mutations.mjs` (20 rows) and
|
|
201
|
-
`uncited-refs.mutations.mjs` (8 rows). They are run by
|
|
202
|
-
hand, not in CI, because each one rewrites a source file dozens of times and takes minutes.
|
|
203
|
-
|
|
204
|
-
> **2026-08-26 — `verify-refs.mjs` moved to the engine and deleted from here** (along with its harness and
|
|
205
|
-
> mutations). Bibliography measurement now `extract-ref-facts.mjs` in the same folder, judgment — twelve
|
|
206
|
-
> rules `refs/*` in `eslint-rules/ref-facts.mjs`, mutations — `eslint-rules/ref-facts.mutations.mjs`
|
|
207
|
-
> (21 rows). Analysis — `the author's private research notes`.
|
|
208
|
-
|
|
209
|
-
**2026-08-14 — the engine was split out into `mutation-driver.mjs`; the tables stayed.** Each file
|
|
210
|
-
now holds only its cases (which defect, which bytes, which message — data about ITS checker) and
|
|
211
|
-
calls `runMutations()`. The reason is not line count: the ten copies of the driver had DRIFTED, and
|
|
212
|
-
the protections lived in whichever file happened to earn them. Counted against the committed
|
|
213
|
-
versions — the no-op guard (a replacement equal to the original leaves a green harness proving
|
|
214
|
-
nothing) was in **4 of 10**; the retry-once on a non-kill was in **1 of 10**; the strict rule that a
|
|
215
|
-
mutation must be killed by its OWN named assertion, not merely by _something_ going red, was in
|
|
216
|
-
**1 of 10**. All ten now have all three. Two dead-path defects were closed on the way: six `ledger`
|
|
217
|
-
cases named `skills.harness.mjs`, deleted by the 2026-08-11 colocation, and `vigiles test` on a path
|
|
218
|
-
matching nothing exits 0 — so those six reported SURVIVED on every run since (they now go red at
|
|
219
|
-
their own assertions, verified by running them). And the end-of-run "restore failed" line had been
|
|
220
|
-
firing on `gates.harness.mjs`, which is red **on purpose** — the driver now baselines the harnesses
|
|
221
|
-
before touching anything, so that claim means what it says. `skills.mutations.mjs` deliberately
|
|
222
|
-
stays outside the engine: it mutates a COPY of `.claude/`, which is right for cases whose subject
|
|
223
|
-
IS the skill corpus, and it is the only one CI runs. **The
|
|
224
|
-
first run of a mutations file has never yet been clean, and that is the argument for writing one.**
|
|
225
|
-
`delivered-pdf`'s first run left five survivors and three wrong-case hits, and unpicking them found
|
|
226
|
-
three fixtures that tested nothing they claimed to (a window assertion satisfied by ordering, a
|
|
227
|
-
digit-width guard the counter never reached), two mutations that did not mutate, and **one dead rule
|
|
228
|
-
in the checker itself** — a table of sixteen Unicode space separators whose removal changed no
|
|
229
|
-
verdict, because Python's `\s` already matched every one of them. It was deleted rather than tested.
|
|
230
|
-
|
|
231
|
-
The two 2026-08-10 files kept the streak, and both findings were about the TEST rather than the
|
|
232
|
-
checker. `uncited-refs`'s heading-offset row SURVIVED, which said either that the offset was dead
|
|
233
|
-
code or that nothing watched it; reading `checkcites.lua` settled it — line 359 prints `=> <name>`
|
|
234
|
-
for an auxiliary file it could not resolve and then CARRIES ON into the report, and LaTeX writes
|
|
235
|
-
`\@input{sub.aux}` for every `\include`, so without the offset a partial build makes the wrapper
|
|
236
|
-
report `nope.aux` as an uncited bibliography entry. A fabricated finding, in a report somebody
|
|
237
|
-
would act on. `textidote`'s run reported `key/rule` as a WRONG-CASE kill three times running, and
|
|
238
|
-
each fix exposed the next layer: the key assertions sat after the delta block (so a key defect was
|
|
239
|
-
named by the delta's message), then a finding's rule was being read back out of the key (so a key
|
|
240
|
-
defect was named by the PARSER's message), then the two warnings in the fixture were in the order
|
|
241
|
-
that hides a collapsing key. **And the third fix silently deleted two assertion blocks** — a
|
|
242
|
-
scripted reorder dropped the delta and case blocks, the harness stayed green, and the only signal
|
|
243
|
-
was the next mutation run reporting `key/case` as SURVIVED. Deleting assertions can never turn a
|
|
244
|
-
harness red; nothing but a mutation run can see it.
|
|
245
|
-
|
|
246
|
-
The two 2026-08-10 arrivals kept the streak, and one of them found a defect in the CHECKER rather
|
|
247
|
-
than in a test. `generated-code`'s first run left **three survivors**:
|
|
248
|
-
|
|
249
|
-
- **A quiet case that was quiet for the wrong reason.** The fixture proving `--seed` in an argparse
|
|
250
|
-
call is recognised drew with `rnd.sample(...)` off a local `random.Random(...)`, which the
|
|
251
|
-
draw pattern does not recognise as a draw at all. So the case passed because _nothing was
|
|
252
|
-
detected_, not because the seed was — neutering the seed rule changed no verdict. This is the
|
|
253
|
-
same shape as `delivered-pdf`'s window assertion satisfied by ordering, and it is invisible on
|
|
254
|
-
inspection: the fixture reads like a correct script, because it is one.
|
|
255
|
-
- **🔴 A dead flag in the checker.** The absolute-path rule is written case-sensitive on purpose —
|
|
256
|
-
without it an Express route `/users/export` matches `/Users/`. But the matcher was built with
|
|
257
|
-
`new RegExp(ABS.source + …, 'g')`, which takes the source and **throws the flags away**. Adding
|
|
258
|
-
an `i` to the pattern therefore changed nothing, i.e. a mutation that did not mutate, i.e. the
|
|
259
|
-
property the harness asserted lived nowhere the harness could reach. Fixed by threading
|
|
260
|
-
`ABS.flags` through, which makes the flag load-bearing for the first time.
|
|
261
|
-
- **A mutation that could not change the answer.** Loosening the READ side of the in-place rule
|
|
262
|
-
alone can never produce a finding, because the WRITE side still demands a quoted literal and no
|
|
263
|
-
operand can pair. That row now spans both arrays, and says so.
|
|
264
|
-
|
|
265
|
-
`artifact-coverage`'s 22 rows all killed on the first run, but four killed at a DIFFERENT assertion
|
|
266
|
-
than named, and two of those were the expectation string matching a fragment of prose that appears
|
|
267
|
-
in more than one message (`"that is the"`) — the `no-witness` defect from `ledger.mutations.mjs`,
|
|
268
|
-
committed again three days later. Two rows legitimately kill at an earlier case that asserts the
|
|
269
|
-
same property, and each says which, so a future reader does not read it as off-target. One harness
|
|
270
|
-
case was also restructured because two rows were landing on a shared assertion: a case that tests
|
|
271
|
-
an EXCLUSION should not assert the finding count, or a mutation of the header lands there and hides
|
|
272
|
-
which property actually broke.
|
|
273
|
-
|
|
274
|
-
The reverse leg's own first run on the live paper found a third defect, this time in the port:
|
|
275
|
-
matching a directory name with `includes()` acquitted a fixture directory called `a-2026-01-01` on
|
|
276
|
-
the strength of the letter "a" appearing in the prose. Substring matching in a cherry-picking
|
|
277
|
-
detector fails in the dangerous direction — it invents evidence that a result was mentioned — and
|
|
278
|
-
it is now whole-token with a minimum length.
|
|
279
|
-
|
|
280
|
-
`round-diff`'s first run was six rows wrong out of twenty-six, and one of those was a hole in the
|
|
281
|
-
checker rather than in the test: disabling the number-prefix rule in `covers()` changed **no**
|
|
282
|
-
verdict, because a substring fallback had been silently doing its work — which also meant
|
|
283
|
-
`touches: ["2"]` covered "Section 12" and every heading containing a 2, so a one-character
|
|
284
|
-
declaration authorised most of the paper while `overbroad-scope` stayed quiet because the _list_ was
|
|
285
|
-
short. Two more rows were the test's own fault in the two documented ways: a `find` string written
|
|
286
|
-
in its already-mutated form (a mutation that did not mutate), and a fixture that MOVED text between
|
|
287
|
-
body and appendix — which keeps the total constant and therefore cannot test the boundary at all.
|
|
288
|
-
|
|
289
|
-
`ledger.mutations.mjs` kept the streak: five survivors and two wrong-case kills on its first run,
|
|
290
|
-
and **every one was a defect in the assertion rather than in the checker.** Two matched a bare word
|
|
291
|
-
(`no-witness`, `input-missing`) that the CLI's usage block prints unconditionally, so the refusal
|
|
292
|
-
message could have said anything at all. The dead-report-path case asserted only a non-zero exit —
|
|
293
|
-
which the next line's `ENOENT` supplies once the check is deleted. The status view's reason
|
|
294
|
-
assertion matched the explanatory PARAGRAPH rather than the check's own row, so the reason could
|
|
295
|
-
have been stripped from every row and the test stayed green. And the legacy fixture recorded `PASS`
|
|
296
|
-
with `findings: 0`, which made "a retired acquittal carries nothing forward" true by accident —
|
|
297
|
-
real `PASS` rows carry counts (`sweep-design-space`, 8). One mutation also turned out to be
|
|
298
|
-
**inexpressible finely**: disabling only the no-report-path arm makes the next line `resolve()` a
|
|
299
|
-
null and throw, so the run goes red for a reason unrelated to the check. That row disables the whole
|
|
300
|
-
evidence contract instead and relies on assertion order, which is written down in the row's note.
|
|
301
|
-
|
|
302
|
-
The `--blocking` flag was found the same way and is worth naming separately, because it is not a
|
|
303
|
-
test defect: it shipped **documented in the usage text and in all 22 SKILL.md files while nothing in
|
|
304
|
-
the CLI parsed it**. Every skill instructing `--blocking` for its desk-reject case would have
|
|
305
|
-
recorded an ordinary advisory finding. That is the repository's oldest failure — a documented
|
|
306
|
-
mechanism nothing implements, the same shape as three hooks dead on arrival and a nudge dead for
|
|
307
|
-
twelve days — arriving _inside_ the refactor written to end it. Block 14 of `gates.harness.mjs` now
|
|
308
|
-
asserts every advertised flag from outside the process, in both directions.
|
|
309
|
-
|
|
310
|
-
⚠️ A mutations file refuses to start on a dirty working tree, because it rewrites the source it is
|
|
311
|
-
proving. Commit first. (`ledger.mutations.mjs` touches `status.mjs` and `run-mechanical.mjs`, which
|
|
312
|
-
another session may be editing; running it against a copy of `.claude/` with `CLAUDE_PROJECT_DIR`
|
|
313
|
-
pointed at the copy works and is how it was last verified.)
|
|
187
|
+
Its non-vacuity proof is `lib/skill-checks.test.mjs` — a copy of the shipped skills with defects
|
|
188
|
+
planted on the copy, checked against both `checkSkill` (pipeline skills) and `checkProseSkill`
|
|
189
|
+
(skills without the pipeline). Run it with `npx vitest run lib/skill-checks.test.mjs`; CI runs it
|
|
190
|
+
too.
|
|
191
|
+
|
|
192
|
+
> **2026-09-26 — hand-written per-checker mutation batteries are REMOVED (#52).** Do not write a
|
|
193
|
+
> `*.mutations.mjs`; see a new test fail before the fix and assert whole values instead (the
|
|
194
|
+
> repository's CLAUDE.md, § Testing).
|
|
314
195
|
|
|
315
196
|
Five rules, each learned by getting it wrong here:
|
|
316
197
|
|
|
@@ -321,10 +202,10 @@ Five rules, each learned by getting it wrong here:
|
|
|
321
202
|
importing `ledger.mjs`. Save-and-restore is not isolation; it loses the race on the first crash.
|
|
322
203
|
3. **Run them by explicit path.** `npx vigiles test` with no arguments finds **nothing** here: its
|
|
323
204
|
glob does not descend into dot-directories. Every harness is therefore named explicitly in
|
|
324
|
-
`.github/workflows/
|
|
205
|
+
the `hooks` job of `.github/workflows/ci.yml` — one not named there does not run, and its
|
|
325
206
|
silence looks exactly like having nothing to say. Add the line in the same commit as the file.
|
|
326
|
-
4. **Prove non-vacuity by
|
|
327
|
-
assertion you have not tested. `skill-checks.mjs` (then `skills.harness.mjs`) shipped its first draft with a record-block
|
|
207
|
+
4. **Prove non-vacuity by SEEING IT FAIL, never by reading.** An assertion you cannot see fail is
|
|
208
|
+
an assertion you have not tested. `skill-checks.mjs` (then `skills.harness.mjs`) shipped its first draft with a record-block
|
|
328
209
|
slicer matching `^#` against an already-sliced string; `^` hit offset 0, every block came back as
|
|
329
210
|
the single character `#`, one assertion then fired on all 21 skills while its neighbour checked
|
|
330
211
|
nothing at all. Both were invisible on inspection and obvious on the first planted defect.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* announce.mjs — the first line of every wired skill's `## Run me` block: a banner on stderr naming
|
|
3
|
+
* the paper and skill versions, and a `started` abstention in the ledger. Run as the skills run it,
|
|
4
|
+
* against a temp consumer and a temp ledger.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { cpSync, readFileSync, rmSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { afterEach, test } from "vitest";
|
|
11
|
+
import { runNode, useTempDir, writeTree } from "../../../test/support.mjs";
|
|
12
|
+
|
|
13
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const SCRIPT = join(HERE, "announce.mjs");
|
|
15
|
+
const root = useTempDir("announce-");
|
|
16
|
+
const consumer = join(root, "consumer");
|
|
17
|
+
const LEDGER = join(root, "runs.jsonl");
|
|
18
|
+
writeTree(root, { "consumer/papers/p/paper.md": "# P\n\ntext\n" });
|
|
19
|
+
cpSync(
|
|
20
|
+
join(HERE, "..", "..", "cold-read-diff"),
|
|
21
|
+
join(consumer, ".claude", "skills", "cold-read-diff"),
|
|
22
|
+
{
|
|
23
|
+
recursive: true,
|
|
24
|
+
verbatimSymlinks: true,
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
const env = { CLAUDE_PROJECT_DIR: consumer, PIPELINE_LEDGER: LEDGER };
|
|
28
|
+
process.env.CLAUDE_PROJECT_DIR = consumer;
|
|
29
|
+
process.env.PIPELINE_LEDGER = LEDGER;
|
|
30
|
+
const { inputHash, skillHash } = await import("./ledger.mjs");
|
|
31
|
+
|
|
32
|
+
const rows = () =>
|
|
33
|
+
readFileSync(LEDGER, "utf8")
|
|
34
|
+
.trim()
|
|
35
|
+
.split("\n")
|
|
36
|
+
.map((l) => JSON.parse(l))
|
|
37
|
+
.map(({ skill, check, paper, kind, reason, note }) => ({
|
|
38
|
+
skill,
|
|
39
|
+
check,
|
|
40
|
+
paper,
|
|
41
|
+
kind,
|
|
42
|
+
reason,
|
|
43
|
+
note,
|
|
44
|
+
}));
|
|
45
|
+
afterEach(() => rmSync(LEDGER, { force: true }));
|
|
46
|
+
|
|
47
|
+
test("no skill name: the usage line, exit 2, nothing recorded", () => {
|
|
48
|
+
assert.deepEqual(runNode(SCRIPT, [], { env }), {
|
|
49
|
+
status: 2,
|
|
50
|
+
stdout: "",
|
|
51
|
+
stderr: "usage: announce.mjs <skill-name> [paper-dir]\n",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a skill on a paper: the banner names both versions, and a `started` row is recorded", () => {
|
|
56
|
+
const paper = join(consumer, "papers", "p");
|
|
57
|
+
const r = runNode(SCRIPT, ["cold-read-diff", paper], { env });
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
{ ...r, rows: rows() },
|
|
60
|
+
{
|
|
61
|
+
status: 0,
|
|
62
|
+
stdout: "",
|
|
63
|
+
stderr:
|
|
64
|
+
"▶ cold-read-diff — p\n" +
|
|
65
|
+
` paper ${inputHash(paper).slice(0, 8)} · skill ${skillHash("cold-read-diff").slice(0, 8)}\n`,
|
|
66
|
+
rows: [
|
|
67
|
+
{
|
|
68
|
+
skill: "cold-read-diff",
|
|
69
|
+
check: "cold-read-diff",
|
|
70
|
+
paper: "p",
|
|
71
|
+
kind: "ABSTAINED",
|
|
72
|
+
reason: "started",
|
|
73
|
+
note: "no verdict recorded yet",
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("no paper directory and an unknown skill still announce, with placeholders", () => {
|
|
81
|
+
const r = runNode(SCRIPT, ["no-such-skill", join(root, "nowhere")], { env });
|
|
82
|
+
assert.deepEqual(
|
|
83
|
+
{ status: r.status, stderr: r.stderr, kind: rows()[0].kind },
|
|
84
|
+
{
|
|
85
|
+
status: 0,
|
|
86
|
+
stderr:
|
|
87
|
+
"▶ no-such-skill — no paper dir\n paper (none) · skill ????????\n",
|
|
88
|
+
kind: "ABSTAINED",
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("no paper argument means the current directory", () => {
|
|
94
|
+
const r = runNode(SCRIPT, ["cold-read-diff"], {
|
|
95
|
+
env,
|
|
96
|
+
cwd: join(consumer, "papers", "p"),
|
|
97
|
+
});
|
|
98
|
+
assert.deepEqual(
|
|
99
|
+
{ status: r.status, firstLine: r.stderr.split("\n")[0] },
|
|
100
|
+
{ status: 0, firstLine: "▶ cold-read-diff — p" },
|
|
101
|
+
);
|
|
102
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* artifact-coverage.mjs on the inputs its harness does not build: no argument, no markdown paper,
|
|
3
|
+
* a free-section heading on the paper's very first line, a numbered section without subsections,
|
|
4
|
+
* an index naming the same path twice / a glob / a URL / a repro path, no repro/ at all, a
|
|
5
|
+
* grandfather row without a reason, and a plain file among the repro/ entries.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
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
|
+
|
|
13
|
+
const SCRIPT = join(
|
|
14
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
15
|
+
"artifact-coverage.mjs",
|
|
16
|
+
);
|
|
17
|
+
const root = useTempDir("artifact-coverage-");
|
|
18
|
+
const run = (...args) => runNode(SCRIPT, args);
|
|
19
|
+
|
|
20
|
+
test("no argument prints the usage; a paper with no markdown source is a named skip", () => {
|
|
21
|
+
assert.deepEqual(run(), {
|
|
22
|
+
status: 0,
|
|
23
|
+
stdout: "",
|
|
24
|
+
stderr: "usage: node artifact-coverage.mjs <paper-dir> [--flags-only]\n",
|
|
25
|
+
});
|
|
26
|
+
const tex = writeTree(join(root, "tex"), { "paper.tex": "x" });
|
|
27
|
+
const r = run(tex);
|
|
28
|
+
assert.equal(r.status, 0);
|
|
29
|
+
assert.match(
|
|
30
|
+
r.stderr,
|
|
31
|
+
/artifact coverage SKIPPED for .* — no paper\.md\/draft\.md/,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a section without subsections is checked against its own number; the index's odd paths are passed over", () => {
|
|
36
|
+
const dir = writeTree(join(root, "odd"), {
|
|
37
|
+
"paper.md": "## 3. Results\n\nWe measured **42 %** of runs.\n",
|
|
38
|
+
"artifact/README.md": [
|
|
39
|
+
"Section 7 only.",
|
|
40
|
+
"`data/rows.tsv` and again `data/rows.tsv`",
|
|
41
|
+
"`runs/*/out.json`",
|
|
42
|
+
"`https://example.org/x.json`",
|
|
43
|
+
"`repro/work/tmp.json`",
|
|
44
|
+
].join("\n"),
|
|
45
|
+
});
|
|
46
|
+
const r = run(dir);
|
|
47
|
+
assert.match(
|
|
48
|
+
r.stdout,
|
|
49
|
+
/§3 reports 1 bolded figure\(s\) and is named NOWHERE/,
|
|
50
|
+
);
|
|
51
|
+
assert.match(
|
|
52
|
+
r.stdout,
|
|
53
|
+
/the index names `data\/rows\.tsv` and `data` is not in the bundle/,
|
|
54
|
+
);
|
|
55
|
+
assert.equal(r.stdout.match(/rows\.tsv/g)?.length, 1);
|
|
56
|
+
assert.doesNotMatch(r.stdout, /out\.json|example\.org|tmp\.json/);
|
|
57
|
+
assert.match(
|
|
58
|
+
r.stdout,
|
|
59
|
+
/reverse check \(results on disk nobody reports\): SKIPPED — no repro\/ directory/,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a free-section heading on the first line does not end the body at offset 0", () => {
|
|
64
|
+
const dir = writeTree(join(root, "first-line"), {
|
|
65
|
+
"paper.md": "## Limitations\n\nNone.\n\n## 2. Method\n\nIt took **9 s**.\n",
|
|
66
|
+
"artifact/README.md": "nothing\n",
|
|
67
|
+
});
|
|
68
|
+
assert.match(run(dir).stdout, /§2 reports 1 bolded figure\(s\)/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("repro/: a plain file is not a candidate, a dot-directory is swallowed and named; a grandfather row with no reason says so", () => {
|
|
72
|
+
const dir = writeTree(join(root, "grandfathered"), {
|
|
73
|
+
"paper.md": "## 1. Intro\n\nText.\n",
|
|
74
|
+
"artifact/README.md": "§1\n",
|
|
75
|
+
"repro/notes.txt": "a file, not an analysis",
|
|
76
|
+
"repro/.cache/RESULTS.md": "a dot-directory, swallowed before counting",
|
|
77
|
+
"repro/old-run/RESULTS.md": "done\n",
|
|
78
|
+
"repro/unreported-grandfathered.txt": "old-run\n",
|
|
79
|
+
});
|
|
80
|
+
const out = run(dir).stdout;
|
|
81
|
+
assert.match(out, / {7}old-run {2}→ {2}\(NO REASON GIVEN\)/);
|
|
82
|
+
assert.match(
|
|
83
|
+
out,
|
|
84
|
+
/• 1 before they were counted — dot-directories and build\/dependency trees[^\n]*\n {7}\.cache/,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
@@ -23,7 +23,14 @@ export interface DanglingSkillLink {
|
|
|
23
23
|
* `SKILL.md`, symlinks followed. Throws an error with `code: "DANGLING_SKILL_LINK"` and a
|
|
24
24
|
* `dangling: DanglingSkillLink[]` property when any entry is a link that does not resolve.
|
|
25
25
|
*/
|
|
26
|
-
|
|
26
|
+
/** The four filesystem calls `installedSkills` makes — a test passes a fake. */
|
|
27
|
+
export interface SkillsFs {
|
|
28
|
+
readdirSync(dir: string): string[];
|
|
29
|
+
statSync(path: string): { isDirectory(): boolean };
|
|
30
|
+
existsSync(path: string): boolean;
|
|
31
|
+
readlinkSync(path: string): string;
|
|
32
|
+
}
|
|
33
|
+
export declare function installedSkills(dir: string, fs?: SkillsFs): string[];
|
|
27
34
|
|
|
28
35
|
/** The directory of venue TeX files this package ships; `paperlint build` prepends it to TEXINPUTS. */
|
|
29
36
|
export declare function packageVenuesDir(): string;
|
|
@@ -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(
|