paperlint 2.1.0 → 3.0.0
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/CLAUDE.md +6 -1
- package/README.md +233 -224
- package/action.yml +4 -6
- package/dist/adapters/references/index.d.ts +13 -0
- package/dist/adapters/references/index.d.ts.map +1 -0
- package/dist/adapters/references/index.js +55 -0
- package/dist/adapters/references/index.js.map +1 -0
- package/dist/adapters/references/reach.io.d.ts +3 -0
- package/dist/adapters/references/reach.io.d.ts.map +1 -0
- package/dist/adapters/references/reach.io.js +14 -0
- package/dist/adapters/references/reach.io.js.map +1 -0
- package/dist/build.d.ts +15 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +38 -4
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +28 -25
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +277 -265
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +40 -72
- package/dist/doctor.js.map +1 -1
- package/dist/facts-file.d.ts +2 -2
- package/dist/facts-file.js +2 -2
- package/dist/hooks-settings.d.ts +0 -21
- package/dist/hooks-settings.d.ts.map +1 -1
- package/dist/hooks-settings.js +17 -135
- package/dist/hooks-settings.js.map +1 -1
- package/dist/init.d.ts +39 -29
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +143 -172
- package/dist/init.js.map +1 -1
- package/dist/link-skills.d.ts +1 -1
- package/dist/link-skills.d.ts.map +1 -1
- package/dist/link-skills.js +11 -29
- package/dist/link-skills.js.map +1 -1
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +2 -13
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-files.d.ts +63 -0
- package/dist/paper-files.d.ts.map +1 -0
- package/dist/paper-files.js +69 -0
- package/dist/paper-files.js.map +1 -0
- package/dist/paper-settings.d.ts +13 -41
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +71 -71
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/check-references.d.ts +31 -0
- package/dist/ports/check-references.d.ts.map +1 -0
- package/dist/ports/check-references.js +7 -0
- package/dist/ports/check-references.js.map +1 -0
- package/dist/presets.d.ts +1 -1
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +4 -6
- package/dist/presets.js.map +1 -1
- package/dist/reference-rules.d.ts +47 -0
- package/dist/reference-rules.d.ts.map +1 -0
- package/dist/reference-rules.js +156 -0
- package/dist/reference-rules.js.map +1 -0
- package/dist/references.d.ts +36 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +91 -0
- package/dist/references.js.map +1 -0
- package/dist/rules-config.d.ts +5 -3
- package/dist/rules-config.d.ts.map +1 -1
- package/dist/rules-config.js +15 -6
- package/dist/rules-config.js.map +1 -1
- package/dist/structure.js +3 -3
- package/dist/structure.js.map +1 -1
- package/dist/tex-requirements.js +2 -2
- package/dist/tex-requirements.js.map +1 -1
- package/dist/types.d.ts +20 -18
- package/dist/types.d.ts.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/configuration.md +119 -104
- package/docs/e2e.md +1 -1
- package/docs/install.md +25 -40
- package/docs/optional-rules.md +5 -4
- package/docs/rules.md +60 -23
- package/eslint-rules/bib-reachable-entry.mjs +75 -0
- package/eslint-rules/latex-language.mjs +29 -1
- package/eslint-rules/paper-stages.harness.mjs +1 -117
- package/eslint-rules/paper-stages.mjs +0 -120
- package/eslint-rules/paper-stages.mutations.mjs +0 -76
- package/eslint-rules/paper-typography.mjs +267 -192
- package/eslint-rules/paper-typography.test.mjs +297 -0
- package/eslint-rules/papers.harness.mjs +8 -12
- package/eslint-rules/papers.mjs +17 -31
- package/eslint-rules/pdf-last-page-balance.mjs +4 -7
- package/eslint-rules/review-frontmatter.mjs +118 -0
- package/eslint-rules/review-frontmatter.schema.json +36 -0
- package/eslint-rules/review-frontmatter.test.mjs +118 -0
- package/eslint-rules/sibling-frontmatter.mjs +19 -0
- package/eslint-rules/sibling-frontmatter.schema.json +14 -0
- package/eslint.config.mjs +0 -15
- package/fixtures/real-markdown-paper/baseline.json +6 -7
- package/hooks/hooks.harness.mjs +39 -98
- package/hooks/hooks.mutations.mjs +6 -6
- package/hooks/paper-edit-guard.hook.d.mts +2 -3
- package/hooks/paper-edit-guard.hook.mjs +31 -42
- package/hooks/paper-skills-nudge.hook.mjs +21 -19
- package/hooks/paper-status-gates.hook.mjs +21 -19
- package/hooks/paper-status-gates.sh +8 -13
- package/lib/paper-config.d.mts +15 -25
- package/lib/paper-config.harness.mjs +18 -35
- package/lib/paper-config.mjs +83 -105
- package/lib/paper-config.mutations.mjs +2 -2
- package/lib/skill-checks.mjs +1 -1
- package/lib/skill-trigger-cases.harness.mjs +9 -8
- package/lib/skill-trigger-cases.mjs +7 -15
- package/package.json +1 -1
- package/scripts/eslint-report-guard.mjs +1 -1
- package/scripts/harness-api.frozen.json +0 -3
- package/scripts/mutation-batteries.frozen.json +4 -13
- package/skills/analyze-sibling-paper/SKILL.md +16 -12
- package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
- package/skills/cold-read-diff/SKILL.md +20 -1
- package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
- package/skills/grade-paper-writing/SKILL.md +20 -1
- package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
- package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
- package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
- package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
- package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
- package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
- package/skills/plan-paper-timeline/SKILL.md +2 -2
- package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
- package/skills/render-paper/check-render.sh +2 -3
- package/skills/render-paper/render-paper.harness.mjs +2 -2
- package/skills/verify-citations/SKILL.md +10 -1
- package/skills/verify-citations/SKILL.md.spec.ts +9 -0
- package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
- package/src/adapters/references/index.ts +102 -0
- package/src/adapters/references/reach.io.ts +12 -0
- package/src/build.harness.mjs +8 -4
- package/src/build.ts +62 -6
- package/src/cli.harness.mjs +147 -292
- package/src/cli.mutations.mjs +35 -44
- package/src/cli.ts +344 -304
- package/src/doctor.harness.mjs +41 -84
- package/src/doctor.mutations.mjs +9 -9
- package/src/doctor.ts +44 -89
- package/src/facts-file.test.ts +0 -7
- package/src/facts-file.ts +2 -2
- package/src/hooks-settings.harness.mjs +7 -92
- package/src/hooks-settings.mutations.mjs +2 -17
- package/src/hooks-settings.ts +18 -162
- package/src/init.test.ts +206 -0
- package/src/init.ts +186 -230
- package/src/link-skills.harness.mjs +1 -38
- package/src/link-skills.mutations.mjs +9 -5
- package/src/link-skills.ts +10 -34
- package/src/lint-fix.test.ts +120 -0
- package/src/new-paper.test.ts +1 -13
- package/src/new-paper.ts +2 -17
- package/src/paper-files.test.ts +290 -0
- package/src/paper-files.ts +144 -0
- package/src/paper-settings-commands.test.ts +84 -121
- package/src/paper-settings.test.ts +93 -64
- package/src/paper-settings.ts +99 -97
- package/src/ports/check-references.ts +28 -0
- package/src/presets.ts +5 -15
- package/src/reference-rules.ts +229 -0
- package/src/references.test.ts +209 -0
- package/src/references.ts +138 -0
- package/src/rules-config.harness.mjs +24 -8
- package/src/rules-config.ts +19 -7
- package/src/structure.harness.mjs +3 -1
- package/src/structure.ts +3 -3
- package/src/tex-requirements.ts +2 -2
- package/src/types.ts +30 -18
- package/src/venue-rules.test.ts +20 -11
- package/src/venue-rules.ts +11 -13
- package/eslint-rules/doc-fields.harness.mjs +0 -336
- package/eslint-rules/doc-fields.mjs +0 -186
- package/eslint-rules/doc-fields.mutations.mjs +0 -96
- package/eslint-rules/paper-typography.harness.mjs +0 -291
- package/eslint-rules/paper-typography.mutations.mjs +0 -131
- package/eslint-rules/review-findings-cause.harness.mjs +0 -228
- package/eslint-rules/review-findings-cause.mjs +0 -135
- package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
- package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
- package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
- package/fixtures/review-findings-cause/clean.md +0 -17
- package/fixtures/review-findings-cause/defect.md +0 -14
- package/fixtures/review-findings-cause/old-debt.md +0 -14
- package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
package/src/venue-rules.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* One rule per reason, and the others are silent — so a paper gets one finding that says what to
|
|
25
25
|
* do, not six that say the same thing:
|
|
26
26
|
*
|
|
27
|
-
* no paperlint.json every rule silent
|
|
27
|
+
* no paperlint.json every rule silent (`paperlint new` writes one)
|
|
28
28
|
* extends null / absent pdf/measured (warn) — no venue chosen yet; it names the file to set
|
|
29
29
|
* preset does not resolve pdf/profile (error) — a typo would otherwise switch every check off
|
|
30
30
|
* not built / no facts pdf/measured (warn) — lint often runs before or without a build (the
|
|
@@ -48,10 +48,7 @@ import type { FlatGeometry } from "./domain/geometry.ts";
|
|
|
48
48
|
import type { AbsolutePath } from "./domain/paths.ts";
|
|
49
49
|
import { sha256Hex } from "./domain/sha256.ts";
|
|
50
50
|
import type { Files } from "./ports/files.ts";
|
|
51
|
-
import {
|
|
52
|
-
LEGACY_PAPER_SETTINGS_MESSAGE,
|
|
53
|
-
PAPER_SETTINGS_FILE,
|
|
54
|
-
} from "../lib/paper-config.mjs";
|
|
51
|
+
import { CONFIG_FILE } from "../lib/paper-config.mjs";
|
|
55
52
|
|
|
56
53
|
// ── the verdict's vocabulary ─────────────────────────────────────────────────────────
|
|
57
54
|
|
|
@@ -109,6 +106,11 @@ function kindOf(
|
|
|
109
106
|
kind: string | null,
|
|
110
107
|
): Pick<Resolved, "kind" | "kindProblem"> {
|
|
111
108
|
const known = [...format.kinds.keys()].join(", ") || "(none)";
|
|
109
|
+
// A preset with no kinds (`acm-sigconf`, a family a paper for an unprofiled venue extends
|
|
110
|
+
// directly) has no page limit to pick, so naming no kind is the only valid declaration. A kind
|
|
111
|
+
// named against it is still `kindUnknown`, whose "its kinds: (none)" says why.
|
|
112
|
+
if (kind === null && format.kinds.size === 0)
|
|
113
|
+
return { kind: null, kindProblem: null };
|
|
112
114
|
if (kind === null)
|
|
113
115
|
return {
|
|
114
116
|
kind: null,
|
|
@@ -150,14 +152,11 @@ export function assessPaper(paperDir: string, deps: VenueRuleDeps): Assessment {
|
|
|
150
152
|
if (p.kind === "none")
|
|
151
153
|
return p.settings === null
|
|
152
154
|
? { kind: "no-venue" }
|
|
153
|
-
: { kind: "no-preset", file: join(paperDir,
|
|
155
|
+
: { kind: "no-preset", file: join(paperDir, CONFIG_FILE) };
|
|
154
156
|
if (p.kind === "settings-problem")
|
|
155
157
|
return {
|
|
156
158
|
kind: "unresolved",
|
|
157
|
-
finding:
|
|
158
|
-
p.problem.kind === "legacy"
|
|
159
|
-
? finding("legacySettings")
|
|
160
|
-
: finding("settingsBroken", { why: p.problem.why }),
|
|
159
|
+
finding: finding("settingsBroken", { why: p.problem.why }),
|
|
161
160
|
};
|
|
162
161
|
if (p.kind === "preset-problem")
|
|
163
162
|
return {
|
|
@@ -415,11 +414,10 @@ const META: Readonly<Record<VenueRuleName, Meta>> = {
|
|
|
415
414
|
"the venue preset a paper's paperlint.json extends resolves, and the kind it names exists",
|
|
416
415
|
},
|
|
417
416
|
messages: {
|
|
418
|
-
settingsBroken: `${
|
|
419
|
-
legacySettings: `this paper's venue checks do not run — ${LEGACY_PAPER_SETTINGS_MESSAGE}`,
|
|
417
|
+
settingsBroken: `${CONFIG_FILE} cannot be read: {{why}}`,
|
|
420
418
|
preset:
|
|
421
419
|
"{{why}} — so this paper's page limit, fonts and format are not checked. Fix `extends` in its paperlint.json, or turn pdf/profile off for this paper",
|
|
422
|
-
kindMissing: `${
|
|
420
|
+
kindMissing: `${CONFIG_FILE} names no \`kind\`, so the page limit of \`{{venue}}\` is not checked; its kinds: {{known}}`,
|
|
423
421
|
kindUnknown:
|
|
424
422
|
"`{{venue}}` has no kind `{{kind}}`, so the page limit is not checked; its kinds: {{known}}",
|
|
425
423
|
},
|
|
@@ -1,336 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Harness for `doc/fields`. Both halves for every case: fires on a planted defect AND
|
|
3
|
-
* stays silent on a correct neighbor — otherwise "silent" is indistinguishable from "dead".
|
|
4
|
-
*
|
|
5
|
-
* The mutation battery sits alongside it: `doc-fields.mutations.mjs`.
|
|
6
|
-
*/
|
|
7
|
-
import assert from "node:assert/strict";
|
|
8
|
-
import { Linter } from "eslint";
|
|
9
|
-
import markdown from "@eslint/markdown";
|
|
10
|
-
import docFields from "./doc-fields.mjs";
|
|
11
|
-
import { recordCheck } from "vigiles";
|
|
12
|
-
|
|
13
|
-
const linter = new Linter();
|
|
14
|
-
|
|
15
|
-
const OPTS = {
|
|
16
|
-
fields: {
|
|
17
|
-
read: {
|
|
18
|
-
values: ["full", "abstract", "none"],
|
|
19
|
-
hint: "what exactly was read",
|
|
20
|
-
},
|
|
21
|
-
},
|
|
22
|
-
sinceCreated: "2026-07-29",
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
/** Run the rule against one document. Returns the findings. */
|
|
26
|
-
function run(src, options = OPTS) {
|
|
27
|
-
return linter.verify(src, {
|
|
28
|
-
plugins: { markdown, doc: docFields },
|
|
29
|
-
language: "markdown/gfm",
|
|
30
|
-
languageOptions: { frontmatter: "yaml" },
|
|
31
|
-
rules: { "doc/fields": ["error", options] },
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const fm = (body, extra = "") =>
|
|
36
|
-
["---", "title: neighbor", 'created: "2026-08-01"', extra, "---", "", body]
|
|
37
|
-
.filter(Boolean)
|
|
38
|
-
.join("\n");
|
|
39
|
-
|
|
40
|
-
// ── 1. STAYS SILENT when the field is declared and the value is allowed ────────────────
|
|
41
|
-
{
|
|
42
|
-
for (const v of ["full", "abstract", "none"]) {
|
|
43
|
-
const m = run(fm("Analysis.", `read: ${v}`));
|
|
44
|
-
assert.deepEqual(
|
|
45
|
-
m,
|
|
46
|
-
[],
|
|
47
|
-
`read: ${v} — an allowed value, silence was expected: ${JSON.stringify(m)}`,
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
recordCheck("the three allowed field values produce no findings");
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ── 2. FIRES when the field is missing ──────────────────────────────────────────
|
|
54
|
-
{
|
|
55
|
-
const m = run(fm("Analysis with no field declared."));
|
|
56
|
-
assert.equal(
|
|
57
|
-
m.length,
|
|
58
|
-
1,
|
|
59
|
-
`case 2: a missing field must produce EXACTLY one finding, got ${m.length}`,
|
|
60
|
-
);
|
|
61
|
-
assert.match(
|
|
62
|
-
m[0].message,
|
|
63
|
-
/no `read` field/,
|
|
64
|
-
`case 2: the finding must name "no field", got: ${m[0].message}`,
|
|
65
|
-
);
|
|
66
|
-
assert.match(
|
|
67
|
-
m[0].message,
|
|
68
|
-
/what exactly was read/,
|
|
69
|
-
"the hint from the option must reach the text",
|
|
70
|
-
);
|
|
71
|
-
recordCheck("a missing field is a finding, with the hint from the option");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ── 3. FIRES on a value outside the list ─────────────────────────────────────
|
|
75
|
-
{
|
|
76
|
-
const m = run(fm("Analysis.", "read: fully"));
|
|
77
|
-
assert.equal(
|
|
78
|
-
m.length,
|
|
79
|
-
1,
|
|
80
|
-
`case 3: a value outside the vocabulary must produce one finding, got ${m.length}`,
|
|
81
|
-
);
|
|
82
|
-
assert.match(
|
|
83
|
-
m[0].message,
|
|
84
|
-
/`read: fully` — value is not in the list/,
|
|
85
|
-
`a value outside the vocabulary must be NAMED as the value outside the vocabulary — got: ${m[0].message}`,
|
|
86
|
-
);
|
|
87
|
-
recordCheck(
|
|
88
|
-
"a value outside the vocabulary is a finding, and it names the value that arrived",
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ── 4. 🔴 THE MAIN CASE: PROSE WITH THE SAME WORD IS NOT A FIELD ───────────────
|
|
93
|
-
// The predecessor searched for the substring `**Read:**` and counted ANY occurrence of
|
|
94
|
-
// it — including an admission of incompleteness. Here the text has no effect on the verdict at all.
|
|
95
|
-
{
|
|
96
|
-
const confession = fm(
|
|
97
|
-
"⚠️ **Read only at the abstract level.** The full text is required before submission.",
|
|
98
|
-
);
|
|
99
|
-
const m = run(confession);
|
|
100
|
-
assert.equal(
|
|
101
|
-
m.length,
|
|
102
|
-
1,
|
|
103
|
-
"bold prose containing the word 'Read' does not declare a field — a finding was expected",
|
|
104
|
-
);
|
|
105
|
-
assert.match(
|
|
106
|
-
m[0].message,
|
|
107
|
-
/no `read` field/,
|
|
108
|
-
`a missing field must be NAMED (case 1) — got: ${m[0].message}`,
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
// and the reverse half: the same admission, declared as a FIELD, is legitimate and silent
|
|
112
|
-
const declared = fm(
|
|
113
|
-
"The full text is required before submission.",
|
|
114
|
-
"read: abstract",
|
|
115
|
-
);
|
|
116
|
-
assert.deepEqual(
|
|
117
|
-
run(declared),
|
|
118
|
-
[],
|
|
119
|
-
"read: abstract — a legitimate state, not a failure",
|
|
120
|
-
);
|
|
121
|
-
recordCheck(
|
|
122
|
-
"prose with the word 'Read' does not count; the same state as a field does",
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// ── 5. A MARKER INSIDE A CODE FENCE AND IN A QUOTE — has no effect ─────────────────
|
|
127
|
-
// A substring search counted both. To the rule this is just document text.
|
|
128
|
-
{
|
|
129
|
-
const fenced = fm(
|
|
130
|
-
["```", "**Read:** an example from someone else's card", "```"].join("\n"),
|
|
131
|
-
);
|
|
132
|
-
assert.equal(
|
|
133
|
-
run(fenced).length,
|
|
134
|
-
1,
|
|
135
|
-
"a marker inside a ``` fence does not declare a field",
|
|
136
|
-
);
|
|
137
|
-
const quoted = fm("> **Read:** a quote from someone else's card");
|
|
138
|
-
assert.equal(
|
|
139
|
-
run(quoted).length,
|
|
140
|
-
1,
|
|
141
|
-
"a marker in a quote does not declare a field",
|
|
142
|
-
);
|
|
143
|
-
recordCheck("a code fence and a quote do not create a field");
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// ── 6. "Rule from a date" — consumer data ───────────────────────────────────
|
|
147
|
-
{
|
|
148
|
-
// Dates are quoted HERE on purpose: this case is about the GATE, not about type parsing.
|
|
149
|
-
// Unquoted dates live only in case 8, otherwise the "don't normalize Date" mutation would
|
|
150
|
-
// kill this case first.
|
|
151
|
-
const old = [
|
|
152
|
-
"---",
|
|
153
|
-
"title: an old card",
|
|
154
|
-
'created: "2026-07-01"',
|
|
155
|
-
"---",
|
|
156
|
-
"",
|
|
157
|
-
"Analysis with no field.",
|
|
158
|
-
].join("\n");
|
|
159
|
-
assert.deepEqual(
|
|
160
|
-
run(old),
|
|
161
|
-
[],
|
|
162
|
-
"a card older than the rule's date — known debt, not a finding",
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
const onTheDay = [
|
|
166
|
-
"---",
|
|
167
|
-
"title: on the rule's day",
|
|
168
|
-
'created: "2026-07-29"',
|
|
169
|
-
"---",
|
|
170
|
-
"",
|
|
171
|
-
"Analysis with no field.",
|
|
172
|
-
].join("\n");
|
|
173
|
-
assert.equal(
|
|
174
|
-
run(onTheDay).length,
|
|
175
|
-
1,
|
|
176
|
-
"the boundary is inclusive: `created == sinceCreated` is checked",
|
|
177
|
-
);
|
|
178
|
-
recordCheck("the rule's date exempts the past and includes its own day");
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// ── 7. 🔴 A MISSING FRONTMATTER IS A FINDING, NOT AN EXEMPTION ──────────────────
|
|
182
|
-
// This family's predecessor let a file with no `created` fall out of the check entirely,
|
|
183
|
-
// meaning the gate was bypassed by deleting the header. Here that hole is closed.
|
|
184
|
-
{
|
|
185
|
-
const bare = "# Neighbor\n\nAnalysis with no header at all.\n";
|
|
186
|
-
const m = run(bare);
|
|
187
|
-
assert.equal(
|
|
188
|
-
m.length,
|
|
189
|
-
1,
|
|
190
|
-
`case 7: a document with no frontmatter must produce a finding, got ${m.length}`,
|
|
191
|
-
);
|
|
192
|
-
assert.match(
|
|
193
|
-
m[0].message,
|
|
194
|
-
/no frontmatter/,
|
|
195
|
-
`a missing header must have ITS OWN verdict — got: ${m[0].message}`,
|
|
196
|
-
);
|
|
197
|
-
recordCheck(
|
|
198
|
-
"a document with no frontmatter is not exempted — otherwise the gate is bypassed by deleting the header",
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// ── 8. 🔴 AN UNQUOTED `created` ARRIVES AS A Date OBJECT ─────────────────────────
|
|
203
|
-
// js-yaml recognizes YAML 1.1 timestamps. Comparing `Date < "2026-07-29"` SILENTLY gives
|
|
204
|
-
// false, meaning the date gate would stop working and it would look like silence.
|
|
205
|
-
// This case checks both sides of the boundary on an unquoted date.
|
|
206
|
-
{
|
|
207
|
-
const oldUnquoted = [
|
|
208
|
-
"---",
|
|
209
|
-
"created: 2026-07-01",
|
|
210
|
-
"---",
|
|
211
|
-
"",
|
|
212
|
-
"No field.",
|
|
213
|
-
].join("\n");
|
|
214
|
-
assert.deepEqual(
|
|
215
|
-
run(oldUnquoted),
|
|
216
|
-
[],
|
|
217
|
-
"case 8: an unquoted OLD date must exempt",
|
|
218
|
-
);
|
|
219
|
-
|
|
220
|
-
const newUnquoted = [
|
|
221
|
-
"---",
|
|
222
|
-
"created: 2026-08-01",
|
|
223
|
-
"---",
|
|
224
|
-
"",
|
|
225
|
-
"No field.",
|
|
226
|
-
].join("\n");
|
|
227
|
-
assert.equal(
|
|
228
|
-
run(newUnquoted).length,
|
|
229
|
-
1,
|
|
230
|
-
"case 8: an unquoted NEW date must TURN ON the check",
|
|
231
|
-
);
|
|
232
|
-
|
|
233
|
-
const quoted = ["---", 'created: "2026-08-01"', "---", "", "No field."].join(
|
|
234
|
-
"\n",
|
|
235
|
-
);
|
|
236
|
-
assert.equal(
|
|
237
|
-
run(quoted).length,
|
|
238
|
-
1,
|
|
239
|
-
"a quoted date behaves the same as an unquoted one",
|
|
240
|
-
);
|
|
241
|
-
recordCheck(
|
|
242
|
-
"a date as a Date and a date as a string give the same verdict on both sides of the boundary",
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// ── 9. Broken YAML — its own separate verdict, not silence ─────────────────────
|
|
247
|
-
{
|
|
248
|
-
const broken = [
|
|
249
|
-
"---",
|
|
250
|
-
"title: [no closing",
|
|
251
|
-
"created: 2026-08-01",
|
|
252
|
-
"---",
|
|
253
|
-
"",
|
|
254
|
-
"Body.",
|
|
255
|
-
].join("\n");
|
|
256
|
-
const m = run(broken);
|
|
257
|
-
assert.equal(
|
|
258
|
-
m.length,
|
|
259
|
-
1,
|
|
260
|
-
`case 9: broken YAML must produce one finding, got ${m.length}`,
|
|
261
|
-
);
|
|
262
|
-
assert.match(
|
|
263
|
-
m[0].message,
|
|
264
|
-
/does not parse as YAML/,
|
|
265
|
-
`a broken header must have ITS OWN verdict — got: ${m[0].message}`,
|
|
266
|
-
);
|
|
267
|
-
recordCheck(
|
|
268
|
-
"an unparseable header is its own message, not silence and not 'no field'",
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// ── 10. An empty value counts as missing ────────────────────────────────
|
|
273
|
-
// The consumer's `frontmatterField` returned the CLOSING FENCE `---` on an empty value, and
|
|
274
|
-
// that would pass as a value. Here empty means absent.
|
|
275
|
-
{
|
|
276
|
-
const empty = [
|
|
277
|
-
"---",
|
|
278
|
-
"created: 2026-08-01",
|
|
279
|
-
"read:",
|
|
280
|
-
"---",
|
|
281
|
-
"",
|
|
282
|
-
"Body.",
|
|
283
|
-
].join("\n");
|
|
284
|
-
const m = run(empty);
|
|
285
|
-
assert.equal(
|
|
286
|
-
m.length,
|
|
287
|
-
1,
|
|
288
|
-
`case 10: an empty value must read as absent, got ${m.length}`,
|
|
289
|
-
);
|
|
290
|
-
assert.match(
|
|
291
|
-
m[0].message,
|
|
292
|
-
/no `read` field/,
|
|
293
|
-
`a missing field must be NAMED (case 2) — got: ${m[0].message}`,
|
|
294
|
-
);
|
|
295
|
-
recordCheck("an empty field value = absent, not 'the value ---'");
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// ── 11. The finding sits ON THE FRONTMATTER, not on the first line of the body ──────────
|
|
299
|
-
{
|
|
300
|
-
const m = run(fm("The body starts here."));
|
|
301
|
-
assert.equal(
|
|
302
|
-
m[0].line,
|
|
303
|
-
1,
|
|
304
|
-
`the finding must point at the header, got line ${m[0].line}`,
|
|
305
|
-
);
|
|
306
|
-
recordCheck("the finding's address is the frontmatter");
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// ── 12. Two fields at once — one finding per field, not one per document ───────────
|
|
310
|
-
{
|
|
311
|
-
const two = {
|
|
312
|
-
fields: {
|
|
313
|
-
read: { values: ["full", "abstract", "none"] },
|
|
314
|
-
venue_checked: { values: ["yes", "no"] },
|
|
315
|
-
},
|
|
316
|
-
sinceCreated: "2026-07-29",
|
|
317
|
-
};
|
|
318
|
-
const m = run(fm("Body."), two);
|
|
319
|
-
assert.equal(
|
|
320
|
-
m.length,
|
|
321
|
-
2,
|
|
322
|
-
`two missing fields — two findings, got ${m.length}`,
|
|
323
|
-
);
|
|
324
|
-
assert.deepEqual(
|
|
325
|
-
m.map((x) => /no `(\w+)` field/.exec(x.message)?.[1]).sort(),
|
|
326
|
-
["read", "venue_checked"],
|
|
327
|
-
"both findings must name THEIR OWN field",
|
|
328
|
-
);
|
|
329
|
-
recordCheck("each missing field gets its own finding");
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
console.log(
|
|
333
|
-
"✓ doc/fields: a declared field is silent, a missing or bad one is a finding; prose with the " +
|
|
334
|
-
"same word does not count; the date gate, a missing header, broken YAML, a js-yaml Date, and " +
|
|
335
|
-
"an empty value — each with its own verdict",
|
|
336
|
-
);
|
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `doc/fields` — a document must declare its FIELDS, not hint at them through markup.
|
|
3
|
-
*
|
|
4
|
-
* ── WHY ──────────────────────────────────────────────────────────────────────
|
|
5
|
-
* A convention like "a card must carry the mark `**Read:**`" describes not data
|
|
6
|
-
* but PRESENTATION. Bold text in markdown means "bold text"; that the author meant
|
|
7
|
-
* a field is our guess. The rule requires a real frontmatter field and checks its
|
|
8
|
-
* value against a list of allowed ones.
|
|
9
|
-
*
|
|
10
|
-
* ── WHAT THIS FILE IS ────────────────────────────────────────────────────────────
|
|
11
|
-
* Unit 3 of step 9 of the extraction, and the first one that is NOT a port of the
|
|
12
|
-
* predecessor. The previous two (`review/findings-cause`, `review/cold-read-cause`)
|
|
13
|
-
* carried the mechanism over as-is; here the mechanism is replaced, because the
|
|
14
|
-
* predecessor searched for the substring `**Read:**` in the raw text, and a
|
|
15
|
-
* measurement showed misses in both directions:
|
|
16
|
-
*
|
|
17
|
-
* | input | substring |
|
|
18
|
-
* |-----------------------------------------------|-----------|
|
|
19
|
-
* | `**Read**: everything` (colon outside) | ❌ rejected|
|
|
20
|
-
* | `__Read:__ everything` (underscores) | ❌ rejected|
|
|
21
|
-
* | a ```-fence with an example inside | ✅ counted |
|
|
22
|
-
* | `missing **Read:** — I have not read it` | ✅ counted |
|
|
23
|
-
*
|
|
24
|
-
* The last row is the heart of the matter: a completeness check counted a direct
|
|
25
|
-
* admission of incompleteness, because it looked at characters, not at the claim.
|
|
26
|
-
*
|
|
27
|
-
* 🔴 AND THE OBVIOUS FIX WOULD HAVE MADE IT WORSE. A naive port to the AST — "there
|
|
28
|
-
* is a `strong` node whose text starts with 'Read'" — would have counted a live
|
|
29
|
-
* card reading `**Read only at the abstract level.** Full text required before
|
|
30
|
-
* submission`, i.e. it would have accepted known debt as completed work. The regex
|
|
31
|
-
* rejected that card BY ACCIDENT — it required a colon right after the word. A
|
|
32
|
-
* field removes the argument entirely: `read: abstract` is a legitimate value, not
|
|
33
|
-
* a bad spelling.
|
|
34
|
-
*
|
|
35
|
-
* ── WHAT THIS RULE DOES NOT DO, AND THIS IS A DECISION ──────────────────────────
|
|
36
|
-
* It does not replace CONTENT checks. A field is the author's claim about
|
|
37
|
-
* themselves, and it cannot be verified: `refs_diffed: true` gets ticked without
|
|
38
|
-
* the work being done. So the requirement "the card has a section analyzing the
|
|
39
|
-
* bibliography" stays a separate heading-level check at the consumer. Only what
|
|
40
|
-
* is already a claim (exactly what was read) becomes a field — not an artefact.
|
|
41
|
-
*
|
|
42
|
-
* ── BOUNDARIES ────────────────────────────────────────────────────────────────────
|
|
43
|
-
* · A missing frontmatter is a FINDING, not an exemption. Otherwise the gate is
|
|
44
|
-
* bypassed by deleting the header; this family's predecessor already had that
|
|
45
|
-
* hole (a file with no `created` fell out of the check entirely).
|
|
46
|
-
* · `sinceCreated` compares ISO strings — this is legitimate because the format is
|
|
47
|
-
* fixed and lexicographic order matches chronological order.
|
|
48
|
-
*/
|
|
49
|
-
import { load } from "js-yaml";
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* `created` from YAML arrives either as a string or as a DATE — js-yaml recognizes
|
|
53
|
-
* YAML 1.1 timestamps by default, and `created: 2026-07-29` without quotes becomes
|
|
54
|
-
* a `Date` object. Comparing `Date < "2026-07-29"` silently gives `false`, meaning
|
|
55
|
-
* the date gate would stop working and nobody would notice. Normalize to `YYYY-MM-DD`.
|
|
56
|
-
*/
|
|
57
|
-
function isoDate(v) {
|
|
58
|
-
if (v instanceof Date) return v.toISOString().slice(0, 10);
|
|
59
|
-
if (typeof v === "string") return /^\d{4}-\d{2}-\d{2}/.exec(v)?.[0] ?? "";
|
|
60
|
-
return "";
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export default {
|
|
64
|
-
rules: {
|
|
65
|
-
fields: {
|
|
66
|
-
meta: {
|
|
67
|
-
type: "problem",
|
|
68
|
-
docs: {
|
|
69
|
-
description:
|
|
70
|
-
"a document declares its required frontmatter fields and their allowed values, instead of hinting at them with markup",
|
|
71
|
-
},
|
|
72
|
-
schema: [
|
|
73
|
-
{
|
|
74
|
-
type: "object",
|
|
75
|
-
properties: {
|
|
76
|
-
fields: {
|
|
77
|
-
type: "object",
|
|
78
|
-
additionalProperties: {
|
|
79
|
-
type: "object",
|
|
80
|
-
properties: {
|
|
81
|
-
values: {
|
|
82
|
-
type: "array",
|
|
83
|
-
items: { type: "string" },
|
|
84
|
-
minItems: 1,
|
|
85
|
-
},
|
|
86
|
-
hint: { type: "string" },
|
|
87
|
-
},
|
|
88
|
-
additionalProperties: false,
|
|
89
|
-
},
|
|
90
|
-
minProperties: 1,
|
|
91
|
-
},
|
|
92
|
-
sinceCreated: {
|
|
93
|
-
type: "string",
|
|
94
|
-
pattern: "^\\d{4}-\\d{2}-\\d{2}$",
|
|
95
|
-
},
|
|
96
|
-
},
|
|
97
|
-
required: ["fields"],
|
|
98
|
-
additionalProperties: false,
|
|
99
|
-
},
|
|
100
|
-
],
|
|
101
|
-
messages: {
|
|
102
|
-
noFrontmatter:
|
|
103
|
-
"no frontmatter — a document of this class must declare the fields {{names}}. A missing header is not an exemption: otherwise the check is bypassed by deleting it.",
|
|
104
|
-
malformed:
|
|
105
|
-
"the frontmatter does not parse as YAML ({{why}}) — there is nothing to read the fields {{names}} from.",
|
|
106
|
-
missing:
|
|
107
|
-
"the frontmatter has no `{{name}}` field{{hint}}. Allowed values: {{values}}. A note in the body is not a field: markup describes presentation, not data.",
|
|
108
|
-
badValue:
|
|
109
|
-
"`{{name}}: {{actual}}` — value is not in the list. Allowed: {{values}}.",
|
|
110
|
-
},
|
|
111
|
-
},
|
|
112
|
-
create(context) {
|
|
113
|
-
const { fields, sinceCreated } = context.options[0] ?? {};
|
|
114
|
-
const names = Object.keys(fields);
|
|
115
|
-
let seenFrontmatter = false;
|
|
116
|
-
|
|
117
|
-
return {
|
|
118
|
-
yaml(node) {
|
|
119
|
-
seenFrontmatter = true;
|
|
120
|
-
let data;
|
|
121
|
-
try {
|
|
122
|
-
data = load(node.value ?? "");
|
|
123
|
-
} catch (e) {
|
|
124
|
-
context.report({
|
|
125
|
-
node,
|
|
126
|
-
messageId: "malformed",
|
|
127
|
-
data: {
|
|
128
|
-
why: e.reason ?? e.message ?? "unparseable",
|
|
129
|
-
names: names.join(", "),
|
|
130
|
-
},
|
|
131
|
-
});
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
if (
|
|
135
|
-
data === null ||
|
|
136
|
-
typeof data !== "object" ||
|
|
137
|
-
Array.isArray(data)
|
|
138
|
-
) {
|
|
139
|
-
context.report({
|
|
140
|
-
node,
|
|
141
|
-
messageId: "malformed",
|
|
142
|
-
data: {
|
|
143
|
-
why: "the header is not a key-value mapping",
|
|
144
|
-
names: names.join(", "),
|
|
145
|
-
},
|
|
146
|
-
});
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
// The date gate stands HERE, not in `root:exit`: a document with no header has no
|
|
150
|
-
// `created`, so it does not fall under the gate and must be a finding (see BOUNDARIES).
|
|
151
|
-
const created = isoDate(data.created);
|
|
152
|
-
if (sinceCreated && (!created || created < sinceCreated)) return;
|
|
153
|
-
|
|
154
|
-
for (const [name, spec] of Object.entries(fields)) {
|
|
155
|
-
const values = spec.values;
|
|
156
|
-
const hint = spec.hint ? ` (${spec.hint})` : "";
|
|
157
|
-
if (!(name in data) || data[name] === null || data[name] === "") {
|
|
158
|
-
context.report({
|
|
159
|
-
node,
|
|
160
|
-
messageId: "missing",
|
|
161
|
-
data: { name, hint, values: values.join(" · ") },
|
|
162
|
-
});
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
const actual = String(data[name]);
|
|
166
|
-
if (!values.includes(actual))
|
|
167
|
-
context.report({
|
|
168
|
-
node,
|
|
169
|
-
messageId: "badValue",
|
|
170
|
-
data: { name, actual, values: values.join(" · ") },
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
},
|
|
174
|
-
"root:exit"(node) {
|
|
175
|
-
if (seenFrontmatter) return;
|
|
176
|
-
context.report({
|
|
177
|
-
node,
|
|
178
|
-
messageId: "noFrontmatter",
|
|
179
|
-
data: { names: names.join(", ") },
|
|
180
|
-
});
|
|
181
|
-
},
|
|
182
|
-
};
|
|
183
|
-
},
|
|
184
|
-
},
|
|
185
|
-
},
|
|
186
|
-
};
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Battery for `doc/fields`: six mutations, each removes its own load-bearing property.
|
|
3
|
-
*
|
|
4
|
-
* The last two are about wounds the predecessor would not have shown at all:
|
|
5
|
-
* normalizing js-yaml's `Date` (otherwise the date gate silently stops working) and
|
|
6
|
-
* failing closed on a missing header (otherwise the check is bypassed by deleting it).
|
|
7
|
-
*/
|
|
8
|
-
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { dirname, join, resolve } from "node:path";
|
|
10
|
-
import { runMutations } from "../lib/mutation-driver.mjs";
|
|
11
|
-
|
|
12
|
-
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
13
|
-
const ROOT = resolve(HERE, "..");
|
|
14
|
-
const RULE = join(HERE, "doc-fields.mjs");
|
|
15
|
-
const HARNESS = join(HERE, "doc-fields.harness.mjs");
|
|
16
|
-
|
|
17
|
-
process.exit(
|
|
18
|
-
runMutations({
|
|
19
|
-
root: ROOT,
|
|
20
|
-
runner: "node",
|
|
21
|
-
cases: [
|
|
22
|
-
{
|
|
23
|
-
name: "a missing field stops being a finding",
|
|
24
|
-
harness: HARNESS,
|
|
25
|
-
expect: 'case 2: the finding must name "no field"',
|
|
26
|
-
disables:
|
|
27
|
-
"the verdict itself — a card with no field declared passes silently",
|
|
28
|
-
edits: [
|
|
29
|
-
[
|
|
30
|
-
RULE,
|
|
31
|
-
'if (!(name in data) || data[name] === null || data[name] === "") {',
|
|
32
|
-
"if (false) {",
|
|
33
|
-
],
|
|
34
|
-
],
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
name: "the value stops being checked against the vocabulary",
|
|
38
|
-
harness: HARNESS,
|
|
39
|
-
expect:
|
|
40
|
-
"case 3: a value outside the vocabulary must produce one finding",
|
|
41
|
-
disables: "the value check — `read: fully` becomes allowed",
|
|
42
|
-
edits: [[RULE, "if (!values.includes(actual))", "if (false)"]],
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
name: "'rule from a date' stops exempting",
|
|
46
|
-
harness: HARNESS,
|
|
47
|
-
expect: "known debt, not a finding",
|
|
48
|
-
disables:
|
|
49
|
-
"the exemption of the historical corpus — 22 old cards go red at once",
|
|
50
|
-
edits: [
|
|
51
|
-
[
|
|
52
|
-
RULE,
|
|
53
|
-
"if (sinceCreated && (!created || created < sinceCreated)) return;",
|
|
54
|
-
"if (false) return;",
|
|
55
|
-
],
|
|
56
|
-
],
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
name: "🔴 a missing frontmatter exempts again",
|
|
60
|
-
harness: HARNESS,
|
|
61
|
-
expect: "case 7: a document with no frontmatter must produce a finding",
|
|
62
|
-
disables:
|
|
63
|
-
"fail-closed — the gate is bypassed again by deleting the header, like the predecessor",
|
|
64
|
-
edits: [[RULE, "if (seenFrontmatter) return;", "if (true) return;"]],
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
name: "🔴 js-yaml's `Date` stops being normalized to a string",
|
|
68
|
-
harness: HARNESS,
|
|
69
|
-
expect: "case 8: an unquoted NEW date must TURN ON the check",
|
|
70
|
-
disables:
|
|
71
|
-
"normalizing the YAML 1.1 timestamp — comparing a Date to a string SILENTLY gives false, and the date gate dies unnoticed",
|
|
72
|
-
edits: [
|
|
73
|
-
[
|
|
74
|
-
RULE,
|
|
75
|
-
"if (v instanceof Date) return v.toISOString().slice(0, 10);",
|
|
76
|
-
"",
|
|
77
|
-
],
|
|
78
|
-
],
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
name: "broken YAML stops having its own verdict",
|
|
82
|
-
harness: HARNESS,
|
|
83
|
-
expect: "a broken header must have ITS OWN verdict",
|
|
84
|
-
disables:
|
|
85
|
-
"the separation of causes — an unparseable header becomes indistinguishable from silence",
|
|
86
|
-
edits: [
|
|
87
|
-
[
|
|
88
|
-
RULE,
|
|
89
|
-
'messageId: "malformed",\n data: {\n why: e.reason',
|
|
90
|
-
'messageId: "missing",\n data: {\n why: e.reason',
|
|
91
|
-
],
|
|
92
|
-
],
|
|
93
|
-
},
|
|
94
|
-
],
|
|
95
|
-
}),
|
|
96
|
-
);
|