pasika 0.2.0 → 0.3.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.
Files changed (30) hide show
  1. package/README.md +93 -57
  2. package/dist/cli/index.d.ts +2 -0
  3. package/dist/cli/index.js +132 -0
  4. package/dist/enforcement/coverage.d.ts +51 -0
  5. package/dist/enforcement/coverage.js +210 -0
  6. package/dist/enforcement/docs-check.d.ts +17 -0
  7. package/dist/enforcement/docs-check.js +162 -0
  8. package/dist/enforcement/normalize.d.ts +11 -0
  9. package/dist/enforcement/normalize.js +21 -0
  10. package/dist/enforcement/parse-docs.d.ts +58 -0
  11. package/dist/enforcement/parse-docs.js +94 -0
  12. package/dist/enforcement/types.d.ts +57 -0
  13. package/dist/enforcement/types.js +59 -0
  14. package/dist/eslint/pasika/index.d.ts +9 -15
  15. package/dist/eslint/pasika/index.js +18 -20
  16. package/dist/eslint/pasika/project/ccf.d.ts +48 -0
  17. package/dist/eslint/pasika/project/ccf.js +119 -0
  18. package/dist/eslint/pasika/project/index.d.ts +21 -0
  19. package/dist/eslint/pasika/project/index.js +139 -0
  20. package/dist/eslint/pasika/project/parse-module.d.ts +27 -0
  21. package/dist/eslint/pasika/project/parse-module.js +128 -0
  22. package/dist/eslint/pasika/rules/component-placement.d.ts +11 -0
  23. package/dist/eslint/pasika/rules/component-placement.js +75 -0
  24. package/dist/eslint/pasika/rules/enforce-cva-variant-props.js +6 -1
  25. package/dist/eslint/pasika/rules/import-boundaries.js +53 -20
  26. package/dist/eslint/pasika/rules/no-arbitrary-tailwind.js +55 -65
  27. package/dist/eslint/pasika/rules/support-file-placement.d.ts +15 -0
  28. package/dist/eslint/pasika/rules/support-file-placement.js +70 -0
  29. package/enforcement/registry.json +1139 -0
  30. package/package.json +22 -4
package/README.md CHANGED
@@ -1,60 +1,83 @@
1
1
  # pasika
2
2
 
3
- Reusable agent setup package.
3
+ Documentation, the lint rules derived from it, and the CLI that applies and diagnoses both.
4
4
 
5
- `pasika` stores portable agent setup assets that individual repos can apply or adapt.
6
-
7
- ## Scope
8
-
9
- This repo intentionally starts narrow:
10
-
11
- - reusable agent setup assets only
12
- - shared docs at the repo root
13
- - no project-specific workflows or business logic
5
+ `pasika` owns the framework's documentation and turns it into checks. Every requirement in `docs/` is recorded in an enforcement registry that says which ESLint rule, which `pasika` check, or which human judgment covers it — and CI fails when a requirement has no answer.
14
6
 
15
7
  ## Layout
16
8
 
17
9
  ```text
18
10
  docs/
19
- code-organization-guide/
20
- documentation-guide/
21
- styling-guide/
22
- agent-conventions.md
11
+ agent-policy.md # repo-wide requirements (Policy)
12
+ code-organization-guide/ # placement, extraction, module conventions
13
+ documentation-guide/ # how documents themselves are written
14
+ framework-adoption-guide/ # adopting and updating the framework
15
+ styling-guide/ # Tailwind theme, composition, variants, states
16
+ enforcement/
17
+ registry.json # requirement → enforcement, keyed by content hash
18
+ coverage.ts # reconciles the docs against the registry
19
+ docs-check.ts # the documentation guide, mechanically
23
20
  eslint/
24
- pasika/
25
- rules/
26
- dist/
27
- ...
21
+ pasika/rules/ # the lint rules, with fixture tests beside them
22
+ cli/
23
+ index.ts # the `pasika` command
28
24
  ```
29
25
 
30
- ## What belongs here
26
+ ## Documentation
31
27
 
32
- - portable setup scripts
33
- - shared naming and layout conventions
34
- - reusable source-organization lint rules
28
+ Documents come in four kinds — Guide, Rule, Policy, and Reference — each with a template and a creation rule under `docs/documentation-guide/`. A Rule owns requirements about one subject and demonstrates them with paired Incorrect/Correct examples; a Reference describes and defines but never constrains; a Policy document collects repo-wide requirements that span unrelated subjects; a Guide sequences the others into workflows. Each guide owns its own glossary, so a term is defined beside the workflow that uses it.
35
29
 
36
- ## What stays in project repos
30
+ ## Enforcement
37
31
 
38
- - final agent config folders in project repos
39
- - project-specific rules, agents, and prompts
40
- - repository-specific plugin choices
41
- - scripts that depend on a specific app, CI setup, or codebase
32
+ Requirements are identified by a hash of their canonical text, not by a hand-written id, so rewording one is visible:
42
33
 
43
- ## Development
34
+ ```jsonc
35
+ {
36
+ "doc": "code-organization-guide/rules/no-mixed-concerns-rule.md",
37
+ "text": "A .tsx file that defines a component MUST contain exactly one component.",
38
+ "hash": "b19fe3bd34",
39
+ "kind": "eslint",
40
+ "ref": "pasika/no-mixed-concerns",
41
+ "note": "counts exported components; a second component that is not exported is not detected"
42
+ }
43
+ ```
44
+
45
+ The `note` field is where a check's known gap is recorded, so a partial check never reads as a complete one.
46
+
47
+ | Kind | Meaning |
48
+ | --- | --- |
49
+ | `eslint` | An ESLint rule reports it, and a fixture test titled with the requirement pins it |
50
+ | `doctor` | A `pasika doctor` check reports it |
51
+ | `docs-check` | A `pasika docs` check reports it |
52
+ | `planned` | Mechanically checkable, not written yet; `note` names the intended check |
53
+ | `judgment` | No mechanical check can decide it; `note` says why |
54
+ | `permission` | The requirement grants permission, so there is nothing to check |
55
+
56
+ `pasika coverage` fails when a requirement is unclassified, when its text changed, when it disappeared, when its `ref` names a check that does not exist, or when a lint-enforced requirement has no test. Confirm a reworded requirement with `pasika coverage --accept`.
57
+
58
+ ## Commands
44
59
 
45
60
  ```bash
46
- npm run lint
47
- npm run typecheck
48
- npm run build
61
+ npx pasika docs # check a docs/ folder against the documentation guide
62
+ npx pasika docs --dir content # check another folder
63
+ npx pasika coverage # check that every requirement has recorded enforcement
64
+ npx pasika coverage --accept # record reworded and removed requirements
49
65
  ```
50
66
 
51
- ## ESLint Pasika Ruleset
67
+ Both accept `--json` for agent use.
52
68
 
53
- Pasika ships enforceable lint rules derived from its documentation. Each rule carries a `@see` annotation linking to its source doc so future audits can verify rule/doc alignment.
69
+ A requirement `coverage` reports as `new` is classified with the hash it prints:
54
70
 
55
- ### Usage with Zirka (recommended)
71
+ ```bash
72
+ npx pasika coverage --classify d311a1457a --kind eslint --ref pasika/import-boundaries
73
+ npx pasika coverage --classify 041b665bd7 --kind judgment --note "no check can compare against the previous state"
74
+ ```
56
75
 
57
- Enable pasika through Zirka's `styleguide`:
76
+ The command refuses a hash no requirement has, a `ref` naming a rule or check that does not exist, a `ref` on a kind that nothing reports, and `judgment` or `planned` without a note — so a mismatch cannot reach the registry by hand. Re-running it on an already-classified requirement reclassifies it and reports what it was.
77
+
78
+ ## ESLint ruleset
79
+
80
+ ### With Zirka (recommended)
58
81
 
59
82
  ```ts
60
83
  // eslint.config.ts
@@ -70,34 +93,47 @@ const { eslintConfig } = styleguide({
70
93
  export default eslintConfig;
71
94
  ```
72
95
 
73
- ### Usage without Zirka
74
-
75
- Import the config directly and compose with your own:
96
+ ### Without Zirka
76
97
 
77
98
  ```ts
78
99
  // eslint.config.ts
79
100
  import { pasikaConfig } from "pasika/eslint";
80
- import { RuleSeverity, styleguide } from "zirka";
81
101
 
82
- const { eslintConfig } = styleguide({
83
- next: RuleSeverity.Error,
84
- node: RuleSeverity.Error,
85
- typescript: RuleSeverity.Error,
86
- });
87
-
88
- export default [...((await eslintConfig) ?? []), pasikaConfig];
102
+ export default [pasikaConfig];
89
103
  ```
90
104
 
91
- ### Rule Doc Mapping
105
+ The ruleset applies to `src/**` only, so a repository without a `src/` tree passes it trivially.
106
+
107
+ | Rule | Enforces |
108
+ | --- | --- |
109
+ | `pasika/filename-case` | kebab-case for files that define no component |
110
+ | `pasika/import-boundaries` | The shorter of the relative path and the `@/*` alias, and the layer boundaries |
111
+ | `pasika/no-mixed-concerns` | One exported React component per `.tsx` file |
112
+ | `pasika/no-arbitrary-tailwind` | No arbitrary `-[value]` classes, including inside `cn()` conditionals |
113
+ | `pasika/enforce-cn-merge` | `cn()` instead of `+` or template literals; at most five classes per group |
114
+ | `pasika/enforce-cva-variant-props` | `VariantProps<typeof …>` instead of hand-written unions |
115
+ | `pasika/enforce-barrel-exports` | A nested `index.ts` re-exports only its component |
116
+ | `pasika/component-placement` † | The folder a component's consumers imply |
117
+ | `pasika/support-file-placement` † | The folder a hook, type, schema, constant, or utility belongs in |
118
+
119
+ Run `pasika coverage --json` for the exact requirement each rule covers.
120
+
121
+ ### † Cross-file rules
92
122
 
93
- Every rule's source file declares the documentation it enforces. Use this table to trace a lint hit back to its reasoning:
123
+ Where a component or support file belongs depends on which files import it, so these two rules index the whole `src/` tree instead of looking at one file. Two consequences:
94
124
 
95
- | Rule | Enforces | Source Doc |
96
- |---|---|---|
97
- | `pasika/filename-case` | Smart/PascalCase vs dumb/kebab-case file names | `docs/code-organization-guide/rules/smart-vs-dumb-component-rule.md` |
98
- | `pasika/import-boundaries` | Relative vs `@/*` imports, layer boundaries | `docs/code-organization-guide/rules/exports-and-imports-rule.md` |
99
- | `pasika/no-mixed-concerns` | One React component per `.tsx` file | `docs/code-organization-guide/rules/no-mixed-concerns-rule.md` |
100
- | `pasika/no-arbitrary-tailwind` | No arbitrary `-[value]` Tailwind classes | `docs/styling-guide/rules/arbitrary-value-rule.md` |
101
- | `pasika/enforce-cn-merge` | Use `cn()` not `+`/template literals; ≤5 classes per group | `docs/styling-guide/rules/class-composition-rule.md` |
102
- | `pasika/enforce-cva-variant-props` | Use `VariantProps<typeof>` not manual union types | `docs/styling-guide/rules/component-variant-rule.md` |
103
- | `pasika/enforce-barrel-exports` | Nested `index.ts` only re-exports the parent component | `docs/code-organization-guide/rules/folder-nesting-rule.md` |
125
+ - **Do not pass `--cache`.** Move a file and the finding belongs to a *different* file, whose cache entry is unchanged — so ESLint would replay a stale verdict. `agent-policy.md` requires lint commands to run without it.
126
+ - The index is read from disk rather than from ESLint's file list, so a partial run such as `lint-staged` still judges against the true graph.
127
+
128
+ Both are inert in a repository with no `src/` tree.
129
+
130
+ ## Development
131
+
132
+ ```bash
133
+ npm run lint
134
+ npm run typecheck
135
+ npm run test
136
+ npm run docs
137
+ npm run coverage
138
+ npm run build
139
+ ```
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { Command } from "commander";
5
+ import { buildCoverageReport, classifyRequirement, readRegistry, writeRegistry, } from "../enforcement/coverage.js";
6
+ import { enforcementKindSchema } from "../enforcement/types.js";
7
+ import { checkDocs } from "../enforcement/docs-check.js";
8
+ const REGISTRY_RELATIVE_PATH = path.join("enforcement", "registry.json");
9
+ /** Walks up from `startDir` to the directory that holds the enforcement registry. */
10
+ function findRegistryRoot(startDir) {
11
+ let dir = path.resolve(startDir);
12
+ for (;;) {
13
+ if (existsSync(path.join(dir, REGISTRY_RELATIVE_PATH)))
14
+ return dir;
15
+ const parent = path.dirname(dir);
16
+ if (parent === dir)
17
+ return undefined;
18
+ dir = parent;
19
+ }
20
+ }
21
+ const ISSUE_LABELS = {
22
+ new: "new ",
23
+ changed: "changed ",
24
+ removed: "removed ",
25
+ "unknown-ref": "bad ref ",
26
+ "missing-test": "no test ",
27
+ };
28
+ function truncate(text, width) {
29
+ return text.length <= width ? text : `${text.slice(0, width - 1)}…`;
30
+ }
31
+ const program = new Command();
32
+ program.name("pasika").description("Applies and diagnoses the pasika framework.");
33
+ program
34
+ .command("docs")
35
+ .description("Check documentation against the documentation guide.")
36
+ .option("--dir <path>", "documentation folder to check", "docs")
37
+ .option("--json", "print findings as JSON")
38
+ .action((options) => {
39
+ const docsRoot = path.resolve(options.dir);
40
+ if (!existsSync(docsRoot)) {
41
+ console.error(`No documentation folder at ${docsRoot}`);
42
+ process.exit(1);
43
+ }
44
+ const { docs, findings } = checkDocs(docsRoot);
45
+ if (options.json) {
46
+ console.log(JSON.stringify({ documents: docs.length, findings }, undefined, 2));
47
+ process.exit(findings.length > 0 ? 1 : 0);
48
+ }
49
+ for (const finding of findings) {
50
+ console.log(` ✗ ${finding.doc}:${String(finding.line)} ${finding.check} ${finding.message}`);
51
+ }
52
+ console.log(findings.length === 0
53
+ ? `\n✓ ${String(docs.length)} documents pass`
54
+ : `\n${String(docs.length)} documents checked · ${String(findings.length)} findings`);
55
+ process.exit(findings.length > 0 ? 1 : 0);
56
+ });
57
+ program
58
+ .command("coverage")
59
+ .description("Check that every documented requirement has recorded enforcement.")
60
+ .option("--accept", "rehash reworded requirements and drop removed ones")
61
+ .option("--classify <hash>", "record how one requirement is checked")
62
+ .option("--kind <kind>", "enforcement kind for --classify")
63
+ .option("--ref <id>", "rule or check id for --classify")
64
+ .option("--note <text>", "why it is not checked, or what the check misses")
65
+ .option("--json", "print the report as JSON")
66
+ .action((options) => {
67
+ const root = findRegistryRoot(process.cwd());
68
+ if (!root) {
69
+ console.error(`No ${REGISTRY_RELATIVE_PATH} found in this directory or any parent.`);
70
+ process.exit(1);
71
+ }
72
+ const docsRoot = path.join(root, "docs");
73
+ if (!existsSync(docsRoot)) {
74
+ console.error(`No documentation folder at ${docsRoot}. Run coverage inside the pasika repository.`);
75
+ process.exit(1);
76
+ }
77
+ const registryPath = path.join(root, REGISTRY_RELATIVE_PATH);
78
+ if (options.classify !== undefined) {
79
+ const kind = enforcementKindSchema.safeParse(options.kind);
80
+ if (!kind.success) {
81
+ console.error(`✗ --kind must be one of ${enforcementKindSchema.options.join(", ")}.`);
82
+ process.exit(1);
83
+ }
84
+ try {
85
+ const result = classifyRequirement({
86
+ docsRoot,
87
+ registry: readRegistry(registryPath),
88
+ input: { hash: options.classify, kind: kind.data, ref: options.ref, note: options.note },
89
+ });
90
+ writeRegistry(registryPath, result.registry);
91
+ const change = result.previousKind === undefined
92
+ ? `recorded as ${result.requirement.kind}`
93
+ : `reclassified from ${result.previousKind} to ${result.requirement.kind}`;
94
+ console.log(`✓ ${change}: ${result.requirement.text}`);
95
+ }
96
+ catch (error) {
97
+ console.error(`✗ ${error instanceof Error ? error.message : String(error)}`);
98
+ process.exit(1);
99
+ }
100
+ }
101
+ const report = buildCoverageReport({
102
+ docsRoot,
103
+ registry: readRegistry(registryPath),
104
+ rulesDir: path.join(root, "eslint", "pasika", "rules"),
105
+ });
106
+ if (options.json) {
107
+ console.log(JSON.stringify(report, undefined, 2));
108
+ process.exit(report.issues.length > 0 ? 1 : 0);
109
+ }
110
+ for (const issue of report.issues) {
111
+ const where = issue.line === undefined ? issue.doc : `${issue.doc}:${String(issue.line)}`;
112
+ console.log(` ✗ ${ISSUE_LABELS[issue.kind]} ${truncate(issue.text, 76)}`);
113
+ console.log(` ${where}${issue.detail ? `\n ${issue.detail}` : ""}`);
114
+ }
115
+ const { counts } = report;
116
+ console.log([
117
+ "",
118
+ `${String(report.total)} requirements · ${String(report.mechanical)} mechanically enforced`,
119
+ ` eslint ${String(counts.eslint)} · doctor ${String(counts.doctor)} · docs-check ${String(counts["docs-check"])}`,
120
+ ` planned ${String(counts.planned)} · judgment ${String(counts.judgment)} · permission ${String(counts.permission)}`,
121
+ ` unclassified ${String(report.issues.filter((issue) => issue.kind === "new").length)}`,
122
+ ].join("\n"));
123
+ if (options.accept) {
124
+ writeRegistry(registryPath, report.nextRegistry);
125
+ const accepted = report.issues.filter((issue) => issue.kind === "changed" || issue.kind === "removed");
126
+ console.log(`\nAccepted ${String(accepted.length)} change(s) into ${REGISTRY_RELATIVE_PATH}.`);
127
+ console.log("Requirements reported as new still need a classification.");
128
+ process.exit(report.issues.some((issue) => issue.kind === "new") ? 1 : 0);
129
+ }
130
+ process.exit(report.issues.length > 0 ? 1 : 0);
131
+ });
132
+ program.parse();
@@ -0,0 +1,51 @@
1
+ import { type EnforcementKind, type Registry, type Requirement } from "./types.js";
2
+ export interface CoverageIssue {
3
+ kind: "new" | "changed" | "removed" | "unknown-ref" | "missing-test";
4
+ doc: string;
5
+ line?: number;
6
+ text: string;
7
+ /** Hash of the requirement as it reads now, so a new entry can be recorded without recomputing it. */
8
+ hash?: string;
9
+ detail?: string;
10
+ }
11
+ export interface CoverageReport {
12
+ counts: Record<EnforcementKind, number>;
13
+ total: number;
14
+ mechanical: number;
15
+ issues: CoverageIssue[];
16
+ /** Registry updated for `--accept`: reworded requirements rehashed, removed ones dropped. */
17
+ nextRegistry: Registry;
18
+ }
19
+ export declare function buildCoverageReport(options: {
20
+ docsRoot: string;
21
+ registry: Registry;
22
+ rulesDir: string;
23
+ }): CoverageReport;
24
+ export interface ClassifyInput {
25
+ /** Hash of the requirement, as `coverage` prints it. */
26
+ hash: string;
27
+ kind: EnforcementKind;
28
+ ref?: string;
29
+ note?: string;
30
+ }
31
+ export interface ClassifyResult {
32
+ registry: Registry;
33
+ requirement: Requirement;
34
+ /** The kind this requirement carried before, when it was already classified. */
35
+ previousKind?: EnforcementKind;
36
+ }
37
+ /**
38
+ * Records how a requirement is checked.
39
+ *
40
+ * Validates that the requirement exists in the documentation as written, that a
41
+ * ref names a check that exists, and that the kinds which are meaningless
42
+ * without an explanation carry one. Throws with a readable message otherwise, so
43
+ * the caller can print it and exit.
44
+ */
45
+ export declare function classifyRequirement(options: {
46
+ docsRoot: string;
47
+ registry: Registry;
48
+ input: ClassifyInput;
49
+ }): ClassifyResult;
50
+ export declare function readRegistry(registryPath: string): Registry;
51
+ export declare function writeRegistry(registryPath: string, registry: Registry): void;
@@ -0,0 +1,210 @@
1
+ import { readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { pasikaRuleIds } from "../eslint/pasika/index.js";
5
+ import { DOCS_CHECKS } from "./docs-check.js";
6
+ import { parseDocs } from "./parse-docs.js";
7
+ import { MECHANICAL_KINDS, registrySchema } from "./types.js";
8
+ /** Ratio of shared words, used only to guess which stored requirement a reworded bullet came from. */
9
+ function similarity(left, right) {
10
+ const leftWords = new Set(left.toLowerCase().split(/\W+/).filter(Boolean));
11
+ const rightWords = new Set(right.toLowerCase().split(/\W+/).filter(Boolean));
12
+ const shared = [...leftWords].filter((word) => rightWords.has(word)).length;
13
+ const union = new Set([...leftWords, ...rightWords]).size;
14
+ return union === 0 ? 0 : shared / union;
15
+ }
16
+ /** Titles passed to `describe` or `test` in the rule test files. */
17
+ function collectTestTitles(rulesDir) {
18
+ const titles = new Set();
19
+ for (const entry of readdirSync(rulesDir)) {
20
+ if (!entry.endsWith(".test.ts"))
21
+ continue;
22
+ const body = readFileSync(path.join(rulesDir, entry), "utf8");
23
+ for (const match of body.matchAll(/\b(?:describe|test|it)\(\s*"(?<title>(?:[^"\\]|\\.)*)"/g)) {
24
+ titles.add((match.groups?.title ?? "").replaceAll('\\"', '"'));
25
+ }
26
+ }
27
+ return titles;
28
+ }
29
+ /** A requirement several checks cover lists them comma-separated. */
30
+ function refParts(ref) {
31
+ return (ref ?? "")
32
+ .split(",")
33
+ .map((part) => part.trim())
34
+ .filter(Boolean);
35
+ }
36
+ function isRefKnown(requirement, docsChecks) {
37
+ const parts = refParts(requirement.ref);
38
+ if (requirement.kind === "eslint")
39
+ return parts.length > 0 && parts.every((part) => pasikaRuleIds.includes(part));
40
+ if (requirement.kind === "docs-check")
41
+ return parts.length > 0 && parts.every((part) => docsChecks.has(part));
42
+ // Doctor checks do not exist yet, so a `doctor` entry is a forward reference.
43
+ return true;
44
+ }
45
+ export function buildCoverageReport(options) {
46
+ const { docsRoot, registry, rulesDir } = options;
47
+ const docs = parseDocs(docsRoot);
48
+ const docsChecks = new Set(DOCS_CHECKS);
49
+ const testTitles = collectTestTitles(rulesDir);
50
+ const byHash = new Map(registry.requirements.map((requirement) => [requirement.hash, requirement]));
51
+ const matched = new Set();
52
+ const issues = [];
53
+ const counts = {
54
+ eslint: 0,
55
+ doctor: 0,
56
+ "docs-check": 0,
57
+ judgment: 0,
58
+ permission: 0,
59
+ planned: 0,
60
+ };
61
+ const nextRequirements = [];
62
+ const parsed = docs.flatMap((doc) => doc.requirements.map((requirement) => ({ doc: doc.doc, requirement })));
63
+ for (const { doc, requirement } of parsed) {
64
+ const recorded = byHash.get(requirement.hash);
65
+ if (recorded) {
66
+ matched.add(requirement.hash);
67
+ counts[recorded.kind] += 1;
68
+ nextRequirements.push({ ...recorded, doc });
69
+ if (!isRefKnown(recorded, docsChecks)) {
70
+ issues.push({
71
+ kind: "unknown-ref",
72
+ doc,
73
+ line: requirement.line,
74
+ text: requirement.text,
75
+ detail: `${recorded.kind} ref "${recorded.ref ?? "(none)"}" does not exist`,
76
+ });
77
+ }
78
+ if (recorded.kind === "eslint" && !testTitles.has(requirement.text)) {
79
+ issues.push({
80
+ kind: "missing-test",
81
+ doc,
82
+ line: requirement.line,
83
+ text: requirement.text,
84
+ detail: `no rule test is titled with this requirement`,
85
+ });
86
+ }
87
+ continue;
88
+ }
89
+ // Not recorded under this hash: either a reworded requirement or a new one.
90
+ const candidate = registry.requirements
91
+ .filter((entry) => entry.doc === doc && !matched.has(entry.hash))
92
+ .map((entry) => ({ entry, score: similarity(entry.text, requirement.text) }))
93
+ .sort((left, right) => right.score - left.score)
94
+ .find(({ score }) => score >= 0.5);
95
+ if (candidate) {
96
+ matched.add(candidate.entry.hash);
97
+ counts[candidate.entry.kind] += 1;
98
+ nextRequirements.push({ ...candidate.entry, doc, text: requirement.text, hash: requirement.hash });
99
+ issues.push({
100
+ kind: "changed",
101
+ doc,
102
+ line: requirement.line,
103
+ text: requirement.text,
104
+ hash: requirement.hash,
105
+ detail: `was "${candidate.entry.text}" — re-verify ${candidate.entry.kind}${candidate.entry.ref ? ` ${candidate.entry.ref}` : ""}`,
106
+ });
107
+ }
108
+ else {
109
+ issues.push({ kind: "new", doc, line: requirement.line, text: requirement.text, hash: requirement.hash });
110
+ }
111
+ }
112
+ for (const entry of registry.requirements) {
113
+ if (matched.has(entry.hash))
114
+ continue;
115
+ issues.push({
116
+ kind: "removed",
117
+ doc: entry.doc,
118
+ text: entry.text,
119
+ detail: `recorded as ${entry.kind}${entry.ref ? ` ${entry.ref}` : ""} but the bullet is gone`,
120
+ });
121
+ }
122
+ const total = parsed.length;
123
+ let mechanical = 0;
124
+ for (const kind of MECHANICAL_KINDS)
125
+ mechanical += counts[kind];
126
+ return {
127
+ counts,
128
+ total,
129
+ mechanical,
130
+ issues,
131
+ nextRegistry: { requirements: nextRequirements },
132
+ };
133
+ }
134
+ /**
135
+ * Records how a requirement is checked.
136
+ *
137
+ * Validates that the requirement exists in the documentation as written, that a
138
+ * ref names a check that exists, and that the kinds which are meaningless
139
+ * without an explanation carry one. Throws with a readable message otherwise, so
140
+ * the caller can print it and exit.
141
+ */
142
+ export function classifyRequirement(options) {
143
+ const { docsRoot, registry, input } = options;
144
+ const parsed = parseDocs(docsRoot).flatMap((doc) => doc.requirements.map((requirement) => ({ doc: doc.doc, requirement })));
145
+ const match = parsed.find((entry) => entry.requirement.hash === input.hash);
146
+ if (!match) {
147
+ throw new Error(`No requirement in the documentation has hash "${input.hash}". Run coverage to list them.`);
148
+ }
149
+ const refs = refParts(input.ref);
150
+ if (input.kind === "eslint") {
151
+ if (refs.length === 0)
152
+ throw new Error('Kind "eslint" needs --ref naming the rule that reports it.');
153
+ const unknown = refs.filter((ref) => !pasikaRuleIds.includes(ref));
154
+ if (unknown.length > 0) {
155
+ throw new Error(`--ref ${unknown.map((ref) => `"${ref}"`).join(", ")} is not a rule in the plugin.`);
156
+ }
157
+ }
158
+ else if (input.kind === "docs-check") {
159
+ if (refs.length === 0)
160
+ throw new Error('Kind "docs-check" needs --ref naming the check that reports it.');
161
+ const known = new Set(DOCS_CHECKS);
162
+ const unknown = refs.filter((ref) => !known.has(ref));
163
+ if (unknown.length > 0) {
164
+ throw new Error(`--ref ${unknown.map((ref) => `"${ref}"`).join(", ")} is not a documentation check.`);
165
+ }
166
+ }
167
+ else if (input.kind !== "doctor" && refs.length > 0) {
168
+ throw new Error(`Kind "${input.kind}" takes no --ref, because nothing reports it.`);
169
+ }
170
+ if ((input.kind === "judgment" || input.kind === "planned") && (input.note ?? "").trim() === "") {
171
+ const reason = input.kind === "judgment" ? "why no check can decide it" : "the check that should cover it";
172
+ throw new Error(`Kind "${input.kind}" needs --note naming ${reason}.`);
173
+ }
174
+ const requirement = {
175
+ doc: match.doc,
176
+ text: match.requirement.text,
177
+ hash: match.requirement.hash,
178
+ kind: input.kind,
179
+ ...(refs.length > 0 ? { ref: refs.join(", ") } : {}),
180
+ ...((input.note ?? "").trim() === "" ? {} : { note: input.note?.trim() }),
181
+ };
182
+ const existing = registry.requirements.find((entry) => entry.hash === input.hash);
183
+ const requirements = registry.requirements.filter((entry) => entry.hash !== input.hash);
184
+ requirements.push(requirement);
185
+ return { registry: { requirements }, requirement, previousKind: existing?.kind };
186
+ }
187
+ export function readRegistry(registryPath) {
188
+ const parsed = JSON.parse(readFileSync(registryPath, "utf8"));
189
+ const result = registrySchema.safeParse(parsed);
190
+ if (!result.success) {
191
+ throw new Error(`${registryPath} is not a valid enforcement registry:\n${z.prettifyError(result.error)}`);
192
+ }
193
+ return result.data;
194
+ }
195
+ /**
196
+ * Code-point order on doc then text. Deliberately not `localeCompare`, whose
197
+ * result depends on the host's locale data — a generated file that is committed
198
+ * has to sort the same way everywhere.
199
+ */
200
+ function compareRequirements(left, right) {
201
+ if (left.doc !== right.doc)
202
+ return left.doc < right.doc ? -1 : 1;
203
+ if (left.text !== right.text)
204
+ return left.text < right.text ? -1 : 1;
205
+ return 0;
206
+ }
207
+ export function writeRegistry(registryPath, registry) {
208
+ const sorted = { requirements: [...registry.requirements].sort(compareRequirements) };
209
+ writeFileSync(registryPath, `${JSON.stringify(sorted, null, 2)}\n`);
210
+ }
@@ -0,0 +1,17 @@
1
+ import { type ParsedDoc } from "./parse-docs.js";
2
+ /**
3
+ * Identifiers of the documentation checks. The enforcement registry points at
4
+ * these, so renaming one is a change the registry has to follow.
5
+ */
6
+ export declare const DOCS_CHECKS: readonly ["doc-kind-suffix", "title-matches-file-name", "overview-present", "overview-length", "guide-overview-no-links", "guide-step-single-sentence", "guide-step-single-link", "guide-states-no-requirement", "guide-folder-entry-point", "requirement-present", "rule-paired-examples", "example-heading-description", "policy-no-examples", "policy-single-document", "no-cross-document-link", "reference-no-rfc-vocabulary", "reference-block-headings", "support-document-placement", "no-template-prompt"];
7
+ export type DocsCheck = (typeof DOCS_CHECKS)[number];
8
+ export interface DocsFinding {
9
+ doc: string;
10
+ line: number;
11
+ check: DocsCheck;
12
+ message: string;
13
+ }
14
+ export declare function checkDocs(docsRoot: string): {
15
+ docs: ParsedDoc[];
16
+ findings: DocsFinding[];
17
+ };