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
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { describe, expect, it } from "vitest";
|
|
6
6
|
import { ESLint } from "eslint";
|
|
7
7
|
import markdown from "@eslint/markdown";
|
|
8
|
-
import review from "./review-frontmatter.mjs";
|
|
8
|
+
import review, { errorsOf } from "./review-frontmatter.mjs";
|
|
9
9
|
import sibling from "./sibling-frontmatter.mjs";
|
|
10
10
|
|
|
11
11
|
async function lint(text) {
|
|
@@ -81,6 +81,42 @@ describe("review/frontmatter — the findings record", () => {
|
|
|
81
81
|
});
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
describe("review/frontmatter — what the parser and the validator may hand back", () => {
|
|
85
|
+
/** The rule's visitors over a fake context; the reports it makes. */
|
|
86
|
+
const visit = (value) => {
|
|
87
|
+
const reports = [];
|
|
88
|
+
const v = review.rules.frontmatter.create({
|
|
89
|
+
report: (r) => reports.push(r),
|
|
90
|
+
});
|
|
91
|
+
v.yaml({ value });
|
|
92
|
+
v["root:exit"]({});
|
|
93
|
+
return reports.map(
|
|
94
|
+
(r) => r.messageId + (r.data?.why ? `: ${r.data.why}` : ""),
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
it("an empty frontmatter block is validated as `{}` — silent", async () => {
|
|
99
|
+
expect(await lint("---\n---\n# Review\n")).toEqual([]);
|
|
100
|
+
expect(visit(undefined)).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("a parser failure without a YAML reason is reported by its message, or as unparseable", () => {
|
|
104
|
+
const throwing = (thrown) => ({
|
|
105
|
+
toString() {
|
|
106
|
+
throw thrown;
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
expect(visit(throwing(new Error("boom")))).toEqual(["malformed: boom"]);
|
|
110
|
+
expect(visit(throwing("not an Error"))).toEqual(["malformed: unparseable"]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("a failed validation with no errors attached names nothing", () => {
|
|
114
|
+
const validate = () => false;
|
|
115
|
+
validate.errors = null;
|
|
116
|
+
expect(errorsOf(validate, {})).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
84
120
|
describe("sibling/frontmatter — the same mechanism, the package's own sibling-card schema", () => {
|
|
85
121
|
async function card(text) {
|
|
86
122
|
const eslint = new ESLint({
|
|
@@ -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
|
|
@@ -17,11 +17,14 @@ if [ -n "${FAKE_INSTALL_TL_FAIL:-}" ]; then
|
|
|
17
17
|
exit 1
|
|
18
18
|
fi
|
|
19
19
|
mkdir -p "$dir/bin/x86_64-linux"
|
|
20
|
-
for t in pdflatex kpsewhich tlmgr; do
|
|
20
|
+
for t in pdflatex kpsewhich tlmgr fmtutil-sys; do
|
|
21
21
|
cp "$here/stub-$t" "$dir/bin/x86_64-linux/$t"
|
|
22
22
|
chmod +x "$dir/bin/x86_64-linux/$t"
|
|
23
23
|
done
|
|
24
24
|
: > "$dir/files.txt"
|
|
25
25
|
# The real install-tl leaves release-texlive.txt at TEXDIR; stub-tlmgr reads the tree's year there.
|
|
26
26
|
cp "$here/release-texlive.txt" "$dir/release-texlive.txt"
|
|
27
|
+
# The real install-tl, when a mirror fails to serve a package, prints "continuing anyway" and
|
|
28
|
+
# exits as if nothing happened. FAKE_INSTALL_TL_DROP names the package it lost.
|
|
29
|
+
if [ -n "${FAKE_INSTALL_TL_DROP:-}" ]; then echo "$FAKE_INSTALL_TL_DROP" > "$dir/missing-deps.txt"; fi
|
|
27
30
|
echo "fake install-tl: scheme-basic into $dir"
|
|
@@ -9,6 +9,15 @@ while [ $# -gt 0 ]; do
|
|
|
9
9
|
case "$1" in
|
|
10
10
|
--repository) repo=$2; shift 2 ;;
|
|
11
11
|
install) shift; break ;;
|
|
12
|
+
# `check depends` in tlmgr's own layout: every name in missing-deps.txt (what the stub
|
|
13
|
+
# install-tl was told to drop) is a dependency without a package. Not logged: it installs nothing.
|
|
14
|
+
check)
|
|
15
|
+
if [ -s "$tree/missing-deps.txt" ]; then
|
|
16
|
+
echo " DEPENDS WITHOUT PACKAGES:"
|
|
17
|
+
sed 's/$/ in: collection-basic latex-bin/' "$tree/missing-deps.txt"
|
|
18
|
+
exit 2
|
|
19
|
+
fi
|
|
20
|
+
exit 0 ;;
|
|
12
21
|
*) shift ;;
|
|
13
22
|
esac
|
|
14
23
|
done
|
|
@@ -34,6 +43,12 @@ for p in "$@"; do
|
|
|
34
43
|
status=1
|
|
35
44
|
continue
|
|
36
45
|
fi
|
|
46
|
+
# Installing a dropped dependency repairs the tree.
|
|
47
|
+
# Portable on purpose: macOS sed -i takes a backup suffix, GNU sed does not.
|
|
48
|
+
if [ -f "$tree/missing-deps.txt" ]; then
|
|
49
|
+
grep -vx "$p" "$tree/missing-deps.txt" > "$tree/missing-deps.tmp" || true
|
|
50
|
+
mv "$tree/missing-deps.tmp" "$tree/missing-deps.txt"
|
|
51
|
+
fi
|
|
37
52
|
for item in ${line#* }; do
|
|
38
53
|
case "$item" in
|
|
39
54
|
bin:*) : > "$bin/${item#bin:}"; chmod +x "$bin/${item#bin:}" ;;
|
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
|
+
});
|