pasika 0.1.6 → 0.3.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.
Files changed (91) hide show
  1. package/README.md +99 -78
  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 +159 -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 +13 -1
  15. package/dist/eslint/pasika/index.js +24 -11
  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-barrel-exports.d.ts +9 -0
  25. package/dist/eslint/pasika/rules/enforce-barrel-exports.js +78 -0
  26. package/dist/eslint/pasika/rules/enforce-cn-merge.d.ts +9 -0
  27. package/dist/eslint/pasika/rules/enforce-cn-merge.js +84 -0
  28. package/dist/eslint/pasika/rules/enforce-cva-variant-props.d.ts +9 -0
  29. package/dist/eslint/pasika/rules/enforce-cva-variant-props.js +81 -0
  30. package/dist/eslint/pasika/rules/filename-case.js +10 -0
  31. package/dist/eslint/pasika/rules/import-boundaries.d.ts +2 -0
  32. package/dist/eslint/pasika/rules/{organization-imports.js → import-boundaries.js} +62 -19
  33. package/dist/eslint/pasika/rules/no-arbitrary-tailwind.d.ts +9 -0
  34. package/dist/eslint/pasika/rules/no-arbitrary-tailwind.js +97 -0
  35. package/dist/eslint/pasika/rules/no-mixed-concerns.d.ts +9 -0
  36. package/dist/eslint/pasika/rules/no-mixed-concerns.js +84 -0
  37. package/dist/eslint/pasika/rules/support-file-placement.d.ts +15 -0
  38. package/dist/eslint/pasika/rules/support-file-placement.js +70 -0
  39. package/enforcement/registry.json +1139 -0
  40. package/package.json +21 -15
  41. package/claude/hooks/.vulyk +0 -3
  42. package/claude/hooks/AGENTS.md +0 -3
  43. package/claude/hooks/CLAUDE.md +0 -1
  44. package/claude/hooks/claude-hooks.md +0 -30
  45. package/claude/hooks/notification.sh +0 -38
  46. package/claude/hooks/protect-files.sh +0 -21
  47. package/claude/hooks/status-line/index.js +0 -57
  48. package/claude/scripts/render-settings.ts +0 -223
  49. package/claude/settings.base.json +0 -38
  50. package/dist/claude/scripts/render-settings.js +0 -145
  51. package/dist/eslint/pasika/rules/organization-imports.d.ts +0 -2
  52. package/dist/eslint.config.js +0 -7
  53. package/dist/scripts/pasika.js +0 -59
  54. package/docs/agent-conventions.md +0 -27
  55. package/docs/claude/hooks.md +0 -30
  56. package/docs/code-organization-guide/code-organization-guide.md +0 -69
  57. package/docs/code-organization-guide/references/application-architecture-reference.md +0 -149
  58. package/docs/code-organization-guide/rules/component-placement-rule.md +0 -119
  59. package/docs/code-organization-guide/rules/configuration-rule.md +0 -42
  60. package/docs/code-organization-guide/rules/constants-rule.md +0 -74
  61. package/docs/code-organization-guide/rules/exports-and-imports-rule.md +0 -84
  62. package/docs/code-organization-guide/rules/folder-nesting-rule.md +0 -82
  63. package/docs/code-organization-guide/rules/hook-extraction-rule.md +0 -141
  64. package/docs/code-organization-guide/rules/interactive-component-rule.md +0 -97
  65. package/docs/code-organization-guide/rules/jsx-hygiene-rule.md +0 -67
  66. package/docs/code-organization-guide/rules/locales-rule.md +0 -53
  67. package/docs/code-organization-guide/rules/nameable-visual-concept-rule.md +0 -66
  68. package/docs/code-organization-guide/rules/no-mixed-concerns-rule.md +0 -63
  69. package/docs/code-organization-guide/rules/repeated-structure-rule.md +0 -93
  70. package/docs/code-organization-guide/rules/smart-vs-dumb-component-rule.md +0 -112
  71. package/docs/code-organization-guide/rules/sole-state-owner-rule.md +0 -101
  72. package/docs/code-organization-guide/rules/types-and-schemas-rule.md +0 -139
  73. package/docs/code-organization-guide/rules/utilities-rule.md +0 -86
  74. package/docs/documentation-guide/_templates/grouped-reference.md +0 -11
  75. package/docs/documentation-guide/_templates/guide.md +0 -19
  76. package/docs/documentation-guide/_templates/rule.md +0 -21
  77. package/docs/documentation-guide/_templates/single-lookup-reference.md +0 -5
  78. package/docs/documentation-guide/documentation-guide.md +0 -13
  79. package/docs/documentation-guide/references/documentation-types-reference.md +0 -9
  80. package/docs/documentation-guide/rules/guide-creation-rule.md +0 -113
  81. package/docs/documentation-guide/rules/reference-creation-rule.md +0 -132
  82. package/docs/documentation-guide/rules/rule-creation-rule.md +0 -81
  83. package/docs/documentation-guide/rules/template-usage-rule.md +0 -49
  84. package/docs/shadcn-theme.md +0 -121
  85. package/docs/styling-guide/rules/arbitrary-value-rule.md +0 -31
  86. package/docs/styling-guide/rules/class-composition-rule.md +0 -52
  87. package/docs/styling-guide/rules/component-ui-state-rule.md +0 -53
  88. package/docs/styling-guide/rules/component-variant-rule.md +0 -125
  89. package/docs/styling-guide/rules/global-stylesheet-rule.md +0 -67
  90. package/docs/styling-guide/rules/theme-and-utility-definition-rule.md +0 -86
  91. package/docs/styling-guide/styling-guide.md +0 -14
package/README.md CHANGED
@@ -1,118 +1,139 @@
1
1
  # pasika
2
2
 
3
- Reusable agent setup package, starting with Claude.
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. Today it focuses on Claude, with room for Codex and other agent-specific setup flows later.
6
-
7
- ## Scope
8
-
9
- This repo intentionally starts narrow:
10
-
11
- - Claude first for v1
12
- - reusable agent setup assets only
13
- - shared docs at the repo root
14
- - 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.
15
6
 
16
7
  ## Layout
17
8
 
18
9
  ```text
19
- claude/
20
- settings.base.json
21
- hooks/
22
- status-line/
23
- index.js
24
- notification.sh
25
- protect-files.sh
26
- scripts/
27
- render-settings.ts
28
10
  docs/
29
- claude/
30
- hooks.md
31
- scripts/
32
- pasika.ts
33
- dist/
34
- ...
35
- vulyk.json
36
- AGENTS.md
37
- CLAUDE.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
20
+ eslint/
21
+ pasika/rules/ # the lint rules, with fixture tests beside them
22
+ cli/
23
+ index.ts # the `pasika` command
38
24
  ```
39
25
 
40
- ## What belongs here
41
-
42
- - portable setup scripts
43
- - base settings templates
44
- - shared docs and generated `CLAUDE.md`
45
- - shared naming and layout conventions
46
- - reusable source-organization lint rules
47
-
48
- ## What stays in project repos
26
+ ## Documentation
49
27
 
50
- - final agent config folders in project repos
51
- - project-specific rules, agents, and prompts
52
- - repository-specific plugin choices
53
- - scripts that depend on a specific app, CI setup, or codebase
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.
54
29
 
55
- ## CLI
30
+ ## Enforcement
56
31
 
57
- The current entry point is:
32
+ Requirements are identified by a hash of their canonical text, not by a hand-written id, so rewording one is visible:
58
33
 
59
- ```bash
60
- npx pasika claude
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
+ }
61
43
  ```
62
44
 
63
- Optional flags:
45
+ The `note` field is where a check's known gap is recorded, so a partial check never reads as a complete one.
64
46
 
65
- - `--target-dir <path>` writes into another repo
66
- - `--force` replaces an existing generated file instead of merging
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 |
67
55
 
68
- ## Development
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`.
69
57
 
70
- The CLI source lives in TypeScript.
58
+ ## Commands
71
59
 
72
60
  ```bash
73
- npm run lint
74
- npm run typecheck
75
- 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
76
65
  ```
77
66
 
78
- `npm run build` uses `vulyk docs` to emit root `AGENTS.md` and `CLAUDE.md`.
67
+ Both accept `--json` for agent use.
79
68
 
80
- ## Recommended Integration
69
+ A requirement `coverage` reports as `new` is classified with the hash it prints:
81
70
 
82
- Hooks and helper executables should come from `node_modules`.
83
-
84
- That gives us:
85
-
86
- - versioned reusable scripts
87
- - easy upgrades through the package manager
88
- - no manual copying of hook files into every repo
89
-
90
- `settings.json` is different. It still needs to exist in each project repo, because Claude Code does not give us a clean inheritance model for it.
91
-
92
- So the recommended pattern is:
93
-
94
- 1. install `pasika` as a dev dependency
95
- 2. run `npx pasika claude`
96
- 3. generate or merge into `.claude/settings.json` in the project
97
- 4. point hook commands at `./node_modules/pasika/claude/...`
98
- 5. keep project-specific plugin, skill, and rule decisions in the project repo
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
+ ```
99
75
 
100
- `npx pasika claude` also copies the packaged Claude hooks docs into `.claude/hooks/` in the target repo.
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.
101
77
 
102
- ## ESLint Pasika Ruleset
78
+ ## ESLint ruleset
103
79
 
104
- Pasika packages its source-organization rules separately from its own Node and TypeScript lint configuration. A consumer composes the exported ruleset with Zirka and enables any framework-specific Zirka blocks it needs.
80
+ ### With Zirka (recommended)
105
81
 
106
82
  ```ts
107
83
  // eslint.config.ts
108
- import { pasikaConfig } from "pasika/eslint";
109
84
  import { RuleSeverity, styleguide } from "zirka";
110
85
 
111
86
  const { eslintConfig } = styleguide({
112
87
  next: RuleSeverity.Error,
113
88
  node: RuleSeverity.Error,
114
89
  typescript: RuleSeverity.Error,
90
+ pasika: RuleSeverity.Error,
115
91
  });
116
92
 
117
- export default [...((await eslintConfig) ?? []), pasikaConfig];
93
+ export default eslintConfig;
94
+ ```
95
+
96
+ ### Without Zirka
97
+
98
+ ```ts
99
+ // eslint.config.ts
100
+ import { pasikaConfig } from "pasika/eslint";
101
+
102
+ export default [pasikaConfig];
103
+ ```
104
+
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
122
+
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:
124
+
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
118
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
+ };