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.
- package/README.md +93 -57
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +132 -0
- package/dist/enforcement/coverage.d.ts +51 -0
- package/dist/enforcement/coverage.js +210 -0
- package/dist/enforcement/docs-check.d.ts +17 -0
- package/dist/enforcement/docs-check.js +162 -0
- package/dist/enforcement/normalize.d.ts +11 -0
- package/dist/enforcement/normalize.js +21 -0
- package/dist/enforcement/parse-docs.d.ts +58 -0
- package/dist/enforcement/parse-docs.js +94 -0
- package/dist/enforcement/types.d.ts +57 -0
- package/dist/enforcement/types.js +59 -0
- package/dist/eslint/pasika/index.d.ts +9 -15
- package/dist/eslint/pasika/index.js +18 -20
- package/dist/eslint/pasika/project/ccf.d.ts +48 -0
- package/dist/eslint/pasika/project/ccf.js +119 -0
- package/dist/eslint/pasika/project/index.d.ts +21 -0
- package/dist/eslint/pasika/project/index.js +139 -0
- package/dist/eslint/pasika/project/parse-module.d.ts +27 -0
- package/dist/eslint/pasika/project/parse-module.js +128 -0
- package/dist/eslint/pasika/rules/component-placement.d.ts +11 -0
- package/dist/eslint/pasika/rules/component-placement.js +75 -0
- package/dist/eslint/pasika/rules/enforce-cva-variant-props.js +6 -1
- package/dist/eslint/pasika/rules/import-boundaries.js +53 -20
- package/dist/eslint/pasika/rules/no-arbitrary-tailwind.js +55 -65
- package/dist/eslint/pasika/rules/support-file-placement.d.ts +15 -0
- package/dist/eslint/pasika/rules/support-file-placement.js +70 -0
- package/enforcement/registry.json +1139 -0
- package/package.json +22 -4
package/README.md
CHANGED
|
@@ -1,60 +1,83 @@
|
|
|
1
1
|
# pasika
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Documentation, the lint rules derived from it, and the CLI that applies and diagnoses both.
|
|
4
4
|
|
|
5
|
-
`pasika`
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
##
|
|
26
|
+
## Documentation
|
|
31
27
|
|
|
32
|
-
-
|
|
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
|
-
##
|
|
30
|
+
## Enforcement
|
|
37
31
|
|
|
38
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
67
|
+
Both accept `--json` for agent use.
|
|
52
68
|
|
|
53
|
-
|
|
69
|
+
A requirement `coverage` reports as `new` is classified with the hash it prints:
|
|
54
70
|
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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,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
|
+
};
|