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
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" +
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown.mjs — the markup readers every script shares, on the shapes their callers rely on:
|
|
3
|
+
* frontmatter blanked (offsets kept) or cut, headings from the parser (not from a `#` inside a
|
|
4
|
+
* fence), sections by level, fences, paragraphs and tables. And the one thing a test run never
|
|
5
|
+
* sees by accident: markdown-it failing to resolve, where every reader must degrade to its
|
|
6
|
+
* documented empty value and `requireMarkdown` must refuse with exit 2.
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { copyFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { test } from "vitest";
|
|
13
|
+
import { runNode, useTempDir, writeTree } from "../test/support.mjs";
|
|
14
|
+
import {
|
|
15
|
+
blankFrontmatter,
|
|
16
|
+
fences,
|
|
17
|
+
frontmatterBlock,
|
|
18
|
+
headings,
|
|
19
|
+
requireMarkdown,
|
|
20
|
+
paragraphs,
|
|
21
|
+
splitSections,
|
|
22
|
+
stripFences,
|
|
23
|
+
stripFrontmatter,
|
|
24
|
+
tables,
|
|
25
|
+
} from "./markdown.mjs";
|
|
26
|
+
|
|
27
|
+
const DOC = [
|
|
28
|
+
"---", // 0
|
|
29
|
+
"title: T", // 1
|
|
30
|
+
" ## not a heading, inside the frontmatter", // 2
|
|
31
|
+
"---", // 3
|
|
32
|
+
"# Title", // 4
|
|
33
|
+
"", // 5
|
|
34
|
+
"Intro para.", // 6
|
|
35
|
+
"", // 7
|
|
36
|
+
"## One", // 8
|
|
37
|
+
"Body one.", // 9
|
|
38
|
+
"", // 10
|
|
39
|
+
"---", // 11
|
|
40
|
+
"", // 12
|
|
41
|
+
"after the rule", // 13
|
|
42
|
+
"", // 14
|
|
43
|
+
"```", // 15
|
|
44
|
+
"## a heading in a fence", // 16
|
|
45
|
+
"```", // 17
|
|
46
|
+
"", // 18
|
|
47
|
+
" indented code", // 19
|
|
48
|
+
"", // 20
|
|
49
|
+
"### One.a", // 21
|
|
50
|
+
"| a | b |", // 22
|
|
51
|
+
"|---|:-:|", // 23
|
|
52
|
+
"| 1 | 2 |", // 24
|
|
53
|
+
"", // 25
|
|
54
|
+
"## Two", // 26
|
|
55
|
+
"last", // 27
|
|
56
|
+
].join("\n");
|
|
57
|
+
|
|
58
|
+
test("frontmatter: blanked with offsets kept, its body, cut; untouched when there is none", () => {
|
|
59
|
+
assert.deepEqual(
|
|
60
|
+
{
|
|
61
|
+
blank: blankFrontmatter("---\na: 1\n---\nx").split("\n"),
|
|
62
|
+
body: frontmatterBlock(DOC),
|
|
63
|
+
none: [
|
|
64
|
+
frontmatterBlock("x"),
|
|
65
|
+
blankFrontmatter("x"),
|
|
66
|
+
stripFrontmatter("x"),
|
|
67
|
+
],
|
|
68
|
+
cut: stripFrontmatter("---\na: 1\n---\n\nx"),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
blank: [" ", " ", " ", "x"],
|
|
72
|
+
body: "title: T\n ## not a heading, inside the frontmatter",
|
|
73
|
+
none: [null, "x", "x"],
|
|
74
|
+
cut: "\nx",
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("headings come from the parser: not from the frontmatter, not from a fence, not setext", () => {
|
|
80
|
+
const offset = (line) => DOC.split("\n").slice(0, line).join("\n").length + 1;
|
|
81
|
+
assert.deepEqual(headings(DOC + "\n\nSetext\n======\n"), [
|
|
82
|
+
{ depth: 1, text: "Title", line: 4, offset: offset(4) },
|
|
83
|
+
{ depth: 2, text: "One", line: 8, offset: offset(8) },
|
|
84
|
+
{ depth: 3, text: "One.a", line: 21, offset: offset(21) },
|
|
85
|
+
{ depth: 2, text: "Two", line: 26, offset: offset(26) },
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("an empty ATX heading has empty text", () => {
|
|
90
|
+
assert.deepEqual(
|
|
91
|
+
headings("#\n").map((h) => [h.depth, h.text]),
|
|
92
|
+
[[1, ""]],
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("splitSections: a preamble always first, then each heading of the level range", () => {
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
splitSections(DOC, { min: 2, max: 2 }).map((s) => [
|
|
99
|
+
s.heading?.text ?? null,
|
|
100
|
+
s.body.split("\n")[0],
|
|
101
|
+
]),
|
|
102
|
+
[
|
|
103
|
+
[null, "---"],
|
|
104
|
+
["One", "Body one."],
|
|
105
|
+
["Two", "last"],
|
|
106
|
+
],
|
|
107
|
+
);
|
|
108
|
+
assert.deepEqual(
|
|
109
|
+
splitSections("## A").map((s) => [s.heading?.text ?? null, s.body]),
|
|
110
|
+
[
|
|
111
|
+
[null, ""],
|
|
112
|
+
["A", ""],
|
|
113
|
+
],
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("fences and indented code, as content; stripped as lines, or blanked in place", () => {
|
|
118
|
+
const src = "a\n```\nx\n```\n\n y\nb";
|
|
119
|
+
assert.deepEqual(
|
|
120
|
+
{
|
|
121
|
+
fences: fences(src),
|
|
122
|
+
stripped: stripFences(src),
|
|
123
|
+
blanked: stripFences(src, { blank: true }),
|
|
124
|
+
},
|
|
125
|
+
{ fences: ["x\n", "y\n"], stripped: "a\n\nb", blanked: "a\n\n\n\n\n\nb" },
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("paragraphs are line ranges; tables are rows of cells, the alignment row skipped", () => {
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
{ paragraphs: paragraphs(DOC), tables: tables(DOC) },
|
|
132
|
+
{
|
|
133
|
+
paragraphs: [
|
|
134
|
+
{ line: 6, endLine: 7 },
|
|
135
|
+
{ line: 9, endLine: 10 },
|
|
136
|
+
{ line: 13, endLine: 14 },
|
|
137
|
+
{ line: 27, endLine: 28 },
|
|
138
|
+
],
|
|
139
|
+
tables: [
|
|
140
|
+
{
|
|
141
|
+
line: 22,
|
|
142
|
+
endLine: 25,
|
|
143
|
+
rows: [
|
|
144
|
+
["a", "b"],
|
|
145
|
+
["1", "2"],
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const root = useTempDir("markdown-no-parser-");
|
|
154
|
+
writeTree(root, {
|
|
155
|
+
// Registered with --import: resolving `markdown-it` fails, as it does with no node_modules.
|
|
156
|
+
"no-markdown-it.mjs":
|
|
157
|
+
"import { register } from 'node:module';\n" +
|
|
158
|
+
"register('data:text/javascript,' + encodeURIComponent(\"export async function resolve(s, c, next) { if (s === 'markdown-it') throw Object.assign(new Error('gone'), { code: 'ERR_MODULE_NOT_FOUND' }); return next(s, c); }\"));\n",
|
|
159
|
+
"probe.mjs":
|
|
160
|
+
`const m = await import(${JSON.stringify(join(dirname(fileURLToPath(import.meta.url)), "markdown.mjs"))});\n` +
|
|
161
|
+
"const t = '# H\\n\\n```\\nx\\n```\\n';\n" +
|
|
162
|
+
"console.log(JSON.stringify([m.MD_AVAILABLE(), m.headings(t), m.fences(t), m.stripFences(t), m.paragraphs(t), m.tables(t)]));\n" +
|
|
163
|
+
"m.requireMarkdown();\n" +
|
|
164
|
+
"console.log('not reached');\n",
|
|
165
|
+
"probe-plugin.mjs":
|
|
166
|
+
`const m = await import(${JSON.stringify(join(dirname(fileURLToPath(import.meta.url)), "markdown.mjs"))});\n` +
|
|
167
|
+
'm.requireMarkdown({ from: "file:///home/u/.claude/plugins/cache/paperlint/lib/markdown.mjs" });\n',
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("this file, told it runs from a plugin cache, sends its user to the npm package", () => {
|
|
171
|
+
const r = runNode(join(root, "probe-plugin.mjs"), [], {
|
|
172
|
+
nodeArgs: ["--import", join(root, "no-markdown-it.mjs")],
|
|
173
|
+
});
|
|
174
|
+
assert.deepEqual(
|
|
175
|
+
[r.status, r.stderr.split("\n").slice(1, 2)],
|
|
176
|
+
[
|
|
177
|
+
2,
|
|
178
|
+
[
|
|
179
|
+
" Running from a PLUGIN copy, which is installed without dependencies. Install the npm",
|
|
180
|
+
],
|
|
181
|
+
],
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("with no markdown-it, every reader degrades to its empty value and requireMarkdown exits 2", () => {
|
|
186
|
+
const r = runNode(join(root, "probe.mjs"), [], {
|
|
187
|
+
nodeArgs: ["--import", join(root, "no-markdown-it.mjs")],
|
|
188
|
+
});
|
|
189
|
+
assert.deepEqual(r, {
|
|
190
|
+
status: 2,
|
|
191
|
+
stdout:
|
|
192
|
+
JSON.stringify([false, [], [], "# H\n\n```\nx\n```\n", [], []]) + "\n",
|
|
193
|
+
stderr:
|
|
194
|
+
"markdown-it does not resolve — this script parses markup with a parser and without it would produce a confident zero instead of an error.\n" +
|
|
195
|
+
" Cured by `npm i` at the repo root.\n",
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("splitSections of a document with no heading is the preamble alone; requireMarkdown is silent with a parser", () => {
|
|
200
|
+
assert.deepEqual(
|
|
201
|
+
[
|
|
202
|
+
splitSections("just text").map((s) => [s.heading, s.body]),
|
|
203
|
+
requireMarkdown(),
|
|
204
|
+
],
|
|
205
|
+
[[[null, "just text"]], undefined],
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("headings read a token stream that breaks markdown-it's shape as empty text and offset 0", () => {
|
|
210
|
+
// A heading not followed by its inline token, mapped past the end of the text.
|
|
211
|
+
const parse = () => [
|
|
212
|
+
{ type: "heading_open", map: [9, 10], markup: "##", tag: "h2" },
|
|
213
|
+
{ type: "paragraph_open" },
|
|
214
|
+
];
|
|
215
|
+
assert.deepEqual(headings("x", { parse }), [
|
|
216
|
+
{ depth: 2, text: "", line: 9, offset: 0 },
|
|
217
|
+
]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("a copy running from a plugin cache without markdown-it is told to install the npm package", () => {
|
|
221
|
+
// A plugin copy is installed without dependencies: here, a copy under `.claude/plugins/` with
|
|
222
|
+
// no node_modules above it, so `markdown-it` does not resolve for real.
|
|
223
|
+
const lib = join(root, ".claude", "plugins", "paperlint", "lib");
|
|
224
|
+
mkdirSync(lib, { recursive: true });
|
|
225
|
+
copyFileSync(
|
|
226
|
+
join(dirname(fileURLToPath(import.meta.url)), "markdown.mjs"),
|
|
227
|
+
join(lib, "markdown.mjs"),
|
|
228
|
+
);
|
|
229
|
+
writeTree(lib, {
|
|
230
|
+
"probe.mjs":
|
|
231
|
+
'const m = await import("./markdown.mjs");\nm.requireMarkdown();\n',
|
|
232
|
+
});
|
|
233
|
+
const r = runNode(join(lib, "probe.mjs"), []);
|
|
234
|
+
assert.deepEqual(
|
|
235
|
+
[r.status, r.stderr.split("\n").slice(1)],
|
|
236
|
+
[
|
|
237
|
+
2,
|
|
238
|
+
[
|
|
239
|
+
" Running from a PLUGIN copy, which is installed without dependencies. Install the npm",
|
|
240
|
+
" package in the project as well (`npm i paperlint`) and call it through",
|
|
241
|
+
" `npx paperlint`, which resolves from the project's own tree.",
|
|
242
|
+
"",
|
|
243
|
+
],
|
|
244
|
+
],
|
|
245
|
+
);
|
|
246
|
+
});
|
|
@@ -23,21 +23,16 @@
|
|
|
23
23
|
* while the value itself is still taken by import.
|
|
24
24
|
*
|
|
25
25
|
* Run: npx vigiles test lib/paper-config.harness.mjs
|
|
26
|
-
* Killed by: lib/paper-config.mutations.mjs
|
|
27
26
|
*/
|
|
28
|
-
import assert from "node:assert/strict";
|
|
29
27
|
import { readFileSync, readdirSync } from "node:fs";
|
|
30
28
|
import { dirname, join, relative, resolve } from "node:path";
|
|
31
29
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
30
|
+
import { createChecker } from "./check.mjs";
|
|
32
31
|
|
|
33
32
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
34
33
|
const ROOT = resolve(HERE, "..");
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
const check = (label, cond) => {
|
|
38
|
-
n++;
|
|
39
|
-
assert.ok(cond, label);
|
|
40
|
-
};
|
|
35
|
+
const check = createChecker();
|
|
41
36
|
|
|
42
37
|
/**
|
|
43
38
|
* Every place that publishes at least one of the two constants. The three hooks duplicate them
|
|
@@ -181,7 +176,7 @@ const loaded = await Promise.all(
|
|
|
181
176
|
if (e.isDirectory()) walk(p);
|
|
182
177
|
else if (
|
|
183
178
|
/\.(mjs|ts)$/.test(e.name) &&
|
|
184
|
-
!/\.
|
|
179
|
+
!/\.harness\.(mjs|ts)$/.test(e.name)
|
|
185
180
|
) {
|
|
186
181
|
if (declares.test(readFileSync(p, "utf8")))
|
|
187
182
|
found.push(relative(ROOT, p));
|
|
@@ -264,6 +259,30 @@ const loaded = await Promise.all(
|
|
|
264
259
|
);
|
|
265
260
|
}
|
|
266
261
|
|
|
262
|
+
// ── the two readers' fallbacks: no marker anywhere up the tree, and JSON that is not an object ──
|
|
263
|
+
{
|
|
264
|
+
const { findProjectRoot, settingsOf } = await import(
|
|
265
|
+
join(HERE, "paper-config.mjs")
|
|
266
|
+
);
|
|
267
|
+
check(
|
|
268
|
+
"findProjectRoot: no paperlint.json and no package.json up to / — the start directory itself",
|
|
269
|
+
findProjectRoot("/nowhere/deep", () => false) === "/nowhere/deep",
|
|
270
|
+
);
|
|
271
|
+
const { mkdtempSync, realpathSync, rmSync, writeFileSync } =
|
|
272
|
+
await import("node:fs");
|
|
273
|
+
const { tmpdir } = await import("node:os");
|
|
274
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "paper-config-")));
|
|
275
|
+
try {
|
|
276
|
+
writeFileSync(join(dir, "paperlint.json"), "[]\n");
|
|
277
|
+
check(
|
|
278
|
+
"settingsOf: a paperlint.json that is JSON but not an object is no settings",
|
|
279
|
+
settingsOf(dir) === undefined,
|
|
280
|
+
);
|
|
281
|
+
} finally {
|
|
282
|
+
rmSync(dir, { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
267
286
|
console.log(
|
|
268
|
-
`✓ ${
|
|
287
|
+
`✓ ${check.count} assertions passed — one source for the consumer's config key`,
|
|
269
288
|
);
|
package/lib/paper-config.mjs
CHANGED
|
@@ -64,11 +64,16 @@ export const PAPER_MARKERS = Object.freeze([
|
|
|
64
64
|
export function findProjectRoot(startDir, isFile = existsSync) {
|
|
65
65
|
const start = resolve(startDir);
|
|
66
66
|
const isPaper = (d) => PAPER_MARKERS.some((m) => isFile(join(d, m)));
|
|
67
|
+
// Up from `start` to the first directory `found` accepts; null past the filesystem root. A
|
|
68
|
+
// loop with an exit condition rather than `for (;;)`: the code after an endless loop is a block
|
|
69
|
+
// that can never run, and coverage counts it as one that did not.
|
|
67
70
|
const walk = (found) => {
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
let d = start;
|
|
72
|
+
while (!found(d)) {
|
|
70
73
|
if (dirname(d) === d) return null;
|
|
74
|
+
d = dirname(d);
|
|
71
75
|
}
|
|
76
|
+
return d;
|
|
72
77
|
};
|
|
73
78
|
return (
|
|
74
79
|
walk((d) => isFile(join(d, CONFIG_FILE)) && !isPaper(d)) ??
|
package/lib/skill-checks.mjs
CHANGED
|
@@ -137,8 +137,8 @@ export async function checkSkill(name) {
|
|
|
137
137
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
138
138
|
// 1. GONE, AND THAT IS THE RESULT (2026-08-19). This slot held a strict-YAML assertion whose whole
|
|
139
139
|
// job was to police the gap between how the corpus was READ here (a hand-rolled lenient reader)
|
|
140
|
-
// and how every other consumer reads it (a real YAML parser). The corpus owner, on being shown the
|
|
141
|
-
//
|
|
140
|
+
// and how every other consumer reads it (a real YAML parser). The corpus owner, on being shown the gap:
|
|
141
|
+
// "sounds like a hack… let's think about how to do it properly". The right fix was not a better
|
|
142
142
|
// check — it was to stop being lenient: the two remaining unparseable descriptions were quoted and
|
|
143
143
|
// `parseFm` in skill-corpus.mjs now throws on anything a real parser rejects.
|
|
144
144
|
//
|
|
@@ -213,7 +213,12 @@ export async function checkSkill(name) {
|
|
|
213
213
|
["announce", PIPELINE.announce, "Run me"],
|
|
214
214
|
["record", PIPELINE.ledger, "Record the verdict"],
|
|
215
215
|
]) {
|
|
216
|
-
|
|
216
|
+
// For the ledger, only a `record` call is the wired call: `ledger.mjs status <dir>` is a
|
|
217
|
+
// read, files under nothing, and until 2026-09-27 was misreported as "files under status".
|
|
218
|
+
const hits = cmds.filter(
|
|
219
|
+
(c) =>
|
|
220
|
+
c.script === script && (label !== "record" || c.args[0] === "record"),
|
|
221
|
+
);
|
|
217
222
|
soft(() =>
|
|
218
223
|
assert.ok(
|
|
219
224
|
hits.length > 0,
|
|
@@ -231,12 +236,7 @@ export async function checkSkill(name) {
|
|
|
231
236
|
),
|
|
232
237
|
);
|
|
233
238
|
// arg[0] for announce is the skill; for ledger.mjs it is the literal `record`, then the skill.
|
|
234
|
-
const passed =
|
|
235
|
-
label === "announce"
|
|
236
|
-
? h.args[0]
|
|
237
|
-
: h.args[0] === "record"
|
|
238
|
-
? h.args[1]
|
|
239
|
-
: h.args[0];
|
|
239
|
+
const passed = label === "announce" ? h.args[0] : h.args[1];
|
|
240
240
|
soft(() =>
|
|
241
241
|
assert.equal(
|
|
242
242
|
passed,
|
|
@@ -551,10 +551,7 @@ function danglingLinks(src, dir) {
|
|
|
551
551
|
* Check the prose skill `name`.
|
|
552
552
|
*
|
|
553
553
|
* @param {string} name — the directory name in `.claude/skills/`
|
|
554
|
-
* @param {{
|
|
555
|
-
* toolsNeeded?: Record<string,string>}} opts
|
|
556
|
-
* `mustSay` — that whose DISAPPEARANCE is a regression (a rule obtained by measurement).
|
|
557
|
-
* `mustNotSay` — that whose APPEARANCE is a regression (a refuted claim).
|
|
554
|
+
* @param {{toolsNeeded?: Record<string,string>}} opts
|
|
558
555
|
* `toolsNeeded` — {tool: REASON}. Lifts the ban on Bash/Agent/Task for a skill that
|
|
559
556
|
* needs the tool IN SUBSTANCE. The reason is mandatory and non-empty.
|
|
560
557
|
*
|
|
@@ -583,6 +580,14 @@ export async function checkProseSkill(name, opts = {}) {
|
|
|
583
580
|
}
|
|
584
581
|
};
|
|
585
582
|
|
|
583
|
+
// Any other option is refused by name: an option this function no longer reads would be a
|
|
584
|
+
// check the caller believes runs and that does nothing.
|
|
585
|
+
const unknown = Object.keys(opts).filter((k) => k !== "toolsNeeded");
|
|
586
|
+
assert.deepEqual(
|
|
587
|
+
unknown,
|
|
588
|
+
[],
|
|
589
|
+
`${name}: unknown option(s) ${unknown.join(", ")} — checkProseSkill takes only toolsNeeded`,
|
|
590
|
+
);
|
|
586
591
|
assert.ok(existsSync(md), `${name}: no ${md} — nothing to check`);
|
|
587
592
|
const src = readFileSync(md, "utf8");
|
|
588
593
|
|
|
@@ -650,12 +655,6 @@ export async function checkProseSkill(name, opts = {}) {
|
|
|
650
655
|
`is a dead instruction: the model reads it, does something adjacent and reports success.`,
|
|
651
656
|
),
|
|
652
657
|
);
|
|
653
|
-
// mutation: a planted broken link must be caught
|
|
654
|
-
assert.equal(
|
|
655
|
-
danglingLinks(src + "\n[x](./net-takogo-fayla.md)\n", dir).length,
|
|
656
|
-
dangling.length + 1,
|
|
657
|
-
`${name}: the link check did not fire on a planted broken link — it is dead`,
|
|
658
|
-
);
|
|
659
658
|
|
|
660
659
|
// 3. Whether it is compiled. Editing the markdown by hand desynchronises the sha and gets
|
|
661
660
|
// quietly rolled back by the next `vigiles compile`.
|
|
@@ -667,33 +666,6 @@ export async function checkProseSkill(name, opts = {}) {
|
|
|
667
666
|
),
|
|
668
667
|
);
|
|
669
668
|
|
|
670
|
-
// 4-5. Substantive anchors, each with BOTH halves.
|
|
671
|
-
for (const [re, why] of opts.mustSay || []) {
|
|
672
|
-
soft(() => assert.ok(re.test(src), `${name}: "${why}" is gone (${re})`));
|
|
673
|
-
// Mutation: without the matched fragment the predicate must fail. The replacement is GLOBAL —
|
|
674
|
-
// `replace` with a non-global regex removes only the first occurrence, and an anchor occurring
|
|
675
|
-
// twice (for example both in the description and in the body) would "pass" on the mutated text
|
|
676
|
-
// too. Then this assertion would fail rather than the anchor itself — that is, the message
|
|
677
|
-
// would point in the wrong direction.
|
|
678
|
-
const g = new RegExp(
|
|
679
|
-
re.source,
|
|
680
|
-
re.flags.includes("g") ? re.flags : re.flags + "g",
|
|
681
|
-
);
|
|
682
|
-
assert.equal(
|
|
683
|
-
re.test(src.replace(g, "")),
|
|
684
|
-
false,
|
|
685
|
-
`${name}: the anchor ${re} passes even on text WITHOUT it — the check pins nothing down`,
|
|
686
|
-
);
|
|
687
|
-
}
|
|
688
|
-
for (const [re, why] of opts.mustNotSay || []) {
|
|
689
|
-
soft(() => assert.ok(!re.test(src), `${name}: "${why}" came back (${re})`));
|
|
690
|
-
assert.equal(
|
|
691
|
-
re.test(src + "\n" + why + "\n"),
|
|
692
|
-
true,
|
|
693
|
-
`${name}: the ban ${re} does not fire on the planted line "${why}" — the check is dead`,
|
|
694
|
-
);
|
|
695
|
-
}
|
|
696
|
-
|
|
697
669
|
if (deferred.length)
|
|
698
670
|
throw new AggregateError(deferred, `${name}: ${deferred.length} findings`);
|
|
699
671
|
recordCheck();
|