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
|
@@ -146,6 +146,55 @@ const cases = [];
|
|
|
146
146
|
);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// ── 5b. `realpathSync.native(…)` resolves too — as a bare import and through a namespace. A
|
|
150
|
+
// `.native(…)` on anything else is NOT a resolve: another object, a computed member, a call
|
|
151
|
+
// result, a bare `native` function. Each of those still gets its finding.
|
|
152
|
+
{
|
|
153
|
+
const root = 'mkdtempSync(join(tmpdir(), "probe-"))';
|
|
154
|
+
const silent = await on(
|
|
155
|
+
`${HEAD}import * as fs from "node:fs";\n` +
|
|
156
|
+
`const a = realpathSync.native(${root});\nconst b = fs.realpathSync.native(${root});\n`,
|
|
157
|
+
);
|
|
158
|
+
assert.deepEqual(
|
|
159
|
+
silent,
|
|
160
|
+
[],
|
|
161
|
+
`realpathSync.native must exempt the root; got: ${JSON.stringify(silent)}`,
|
|
162
|
+
);
|
|
163
|
+
for (const wrap of [
|
|
164
|
+
"keep",
|
|
165
|
+
"other.native",
|
|
166
|
+
"fs[name].native",
|
|
167
|
+
"pick().native",
|
|
168
|
+
"native",
|
|
169
|
+
]) {
|
|
170
|
+
const m = await on(`${HEAD}const r = ${wrap}(${root});\n`);
|
|
171
|
+
assert.equal(
|
|
172
|
+
m.length,
|
|
173
|
+
1,
|
|
174
|
+
`${wrap}(…) does not resolve the path — one finding expected, got ${JSON.stringify(m)}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
cases.push(
|
|
178
|
+
"realpathSync.native / fs.realpathSync.native exempt; keep, other.native, fs[name].native, pick().native, native do not",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── 5c. The walk over the arguments survives the shapes that are not plain nodes: an array hole
|
|
183
|
+
// (null) and a regex literal's `regex` object (no `type`). Neither is a `tmpdir()` call.
|
|
184
|
+
{
|
|
185
|
+
const m = await on(
|
|
186
|
+
`${HEAD}const a = mkdtempSync([, /tmp/g]);\nconst b = mkdtempSync([, /x/, tmpdir()]);\n`,
|
|
187
|
+
);
|
|
188
|
+
assert.deepEqual(
|
|
189
|
+
m.map((x) => x.line),
|
|
190
|
+
[5],
|
|
191
|
+
`only the second call names tmpdir(); got: ${JSON.stringify(m)}`,
|
|
192
|
+
);
|
|
193
|
+
cases.push(
|
|
194
|
+
"an array hole and a regex literal in the arguments are walked, not mistaken",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
149
198
|
// ── 6. 🔴 THE CORPUS ITSELF IS CLEAN. The "silent" half on a made-up string says nothing
|
|
150
199
|
// about the repository: the rule could also be silent because it was never handed a
|
|
151
200
|
// single file (the recorded class — `scripts/rules-see-files.mjs`). So this check runs
|
|
@@ -156,10 +156,10 @@ const futurePromise = {
|
|
|
156
156
|
return {
|
|
157
157
|
root() {
|
|
158
158
|
// 🔴 ONE GUARD, NOT THREE. The first draft had `if (!file || !existsSync(file))`
|
|
159
|
-
// BEFORE the read —
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
159
|
+
// BEFORE the read — removing that line on 2026-09-07 showed it is caught by NOTHING:
|
|
160
|
+
// the `try/catch` around the read covers exactly the same cases (no path, a `<text>` run
|
|
161
|
+
// through stdin, no permission). Two guards doing one job is a guard the harness can
|
|
162
|
+
// never fail on, i.e. a dead assertion dressed as robustness.
|
|
163
163
|
// What is left is the read inside `try`: stay quiet rather than judge content we never
|
|
164
164
|
// saw.
|
|
165
165
|
let text;
|
|
@@ -264,7 +264,7 @@ const acmFrontmatterOverride = {
|
|
|
264
264
|
root() {
|
|
265
265
|
// Same single guard as the sibling rule, and for the same measured reason: a separate
|
|
266
266
|
// `existsSync` before the read is covered by this `catch` in every case it could
|
|
267
|
-
// fire, i.e. it is an assertion
|
|
267
|
+
// fire, i.e. it is an assertion the harness can never fail on.
|
|
268
268
|
let text;
|
|
269
269
|
try {
|
|
270
270
|
text = readFileSync(context.filename, "utf8");
|
|
@@ -289,14 +289,14 @@ const acmFrontmatterOverride = {
|
|
|
289
289
|
for (const { re, removes } of FRONTMATTER_OVERRIDES) {
|
|
290
290
|
// 🔴 NO `re.lastIndex = 0` HERE, AND THAT IS A MEASUREMENT. These regexes are
|
|
291
291
|
// module-level and `/g`, so they carry `lastIndex` across files, and the obvious
|
|
292
|
-
// defensive line is to reset it.
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
292
|
+
// defensive line is to reset it. Removing that line on 2026-09-11 left the harness
|
|
293
|
+
// GREEN — because the loop below always drains the regex, and a `/g` `exec` that
|
|
294
|
+
// returns `null` resets `lastIndex` to 0 by itself. The reset was a guard the
|
|
295
|
+
// harness could never fail on, i.e. the same dead assertion dressed as robustness
|
|
296
|
+
// that the sibling rule's single-`try` comment describes. What keeps the property
|
|
297
|
+
// honest instead is an ASSERTION, not a line of code: the harness lints the same
|
|
298
|
+
// input twice in one process and requires the two runs to agree. Add a `break` to
|
|
299
|
+
// this loop and that assertion is what will fail.
|
|
300
300
|
let m;
|
|
301
301
|
while ((m = re.exec(line)) !== null) {
|
|
302
302
|
const column = m.index + 1;
|
package/eslint.config.mjs
CHANGED
|
@@ -138,6 +138,9 @@ export const layerBoundaries = (root) => ({
|
|
|
138
138
|
partialMatch: false,
|
|
139
139
|
capture: ["kind"],
|
|
140
140
|
},
|
|
141
|
+
// test/support.mjs — shared helpers for tests. Only the `test` category may import it: the
|
|
142
|
+
// policies below allow tests any element, and allow no other file this one.
|
|
143
|
+
{ type: "test-support", pattern: "test", partialMatch: false },
|
|
141
144
|
],
|
|
142
145
|
"boundaries/files": [
|
|
143
146
|
// `exclusive`: the root also matches the app pattern below, and is only the root.
|
|
@@ -369,8 +372,9 @@ export default [
|
|
|
369
372
|
* 🔴 THIS BLOCK COVERS THE PACKAGE ITSELF, and before 2026-09-15 it was not here: the config held only
|
|
370
373
|
* one block for `.tex` (I don't quote the glob inside this comment: the sequence
|
|
371
374
|
* "star-slash" would close the comment itself — which is where I tripped), and a file with no block gets simply IGNORED by ESLint 9.
|
|
372
|
-
* Meaning: 133 own `.mjs` — mutation engine, three hooks, scripts for
|
|
373
|
-
* with green `npm run lint`. A tool that checks others' papers and
|
|
375
|
+
* Meaning: 133 own `.mjs` — a since-removed mutation-testing engine, three hooks, scripts for
|
|
376
|
+
* 24 skills — went unchecked, with green `npm run lint`. A tool that checks others' papers and
|
|
377
|
+
* not itself.
|
|
374
378
|
*
|
|
375
379
|
* Measurement on the first run: 15 files with dead imports (`resolve`, `pathToFileURL`) and
|
|
376
380
|
* orphaned constants — remnants of a move from `mine`, where those names were needed. All
|
package/hooks/hooks.harness.mjs
CHANGED
|
@@ -21,9 +21,7 @@
|
|
|
21
21
|
* `.ts` source would be: a twin can drift from its build, and there is no twin here to drift.
|
|
22
22
|
*
|
|
23
23
|
* Run: `npx vigiles test hooks/hooks.harness.mjs`
|
|
24
|
-
* Killed by: `hooks/hooks.mutations.mjs`
|
|
25
24
|
*/
|
|
26
|
-
import assert from "node:assert/strict";
|
|
27
25
|
import { runHook } from "vigiles";
|
|
28
26
|
import { checkHookImports } from "vigiles/hook";
|
|
29
27
|
import {
|
|
@@ -40,6 +38,7 @@ import { tmpdir } from "node:os";
|
|
|
40
38
|
import { dirname, join, resolve } from "node:path";
|
|
41
39
|
import { fileURLToPath } from "node:url";
|
|
42
40
|
import { PAPERS_DIR_FIELD } from "../lib/paper-config.mjs";
|
|
41
|
+
import { createChecker } from "../lib/check.mjs";
|
|
43
42
|
|
|
44
43
|
const HOOKS = dirname(fileURLToPath(import.meta.url));
|
|
45
44
|
const ROOT = resolve(HOOKS, "..");
|
|
@@ -51,11 +50,7 @@ const SHIPPED = readdirSync(HOOKS)
|
|
|
51
50
|
.filter((f) => f.endsWith(".hook.mjs"))
|
|
52
51
|
.sort();
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
const check = (label, cond) => {
|
|
56
|
-
assert.ok(cond, label);
|
|
57
|
-
n++;
|
|
58
|
-
};
|
|
53
|
+
const check = createChecker();
|
|
59
54
|
|
|
60
55
|
/**
|
|
61
56
|
* A throwaway consumer repository: a root `paperlint.json` holding the given settings (none when
|
|
@@ -492,7 +487,7 @@ try {
|
|
|
492
487
|
// 🔴 AND SILENT ON THE DEFAULT ROOT TOO — this is the half that catches `??`. With
|
|
493
488
|
// `declared ?? DEFAULT` an explicit `null` is read as «nothing was declared», the prefix
|
|
494
489
|
// becomes `papers/`, and the nudge starts firing about a tree the consumer never named.
|
|
495
|
-
// Without this case that
|
|
490
|
+
// Without this case that defect survives, because the assertion above passes either way.
|
|
496
491
|
const d = at(
|
|
497
492
|
broken,
|
|
498
493
|
"paper-skills-nudge",
|
|
@@ -629,8 +624,8 @@ try {
|
|
|
629
624
|
);
|
|
630
625
|
// 🔴 THE NASTY NAME NEEDS A REAL DIRECTORY BEHIND IT, or the assertion cannot fail. First
|
|
631
626
|
// draft just passed `a;b` and checked for silence — but a name that names nothing is silent
|
|
632
|
-
// anyway, for want of a status file, so the
|
|
633
|
-
// green. The fixture now contains a paper literally called `a;b` with a status file in it:
|
|
627
|
+
// anyway, for want of a status file, so deleting the name check would leave this harness
|
|
628
|
+
// green regardless. The fixture now contains a paper literally called `a;b` with a status file in it:
|
|
634
629
|
// with the check, silence; without it, the verdict comes out. Measured 2026-09-12.
|
|
635
630
|
mkdirSync(join(dir, "docs/papers/a;b"), { recursive: true });
|
|
636
631
|
writeFileSync(
|
|
@@ -648,7 +643,7 @@ try {
|
|
|
648
643
|
);
|
|
649
644
|
}
|
|
650
645
|
|
|
651
|
-
console.log(`hooks.harness: ${
|
|
646
|
+
console.log(`hooks.harness: ${check.count} assertions passed`);
|
|
652
647
|
} finally {
|
|
653
648
|
for (const d of tmps) rmSync(d, { recursive: true, force: true });
|
|
654
649
|
}
|
|
@@ -355,8 +355,8 @@ const MUTATORS = [
|
|
|
355
355
|
* `runs()` now sees through `env`, `env -C`, `command`, `nice`, `xargs`, `sudo`, `time`, a
|
|
356
356
|
* repeated space and a quoted head — every form tried. So the crutch defended nothing, which is
|
|
357
357
|
* not a judgement but a measurement: removing it left all 78 harness assertions GREEN, and no
|
|
358
|
-
* command form could be found that it alone catches. A line
|
|
359
|
-
* documenting a defence that does not exist, so it was deleted rather than kept for comfort.
|
|
358
|
+
* command form could be found that it alone catches. A line the harness can never fail on is a
|
|
359
|
+
* line documenting a defence that does not exist, so it was deleted rather than kept for comfort.
|
|
360
360
|
*
|
|
361
361
|
* ⚠️ WHAT THIS DOES NOT FIX: rows 3 and 4 above. A wrapper that moves the working directory is
|
|
362
362
|
* still invisible, because `touches`/`namesPaperSource` compare against a repo-root prefix and
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* paper-edit-guard's Bash scan on the shapes the hooks harness does not carry: a `>` inside quotes
|
|
3
|
+
* or escaped (not a redirection), a target spelled with backslashes (read through them), a target
|
|
4
|
+
* ended by a delimiter, and a mutator that moves a paper source AWAY — a write `writesTo` does not
|
|
5
|
+
* see, which the `touches` fallback catches. Each case runs the hook the way a consumer's settings
|
|
6
|
+
* run it: `vigiles hook-runtime run-program`, from a throwaway project.
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { test } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
hookConsumer,
|
|
12
|
+
onBash,
|
|
13
|
+
runShippedHook,
|
|
14
|
+
} from "../test/hook-consumer.mjs";
|
|
15
|
+
import { useTempDir } from "../test/support.mjs";
|
|
16
|
+
|
|
17
|
+
const dir = hookConsumer(useTempDir("paper-edit-guard-"), {
|
|
18
|
+
"papers/alpha/PIPELINE-STATUS.md": "# S\n",
|
|
19
|
+
});
|
|
20
|
+
/** `>` built rather than typed: a literal one here would be a redirection to the guard's own scan. */
|
|
21
|
+
const GT = String.fromCharCode(62);
|
|
22
|
+
const P = "papers/alpha/paper.md";
|
|
23
|
+
|
|
24
|
+
/** The guard's exit code for one Bash command: 2 denies, 0 lets it through. */
|
|
25
|
+
const guard = (command) =>
|
|
26
|
+
runShippedHook("paper-edit-guard", onBash(command), dir).exitCode;
|
|
27
|
+
|
|
28
|
+
test("a `>` inside quotes, after an escaped quote, or escaped itself is not a redirection", () => {
|
|
29
|
+
assert.deepEqual(
|
|
30
|
+
[
|
|
31
|
+
guard(`echo "a ${GT} ${P}"`),
|
|
32
|
+
guard(`echo "a \\" ${GT} b"`),
|
|
33
|
+
guard(`echo a\\${GT} ${P}`),
|
|
34
|
+
],
|
|
35
|
+
[0, 0, 0],
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a redirection target is read through backslashes, quoted or not, and ends at a delimiter", () => {
|
|
40
|
+
assert.deepEqual(
|
|
41
|
+
[
|
|
42
|
+
guard(`echo x ${GT} "papers/alpha/pa\\per.md"`),
|
|
43
|
+
guard(`echo x ${GT} papers/alpha/pa\\per.md`),
|
|
44
|
+
guard(`echo x ${GT}${P}; true`),
|
|
45
|
+
],
|
|
46
|
+
[2, 2, 2],
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("moving a paper source away is a write to it: denied", () => {
|
|
51
|
+
assert.equal(guard(`mv ${P} /tmp/elsewhere.md`), 2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a command cut off right after a backslash still names its target, quoted or not", () => {
|
|
55
|
+
// The backslash escapes nothing — there is no next character — and the target read so far
|
|
56
|
+
// is still a paper source.
|
|
57
|
+
assert.deepEqual(
|
|
58
|
+
[guard(`echo x ${GT} ${P}\\`), guard(`echo x ${GT} "${P}\\`)],
|
|
59
|
+
[2, 2],
|
|
60
|
+
);
|
|
61
|
+
});
|
|
@@ -71,8 +71,8 @@ const READ_CONFIG = `f="\${CLAUDE_PROJECT_DIR:-.}/${CONFIG_FILE}"; if [ -e "$f"
|
|
|
71
71
|
* `e.path.under()`, and vigiles normalises the prefix itself (`normalizePrefix` →
|
|
72
72
|
* `trimTrailingSeparators`), so `"docs/papers/"` and `"docs/papers"` are the same prefix to it.
|
|
73
73
|
* A normalise here would be a line no test could kill, which is a line that documents a
|
|
74
|
-
* defence that does not exist.
|
|
75
|
-
*
|
|
74
|
+
* defence that does not exist. Confirmed 2026-09-12: removing it left the harness green, which
|
|
75
|
+
* is the finding that produced this paragraph.
|
|
76
76
|
*
|
|
77
77
|
* ⚠️ A NUDGE FALLS SILENT WHERE THE GATE REFUSES, and the asymmetry is the decision, not an
|
|
78
78
|
* inconsistency. `paper-edit-guard` denies on an unreadable declaration because a gate that
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* paper-skills-nudge on the two declarations the hooks harness does not carry: none at all (the
|
|
3
|
+
* default `papers/` root applies) and one that does not parse (silence — a nudge never blocks).
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { test } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
hookConsumer,
|
|
10
|
+
onEdit,
|
|
11
|
+
runShippedHook,
|
|
12
|
+
} from "../test/hook-consumer.mjs";
|
|
13
|
+
import { useTempDir } from "../test/support.mjs";
|
|
14
|
+
|
|
15
|
+
const root = useTempDir("paper-skills-nudge-");
|
|
16
|
+
|
|
17
|
+
test("no paperlint.json: an edit to papers/<p>/paper.md gets the checklist", () => {
|
|
18
|
+
const dir = hookConsumer(join(root, "default"));
|
|
19
|
+
const r = runShippedHook(
|
|
20
|
+
"paper-skills-nudge",
|
|
21
|
+
onEdit("papers/alpha/paper.md"),
|
|
22
|
+
dir,
|
|
23
|
+
);
|
|
24
|
+
assert.equal(r.exitCode, 0);
|
|
25
|
+
assert.notEqual(r.stdout, "");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a paperlint.json that does not parse: silent, exit 0", () => {
|
|
29
|
+
const dir = hookConsumer(join(root, "broken"), {
|
|
30
|
+
"paperlint.json": "{ nope",
|
|
31
|
+
});
|
|
32
|
+
const r = runShippedHook(
|
|
33
|
+
"paper-skills-nudge",
|
|
34
|
+
onEdit("papers/alpha/paper.md"),
|
|
35
|
+
dir,
|
|
36
|
+
);
|
|
37
|
+
assert.deepEqual([r.exitCode, r.stdout, r.stderr], [0, "", ""]);
|
|
38
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* paper-status-gates on the two declarations the hooks harness does not carry: none at all (the
|
|
3
|
+
* default `papers/` root applies) and one that does not parse (silence — this hook never blocks).
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { test } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
hookConsumer,
|
|
10
|
+
onEdit,
|
|
11
|
+
runShippedHook,
|
|
12
|
+
} from "../test/hook-consumer.mjs";
|
|
13
|
+
import { useTempDir } from "../test/support.mjs";
|
|
14
|
+
|
|
15
|
+
const root = useTempDir("paper-status-gates-");
|
|
16
|
+
const STATUS = "**Readiness verdict:** ✅ fixture verdict line\n";
|
|
17
|
+
|
|
18
|
+
test("no paperlint.json: an edit under papers/ runs the gates for that paper", () => {
|
|
19
|
+
const dir = hookConsumer(join(root, "default"), {
|
|
20
|
+
"papers/alpha/PIPELINE-STATUS.md": STATUS,
|
|
21
|
+
});
|
|
22
|
+
const r = runShippedHook(
|
|
23
|
+
"paper-status-gates",
|
|
24
|
+
onEdit("papers/alpha/paper.md"),
|
|
25
|
+
dir,
|
|
26
|
+
);
|
|
27
|
+
assert.equal(r.exitCode, 0);
|
|
28
|
+
assert.match(r.stderr, /fixture verdict line/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a paperlint.json that does not parse: silent, exit 0", () => {
|
|
32
|
+
const dir = hookConsumer(join(root, "broken"), {
|
|
33
|
+
"paperlint.json": "{ nope",
|
|
34
|
+
"papers/alpha/PIPELINE-STATUS.md": STATUS,
|
|
35
|
+
});
|
|
36
|
+
const r = runShippedHook(
|
|
37
|
+
"paper-status-gates",
|
|
38
|
+
onEdit("papers/alpha/paper.md"),
|
|
39
|
+
dir,
|
|
40
|
+
);
|
|
41
|
+
assert.deepEqual([r.exitCode, r.stdout, r.stderr], [0, "", ""]);
|
|
42
|
+
});
|
|
@@ -107,6 +107,13 @@ const cases = [];
|
|
|
107
107
|
cases.push(
|
|
108
108
|
'exit 0 + empty stdout (the `stdio: "ignore"` shape) → throws, does not report a blank',
|
|
109
109
|
);
|
|
110
|
+
// …and what the program DID say, on stderr, is carried into the refusal: it is the only clue.
|
|
111
|
+
const chatty = spawner({ status: 0, stdout: "", stderr: " warming up\n" });
|
|
112
|
+
assert.throws(
|
|
113
|
+
() => observeAgentCli({ spawnSync: chatty }),
|
|
114
|
+
/\(got "", stderr "warming up"\)/,
|
|
115
|
+
);
|
|
116
|
+
cases.push("…and the stderr it printed instead is quoted in the refusal");
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
// ── 5. THE CALL SHAPE, asserted directly. Case 4 catches a discarding probe only because the
|
package/lib/check.d.mts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Types for lib/check.mjs — the shared harness assertion helper (see the module).
|
|
2
|
+
|
|
3
|
+
/** A failure detail: text, any value (rendered with `util.inspect`), or a thunk producing one. */
|
|
4
|
+
export type Detail = unknown;
|
|
5
|
+
|
|
6
|
+
export declare function renderDetail(detail: Detail): string;
|
|
7
|
+
export declare function failureMessage(label: string, detail: Detail): string;
|
|
8
|
+
|
|
9
|
+
export interface Check {
|
|
10
|
+
(label: string, cond: unknown, detail?: Detail): void;
|
|
11
|
+
/** How many times this checker has been called. */
|
|
12
|
+
readonly count: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export declare function createChecker(options?: {
|
|
16
|
+
log?: (line: string) => void;
|
|
17
|
+
}): Check;
|
package/lib/check.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one assertion helper every `*.harness.mjs` uses.
|
|
3
|
+
*
|
|
4
|
+
* Until #52 each harness defined its own `check()`, and the copies had drifted: some printed a
|
|
5
|
+
* detail on failure and some dropped it, some joined it with " — " and some with a newline, and two
|
|
6
|
+
* passed the LABEL to vigiles' `recordCheck(n)`, which adds its argument to a number — so the
|
|
7
|
+
* recorded count became a string. One helper removes the drift instead of documenting it.
|
|
8
|
+
*
|
|
9
|
+
* What it guarantees:
|
|
10
|
+
* - a failure names the label AND the detail, on stderr and in the thrown assertion — the detail
|
|
11
|
+
* is what makes a red run diagnosable without re-running it;
|
|
12
|
+
* - `detail` may be a function, so an expensive rendering is paid only on failure;
|
|
13
|
+
* - every call is counted (`check.count`) and reported to the vigiles runner (`recordCheck`), so
|
|
14
|
+
* a harness's summary line and the runner's check count come from the same place.
|
|
15
|
+
*/
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { inspect } from "node:util";
|
|
18
|
+
import { recordCheck } from "vigiles";
|
|
19
|
+
|
|
20
|
+
/** A failure detail as text: strings verbatim, other values through `util.inspect`, a thunk called. */
|
|
21
|
+
export function renderDetail(detail) {
|
|
22
|
+
const value = typeof detail === "function" ? detail() : detail;
|
|
23
|
+
if (value === undefined || value === null || value === "") return "";
|
|
24
|
+
return typeof value === "string" ? value : inspect(value, { depth: 6 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The label, then the detail: on the same line when it is one line, below it when it is many. */
|
|
28
|
+
export function failureMessage(label, detail) {
|
|
29
|
+
const shown = renderDetail(detail);
|
|
30
|
+
if (!shown) return label;
|
|
31
|
+
return shown.includes("\n") ? `${label}\n${shown}` : `${label} — ${shown}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A fresh `check(label, cond, detail?)`. `check.count` is how many times it has been called.
|
|
36
|
+
* `log` is where a failure is printed — stderr unless this module's own test injects one.
|
|
37
|
+
*/
|
|
38
|
+
export function createChecker({ log = console.error } = {}) {
|
|
39
|
+
let count = 0;
|
|
40
|
+
const check = (label, cond, detail) => {
|
|
41
|
+
count++;
|
|
42
|
+
recordCheck();
|
|
43
|
+
if (cond) return;
|
|
44
|
+
const message = failureMessage(label, detail);
|
|
45
|
+
log(`✗ ${message}`);
|
|
46
|
+
assert.fail(message);
|
|
47
|
+
};
|
|
48
|
+
Object.defineProperty(check, "count", { get: () => count });
|
|
49
|
+
return check;
|
|
50
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "vitest";
|
|
3
|
+
import { createChecker, failureMessage, renderDetail } from "./check.mjs";
|
|
4
|
+
|
|
5
|
+
test("renderDetail: a string verbatim, nothing for empty, a value inspected, a thunk called", () => {
|
|
6
|
+
assert.deepEqual(
|
|
7
|
+
[
|
|
8
|
+
renderDetail("as is"),
|
|
9
|
+
renderDetail(""),
|
|
10
|
+
renderDetail(undefined),
|
|
11
|
+
renderDetail(null),
|
|
12
|
+
renderDetail({ a: [1, 2] }),
|
|
13
|
+
renderDetail(() => "lazy"),
|
|
14
|
+
renderDetail(() => undefined),
|
|
15
|
+
],
|
|
16
|
+
["as is", "", "", "", "{ a: [ 1, 2 ] }", "lazy", ""],
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("failureMessage: label alone, label — detail, or label on its own line above a multi-line detail", () => {
|
|
21
|
+
assert.deepEqual(
|
|
22
|
+
[
|
|
23
|
+
failureMessage("L", undefined),
|
|
24
|
+
failureMessage("L", "d"),
|
|
25
|
+
failureMessage("L", "one\ntwo"),
|
|
26
|
+
],
|
|
27
|
+
["L", "L — d", "L\none\ntwo"],
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("createChecker: counts every call, stays silent on a pass", () => {
|
|
32
|
+
const printed: string[] = [];
|
|
33
|
+
const check = createChecker({ log: (s) => printed.push(s) });
|
|
34
|
+
check("a", true);
|
|
35
|
+
check("b", 1 === 1, "unused detail");
|
|
36
|
+
assert.deepEqual({ count: check.count, printed }, { count: 2, printed: [] });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("createChecker: a failure throws AND prints the label with its detail, and is counted", () => {
|
|
40
|
+
const printed: string[] = [];
|
|
41
|
+
const check = createChecker({ log: (s) => printed.push(s) });
|
|
42
|
+
// Guards: the detail reaches the reader — the drift this helper replaced dropped it.
|
|
43
|
+
assert.throws(() => check("the label", false, { got: 3 }), {
|
|
44
|
+
name: "AssertionError",
|
|
45
|
+
message: "the label — { got: 3 }",
|
|
46
|
+
});
|
|
47
|
+
assert.deepEqual(
|
|
48
|
+
{ count: check.count, printed },
|
|
49
|
+
{ count: 1, printed: ["✗ the label — { got: 3 }"] },
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("createChecker: a detail thunk is only evaluated on failure", () => {
|
|
54
|
+
let calls = 0;
|
|
55
|
+
const check = createChecker({ log: () => {} });
|
|
56
|
+
check("pass", true, () => {
|
|
57
|
+
calls++;
|
|
58
|
+
return "x";
|
|
59
|
+
});
|
|
60
|
+
assert.throws(() =>
|
|
61
|
+
check("fail", false, () => {
|
|
62
|
+
calls++;
|
|
63
|
+
return "x";
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
assert.equal(calls, 1);
|
|
67
|
+
});
|
package/lib/markdown.mjs
CHANGED
|
@@ -49,6 +49,9 @@ try {
|
|
|
49
49
|
*/
|
|
50
50
|
export const MD_AVAILABLE = () => md !== null;
|
|
51
51
|
|
|
52
|
+
/** markdown-it's block tokens for `text`, or null when it did not resolve. A test passes its own. */
|
|
53
|
+
const defaultParse = () => (md === null ? null : (text) => md.parse(text, {}));
|
|
54
|
+
|
|
52
55
|
/**
|
|
53
56
|
* The frontmatter is BLANKED with spaces, not cut out: the offsets must stay the
|
|
54
57
|
* same, because the checks cut the document by offset. Blanking is necessary — inside
|
|
@@ -118,15 +121,17 @@ function lineStarts(text) {
|
|
|
118
121
|
* under without a blank line would become a level-2 heading per CommonMark. The former
|
|
119
122
|
* regexps knew only ATX, so this preserves the contract rather than narrowing it.
|
|
120
123
|
*/
|
|
121
|
-
export function headings(text) {
|
|
122
|
-
if (
|
|
123
|
-
const toks =
|
|
124
|
+
export function headings(text, { parse = defaultParse() } = {}) {
|
|
125
|
+
if (parse === null) return [];
|
|
126
|
+
const toks = parse(blankFrontmatter(text));
|
|
124
127
|
const starts = lineStarts(text);
|
|
125
128
|
const out = [];
|
|
126
129
|
for (let i = 0; i < toks.length; i++) {
|
|
127
130
|
const t = toks[i];
|
|
128
131
|
if (t.type !== "heading_open" || !t.map) continue;
|
|
129
132
|
if (!t.markup || !t.markup.startsWith("#")) continue; // setext — see the caveat above
|
|
133
|
+
// markdown-it follows `heading_open` with its `inline` token and maps blocks to lines of the
|
|
134
|
+
// parsed text; the fallbacks are for a token stream that does not (another parser version).
|
|
130
135
|
const inline = toks[i + 1];
|
|
131
136
|
out.push({
|
|
132
137
|
depth: Number(t.tag.slice(1)),
|
|
@@ -138,64 +143,6 @@ export function headings(text) {
|
|
|
138
143
|
return out;
|
|
139
144
|
}
|
|
140
145
|
|
|
141
|
-
/** Offsets of the `---` thematic breaks (in this knowledge base — a prose separator). */
|
|
142
|
-
export function thematicBreaks(text) {
|
|
143
|
-
if (md === null) return [];
|
|
144
|
-
const starts = lineStarts(text);
|
|
145
|
-
return md
|
|
146
|
-
.parse(blankFrontmatter(text), {})
|
|
147
|
-
.filter((t) => t.type === "hr" && t.map)
|
|
148
|
-
.map((t) => starts[t.map[0]] ?? 0);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* A section's body: from the line AFTER the heading matching `match`, up to the next
|
|
153
|
-
* heading of THE SAME OR A LOWER level. `{ heading, body }` or `null`.
|
|
154
|
-
*
|
|
155
|
-
* `match` — a regex over the heading text OR a predicate over `{depth,text,line,offset}`.
|
|
156
|
-
*
|
|
157
|
-
* Level-or-lower, not "exactly `##`": a `###` subsection inside the section does not
|
|
158
|
-
* end it — that is exactly how the former expressions with the `(?=\n## )` lookahead
|
|
159
|
-
* behaved. A regex cannot know this distinction in principle: it has no notion of level.
|
|
160
|
-
*
|
|
161
|
-
* `depth` — the CEILING on the level of the heading itself. Without it an `### Abstract`
|
|
162
|
-
* quoted in an appendix becomes the abstract — and the abstract is judged with a
|
|
163
|
-
* threshold of ZERO.
|
|
164
|
-
*
|
|
165
|
-
* `stopAtRule` additionally cuts the section off at the first `---`. That is NOT markdown
|
|
166
|
-
* structure but a paper convention, and it is preserved deliberately: the conclusion has
|
|
167
|
-
* a `---` before the appendices, and without that boundary a section with no following
|
|
168
|
-
* `##` would reach to the end of the file.
|
|
169
|
-
*
|
|
170
|
-
* 🔴 A MERGED VERSION. On 2026-08-11 this function appeared SIMULTANEOUSLY in two places:
|
|
171
|
-
* here (for `kb-lint`) and in `paper-lint.mjs` (richer — with the level ceiling, the
|
|
172
|
-
* heading in the result and `stopAtRule`). Both were written within one hour by two
|
|
173
|
-
* parallel passes, neither saw the other. Merged into the rich version: the weak one was
|
|
174
|
-
* a subset, and keeping both would have meant diverging in behaviour at the very next edit.
|
|
175
|
-
*/
|
|
176
|
-
export function sectionBody(
|
|
177
|
-
text,
|
|
178
|
-
match,
|
|
179
|
-
{ depth = 6, stopAtRule = false } = {},
|
|
180
|
-
) {
|
|
181
|
-
const test = typeof match === "function" ? match : (h) => match.test(h.text);
|
|
182
|
-
const hs = headings(text);
|
|
183
|
-
const i = hs.findIndex((h) => h.depth <= depth && test(h));
|
|
184
|
-
if (i === -1) return null;
|
|
185
|
-
const h = hs[i];
|
|
186
|
-
const next = hs.slice(i + 1).find((n) => n.depth <= h.depth);
|
|
187
|
-
let end = next ? next.offset : text.length;
|
|
188
|
-
if (stopAtRule) {
|
|
189
|
-
const hr = thematicBreaks(text).find((o) => o > h.offset && o < end);
|
|
190
|
-
if (hr !== undefined) end = hr;
|
|
191
|
-
}
|
|
192
|
-
// The body starts on the line AFTER the heading: the heading itself is not part of the
|
|
193
|
-
// section, just as the former expressions' group did not capture it.
|
|
194
|
-
const nl = text.indexOf("\n", h.offset);
|
|
195
|
-
const start = nl === -1 || nl + 1 > end ? end : nl + 1;
|
|
196
|
-
return { heading: h, body: text.slice(start, end) };
|
|
197
|
-
}
|
|
198
|
-
|
|
199
146
|
/**
|
|
200
147
|
* Slicing the document into chunks "a heading + everything up to the next heading FROM
|
|
201
148
|
* THE SAME RANGE of levels". → `[{ heading, raw, body }]`.
|
|
@@ -302,15 +249,15 @@ export function stripFences(text, { blank = false } = {}) {
|
|
|
302
249
|
* indistinguishable from "the paper has no sections". A confident zero has already been
|
|
303
250
|
* read as a finding four times in this repo; here it is inexpressible.
|
|
304
251
|
*/
|
|
305
|
-
export function requireMarkdown() {
|
|
252
|
+
export function requireMarkdown({ from = import.meta.url } = {}) {
|
|
306
253
|
if (MD_AVAILABLE()) return;
|
|
307
|
-
// 🔴 THE CURE DEPENDS ON
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
const fromPluginCache =
|
|
254
|
+
// 🔴 THE CURE DEPENDS ON WHERE THIS COPY RUNS FROM. Measured 2026-09-19: a Claude Code plugin
|
|
255
|
+
// copy is installed without dependencies (`npm pack` strips `package-lock.json`, and the plugin
|
|
256
|
+
// cache runs `npm ci` only with a lockfile). The plugin channel was removed in #82, but a copy
|
|
257
|
+
// installed before then still runs from there, and "npm i at the repo root" would point its user
|
|
258
|
+
// at a repository they do not have. The location is readable from this file's own URL (`from`,
|
|
259
|
+
// a parameter only so a test can name a plugin-cache location without copying the file there).
|
|
260
|
+
const fromPluginCache = from.includes("/.claude/plugins/");
|
|
314
261
|
console.error(
|
|
315
262
|
"markdown-it does not resolve — this script parses markup with a parser and without it " +
|
|
316
263
|
"would produce a confident zero instead of an error.\n" +
|