@drkmttr/adhere 0.21.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -1552
- package/package.json +10 -7
- package/presets/README.md +9 -1
- package/presets/alchemy/runtime/bind-tag-not-implementation/RULE.md +1 -0
- package/presets/effect/data/brand-meaningful-primitives/RULE.md +1 -0
- package/presets/effect/data/variants-are-tagged-unions/RULE.md +1 -0
- package/presets/typescript/types/derive-dont-restate/RULE.md +1 -0
- package/skills/adhere/SKILL.md +214 -30
- package/skills/adhere-fix/SKILL.md +1 -1
- package/skills/adhere-setup/SKILL.md +49 -16
- package/src/cli.ts +216 -25
- package/src/config.ts +73 -4
- package/src/create/ask.ts +255 -9
- package/src/create/examples.ts +94 -0
- package/src/create/look.ts +62 -19
- package/src/create/oxlint-trial.ts +464 -0
- package/src/create/oxlint.ts +1012 -0
- package/src/create/trial-review.ts +625 -0
- package/src/create/trial.ts +663 -0
- package/src/create/wizard.ts +131 -95
- package/src/filters.ts +86 -0
- package/src/init.ts +169 -40
- package/src/install.ts +295 -93
- package/src/instructions.ts +34 -12
- package/src/markdown.ts +21 -2
- package/src/oxlint/catalog.json +7844 -0
- package/src/oxlint/catalog.ts +72 -0
- package/src/oxlint/shared.ts +140 -0
- package/src/readme-guide.ts +431 -0
- package/src/readme.ts +291 -0
- package/src/services/AdhereConfig.ts +36 -3
- package/src/services/AuditCache.ts +176 -169
- package/src/services/SourceWalker.ts +12 -5
- package/src/wording.ts +3 -3
- package/src/workflows/audit.ts +40 -7
- package/src/workflows/format.ts +1 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every rule the pinned oxlint version knows, with the ID its configuration
|
|
3
|
+
* accepts, and the one-line description from the rule's documentation.
|
|
4
|
+
*
|
|
5
|
+
* `catalog.json` is generated from the installed `oxlint --rules --format=json`
|
|
6
|
+
* and the docs pages by `bun scripts/oxlint-catalog.ts`; CI rejects a snapshot
|
|
7
|
+
* that drifts with `bun run oxlint:catalog:check`. Never edit it by hand.
|
|
8
|
+
*/
|
|
9
|
+
import { Schema } from "effect";
|
|
10
|
+
import catalogJson from "./catalog.json" with { type: "json" };
|
|
11
|
+
|
|
12
|
+
/** The category oxlint groups a rule under; a config can enable a whole one. */
|
|
13
|
+
export const OxlintCategory = Schema.Literals([
|
|
14
|
+
"correctness",
|
|
15
|
+
"nursery",
|
|
16
|
+
"pedantic",
|
|
17
|
+
"perf",
|
|
18
|
+
"restriction",
|
|
19
|
+
"style",
|
|
20
|
+
"suspicious",
|
|
21
|
+
]);
|
|
22
|
+
export type OxlintCategory = typeof OxlintCategory.Type;
|
|
23
|
+
|
|
24
|
+
/** How oxlint reports a rule's availability for `--fix`. */
|
|
25
|
+
export const OxlintFix = Schema.Literals([
|
|
26
|
+
"conditional_dangerous_fix",
|
|
27
|
+
"conditional_dangerous_fix_or_suggestion",
|
|
28
|
+
"conditional_fix",
|
|
29
|
+
"conditional_safe_fix_or_suggestion",
|
|
30
|
+
"conditional_suggestion",
|
|
31
|
+
"fixable_dangerous_fix",
|
|
32
|
+
"fixable_dangerous_fix_or_suggestion",
|
|
33
|
+
"fixable_dangerous_suggestion",
|
|
34
|
+
"fixable_fix",
|
|
35
|
+
"fixable_safe_fix_or_suggestion",
|
|
36
|
+
"fixable_suggestion",
|
|
37
|
+
"none",
|
|
38
|
+
"pending",
|
|
39
|
+
]);
|
|
40
|
+
export type OxlintFix = typeof OxlintFix.Type;
|
|
41
|
+
|
|
42
|
+
export const OxlintRule = Schema.Struct({
|
|
43
|
+
/** The ID a `.oxlintrc.json` `rules` entry uses. */
|
|
44
|
+
id: Schema.String,
|
|
45
|
+
category: OxlintCategory,
|
|
46
|
+
/** True for native type-aware rules; a JavaScript plugin cannot be one. */
|
|
47
|
+
typeAware: Schema.Boolean,
|
|
48
|
+
fix: OxlintFix,
|
|
49
|
+
/** Whether oxlint enables the rule without configuration. */
|
|
50
|
+
default: Schema.Boolean,
|
|
51
|
+
docsUrl: Schema.String,
|
|
52
|
+
/** The rule's "What it does" paragraph from `docsUrl`. */
|
|
53
|
+
summary: Schema.String,
|
|
54
|
+
});
|
|
55
|
+
export type OxlintRule = typeof OxlintRule.Type;
|
|
56
|
+
|
|
57
|
+
export const OxlintCatalog = Schema.Struct({
|
|
58
|
+
/** The oxlint version the snapshot was generated from. */
|
|
59
|
+
version: Schema.String,
|
|
60
|
+
rules: Schema.Array(OxlintRule),
|
|
61
|
+
});
|
|
62
|
+
export type OxlintCatalog = typeof OxlintCatalog.Type;
|
|
63
|
+
|
|
64
|
+
export const oxlintCatalog: OxlintCatalog = Schema.decodeUnknownSync(OxlintCatalog)(catalogJson);
|
|
65
|
+
|
|
66
|
+
export const oxlintRuleIds: readonly string[] = oxlintCatalog.rules.map((rule) => rule.id);
|
|
67
|
+
|
|
68
|
+
const rulesById = new Map(oxlintCatalog.rules.map((rule) => [rule.id, rule] as const));
|
|
69
|
+
|
|
70
|
+
export function oxlintRule(id: string): OxlintRule | undefined {
|
|
71
|
+
return rulesById.get(id);
|
|
72
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The files a shared rules repo checks its lintable rules with.
|
|
3
|
+
* `oxlint/index.js` is plain ESM and reads `oxlint/rules.json`, so a consumer
|
|
4
|
+
* needs no Vite+ or other package at runtime, only the shared repo as a git
|
|
5
|
+
* dependency.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Where a shared repo keeps its Oxlint package. */
|
|
9
|
+
export const SHARED_LINT_DIRECTORY = "oxlint";
|
|
10
|
+
|
|
11
|
+
/** The package export a consumer imports: `@org/repo/oxlint`. */
|
|
12
|
+
export const SHARED_LINT_EXPORT = "./oxlint";
|
|
13
|
+
|
|
14
|
+
/** `oxlint/index.js`, the module behind the export. */
|
|
15
|
+
export const SHARED_LINT_ENTRY = "./oxlint/index.js";
|
|
16
|
+
|
|
17
|
+
/** The declarations beside it, so a TypeScript config can import the export. */
|
|
18
|
+
export const SHARED_LINT_TYPES = `${SHARED_LINT_DIRECTORY}/index.d.ts`;
|
|
19
|
+
|
|
20
|
+
/** The Oxlint-shaped settings the repo's rules and plugins are enabled in. */
|
|
21
|
+
export const SHARED_LINT_RULES = `${SHARED_LINT_DIRECTORY}/rules.json`;
|
|
22
|
+
|
|
23
|
+
/** No rule or plugin is enabled until one is added. */
|
|
24
|
+
export const SHARED_LINT_RULES_JSON = "{}\n";
|
|
25
|
+
|
|
26
|
+
/** The package name consumers import: npm names are lowercase, though org/repo may not be. */
|
|
27
|
+
export const sharedPackageName = (org: string, repo: string) =>
|
|
28
|
+
`@${org.toLowerCase()}/${repo.toLowerCase()}`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The shared repo's package.json. alchemy's CLI runs on effect and its Node
|
|
32
|
+
* platform, which are pinned, as is the platform's own dependency, since a
|
|
33
|
+
* range of prereleases takes the newest: effect 4.0.0-rc.118 moved modules
|
|
34
|
+
* alchemy 2.0.0-beta.79 imports (checked 2026-09-28). Only `./oxlint` is
|
|
35
|
+
* exported; the alchemy stack is a development dependency, not a runtime one.
|
|
36
|
+
*/
|
|
37
|
+
export const sharedPackageJson = (org: string, repo: string) =>
|
|
38
|
+
`${JSON.stringify(
|
|
39
|
+
{
|
|
40
|
+
name: sharedPackageName(org, repo),
|
|
41
|
+
private: true,
|
|
42
|
+
type: "module",
|
|
43
|
+
files: [SHARED_LINT_DIRECTORY],
|
|
44
|
+
exports: { [SHARED_LINT_EXPORT]: SHARED_LINT_ENTRY },
|
|
45
|
+
devDependencies: {
|
|
46
|
+
"@effect/platform-node": "4.0.0-rc.117",
|
|
47
|
+
alchemy: "2.0.0-beta.79",
|
|
48
|
+
effect: "4.0.0-rc.117",
|
|
49
|
+
},
|
|
50
|
+
overrides: { "@effect/platform-node-shared": "4.0.0-rc.117" },
|
|
51
|
+
},
|
|
52
|
+
null,
|
|
53
|
+
2,
|
|
54
|
+
)}\n`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reads `rules.json` beside itself, so a checkout, a git dependency, and a
|
|
58
|
+
* consumer's config all see the same file. A plugin path that starts with `.`
|
|
59
|
+
* names a file in `oxlint/` and becomes absolute, since Oxlint resolves a
|
|
60
|
+
* relative plugin path against the consumer's config, not this package.
|
|
61
|
+
*/
|
|
62
|
+
export const SHARED_LINT_INDEX = `import { readFileSync } from "node:fs";
|
|
63
|
+
import { fileURLToPath } from "node:url";
|
|
64
|
+
|
|
65
|
+
const settings = JSON.parse(readFileSync(new URL("./rules.json", import.meta.url), "utf8"));
|
|
66
|
+
|
|
67
|
+
const resolvePlugin = (entry) => {
|
|
68
|
+
if (typeof entry !== "string") {
|
|
69
|
+
return entry.specifier.startsWith(".")
|
|
70
|
+
? { ...entry, specifier: fileURLToPath(new URL(entry.specifier, import.meta.url)) }
|
|
71
|
+
: entry;
|
|
72
|
+
}
|
|
73
|
+
return entry.startsWith(".") ? fileURLToPath(new URL(entry, import.meta.url)) : entry;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const plugins = settings.plugins;
|
|
77
|
+
export const jsPlugins = (settings.jsPlugins ?? []).map(resolvePlugin);
|
|
78
|
+
export const rules = settings.rules ?? {};
|
|
79
|
+
export const options = settings.options;
|
|
80
|
+
|
|
81
|
+
// Leaving plugins out keeps oxlint's base set; [] replaces it.
|
|
82
|
+
export default {
|
|
83
|
+
...(plugins === undefined ? {} : { plugins }),
|
|
84
|
+
jsPlugins,
|
|
85
|
+
rules,
|
|
86
|
+
...(options === undefined ? {} : { options }),
|
|
87
|
+
};
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
/** The same export, typed for the TypeScript config that imports it. */
|
|
91
|
+
export const SHARED_LINT_TYPES_SOURCE = `/** A built-in Oxlint plugin, as its plugins setting names them. */
|
|
92
|
+
export type SharedPluginName =
|
|
93
|
+
| "eslint"
|
|
94
|
+
| "react"
|
|
95
|
+
| "unicorn"
|
|
96
|
+
| "typescript"
|
|
97
|
+
| "oxc"
|
|
98
|
+
| "import"
|
|
99
|
+
| "jsdoc"
|
|
100
|
+
| "jest"
|
|
101
|
+
| "vitest"
|
|
102
|
+
| "jsx-a11y"
|
|
103
|
+
| "nextjs"
|
|
104
|
+
| "react-perf"
|
|
105
|
+
| "promise"
|
|
106
|
+
| "node"
|
|
107
|
+
| "vue";
|
|
108
|
+
|
|
109
|
+
/** An Oxlint rule severity. */
|
|
110
|
+
export type SharedRuleLevel = "allow" | "off" | "warn" | "error" | "deny" | number;
|
|
111
|
+
|
|
112
|
+
/** A JS plugin path, or a package specifier under a name. */
|
|
113
|
+
export type SharedPlugin = string | { name: string; specifier: string };
|
|
114
|
+
|
|
115
|
+
/** The linter options the generated package supports. */
|
|
116
|
+
export interface SharedLintOptions {
|
|
117
|
+
denyWarnings?: boolean;
|
|
118
|
+
maxWarnings?: number;
|
|
119
|
+
reportUnusedDisableDirectives?: SharedRuleLevel;
|
|
120
|
+
respectEslintDisableDirectives?: boolean;
|
|
121
|
+
typeAware?: boolean;
|
|
122
|
+
typeCheck?: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The Oxlint settings the generated package exposes. */
|
|
126
|
+
export interface SharedLintConfig {
|
|
127
|
+
plugins?: SharedPluginName[];
|
|
128
|
+
jsPlugins: SharedPlugin[];
|
|
129
|
+
rules: Record<string, SharedRuleLevel>;
|
|
130
|
+
options?: SharedLintOptions;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const plugins: SharedPluginName[] | undefined;
|
|
134
|
+
export const jsPlugins: SharedPlugin[];
|
|
135
|
+
export const rules: Record<string, SharedRuleLevel>;
|
|
136
|
+
export const options: SharedLintOptions | undefined;
|
|
137
|
+
|
|
138
|
+
declare const config: SharedLintConfig;
|
|
139
|
+
export default config;
|
|
140
|
+
`;
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The managed guide in a shared repo's README: a contents list, its
|
|
3
|
+
* `.adhere/rules/` grouped by topic with each rule's statement and its own
|
|
4
|
+
* examples, the native Oxlint rules its `oxlint/rules.json` enables, and the
|
|
5
|
+
* rules its custom Oxlint plugins add.
|
|
6
|
+
*
|
|
7
|
+
* A RULE.md body is embedded by editing what Markdown makes of it: heading
|
|
8
|
+
* markers are demoted and anchored, and link, image, and reference definition
|
|
9
|
+
* targets are rebased. Every other byte stays as the rule wrote it, so code,
|
|
10
|
+
* HTML, entities, and list and paragraph layout are untouched.
|
|
11
|
+
*/
|
|
12
|
+
import { oxlintRule } from "#oxlint/catalog.ts";
|
|
13
|
+
import { SHARED_LINT_DIRECTORY, SHARED_LINT_RULES } from "#oxlint/shared.ts";
|
|
14
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
15
|
+
import type { Definition, Heading, Image, Link, Nodes } from "mdast";
|
|
16
|
+
|
|
17
|
+
/** A rule as the guide shows it: its description, and the body a RULE.md embeds. */
|
|
18
|
+
export interface ReadmeRule {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly name: "RULE.md" | "RULE.ts";
|
|
21
|
+
readonly description: string;
|
|
22
|
+
/** A RULE.md's Markdown after its front matter; absent for a RULE.ts, which is never read. */
|
|
23
|
+
readonly body?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The lint settings an index reads: enabled rules and configured plugin entries. */
|
|
27
|
+
export interface LintConfig {
|
|
28
|
+
readonly rules?: Readonly<Record<string, unknown>>;
|
|
29
|
+
readonly jsPlugins?: ReadonlyArray<unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const OFF = new Set<unknown>(["off", "allow", "0", 0]);
|
|
33
|
+
|
|
34
|
+
/** The severity a rule setting turns on, or undefined when it is off. */
|
|
35
|
+
const levelOf = (setting: unknown): "error" | "warn" | "on" | undefined => {
|
|
36
|
+
const level = Array.isArray(setting) ? setting[0] : setting;
|
|
37
|
+
if (OFF.has(level)) return undefined;
|
|
38
|
+
if (level === "error" || level === 2) return "error";
|
|
39
|
+
if (level === "warn" || level === 1) return "warn";
|
|
40
|
+
return "on";
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const enabledAs = (setting: unknown): string => {
|
|
44
|
+
const level = levelOf(setting);
|
|
45
|
+
return level === "on" || level === undefined ? "enabled" : `enabled as \`${level}\``;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** A plugin entry's name and specifier: `{ name, specifier }` or a bare specifier. */
|
|
49
|
+
const pluginsIn = (config: LintConfig): ReadonlyMap<string, string | undefined> => {
|
|
50
|
+
const plugins = new Map<string, string | undefined>();
|
|
51
|
+
for (const entry of config.jsPlugins ?? []) {
|
|
52
|
+
if (typeof entry === "string") {
|
|
53
|
+
const base = entry.split("/").at(-1) ?? entry;
|
|
54
|
+
plugins.set(base.replace(/\.(?:mjs|cjs|js)$/, ""), entry);
|
|
55
|
+
} else if (
|
|
56
|
+
typeof entry === "object" &&
|
|
57
|
+
entry !== null &&
|
|
58
|
+
"name" in entry &&
|
|
59
|
+
typeof entry.name === "string"
|
|
60
|
+
) {
|
|
61
|
+
plugins.set(
|
|
62
|
+
entry.name,
|
|
63
|
+
"specifier" in entry && typeof entry.specifier === "string" ? entry.specifier : undefined,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return plugins;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** An `oxlint/rules.json` plugin path as a link from the README, or undefined when it is a package. */
|
|
71
|
+
const pluginLink = (specifier: string | undefined): string | undefined => {
|
|
72
|
+
if (specifier === undefined || !specifier.startsWith("./") || specifier.includes("..")) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
return `./${SHARED_LINT_DIRECTORY}/${specifier.slice(2)}`;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const link = (label: string, target: string | undefined) =>
|
|
79
|
+
target === undefined ? `\`${label}\`` : `[\`${label}\`](${target})`;
|
|
80
|
+
|
|
81
|
+
/** One line per enabled lint setting; a rule with an off setting does nothing. */
|
|
82
|
+
export const lintEntries = (config: LintConfig) => {
|
|
83
|
+
const plugins = pluginsIn(config);
|
|
84
|
+
const native: Array<string> = [];
|
|
85
|
+
const custom: Array<string> = [];
|
|
86
|
+
for (const id of Object.keys(config.rules ?? {}).sort()) {
|
|
87
|
+
const setting = config.rules?.[id];
|
|
88
|
+
const level = levelOf(setting);
|
|
89
|
+
if (level === undefined) continue;
|
|
90
|
+
const plugin = id.includes("/") ? id.slice(0, id.indexOf("/")) : undefined;
|
|
91
|
+
const specifier = plugin === undefined ? undefined : plugins.get(plugin);
|
|
92
|
+
if (plugin !== undefined && specifier !== undefined) {
|
|
93
|
+
custom.push(
|
|
94
|
+
`- ${link(id, pluginLink(specifier))}: custom plugin \`${plugin}\`, ${enabledAs(setting)}.`,
|
|
95
|
+
);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const summary = (oxlintRule(id)?.summary ?? "").replace(/\s+/g, " ").trim();
|
|
99
|
+
native.push(
|
|
100
|
+
summary === ""
|
|
101
|
+
? `- ${link(id, `./${SHARED_LINT_RULES}`)}: ${enabledAs(setting)}.`
|
|
102
|
+
: `- ${link(id, `./${SHARED_LINT_RULES}`)}: ${summary} (${enabledAs(setting)}).`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return { native, custom };
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The block's own anchors. Each is explicit rather than GitHub's heading slug,
|
|
110
|
+
* and prefixed, so an author's heading cannot claim one.
|
|
111
|
+
*/
|
|
112
|
+
const CONTENTS_ANCHOR = "adhere-contents";
|
|
113
|
+
const RULES_ANCHOR = "adhere-rules";
|
|
114
|
+
const LINT_SETTINGS_ANCHOR = "adhere-lint-settings";
|
|
115
|
+
const PLUGINS_ANCHOR = "adhere-custom-lint-plugins";
|
|
116
|
+
|
|
117
|
+
/** The topic of a rule whose id is one segment, with no topic directory. */
|
|
118
|
+
const OTHER_TOPIC = "Other";
|
|
119
|
+
|
|
120
|
+
const backToContents = `**[↑ Back to contents](#${CONTENTS_ANCHOR})**`;
|
|
121
|
+
|
|
122
|
+
/** An id, topic, or heading as part of an anchor: lowercase, hyphenated. */
|
|
123
|
+
const slugOf = (text: string): string =>
|
|
124
|
+
text
|
|
125
|
+
.toLowerCase()
|
|
126
|
+
.replace(/[^\p{L}\p{N}_-]+/gu, "-")
|
|
127
|
+
.replace(/^-+|-+$/g, "");
|
|
128
|
+
|
|
129
|
+
/** The anchor for `key`, numbered when an earlier one slugs the same. */
|
|
130
|
+
const uniqueAnchor = (used: Set<string>, prefix: string, key: string): string => {
|
|
131
|
+
const base = `${prefix}-${slugOf(key)}`;
|
|
132
|
+
let anchor = base;
|
|
133
|
+
for (let number = 2; used.has(anchor); number += 1) anchor = `${base}-${number}`;
|
|
134
|
+
used.add(anchor);
|
|
135
|
+
return anchor;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** A rule and the anchor its entry answers to. */
|
|
139
|
+
interface IndexRule {
|
|
140
|
+
readonly rule: ReadmeRule;
|
|
141
|
+
readonly anchor: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A rule's first id segment, its rules sorted by id. */
|
|
145
|
+
interface Topic {
|
|
146
|
+
readonly name: string;
|
|
147
|
+
readonly anchor: string;
|
|
148
|
+
readonly rules: ReadonlyArray<IndexRule>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The topics, and every anchor the block has emitted so far. */
|
|
152
|
+
interface Grouping {
|
|
153
|
+
readonly topics: ReadonlyArray<Topic>;
|
|
154
|
+
/** Shared with the headings each rule's body adds, so no two anchors collide. */
|
|
155
|
+
readonly used: Set<string>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Rules by topic, topics by name, each with its anchor: the same set of rules
|
|
160
|
+
* always groups and orders the same way.
|
|
161
|
+
*/
|
|
162
|
+
const topicsOf = (rules: ReadonlyArray<ReadmeRule>): Grouping => {
|
|
163
|
+
const used = new Set<string>();
|
|
164
|
+
const groups = new Map<string, { name: string; anchor: string; rules: Array<IndexRule> }>();
|
|
165
|
+
for (const rule of [...rules].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
166
|
+
const cut = rule.id.indexOf("/");
|
|
167
|
+
const name = cut === -1 ? OTHER_TOPIC : rule.id.slice(0, cut);
|
|
168
|
+
let group = groups.get(name);
|
|
169
|
+
if (group === undefined) {
|
|
170
|
+
group = { name, anchor: uniqueAnchor(used, "adhere-topic", name), rules: [] };
|
|
171
|
+
groups.set(name, group);
|
|
172
|
+
}
|
|
173
|
+
group.rules.push({ rule, anchor: uniqueAnchor(used, "adhere-rule", rule.id) });
|
|
174
|
+
}
|
|
175
|
+
return { topics: [...groups.values()].sort((a, b) => a.name.localeCompare(b.name)), used };
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** A description as `<summary>` reads it: Markdown does not run in raw HTML, so code spans become tags. */
|
|
179
|
+
const summaryText = (text: string): string =>
|
|
180
|
+
text
|
|
181
|
+
.replace(/&/g, "&")
|
|
182
|
+
.replace(/</g, "<")
|
|
183
|
+
.replace(/>/g, ">")
|
|
184
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
185
|
+
|
|
186
|
+
/** A heading's text as GitHub anchors it, for the fragment a rule's body links to. */
|
|
187
|
+
const headingSlug = (text: string): string =>
|
|
188
|
+
text
|
|
189
|
+
.replace(/<[^>]+>/g, "")
|
|
190
|
+
.trim()
|
|
191
|
+
.toLowerCase()
|
|
192
|
+
.replace(/[^\p{L}\p{N}_\- ]/gu, "")
|
|
193
|
+
.replace(/ /g, "-");
|
|
194
|
+
|
|
195
|
+
/** A target written inside a rule's file, as a link from the README. */
|
|
196
|
+
const rebase = (source: string, target: string, byHeading: ReadonlyMap<string, string>): string => {
|
|
197
|
+
if (target.startsWith("<") || target.startsWith("/")) return target;
|
|
198
|
+
if (target.startsWith("#")) {
|
|
199
|
+
const anchor = byHeading.get(target.slice(1).toLowerCase());
|
|
200
|
+
// Not a heading the body spells: the fragment is still the rule file's to answer.
|
|
201
|
+
return anchor === undefined ? `${source}${target}` : `#${anchor}`;
|
|
202
|
+
}
|
|
203
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(target)) return target;
|
|
204
|
+
const directory = source.slice(0, source.lastIndexOf("/") + 1);
|
|
205
|
+
return `${directory}${target.startsWith("./") ? target.slice(2) : target}`;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** A parsed node's point: mdast records columns, and offsets in the parsed text. */
|
|
209
|
+
interface MarkdownPoint {
|
|
210
|
+
readonly line: number;
|
|
211
|
+
readonly column: number;
|
|
212
|
+
readonly offset?: number | undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const offsetOf = (
|
|
216
|
+
point: MarkdownPoint | undefined,
|
|
217
|
+
starts: ReadonlyArray<number>,
|
|
218
|
+
): number | undefined =>
|
|
219
|
+
point === undefined
|
|
220
|
+
? undefined
|
|
221
|
+
: (point.offset ?? (starts[point.line - 1] ?? 0) + point.column - 1);
|
|
222
|
+
|
|
223
|
+
/** Where each line of `text` starts. */
|
|
224
|
+
const lineStartsOf = (text: string): Array<number> => {
|
|
225
|
+
const starts = [0];
|
|
226
|
+
for (let at = text.indexOf("\n"); at !== -1; at = text.indexOf("\n", at + 1)) starts.push(at + 1);
|
|
227
|
+
return starts;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** Every node of a tree, the tree first. */
|
|
231
|
+
const each = (node: Nodes, visit: (node: Nodes) => void): void => {
|
|
232
|
+
visit(node);
|
|
233
|
+
if ("children" in node) for (const child of node.children) each(child, visit);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** Text as GitHub slugs a heading: code values, link text, and image alt. */
|
|
237
|
+
const textOf = (node: Nodes): string => {
|
|
238
|
+
if (node.type === "image" || node.type === "imageReference") return node.alt ?? "";
|
|
239
|
+
if ("value" in node && typeof node.value === "string") return node.value;
|
|
240
|
+
return "children" in node ? node.children.map(textOf).join("") : "";
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/** Where a link, image, or definition's target sits, when the source spells it verbatim. */
|
|
244
|
+
const targetRange = (
|
|
245
|
+
body: string,
|
|
246
|
+
node: Link | Image | Definition,
|
|
247
|
+
target: string,
|
|
248
|
+
starts: ReadonlyArray<number>,
|
|
249
|
+
): { start: number; end: number } | undefined => {
|
|
250
|
+
const start = offsetOf(node.position?.start, starts);
|
|
251
|
+
const end = offsetOf(node.position?.end, starts);
|
|
252
|
+
if (start === undefined || end === undefined) return undefined;
|
|
253
|
+
const raw = body.slice(start, end);
|
|
254
|
+
const opener = raw.indexOf(node.type === "definition" ? "]:" : "](");
|
|
255
|
+
const at = opener === -1 ? -1 : raw.indexOf(target, opener + 2);
|
|
256
|
+
return at === -1 ? undefined : { start: start + at, end: start + at + target.length };
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** The anchor and blank line that introduce a demoted heading, indented to match it. */
|
|
260
|
+
const anchorBlock = (body: string, lineStart: number, anchor: string): string => {
|
|
261
|
+
const previous =
|
|
262
|
+
lineStart === 0 ? "" : body.slice(body.lastIndexOf("\n", lineStart - 2) + 1, lineStart - 1);
|
|
263
|
+
// At column 0 the anchor would end the list item an indented heading belongs to.
|
|
264
|
+
const indent = /^ */.exec(body.slice(lineStart, lineStart + 3))?.[0] ?? "";
|
|
265
|
+
return `${previous.trim() === "" ? "" : "\n"}${indent}<a id="${anchor}"></a>\n\n`;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* A RULE.md's body as the guide shows it: headings demoted under their topic,
|
|
270
|
+
* each with an `adhere-heading-` anchor of its own, and link, image, and
|
|
271
|
+
* reference definition targets rebased from the rule's directory onto the
|
|
272
|
+
* README. A `#fragment` naming one of the body's headings resolves by the
|
|
273
|
+
* source name GitHub gives it (`#must`, then `#must-1`), since a heading like
|
|
274
|
+
* `Must` repeats; any other fragment becomes a link into the rule's file.
|
|
275
|
+
* Everything else stays as the rule wrote it: fenced and indented code, inline
|
|
276
|
+
* code, HTML, entities, and list layout are all left untouched. The one thing
|
|
277
|
+
* added is a closing fence when the body ends inside one, since the guide
|
|
278
|
+
* embeds it at the top level of the README and the fence would run on through
|
|
279
|
+
* `Source:`, the later rules, and the author's own sections.
|
|
280
|
+
*/
|
|
281
|
+
const embedBody = (source: string, anchor: string, body: string, used: Set<string>): string => {
|
|
282
|
+
const prefix = anchor.replace("adhere-rule-", "adhere-heading-");
|
|
283
|
+
const starts = lineStartsOf(body);
|
|
284
|
+
const headings: Array<Heading> = [];
|
|
285
|
+
const resources: Array<Link | Image | Definition> = [];
|
|
286
|
+
const tree = fromMarkdown(body);
|
|
287
|
+
each(tree, (node) => {
|
|
288
|
+
if (node.type === "heading") headings.push(node);
|
|
289
|
+
else if (node.type === "link" || node.type === "image" || node.type === "definition") {
|
|
290
|
+
resources.push(node);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
const edits: Array<{ start: number; end: number; text: string }> = [];
|
|
294
|
+
const byHeading = new Map<string, string>();
|
|
295
|
+
const repeats = new Map<string, number>();
|
|
296
|
+
for (const heading of headings) {
|
|
297
|
+
const start = offsetOf(heading.position?.start, starts);
|
|
298
|
+
if (start === undefined) continue;
|
|
299
|
+
const lineStart = body.lastIndexOf("\n", start - 1) + 1;
|
|
300
|
+
const lineEnd = body.indexOf("\n", lineStart);
|
|
301
|
+
const line = body.slice(lineStart, lineEnd === -1 ? body.length : lineEnd);
|
|
302
|
+
const marker = /^ {0,3}(#{1,6})(?:[ \t]|$)/.exec(line);
|
|
303
|
+
// A setext heading is left as written: the guide only demotes ATX ones.
|
|
304
|
+
if (marker === null) continue;
|
|
305
|
+
const slug = headingSlug(textOf(heading));
|
|
306
|
+
const repeat = repeats.get(slug) ?? 0;
|
|
307
|
+
repeats.set(slug, repeat + 1);
|
|
308
|
+
const at = uniqueAnchor(used, prefix, slug);
|
|
309
|
+
// GitHub numbers a repeated heading from `-1`, and a rule's links name that.
|
|
310
|
+
const named = repeat === 0 ? slug : `${slug}-${repeat}`;
|
|
311
|
+
if (!byHeading.has(named)) byHeading.set(named, at);
|
|
312
|
+
const hashStart = lineStart + marker[0].indexOf("#");
|
|
313
|
+
edits.push({ start: lineStart, end: lineStart, text: anchorBlock(body, lineStart, at) });
|
|
314
|
+
edits.push({
|
|
315
|
+
start: hashStart,
|
|
316
|
+
end: hashStart + (marker[1] ?? "#").length,
|
|
317
|
+
text: "#".repeat(Math.min(6, heading.depth + 3)),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
for (const node of resources) {
|
|
321
|
+
const target = node.url;
|
|
322
|
+
if (target === "") continue;
|
|
323
|
+
const rewritten = rebase(source, target, byHeading);
|
|
324
|
+
if (rewritten === target) continue;
|
|
325
|
+
const range = targetRange(body, node, target, starts);
|
|
326
|
+
// A target the source escapes or encodes away from its parsed form stays as written.
|
|
327
|
+
if (range === undefined) continue;
|
|
328
|
+
edits.push({ ...range, text: rewritten });
|
|
329
|
+
}
|
|
330
|
+
let edited = body;
|
|
331
|
+
let limit = Number.POSITIVE_INFINITY;
|
|
332
|
+
for (const edit of [...edits].sort((a, b) => b.start - a.start || b.end - a.end)) {
|
|
333
|
+
if (edit.end > limit) continue;
|
|
334
|
+
edited = `${edited.slice(0, edit.start)}${edit.text}${edited.slice(edit.end)}`;
|
|
335
|
+
limit = edit.start;
|
|
336
|
+
}
|
|
337
|
+
// A fence left open at the end would swallow everything the README says after it.
|
|
338
|
+
const last = tree.children.at(-1);
|
|
339
|
+
const tail =
|
|
340
|
+
last?.type === "code" ? body.slice(offsetOf(last.position?.start, starts) ?? 0).trimEnd() : "";
|
|
341
|
+
const fence = /^ {0,3}(`{3,}|~{3,})/.exec(tail)?.[1];
|
|
342
|
+
if (fence === undefined) return edited;
|
|
343
|
+
const closing = tail.split("\n").at(-1)?.trim() ?? "";
|
|
344
|
+
const closed = tail.includes("\n") && closing.startsWith(fence) && /^(`+|~+)$/.test(closing);
|
|
345
|
+
return closed ? edited : `${edited}\n${fence}`;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/** One rule's expandable entry: its statement, its body, and the file it came from. */
|
|
349
|
+
const ruleEntry = (rule: ReadmeRule, anchor: string, used: Set<string>): ReadonlyArray<string> => {
|
|
350
|
+
const source = `./.adhere/rules/${rule.id}/${rule.name}`;
|
|
351
|
+
const label = `<code>${summaryText(rule.id)}</code>`;
|
|
352
|
+
const statement = summaryText(rule.description.replace(/\s+/g, " ").trim());
|
|
353
|
+
// Only the blank line after the front matter goes: body bytes stay as written.
|
|
354
|
+
const body = (rule.body ?? "").replace(/^\n+/, "").trimEnd();
|
|
355
|
+
return [
|
|
356
|
+
`<a id="${anchor}"></a>`,
|
|
357
|
+
"",
|
|
358
|
+
"<details>",
|
|
359
|
+
rule.name === "RULE.ts"
|
|
360
|
+
? `<summary><strong>TypeScript rule</strong> ${label}</summary>`
|
|
361
|
+
: `<summary><strong>${statement}</strong> ${label}</summary>`,
|
|
362
|
+
"",
|
|
363
|
+
...(rule.name === "RULE.ts"
|
|
364
|
+
? [
|
|
365
|
+
`This rule is TypeScript, so the guide does not read or run it: [\`${rule.id}\`](${source}).`,
|
|
366
|
+
]
|
|
367
|
+
: [
|
|
368
|
+
...(body === "" ? [] : [embedBody(source, anchor, body, used), ""]),
|
|
369
|
+
`Source: [\`${rule.id}\`](${source}).`,
|
|
370
|
+
]),
|
|
371
|
+
"",
|
|
372
|
+
"</details>",
|
|
373
|
+
];
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The managed block's body: a contents list naming every rule, each topic's
|
|
378
|
+
* rules as expandable entries, then the lint settings and plugins. Rules sort
|
|
379
|
+
* by id and topics by name, so the same checkout always writes the same text.
|
|
380
|
+
*/
|
|
381
|
+
export const renderReadmeIndex = (rules: ReadonlyArray<ReadmeRule>, config: LintConfig): string => {
|
|
382
|
+
const entries = lintEntries(config);
|
|
383
|
+
const { topics, used } = topicsOf(rules);
|
|
384
|
+
const contents = [
|
|
385
|
+
`- [Rules](#${RULES_ANCHOR})`,
|
|
386
|
+
...topics.flatMap(({ name, anchor, rules: group }) => [
|
|
387
|
+
` - [\`${name}\`](#${anchor})`,
|
|
388
|
+
...group.map(({ rule, anchor: at }) => ` - [\`${rule.id}\`](#${at})`),
|
|
389
|
+
]),
|
|
390
|
+
`- [Lint settings](#${LINT_SETTINGS_ANCHOR})`,
|
|
391
|
+
`- [Custom lint plugins](#${PLUGINS_ANCHOR})`,
|
|
392
|
+
];
|
|
393
|
+
const body =
|
|
394
|
+
topics.length === 0
|
|
395
|
+
? ["- None.", ""]
|
|
396
|
+
: topics.flatMap(({ name, anchor, rules: group }) => [
|
|
397
|
+
`<a id="${anchor}"></a>`,
|
|
398
|
+
"",
|
|
399
|
+
`#### ${name}`,
|
|
400
|
+
"",
|
|
401
|
+
...group.flatMap(({ rule, anchor: at }) => [...ruleEntry(rule, at, used), ""]),
|
|
402
|
+
backToContents,
|
|
403
|
+
"",
|
|
404
|
+
]);
|
|
405
|
+
return [
|
|
406
|
+
`<a id="${CONTENTS_ANCHOR}"></a>`,
|
|
407
|
+
"",
|
|
408
|
+
"### Contents",
|
|
409
|
+
"",
|
|
410
|
+
...contents,
|
|
411
|
+
"",
|
|
412
|
+
`<a id="${RULES_ANCHOR}"></a>`,
|
|
413
|
+
"",
|
|
414
|
+
"### Rules",
|
|
415
|
+
"",
|
|
416
|
+
...body,
|
|
417
|
+
`<a id="${LINT_SETTINGS_ANCHOR}"></a>`,
|
|
418
|
+
"",
|
|
419
|
+
"### Lint settings",
|
|
420
|
+
"",
|
|
421
|
+
...(entries.native.length === 0 ? ["- None."] : entries.native),
|
|
422
|
+
"",
|
|
423
|
+
`<a id="${PLUGINS_ANCHOR}"></a>`,
|
|
424
|
+
"",
|
|
425
|
+
"### Custom lint plugins",
|
|
426
|
+
"",
|
|
427
|
+
...(entries.custom.length === 0 ? ["- None."] : entries.custom),
|
|
428
|
+
"",
|
|
429
|
+
backToContents,
|
|
430
|
+
].join("\n");
|
|
431
|
+
};
|