pasika 0.2.0 → 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 (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 +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 +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 +21 -4
@@ -0,0 +1,159 @@
1
+ import path from "node:path";
2
+ import { parseDocs, RFC_2119 } from "./parse-docs.js";
3
+ /**
4
+ * Identifiers of the documentation checks. The enforcement registry points at
5
+ * these, so renaming one is a change the registry has to follow.
6
+ */
7
+ export const DOCS_CHECKS = [
8
+ "doc-kind-suffix",
9
+ "title-matches-file-name",
10
+ "overview-present",
11
+ "overview-length",
12
+ "guide-overview-no-links",
13
+ "guide-step-single-sentence",
14
+ "guide-step-single-link",
15
+ "guide-states-no-requirement",
16
+ "guide-folder-entry-point",
17
+ "requirement-present",
18
+ "rule-paired-examples",
19
+ "example-heading-description",
20
+ "policy-no-examples",
21
+ "policy-single-document",
22
+ "no-cross-document-link",
23
+ "reference-no-rfc-vocabulary",
24
+ "reference-block-headings",
25
+ "support-document-placement",
26
+ "no-template-prompt",
27
+ ];
28
+ function countSentences(text) {
29
+ // Abbreviations inside requirement text are rare, so a period followed by
30
+ // whitespace is a reliable sentence boundary here.
31
+ return text.split(/[.!?](?:\s+|$)/).filter((part) => part.trim() !== "").length;
32
+ }
33
+ function checkDoc(doc, allDocs) {
34
+ const findings = [];
35
+ const add = (line, check, message) => {
36
+ findings.push({ doc: doc.doc, line, check, message });
37
+ };
38
+ if (!doc.kind) {
39
+ add(1, "doc-kind-suffix", "file name carries no -guide, -rule, -reference, or -policy suffix");
40
+ return findings;
41
+ }
42
+ const expectedFileName = `${doc.title
43
+ .toLowerCase()
44
+ .replaceAll(/[^a-z0-9]+/g, "-")
45
+ .replace(/^-|-$/g, "")}.md`;
46
+ if (doc.title === "") {
47
+ add(1, "title-matches-file-name", "document has no `# Title` heading");
48
+ }
49
+ else if (expectedFileName !== doc.fileName) {
50
+ add(1, "title-matches-file-name", `title "${doc.title}" expects file name ${expectedFileName}`);
51
+ }
52
+ if (doc.overview === undefined) {
53
+ add(2, "overview-present", "no overview follows the title");
54
+ }
55
+ else if (countSentences(doc.overview) > 2) {
56
+ add(2, "overview-length", `overview uses ${String(countSentences(doc.overview))} sentences, at most two are allowed`);
57
+ }
58
+ if (doc.kind === "guide") {
59
+ if (doc.overview?.includes("](")) {
60
+ add(2, "guide-overview-no-links", "guide overview links another document");
61
+ }
62
+ for (const step of doc.steps) {
63
+ if (countSentences(step.text) > 1) {
64
+ add(step.line, "guide-step-single-sentence", `step uses ${String(countSentences(step.text))} sentences`);
65
+ }
66
+ if (step.links.length > 1) {
67
+ add(step.line, "guide-step-single-link", `step links ${String(step.links.length)} documents`);
68
+ }
69
+ }
70
+ if (doc.requirements.length > 0) {
71
+ const first = doc.requirements[0];
72
+ add(first?.line ?? 1, "guide-states-no-requirement", "guide states a requirement with RFC 2119 vocabulary");
73
+ }
74
+ }
75
+ else {
76
+ for (const link of doc.docLinks) {
77
+ add(link.line, "no-cross-document-link", `${doc.kind} links another document: ${link.target}`);
78
+ }
79
+ }
80
+ if ((doc.kind === "rule" || doc.kind === "policy") && doc.requirements.length === 0) {
81
+ add(1, "requirement-present", `${doc.kind} document states no requirement`);
82
+ }
83
+ if (doc.kind === "rule") {
84
+ const incorrect = doc.exampleHeadings.filter((heading) => heading.text.startsWith("Incorrect"));
85
+ const correct = doc.exampleHeadings.filter((heading) => heading.text.startsWith("Correct"));
86
+ if (incorrect.length === 0 || incorrect.length !== correct.length) {
87
+ add(1, "rule-paired-examples", `${String(incorrect.length)} Incorrect and ${String(correct.length)} Correct examples`);
88
+ }
89
+ for (const heading of doc.exampleHeadings) {
90
+ if (!/^(?:Incorrect|Correct) — .+/.test(heading.text)) {
91
+ add(heading.line, "example-heading-description", `example heading has no em-dash description: ${heading.text}`);
92
+ }
93
+ }
94
+ }
95
+ if (doc.kind === "policy") {
96
+ for (const heading of doc.exampleHeadings) {
97
+ add(heading.line, "policy-no-examples", `policy document contains an example: ${heading.text}`);
98
+ }
99
+ const policyDocs = allDocs.filter((other) => other.kind === "policy");
100
+ if (policyDocs.length > 1 && policyDocs[0] === doc) {
101
+ add(1, "policy-single-document", `${String(policyDocs.length)} policy documents exist`);
102
+ }
103
+ }
104
+ if (doc.kind === "reference") {
105
+ doc.proseWithoutCode.forEach((line, index) => {
106
+ const match = RFC_2119.exec(line);
107
+ if (match) {
108
+ add(index + 1, "reference-no-rfc-vocabulary", `reference uses RFC 2119 vocabulary: ${match.groups?.keyword ?? ""}`);
109
+ }
110
+ });
111
+ if (doc.sectionHeadings.length === 1) {
112
+ add(doc.sectionHeadings[0]?.line ?? 1, "reference-block-headings", "reference has exactly one section heading, so either a single block is headed or a first block is not");
113
+ }
114
+ }
115
+ // Rules and references a guide owns live in its rules/ and references/ folders.
116
+ const parentFolder = path.basename(path.dirname(doc.filePath));
117
+ if (doc.kind === "rule" && parentFolder !== "rules") {
118
+ add(1, "support-document-placement", `rule lives in "${parentFolder}/" instead of "rules/"`);
119
+ }
120
+ if (doc.kind === "reference" && parentFolder !== "references") {
121
+ add(1, "support-document-placement", `reference lives in "${parentFolder}/" instead of "references/"`);
122
+ }
123
+ doc.prose.forEach((line, index) => {
124
+ if (/^\s*\[[A-Z0-9].*\]\s*$/.test(line)) {
125
+ add(index + 1, "no-template-prompt", "leftover bracketed template prompt");
126
+ }
127
+ });
128
+ return findings;
129
+ }
130
+ /**
131
+ * A folder that holds `rules/` or `references/` is a guide folder, so it needs a
132
+ * guide named after it as its entry point. Other guides may share that folder.
133
+ */
134
+ function checkGuideFolders(docs) {
135
+ const guideFolders = new Set(docs
136
+ .filter((doc) => ["rules", "references"].includes(path.basename(path.dirname(doc.filePath))))
137
+ .map((doc) => path.dirname(path.dirname(doc.filePath))));
138
+ return [...guideFolders]
139
+ .sort((left, right) => left.localeCompare(right))
140
+ .flatMap((folder) => {
141
+ const expectedEntryPoint = `${path.basename(folder)}.md`;
142
+ const hasEntryPoint = docs.some((doc) => doc.kind === "guide" && path.dirname(doc.filePath) === folder && doc.fileName === expectedEntryPoint);
143
+ if (hasEntryPoint)
144
+ return [];
145
+ const anyDoc = docs.find((doc) => doc.filePath.startsWith(`${folder}${path.sep}`));
146
+ return [
147
+ {
148
+ doc: anyDoc ? path.dirname(path.dirname(anyDoc.doc)) : path.basename(folder),
149
+ line: 1,
150
+ check: "guide-folder-entry-point",
151
+ message: `folder holds support documents but has no ${expectedEntryPoint} entry point`,
152
+ },
153
+ ];
154
+ });
155
+ }
156
+ export function checkDocs(docsRoot) {
157
+ const docs = parseDocs(docsRoot);
158
+ return { docs, findings: [...docs.flatMap((doc) => checkDoc(doc, docs)), ...checkGuideFolders(docs)] };
159
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Reduces a requirement bullet to the text its meaning depends on.
3
+ *
4
+ * Markdown links collapse to their link text and code spans lose their
5
+ * backticks, so editing a URL or adding backticks does not read as a change in
6
+ * what the requirement demands. Whitespace collapses so re-wrapping a long
7
+ * bullet is not a change either.
8
+ */
9
+ export declare function normalizeRequirement(bullet: string): string;
10
+ /** Short, stable fingerprint of a canonical requirement text. */
11
+ export declare function hashRequirement(canonicalText: string): string;
@@ -0,0 +1,21 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * Reduces a requirement bullet to the text its meaning depends on.
4
+ *
5
+ * Markdown links collapse to their link text and code spans lose their
6
+ * backticks, so editing a URL or adding backticks does not read as a change in
7
+ * what the requirement demands. Whitespace collapses so re-wrapping a long
8
+ * bullet is not a change either.
9
+ */
10
+ export function normalizeRequirement(bullet) {
11
+ return bullet
12
+ .replace(/^\s*[-*]\s+/, "")
13
+ .replaceAll(/\[(?<text>[^\]]+)\]\([^)]*\)/g, "$<text>")
14
+ .replaceAll("`", "")
15
+ .replaceAll(/\s+/g, " ")
16
+ .trim();
17
+ }
18
+ /** Short, stable fingerprint of a canonical requirement text. */
19
+ export function hashRequirement(canonicalText) {
20
+ return createHash("sha256").update(canonicalText).digest("hex").slice(0, 10);
21
+ }
@@ -0,0 +1,58 @@
1
+ /** The four document kinds the documentation guide defines. */
2
+ export type DocKind = "guide" | "rule" | "reference" | "policy";
3
+ /** Words that carry requirement strength, longest first so `MUST NOT` wins over `MUST`. */
4
+ declare const RFC_2119: RegExp;
5
+ export interface ParsedRequirement {
6
+ /** Canonical text used for identity. */
7
+ text: string;
8
+ hash: string;
9
+ /** The bullet as written, without its list marker. */
10
+ raw: string;
11
+ line: number;
12
+ }
13
+ export interface ParsedStep {
14
+ line: number;
15
+ text: string;
16
+ /** Documents this step links, as written. */
17
+ links: string[];
18
+ }
19
+ export interface ParsedDoc {
20
+ /** Absolute path on disk. */
21
+ filePath: string;
22
+ /** Path relative to the docs root, with forward slashes. */
23
+ doc: string;
24
+ fileName: string;
25
+ /** Undefined when no file-name suffix identifies the kind. */
26
+ kind?: DocKind;
27
+ title: string;
28
+ /** First non-empty prose line under the title. */
29
+ overview?: string;
30
+ /** Prose lines with fenced code blocks blanked and code spans unwrapped, indexed from 0. */
31
+ prose: string[];
32
+ /**
33
+ * Prose lines with code spans removed rather than unwrapped, so a backticked
34
+ * keyword counts as naming the keyword rather than using it.
35
+ */
36
+ proseWithoutCode: string[];
37
+ requirements: ParsedRequirement[];
38
+ /** Numbered list items, which only a Guide is expected to have. */
39
+ steps: ParsedStep[];
40
+ /** `## Incorrect`/`## Correct` headings in document order. */
41
+ exampleHeadings: {
42
+ line: number;
43
+ text: string;
44
+ }[];
45
+ /** Every link to another Markdown document, outside fenced code. */
46
+ docLinks: {
47
+ line: number;
48
+ target: string;
49
+ }[];
50
+ /** `## ` section headings, excluding the example headings. */
51
+ sectionHeadings: {
52
+ line: number;
53
+ text: string;
54
+ }[];
55
+ }
56
+ export declare function parseDoc(filePath: string, docsRoot: string): ParsedDoc;
57
+ export declare function parseDocs(docsRoot: string): ParsedDoc[];
58
+ export { RFC_2119 };
@@ -0,0 +1,94 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { hashRequirement, normalizeRequirement } from "./normalize.js";
4
+ const KIND_BY_SUFFIX = [
5
+ ["-rule.md", "rule"],
6
+ ["-guide.md", "guide"],
7
+ ["-reference.md", "reference"],
8
+ ["-policy.md", "policy"],
9
+ ];
10
+ /** Words that carry requirement strength, longest first so `MUST NOT` wins over `MUST`. */
11
+ const RFC_2119 = /\b(?<keyword>MUST NOT|MUST|SHOULD NOT|SHOULD|MAY)\b/;
12
+ function listMarkdownFiles(dir) {
13
+ return readdirSync(dir).flatMap((entry) => {
14
+ const entryPath = path.join(dir, entry);
15
+ if (statSync(entryPath).isDirectory()) {
16
+ // Underscore folders hold templates and other support assets, not documents.
17
+ return entry.startsWith("_") ? [] : listMarkdownFiles(entryPath);
18
+ }
19
+ return entry.endsWith(".md") ? [entryPath] : [];
20
+ });
21
+ }
22
+ /**
23
+ * Blanks fenced code blocks and unwraps inline code spans, keeping one entry per
24
+ * source line so reported line numbers still match the file.
25
+ */
26
+ function toProse(body, codeSpanReplacement) {
27
+ let insideFence = false;
28
+ return body.split("\n").map((line) => {
29
+ if (/^\s*(?:```|~~~)/.test(line)) {
30
+ insideFence = !insideFence;
31
+ return "";
32
+ }
33
+ if (insideFence)
34
+ return "";
35
+ return line.replaceAll(/`(?<content>[^`]*)`/g, codeSpanReplacement);
36
+ });
37
+ }
38
+ function findDocLinks(line) {
39
+ return [...line.matchAll(/\]\((?<target>[^)]*\.md[^)]*)\)/g)].map((match) => match.groups?.target ?? "");
40
+ }
41
+ export function parseDoc(filePath, docsRoot) {
42
+ const body = readFileSync(filePath, "utf8");
43
+ const prose = toProse(body, "$<content>");
44
+ const proseWithoutCode = toProse(body, "");
45
+ const fileName = path.basename(filePath);
46
+ const kind = KIND_BY_SUFFIX.find(([suffix]) => fileName.endsWith(suffix))?.[1];
47
+ const requirements = [];
48
+ const steps = [];
49
+ const exampleHeadings = [];
50
+ const sectionHeadings = [];
51
+ const docLinks = [];
52
+ prose.forEach((line, index) => {
53
+ const lineNumber = index + 1;
54
+ for (const target of findDocLinks(line)) {
55
+ docLinks.push({ line: lineNumber, target });
56
+ }
57
+ if (/^\s*[-*]\s/.test(line) && RFC_2119.test(line)) {
58
+ const text = normalizeRequirement(line);
59
+ requirements.push({ text, hash: hashRequirement(text), raw: line.replace(/^\s*[-*]\s+/, ""), line: lineNumber });
60
+ }
61
+ if (/^\s*\d+\.\s/.test(line)) {
62
+ steps.push({ line: lineNumber, text: line.replace(/^\s*\d+\.\s+/, ""), links: findDocLinks(line) });
63
+ }
64
+ if (/^## (?:Incorrect|Correct)\b/.test(line)) {
65
+ exampleHeadings.push({ line: lineNumber, text: line.slice(3).trim() });
66
+ }
67
+ else if (line.startsWith("## ")) {
68
+ sectionHeadings.push({ line: lineNumber, text: line.slice(3).trim() });
69
+ }
70
+ });
71
+ const title = /^# (?<title>.+)$/m.exec(body)?.groups?.title?.trim() ?? "";
72
+ const overview = prose.slice(1).find((line) => line.trim() !== "" && !line.startsWith("#"));
73
+ return {
74
+ filePath,
75
+ doc: path.relative(docsRoot, filePath).split(path.sep).join("/"),
76
+ fileName,
77
+ kind,
78
+ title,
79
+ overview,
80
+ prose,
81
+ proseWithoutCode,
82
+ requirements,
83
+ steps,
84
+ exampleHeadings,
85
+ docLinks,
86
+ sectionHeadings,
87
+ };
88
+ }
89
+ export function parseDocs(docsRoot) {
90
+ return listMarkdownFiles(docsRoot)
91
+ .sort((a, b) => a.localeCompare(b))
92
+ .map((filePath) => parseDoc(filePath, docsRoot));
93
+ }
94
+ export { RFC_2119 };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The enforcement registry — the record of how every documented requirement is
3
+ * checked.
4
+ *
5
+ * A requirement is identified by the hash of its canonical text, not by a
6
+ * hand-written id. Rewording a requirement therefore changes its hash, which
7
+ * makes `pasika coverage` report it as changed until someone re-confirms that
8
+ * the recorded enforcement still covers it.
9
+ */
10
+ import { z } from "zod";
11
+ export declare const enforcementKindSchema: z.ZodEnum<{
12
+ eslint: "eslint";
13
+ doctor: "doctor";
14
+ "docs-check": "docs-check";
15
+ judgment: "judgment";
16
+ permission: "permission";
17
+ planned: "planned";
18
+ }>;
19
+ export declare const requirementSchema: z.ZodObject<{
20
+ doc: z.ZodString;
21
+ text: z.ZodString;
22
+ hash: z.ZodString;
23
+ kind: z.ZodEnum<{
24
+ eslint: "eslint";
25
+ doctor: "doctor";
26
+ "docs-check": "docs-check";
27
+ judgment: "judgment";
28
+ permission: "permission";
29
+ planned: "planned";
30
+ }>;
31
+ ref: z.ZodOptional<z.ZodString>;
32
+ note: z.ZodOptional<z.ZodString>;
33
+ }, z.core.$strip>;
34
+ export declare const registrySchema: z.ZodObject<{
35
+ requirements: z.ZodArray<z.ZodObject<{
36
+ doc: z.ZodString;
37
+ text: z.ZodString;
38
+ hash: z.ZodString;
39
+ kind: z.ZodEnum<{
40
+ eslint: "eslint";
41
+ doctor: "doctor";
42
+ "docs-check": "docs-check";
43
+ judgment: "judgment";
44
+ permission: "permission";
45
+ planned: "planned";
46
+ }>;
47
+ ref: z.ZodOptional<z.ZodString>;
48
+ note: z.ZodOptional<z.ZodString>;
49
+ }, z.core.$strip>>;
50
+ }, z.core.$strip>;
51
+ export type EnforcementKind = z.infer<typeof enforcementKindSchema>;
52
+ export type Requirement = z.infer<typeof requirementSchema>;
53
+ export type Registry = z.infer<typeof registrySchema>;
54
+ /** Every kind, in the order the coverage summary prints them. */
55
+ export declare const ENFORCEMENT_KINDS: EnforcementKind[];
56
+ /** The kinds that count as mechanically enforced today. */
57
+ export declare const MECHANICAL_KINDS: EnforcementKind[];
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The enforcement registry — the record of how every documented requirement is
3
+ * checked.
4
+ *
5
+ * A requirement is identified by the hash of its canonical text, not by a
6
+ * hand-written id. Rewording a requirement therefore changes its hash, which
7
+ * makes `pasika coverage` report it as changed until someone re-confirms that
8
+ * the recorded enforcement still covers it.
9
+ */
10
+ import { z } from "zod";
11
+ export const enforcementKindSchema = z.enum([
12
+ /** An ESLint rule reports it. */
13
+ "eslint",
14
+ /** A `pasika doctor` check reports it. */
15
+ "doctor",
16
+ /** A `pasika docs` check reports it. */
17
+ "docs-check",
18
+ /** No mechanical check can decide it; a reviewer or agent applies it. */
19
+ "judgment",
20
+ /** The requirement grants permission, so there is nothing to check. */
21
+ "permission",
22
+ /** A mechanical check is possible but not written yet. */
23
+ "planned",
24
+ ]);
25
+ export const requirementSchema = z.object({
26
+ /** Document path relative to the docs root. */
27
+ doc: z.string(),
28
+ /** Canonical requirement text: links flattened, code spans unwrapped, whitespace collapsed. */
29
+ text: z.string(),
30
+ /** Short hash of `text`. */
31
+ hash: z.string(),
32
+ kind: enforcementKindSchema,
33
+ /**
34
+ * Identifier of the check that covers this requirement: an ESLint rule id, a
35
+ * doctor check id, or a docs-check id. Several ids are comma-separated.
36
+ * Absent for judgment and permission.
37
+ */
38
+ ref: z.string().optional(),
39
+ /**
40
+ * For `judgment`, why no mechanical check can decide it. For `planned`, the
41
+ * check that should cover it. For `eslint` and `doctor`, what the existing
42
+ * check does not cover.
43
+ */
44
+ note: z.string().optional(),
45
+ });
46
+ export const registrySchema = z.object({
47
+ requirements: z.array(requirementSchema),
48
+ });
49
+ /** Every kind, in the order the coverage summary prints them. */
50
+ export const ENFORCEMENT_KINDS = [
51
+ "eslint",
52
+ "doctor",
53
+ "docs-check",
54
+ "planned",
55
+ "judgment",
56
+ "permission",
57
+ ];
58
+ /** The kinds that count as mechanically enforced today. */
59
+ export const MECHANICAL_KINDS = ["eslint", "doctor", "docs-check"];
@@ -1,20 +1,14 @@
1
1
  /**
2
2
  * Pasika ESLint Ruleset
3
3
  *
4
- * Each rule enforces a specific documentation Rule from the `docs/` tree.
5
- * Every rule file carries a `@see` annotation linking to the source doc.
6
- * This mapping is maintained so future agents can audit rule/doc alignment.
7
- *
8
- * Rule → Doc mapping:
9
- *
10
- * filename-case → docs/code-organization-guide/rules/smart-vs-dumb-component-rule.md
11
- * import-boundaries → docs/code-organization-guide/rules/exports-and-imports-rule.md
12
- * no-mixed-concerns → docs/code-organization-guide/rules/no-mixed-concerns-rule.md
13
- * no-arbitrary-tailwind → docs/styling-guide/rules/arbitrary-value-rule.md
14
- * enforce-cn-merge → docs/styling-guide/rules/class-composition-rule.md
15
- * enforce-cva-variant-props → docs/styling-guide/rules/component-variant-rule.md
16
- * enforce-barrel-exports → docs/code-organization-guide/rules/folder-nesting-rule.md
17
- * + docs/code-organization-guide/rules/exports-and-imports-rule.md
4
+ * Each rule enforces requirements from the `docs/` tree, and each rule file
5
+ * carries a `@see` annotation naming the document it comes from. Which
6
+ * documented requirement each rule covers is recorded in
7
+ * `enforcement/registry.json` and verified by `pasika coverage`.
18
8
  */
19
- import type { Linter } from "eslint";
9
+ import type { Linter, Rule } from "eslint";
10
+ /** Every rule the plugin provides, keyed by its unprefixed name. */
11
+ export declare const pasikaRules: Record<string, Rule.RuleModule>;
12
+ /** Rule ids as they appear in configuration and in lint output. */
13
+ export declare const pasikaRuleIds: string[];
20
14
  export declare const pasikaConfig: Linter.Config;
@@ -5,28 +5,26 @@ import { noArbitraryTailwindRule } from "./rules/no-arbitrary-tailwind.js";
5
5
  import { enforceCnMergeRule } from "./rules/enforce-cn-merge.js";
6
6
  import { enforceCvaVariantPropsRule } from "./rules/enforce-cva-variant-props.js";
7
7
  import { enforceBarrelExportsRule } from "./rules/enforce-barrel-exports.js";
8
+ import { componentPlacementRule } from "./rules/component-placement.js";
9
+ import { supportFilePlacementRule } from "./rules/support-file-placement.js";
10
+ /** Every rule the plugin provides, keyed by its unprefixed name. */
11
+ export const pasikaRules = {
12
+ "component-placement": componentPlacementRule,
13
+ "support-file-placement": supportFilePlacementRule,
14
+ "filename-case": filenameCaseRule,
15
+ "import-boundaries": importBoundariesRule,
16
+ "no-mixed-concerns": noMixedConcernsRule,
17
+ "no-arbitrary-tailwind": noArbitraryTailwindRule,
18
+ "enforce-cn-merge": enforceCnMergeRule,
19
+ "enforce-cva-variant-props": enforceCvaVariantPropsRule,
20
+ "enforce-barrel-exports": enforceBarrelExportsRule,
21
+ };
22
+ /** Rule ids as they appear in configuration and in lint output. */
23
+ export const pasikaRuleIds = Object.keys(pasikaRules).map((name) => `pasika/${name}`);
8
24
  export const pasikaConfig = {
9
25
  files: ["src/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}"],
10
26
  plugins: {
11
- pasika: {
12
- rules: {
13
- "filename-case": filenameCaseRule,
14
- "import-boundaries": importBoundariesRule,
15
- "no-mixed-concerns": noMixedConcernsRule,
16
- "no-arbitrary-tailwind": noArbitraryTailwindRule,
17
- "enforce-cn-merge": enforceCnMergeRule,
18
- "enforce-cva-variant-props": enforceCvaVariantPropsRule,
19
- "enforce-barrel-exports": enforceBarrelExportsRule,
20
- },
21
- },
22
- },
23
- rules: {
24
- "pasika/filename-case": "error",
25
- "pasika/import-boundaries": "error",
26
- "pasika/no-mixed-concerns": "error",
27
- "pasika/no-arbitrary-tailwind": "error",
28
- "pasika/enforce-cn-merge": "error",
29
- "pasika/enforce-cva-variant-props": "error",
30
- "pasika/enforce-barrel-exports": "error",
27
+ pasika: { rules: pasikaRules },
31
28
  },
29
+ rules: Object.fromEntries(pasikaRuleIds.map((id) => [id, "error"])),
32
30
  };
@@ -0,0 +1,48 @@
1
+ import type { ProjectIndex } from "./index.js";
2
+ export declare const SUPPORT_FOLDERS: Set<string>;
3
+ /** Path of a file relative to the source root, as segments. */
4
+ export declare function segmentsOf(file: string, sourceRoot: string): string[];
5
+ /** Folder of a file relative to the source root, as segments. */
6
+ export declare function folderSegmentsOf(file: string, sourceRoot: string): string[];
7
+ export declare const isUnderApp: (segments: string[]) => boolean;
8
+ export declare const isConfigModule: (segments: string[]) => boolean;
9
+ export declare const isUnderCompositions: (segments: string[]) => boolean;
10
+ export interface ComponentPlacement {
11
+ /** Consumers that count toward the calculation, as absolute paths. */
12
+ countedConsumers: string[];
13
+ /** Folder the component belongs in, relative to the source root. */
14
+ expectedFolder: string[];
15
+ /** Why the expected folder is what it is, for the report. */
16
+ reason: "ccf" | "across-features" | "across-layers";
17
+ }
18
+ /**
19
+ * Resolves where a component belongs from the files that import it.
20
+ *
21
+ * Imports from `src/app/` and from configuration modules drop out, consumers
22
+ * under `src/compositions/` count only when every consumer is there, and a
23
+ * result of `src/features/` becomes `src/shared/` because no feature may import
24
+ * from another. Returns undefined when no consumer counts, which is the case the
25
+ * "lives in the feature it represents" requirement covers instead.
26
+ */
27
+ export declare function resolveComponentPlacement(componentFile: string, index: ProjectIndex): ComponentPlacement | undefined;
28
+ export declare const formatFolder: (folder: string[]) => string;
29
+ export interface SupportPlacement {
30
+ countedConsumers: string[];
31
+ expectedFolder: string[];
32
+ reason: "app-consumer" | "config-module" | "ccf" | "across-features" | "across-layers";
33
+ }
34
+ /**
35
+ * Resolves where a support file belongs from the files that import it.
36
+ *
37
+ * A consumer inside a support folder is owned by that folder's parent, so the
38
+ * calculation lands on the scope that uses the file rather than on a sibling
39
+ * support folder. A consumer under `src/app/` forces the root support folder, a
40
+ * set of consumers inside one configuration module keeps the file in that module,
41
+ * and consumers spanning features land in the root support folder.
42
+ */
43
+ export declare function resolveSupportPlacement(supportFile: string, supportFolder: string, index: ProjectIndex): SupportPlacement | undefined;
44
+ /**
45
+ * Consumers for a message. A widely used file can have dozens, so the list is
46
+ * capped: the point is to show where the requirement comes from, not to enumerate.
47
+ */
48
+ export declare function describeConsumers(consumers: string[], sourceRoot: string): string;