@trackunit/repo-checks 0.0.0-stage → 0.0.2
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/CHANGELOG.md +3 -0
- package/LICENSE.txt +191 -0
- package/README.md +319 -2
- package/bin/index.d.ts +2 -0
- package/bin/index.js +34 -0
- package/migrations/entry.js +3 -0
- package/migrations.json +3 -0
- package/package.json +34 -3
- package/src/cli.d.ts +27 -0
- package/src/cli.js +109 -0
- package/src/index.d.ts +8 -0
- package/src/index.js +21 -0
- package/src/lib/config-error.d.ts +8 -0
- package/src/lib/config-error.js +16 -0
- package/src/lib/config.d.ts +22 -0
- package/src/lib/config.js +61 -0
- package/src/lib/doc-references/case-fix.d.ts +21 -0
- package/src/lib/doc-references/case-fix.js +66 -0
- package/src/lib/doc-references/check-doc-references.d.ts +28 -0
- package/src/lib/doc-references/check-doc-references.js +147 -0
- package/src/lib/doc-references/classify.d.ts +49 -0
- package/src/lib/doc-references/classify.js +59 -0
- package/src/lib/doc-references/discover.d.ts +6 -0
- package/src/lib/doc-references/discover.js +84 -0
- package/src/lib/doc-references/extract.d.ts +29 -0
- package/src/lib/doc-references/extract.js +46 -0
- package/src/lib/doc-references/finding.d.ts +24 -0
- package/src/lib/doc-references/finding.js +11 -0
- package/src/lib/doc-references/fs-errors.d.ts +14 -0
- package/src/lib/doc-references/fs-errors.js +25 -0
- package/src/lib/doc-references/headings.d.ts +79 -0
- package/src/lib/doc-references/headings.js +219 -0
- package/src/lib/doc-references/parse.d.ts +72 -0
- package/src/lib/doc-references/parse.js +72 -0
- package/src/lib/doc-references/placeholders.d.ts +74 -0
- package/src/lib/doc-references/placeholders.js +176 -0
- package/src/lib/doc-references/repo-root.d.ts +18 -0
- package/src/lib/doc-references/repo-root.js +36 -0
- package/src/lib/doc-references/resolver.d.ts +96 -0
- package/src/lib/doc-references/resolver.js +430 -0
- package/src/lib/doc-references/settings.d.ts +163 -0
- package/src/lib/doc-references/settings.js +167 -0
- package/src/lib/doc-references/suppressions.d.ts +8 -0
- package/src/lib/doc-references/suppressions.js +24 -0
- package/src/lib/format.d.ts +13 -0
- package/src/lib/format.js +33 -0
package/migrations.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trackunit/repo-checks",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"license": "SEE LICENSE IN LICENSE.txt",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=24.x"
|
|
7
|
+
},
|
|
8
|
+
"type": "commonjs",
|
|
9
|
+
"main": "./src/index.js",
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./src/index.d.ts",
|
|
14
|
+
"require": "./src/index.js",
|
|
15
|
+
"default": "./src/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"repo-checks": "./bin/index.js"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"github-slugger": "^2.0.0",
|
|
24
|
+
"mdast-util-from-markdown": "^2.0.2",
|
|
25
|
+
"mdast-util-frontmatter": "^2.0.1",
|
|
26
|
+
"mdast-util-gfm": "^3.1.0",
|
|
27
|
+
"mdast-util-mdx": "^3.0.0",
|
|
28
|
+
"micromark-extension-frontmatter": "^2.0.0",
|
|
29
|
+
"micromark-extension-gfm": "^3.0.0",
|
|
30
|
+
"micromark-extension-mdxjs": "^3.0.0",
|
|
31
|
+
"picomatch": "^4.0.4",
|
|
32
|
+
"tslib": "^2.6.2",
|
|
33
|
+
"zod": "^3.25.76"
|
|
34
|
+
},
|
|
35
|
+
"repository": "https://github.com/Trackunit/manager",
|
|
36
|
+
"migrations": "./migrations.json"
|
|
6
37
|
}
|
package/src/cli.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type OutputFormat } from "./lib/format";
|
|
2
|
+
type WritableStream = {
|
|
3
|
+
readonly write: (message: string) => void;
|
|
4
|
+
};
|
|
5
|
+
export type CliStreams = {
|
|
6
|
+
readonly stdout: WritableStream;
|
|
7
|
+
readonly stderr: WritableStream;
|
|
8
|
+
};
|
|
9
|
+
export type CliOptions = {
|
|
10
|
+
/** The directory a relative `--root`, and the default root, resolve from. Defaults to `process.cwd()`. */
|
|
11
|
+
readonly cwd?: string;
|
|
12
|
+
};
|
|
13
|
+
export type CliCommand = {
|
|
14
|
+
readonly kind: "help";
|
|
15
|
+
} | {
|
|
16
|
+
readonly kind: "usageError";
|
|
17
|
+
readonly message: string;
|
|
18
|
+
} | {
|
|
19
|
+
readonly kind: "doc-references";
|
|
20
|
+
readonly root: string | undefined;
|
|
21
|
+
readonly format: OutputFormat;
|
|
22
|
+
};
|
|
23
|
+
/** Parse the command line into the command to run, without touching the file system. */
|
|
24
|
+
export declare const parseCliArgs: (argv: ReadonlyArray<string>) => CliCommand;
|
|
25
|
+
/** Run the CLI and return its exit code; output goes to `streams`, never to `process` directly. */
|
|
26
|
+
export declare const runCli: (argv: ReadonlyArray<string>, streams: CliStreams, { cwd }?: CliOptions) => number;
|
|
27
|
+
export {};
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runCli = exports.parseCliArgs = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const util_1 = require("util");
|
|
8
|
+
const config_1 = require("./lib/config");
|
|
9
|
+
const check_doc_references_1 = require("./lib/doc-references/check-doc-references");
|
|
10
|
+
const fs_errors_1 = require("./lib/doc-references/fs-errors");
|
|
11
|
+
const repo_root_1 = require("./lib/doc-references/repo-root");
|
|
12
|
+
const format_1 = require("./lib/format");
|
|
13
|
+
/** Exit codes: findings (1) are kept apart from a tool, usage, config, or parse failure (2), so CI can tell them apart. */
|
|
14
|
+
const EXIT_CLEAN = 0;
|
|
15
|
+
const EXIT_FINDINGS = 1;
|
|
16
|
+
const EXIT_ERROR = 2;
|
|
17
|
+
const USAGE = `Usage: repo-checks doc-references [--root <dir>] [--format text|json]
|
|
18
|
+
|
|
19
|
+
Repository-wide checks for Trackunit repositories.
|
|
20
|
+
|
|
21
|
+
Commands:
|
|
22
|
+
doc-references
|
|
23
|
+
Report file references in .md and .mdx documents whose target or #anchor doesn't exist.
|
|
24
|
+
Exits 0 when clean, 1 with findings, 2 on a usage, config, document parse, or other tool error.
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
--root <dir> Directory to check. Defaults to the repository root of the current directory
|
|
28
|
+
(or the current directory outside a repository).
|
|
29
|
+
--format <format> text (default): one "file:line:col target reason[ did you mean <path>]"
|
|
30
|
+
line per finding. json: an array of findings.
|
|
31
|
+
--help, -h Show this help message.
|
|
32
|
+
`;
|
|
33
|
+
const isOutputFormat = (value) => format_1.OUTPUT_FORMATS.some(format => format === value);
|
|
34
|
+
const parseCliOptions = (argv) => (0, util_1.parseArgs)({
|
|
35
|
+
args: [...argv],
|
|
36
|
+
allowPositionals: true,
|
|
37
|
+
strict: true,
|
|
38
|
+
tokens: true,
|
|
39
|
+
options: {
|
|
40
|
+
root: { type: "string" },
|
|
41
|
+
format: { type: "string" },
|
|
42
|
+
help: { type: "boolean", short: "h" },
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
/** Parse the command line into the command to run, without touching the file system. */
|
|
46
|
+
const parseCliArgs = (argv) => {
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = parseCliOptions(argv);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
return { kind: "usageError", message: (0, fs_errors_1.errorMessage)(error) };
|
|
53
|
+
}
|
|
54
|
+
const { values, positionals, tokens } = parsed;
|
|
55
|
+
if (values.help)
|
|
56
|
+
return { kind: "help" };
|
|
57
|
+
const optionNames = tokens.flatMap(token => (token.kind === "option" ? [token.name] : []));
|
|
58
|
+
const repeated = optionNames.find((name, index) => optionNames.indexOf(name) !== index);
|
|
59
|
+
if (repeated !== undefined)
|
|
60
|
+
return { kind: "usageError", message: `--${repeated} given more than once` };
|
|
61
|
+
if (values.root === "")
|
|
62
|
+
return { kind: "usageError", message: "--root requires a directory" };
|
|
63
|
+
const [subcommand, ...extra] = positionals;
|
|
64
|
+
if (subcommand === undefined)
|
|
65
|
+
return { kind: "usageError", message: "Missing command" };
|
|
66
|
+
if (subcommand !== "doc-references")
|
|
67
|
+
return { kind: "usageError", message: `Unknown command "${subcommand}"` };
|
|
68
|
+
if (extra.length > 0)
|
|
69
|
+
return { kind: "usageError", message: `Unexpected argument "${extra.join(" ")}"` };
|
|
70
|
+
const format = values.format ?? "text";
|
|
71
|
+
if (!isOutputFormat(format)) {
|
|
72
|
+
return { kind: "usageError", message: `Unknown --format "${format}". Expected ${format_1.OUTPUT_FORMATS.join(" or ")}` };
|
|
73
|
+
}
|
|
74
|
+
return { kind: "doc-references", root: values.root, format };
|
|
75
|
+
};
|
|
76
|
+
exports.parseCliArgs = parseCliArgs;
|
|
77
|
+
/** Run the CLI and return its exit code; output goes to `streams`, never to `process` directly. */
|
|
78
|
+
const runCli = (argv, streams, { cwd } = {}) => {
|
|
79
|
+
const command = (0, exports.parseCliArgs)(argv);
|
|
80
|
+
if (command.kind === "help") {
|
|
81
|
+
streams.stdout.write(USAGE);
|
|
82
|
+
return EXIT_CLEAN;
|
|
83
|
+
}
|
|
84
|
+
if (command.kind === "usageError") {
|
|
85
|
+
streams.stderr.write(`Error: ${command.message}\n\n${USAGE}`);
|
|
86
|
+
return EXIT_ERROR;
|
|
87
|
+
}
|
|
88
|
+
const workingDir = path.resolve(cwd ?? process.cwd());
|
|
89
|
+
const rootDir = command.root === undefined
|
|
90
|
+
? ((0, repo_root_1.findRepoRootFromDir)(workingDir) ?? workingDir)
|
|
91
|
+
: path.resolve(workingDir, command.root);
|
|
92
|
+
try {
|
|
93
|
+
const rootStat = fs.statSync(rootDir, { throwIfNoEntry: false });
|
|
94
|
+
if (!rootStat?.isDirectory()) {
|
|
95
|
+
streams.stderr.write(`Error: --root ${rootDir} ${rootStat ? "is not a directory" : "does not exist"}\n`);
|
|
96
|
+
return EXIT_ERROR;
|
|
97
|
+
}
|
|
98
|
+
const { config } = (0, config_1.loadConfig)(rootDir);
|
|
99
|
+
const findings = (0, check_doc_references_1.checkDocReferences)({ rootDir, config: config.docReferences });
|
|
100
|
+
streams.stdout.write((0, format_1.formatFindings)(findings, command.format));
|
|
101
|
+
return findings.length === 0 ? EXIT_CLEAN : EXIT_FINDINGS;
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
streams.stderr.write((0, format_1.formatError)(error));
|
|
105
|
+
return EXIT_ERROR;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
exports.runCli = runCli;
|
|
109
|
+
//# sourceMappingURL=cli.js.map
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { CONFIG_FILE_NAME, ConfigError, loadConfig, type LoadedConfig, type RepoChecksConfig } from "./lib/config";
|
|
2
|
+
export { checkDocReferences, type CheckDocReferencesOptions } from "./lib/doc-references/check-doc-references";
|
|
3
|
+
export type { ReferenceConstruct } from "./lib/doc-references/extract";
|
|
4
|
+
export type { Finding, FindingReason } from "./lib/doc-references/finding";
|
|
5
|
+
export { ReadError } from "./lib/doc-references/fs-errors";
|
|
6
|
+
export { DocumentParseError } from "./lib/doc-references/parse";
|
|
7
|
+
export { DEFAULT_DOCS_SETTINGS, parseDocsSettings, type DocReferencesConfig, type DocsSettings, } from "./lib/doc-references/settings";
|
|
8
|
+
export { OUTPUT_FORMATS, formatFinding, formatFindings, type OutputFormat } from "./lib/format";
|
package/src/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatFindings = exports.formatFinding = exports.OUTPUT_FORMATS = exports.parseDocsSettings = exports.DEFAULT_DOCS_SETTINGS = exports.DocumentParseError = exports.ReadError = exports.checkDocReferences = exports.loadConfig = exports.ConfigError = exports.CONFIG_FILE_NAME = void 0;
|
|
4
|
+
var config_1 = require("./lib/config");
|
|
5
|
+
Object.defineProperty(exports, "CONFIG_FILE_NAME", { enumerable: true, get: function () { return config_1.CONFIG_FILE_NAME; } });
|
|
6
|
+
Object.defineProperty(exports, "ConfigError", { enumerable: true, get: function () { return config_1.ConfigError; } });
|
|
7
|
+
Object.defineProperty(exports, "loadConfig", { enumerable: true, get: function () { return config_1.loadConfig; } });
|
|
8
|
+
var check_doc_references_1 = require("./lib/doc-references/check-doc-references");
|
|
9
|
+
Object.defineProperty(exports, "checkDocReferences", { enumerable: true, get: function () { return check_doc_references_1.checkDocReferences; } });
|
|
10
|
+
var fs_errors_1 = require("./lib/doc-references/fs-errors");
|
|
11
|
+
Object.defineProperty(exports, "ReadError", { enumerable: true, get: function () { return fs_errors_1.ReadError; } });
|
|
12
|
+
var parse_1 = require("./lib/doc-references/parse");
|
|
13
|
+
Object.defineProperty(exports, "DocumentParseError", { enumerable: true, get: function () { return parse_1.DocumentParseError; } });
|
|
14
|
+
var settings_1 = require("./lib/doc-references/settings");
|
|
15
|
+
Object.defineProperty(exports, "DEFAULT_DOCS_SETTINGS", { enumerable: true, get: function () { return settings_1.DEFAULT_DOCS_SETTINGS; } });
|
|
16
|
+
Object.defineProperty(exports, "parseDocsSettings", { enumerable: true, get: function () { return settings_1.parseDocsSettings; } });
|
|
17
|
+
var format_1 = require("./lib/format");
|
|
18
|
+
Object.defineProperty(exports, "OUTPUT_FORMATS", { enumerable: true, get: function () { return format_1.OUTPUT_FORMATS; } });
|
|
19
|
+
Object.defineProperty(exports, "formatFinding", { enumerable: true, get: function () { return format_1.formatFinding; } });
|
|
20
|
+
Object.defineProperty(exports, "formatFindings", { enumerable: true, get: function () { return format_1.formatFindings; } });
|
|
21
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A configuration that can't be read or is invalid: the checks never ran, so it is distinct from
|
|
3
|
+
* findings and from a {@link DocumentParseError} about a checked document.
|
|
4
|
+
*/
|
|
5
|
+
export declare class ConfigError extends Error {
|
|
6
|
+
/** @param message What is wrong, naming the config file or the setting. */
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ConfigError = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* A configuration that can't be read or is invalid: the checks never ran, so it is distinct from
|
|
6
|
+
* findings and from a {@link DocumentParseError} about a checked document.
|
|
7
|
+
*/
|
|
8
|
+
class ConfigError extends Error {
|
|
9
|
+
/** @param message What is wrong, naming the config file or the setting. */
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "ConfigError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
exports.ConfigError = ConfigError;
|
|
16
|
+
//# sourceMappingURL=config-error.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type DocReferencesConfig } from "./doc-references/settings";
|
|
2
|
+
export { ConfigError } from "./config-error";
|
|
3
|
+
/** The config file, read from the repository root. */
|
|
4
|
+
export declare const CONFIG_FILE_NAME = "repo-checks.config.json";
|
|
5
|
+
/** A validated `repo-checks.config.json`; each section is passed as-is to its check, which resolves it onto the defaults. */
|
|
6
|
+
export interface RepoChecksConfig {
|
|
7
|
+
readonly docReferences?: DocReferencesConfig;
|
|
8
|
+
}
|
|
9
|
+
export interface LoadedConfig {
|
|
10
|
+
/** The absolute path of the file read, or `undefined` when there is none. */
|
|
11
|
+
readonly file: string | undefined;
|
|
12
|
+
readonly config: RepoChecksConfig;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Read and validate `repo-checks.config.json` at the root of the repository enclosing `rootDir`
|
|
16
|
+
* (the nearest `.git` at or above it, so a subdirectory or git worktree finds its own checkout's
|
|
17
|
+
* file), or in `rootDir` itself outside a repository. No file is an empty config: every default.
|
|
18
|
+
* A relative `docReferences.rootDir` is returned resolved against the file's directory.
|
|
19
|
+
*
|
|
20
|
+
* @throws {ConfigError} when the file can't be read, isn't valid JSON, or fails validation
|
|
21
|
+
*/
|
|
22
|
+
export declare const loadConfig: (rootDir: string) => LoadedConfig;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.loadConfig = exports.CONFIG_FILE_NAME = exports.ConfigError = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const config_error_1 = require("./config-error");
|
|
8
|
+
const fs_errors_1 = require("./doc-references/fs-errors");
|
|
9
|
+
const repo_root_1 = require("./doc-references/repo-root");
|
|
10
|
+
const settings_1 = require("./doc-references/settings");
|
|
11
|
+
var config_error_2 = require("./config-error");
|
|
12
|
+
Object.defineProperty(exports, "ConfigError", { enumerable: true, get: function () { return config_error_2.ConfigError; } });
|
|
13
|
+
/** The config file, read from the repository root. */
|
|
14
|
+
exports.CONFIG_FILE_NAME = "repo-checks.config.json";
|
|
15
|
+
const repoChecksConfigSchema = (0, settings_1.strictObjectNamingKeys)({ docReferences: settings_1.docReferencesConfigSchema.optional() }, { noun: "key", expected: "a JSON object" });
|
|
16
|
+
const readConfigFile = (file) => {
|
|
17
|
+
try {
|
|
18
|
+
return fs.readFileSync(file, "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if ((0, fs_errors_1.isNotFoundError)(error))
|
|
22
|
+
return undefined;
|
|
23
|
+
throw new config_error_1.ConfigError(`Cannot read ${file}: ${(0, fs_errors_1.errorMessage)(error)}`);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/** A relative `rootDir` is relative to the config file, so it means the same from any checked directory. */
|
|
27
|
+
const resolveRootDir = (config, file) => {
|
|
28
|
+
const rootDir = config.docReferences?.rootDir;
|
|
29
|
+
return rootDir === undefined
|
|
30
|
+
? config
|
|
31
|
+
: { ...config, docReferences: { ...config.docReferences, rootDir: path.resolve(path.dirname(file), rootDir) } };
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Read and validate `repo-checks.config.json` at the root of the repository enclosing `rootDir`
|
|
35
|
+
* (the nearest `.git` at or above it, so a subdirectory or git worktree finds its own checkout's
|
|
36
|
+
* file), or in `rootDir` itself outside a repository. No file is an empty config: every default.
|
|
37
|
+
* A relative `docReferences.rootDir` is returned resolved against the file's directory.
|
|
38
|
+
*
|
|
39
|
+
* @throws {ConfigError} when the file can't be read, isn't valid JSON, or fails validation
|
|
40
|
+
*/
|
|
41
|
+
const loadConfig = (rootDir) => {
|
|
42
|
+
const directory = path.resolve(rootDir);
|
|
43
|
+
const file = path.join((0, repo_root_1.findRepoRootFromDir)(directory) ?? directory, exports.CONFIG_FILE_NAME);
|
|
44
|
+
const source = readConfigFile(file);
|
|
45
|
+
if (source === undefined)
|
|
46
|
+
return { file: undefined, config: {} };
|
|
47
|
+
let json;
|
|
48
|
+
try {
|
|
49
|
+
json = JSON.parse(source.replace(/^\uFEFF/u, ""));
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
throw new config_error_1.ConfigError(`Invalid JSON in ${file}: ${(0, fs_errors_1.errorMessage)(error)}`);
|
|
53
|
+
}
|
|
54
|
+
const parsed = repoChecksConfigSchema.safeParse(json);
|
|
55
|
+
if (!parsed.success) {
|
|
56
|
+
throw new config_error_1.ConfigError(`Invalid config ${file}: ${(0, settings_1.formatConfigIssues)(parsed.error)}`);
|
|
57
|
+
}
|
|
58
|
+
return { file, config: resolveRootDir(parsed.data, file) };
|
|
59
|
+
};
|
|
60
|
+
exports.loadConfig = loadConfig;
|
|
61
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ReferenceKind } from "./resolver";
|
|
2
|
+
export interface CaseFixInput {
|
|
3
|
+
/** The reference as written, including any `?query` (URLs) and `#fragment`. */
|
|
4
|
+
readonly reference: string;
|
|
5
|
+
readonly referenceKind: ReferenceKind;
|
|
6
|
+
/** The directory the reference resolved against (`caseMismatch.base`). */
|
|
7
|
+
readonly base: string;
|
|
8
|
+
/** The absolute path the resolver derived from the reference (`caseMismatch.path`). */
|
|
9
|
+
readonly resolvedPath: string;
|
|
10
|
+
/** The on-disk path it matched case-insensitively (`caseMismatch.actualPath`). */
|
|
11
|
+
readonly actualPath: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Rewrite `reference` to the on-disk casing, changing only the path segments whose case differs.
|
|
15
|
+
* Everything else — `./` and `../` prefixes, a leading `/`, a URL's query, the fragment, and untouched segments
|
|
16
|
+
* (including their percent-encoding) — is kept exactly as written.
|
|
17
|
+
*
|
|
18
|
+
* Returns `undefined` when no safe rewrite exists: a segment that needs recasing is percent-encoded
|
|
19
|
+
* (re-encoding it could change more than case), or the segments don't line up with `actualPath`.
|
|
20
|
+
*/
|
|
21
|
+
export declare const correctReferenceCase: ({ reference, referenceKind, base, resolvedPath, actualPath, }: CaseFixInput) => string | undefined;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.correctReferenceCase = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const path = tslib_1.__importStar(require("path"));
|
|
6
|
+
const resolver_1 = require("./resolver");
|
|
7
|
+
const decodeSegment = (segment, referenceKind) => {
|
|
8
|
+
if (referenceKind === "inline")
|
|
9
|
+
return segment;
|
|
10
|
+
try {
|
|
11
|
+
return decodeURIComponent(segment);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return segment;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const isAncestorOrSelf = (ancestor, descendant) => descendant === ancestor || descendant.startsWith(ancestor + path.sep);
|
|
18
|
+
/**
|
|
19
|
+
* Rewrite `reference` to the on-disk casing, changing only the path segments whose case differs.
|
|
20
|
+
* Everything else — `./` and `../` prefixes, a leading `/`, a URL's query, the fragment, and untouched segments
|
|
21
|
+
* (including their percent-encoding) — is kept exactly as written.
|
|
22
|
+
*
|
|
23
|
+
* Returns `undefined` when no safe rewrite exists: a segment that needs recasing is percent-encoded
|
|
24
|
+
* (re-encoding it could change more than case), or the segments don't line up with `actualPath`.
|
|
25
|
+
*/
|
|
26
|
+
const correctReferenceCase = ({ reference, referenceKind, base, resolvedPath, actualPath, }) => {
|
|
27
|
+
const { pathPart, suffix } = (0, resolver_1.splitReference)(reference, referenceKind);
|
|
28
|
+
const rawSegments = pathPart.split("/");
|
|
29
|
+
const decodedSegments = rawSegments.map(segment => decodeSegment(segment, referenceKind));
|
|
30
|
+
const positions = [];
|
|
31
|
+
let position = base;
|
|
32
|
+
for (const segment of decodedSegments) {
|
|
33
|
+
position = path.resolve(position, segment);
|
|
34
|
+
positions.push(position);
|
|
35
|
+
}
|
|
36
|
+
const actualSegments = actualPath.split(path.sep);
|
|
37
|
+
if (position !== resolvedPath || actualSegments.length !== resolvedPath.split(path.sep).length) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
let changed = false;
|
|
41
|
+
const fixedSegments = [];
|
|
42
|
+
for (const [index, raw] of rawSegments.entries()) {
|
|
43
|
+
const decoded = decodedSegments[index] ?? raw;
|
|
44
|
+
const segmentPosition = positions[index] ?? base;
|
|
45
|
+
if (decoded === "" || decoded === "." || decoded === ".." || !isAncestorOrSelf(segmentPosition, resolvedPath)) {
|
|
46
|
+
fixedSegments.push(raw);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const actual = actualSegments[segmentPosition.split(path.sep).length - 1];
|
|
50
|
+
if (actual === undefined || actual.toLowerCase() !== decoded.toLowerCase()) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
if (actual === decoded) {
|
|
54
|
+
fixedSegments.push(raw);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (referenceKind === "url" && raw.includes("%")) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
fixedSegments.push(actual);
|
|
61
|
+
changed = true;
|
|
62
|
+
}
|
|
63
|
+
return changed ? fixedSegments.join("/") + suffix : undefined;
|
|
64
|
+
};
|
|
65
|
+
exports.correctReferenceCase = correctReferenceCase;
|
|
66
|
+
//# sourceMappingURL=case-fix.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Finding } from "./finding";
|
|
2
|
+
import { type DocReferencesConfig } from "./settings";
|
|
3
|
+
export interface CheckDocReferencesOptions {
|
|
4
|
+
/** The directory whose documents are checked: a repository, a git worktree, or a subdirectory of one. */
|
|
5
|
+
readonly rootDir: string;
|
|
6
|
+
/**
|
|
7
|
+
* The `docReferences` section of a config, as `loadConfig` returns it or written inline, validated
|
|
8
|
+
* with `parseDocsSettings`; the defaults when omitted.
|
|
9
|
+
*/
|
|
10
|
+
readonly config?: DocReferencesConfig;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Check every `.md` and `.mdx` document under `rootDir`, except those the config excludes, for
|
|
14
|
+
* references whose targets don't exist, or exist only under different letter case (decided
|
|
15
|
+
* case-exactly on every filesystem), and for `#fragment`s that name no heading or anchor in the
|
|
16
|
+
* `.md` or `.mdx` document they point into, or in their own document when they have no path; never
|
|
17
|
+
* against an `.mdx` document whose MDX may render anchors parsing can't see (see
|
|
18
|
+
* `knownDocumentAnchors`). Each reference gives at most one finding.
|
|
19
|
+
*
|
|
20
|
+
* Relative references resolve from the document's directory and a leading `/` from the repository
|
|
21
|
+
* root (the nearest `.git` at or above `rootDir`, or the config's `rootDir`). Findings are sorted by
|
|
22
|
+
* file, line, and column, with `file` relative to the repository root.
|
|
23
|
+
*
|
|
24
|
+
* @throws {ConfigError} when the config is invalid, before any document is read
|
|
25
|
+
* @throws {DocumentParseError} when a document can't be parsed
|
|
26
|
+
* @throws {ReadError} when a document or directory can't be read
|
|
27
|
+
*/
|
|
28
|
+
export declare const checkDocReferences: ({ rootDir, config }: CheckDocReferencesOptions) => Array<Finding>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkDocReferences = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const case_fix_1 = require("./case-fix");
|
|
8
|
+
const classify_1 = require("./classify");
|
|
9
|
+
const discover_1 = require("./discover");
|
|
10
|
+
const extract_1 = require("./extract");
|
|
11
|
+
const finding_1 = require("./finding");
|
|
12
|
+
const fs_errors_1 = require("./fs-errors");
|
|
13
|
+
const headings_1 = require("./headings");
|
|
14
|
+
const parse_1 = require("./parse");
|
|
15
|
+
const placeholders_1 = require("./placeholders");
|
|
16
|
+
const repo_root_1 = require("./repo-root");
|
|
17
|
+
const resolver_1 = require("./resolver");
|
|
18
|
+
const settings_1 = require("./settings");
|
|
19
|
+
const toPosix = (relativePath) => relativePath.split(path.sep).join("/");
|
|
20
|
+
const readDocument = (absolutePath, file) => {
|
|
21
|
+
try {
|
|
22
|
+
return fs.readFileSync(absolutePath, "utf8");
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
throw new fs_errors_1.ReadError(`Cannot read ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The reference rewritten to the on-disk casing, or `undefined` when there's no single safe rewrite:
|
|
30
|
+
* several siblings match case-insensitively, the reference has placeholders (so `actualPath` is one
|
|
31
|
+
* instance of it), or a segment needing recasing is percent-encoded.
|
|
32
|
+
*/
|
|
33
|
+
const caseSuggestion = (reference, referenceKind, resolution) => resolution.ambiguous || resolution.placeholder
|
|
34
|
+
? undefined
|
|
35
|
+
: (0, case_fix_1.correctReferenceCase)({
|
|
36
|
+
reference,
|
|
37
|
+
referenceKind,
|
|
38
|
+
base: resolution.base,
|
|
39
|
+
resolvedPath: resolution.path,
|
|
40
|
+
actualPath: resolution.actualPath,
|
|
41
|
+
});
|
|
42
|
+
const ANCHOR_CHECKED_EXTENSIONS = new Set([".md", ".mdx"]);
|
|
43
|
+
/** Whether a fragment, percent-decoded, is written in a placeholder convention (`#<name>`, `#{name}`, `#:name`). */
|
|
44
|
+
const isPlaceholderFragment = (fragment, placeholders) => {
|
|
45
|
+
let decoded = fragment;
|
|
46
|
+
try {
|
|
47
|
+
decoded = decodeURIComponent(fragment);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// checked as written
|
|
51
|
+
}
|
|
52
|
+
return (0, placeholders_1.compileReference)(decoded, placeholders) !== undefined;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Whether a reference that resolved exactly has a `#fragment` naming no heading or anchor in its
|
|
56
|
+
* `.md` or `.mdx` target. A placeholder reference's match is one arbitrary instance, a placeholder
|
|
57
|
+
* fragment names no particular anchor, and a target that can't be read or parsed, is too large, or
|
|
58
|
+
* has MDX that may render anchors parsing can't see (Storybook's `<Title />`, imports,
|
|
59
|
+
* `{expressions}`), has no known anchors, so none of these is ever reported.
|
|
60
|
+
*/
|
|
61
|
+
const isMissingAnchor = (reference, resolution, placeholders) => {
|
|
62
|
+
const fragment = (0, resolver_1.referenceFragment)(reference);
|
|
63
|
+
const extension = path.extname(resolution.path).toLowerCase();
|
|
64
|
+
if (fragment === "" || resolution.placeholder || !ANCHOR_CHECKED_EXTENSIONS.has(extension))
|
|
65
|
+
return false;
|
|
66
|
+
if (isPlaceholderFragment(fragment, placeholders))
|
|
67
|
+
return false;
|
|
68
|
+
const anchors = (0, headings_1.readDocumentAnchors)(resolution.path);
|
|
69
|
+
return anchors !== undefined && !(0, headings_1.hasAnchor)(anchors, fragment);
|
|
70
|
+
};
|
|
71
|
+
const checkDocument = (absolutePath, repoRoot, settings) => {
|
|
72
|
+
const file = toPosix(path.relative(repoRoot, absolutePath));
|
|
73
|
+
const tree = (0, parse_1.parseMarkdown)(readDocument(absolutePath, file), file);
|
|
74
|
+
const documentDir = path.dirname(absolutePath);
|
|
75
|
+
let own;
|
|
76
|
+
return (0, extract_1.extractReferences)(tree, settings).flatMap(({ kind, target, line, column }) => {
|
|
77
|
+
const referenceKind = (0, extract_1.referenceKindOf)(kind);
|
|
78
|
+
// A fragment-only URL points into this document; inline code like `#channel` isn't a link.
|
|
79
|
+
if (referenceKind === "url" && target.startsWith("#")) {
|
|
80
|
+
const fragment = target.slice(1);
|
|
81
|
+
if (fragment === "" || isPlaceholderFragment(fragment, settings.placeholders))
|
|
82
|
+
return [];
|
|
83
|
+
own ?? (own = { anchors: (0, headings_1.knownDocumentAnchors)(tree, file) });
|
|
84
|
+
return own.anchors === undefined || (0, headings_1.hasAnchor)(own.anchors, fragment)
|
|
85
|
+
? []
|
|
86
|
+
: [{ file, line, column, target, kind, reason: "missingAnchor" }];
|
|
87
|
+
}
|
|
88
|
+
if (!(0, classify_1.classifyReference)(target, referenceKind, settings).check)
|
|
89
|
+
return [];
|
|
90
|
+
const resolution = (0, resolver_1.resolveReference)({
|
|
91
|
+
reference: target,
|
|
92
|
+
referenceKind,
|
|
93
|
+
documentDir,
|
|
94
|
+
repoRoot,
|
|
95
|
+
ignore: settings.ignore,
|
|
96
|
+
placeholders: settings.placeholders,
|
|
97
|
+
});
|
|
98
|
+
if (resolution.kind === "missing")
|
|
99
|
+
return [{ file, line, column, target, kind, reason: "missing" }];
|
|
100
|
+
if (resolution.kind === "caseMismatch") {
|
|
101
|
+
const suggestion = caseSuggestion(target, referenceKind, resolution);
|
|
102
|
+
return [
|
|
103
|
+
{
|
|
104
|
+
file,
|
|
105
|
+
line,
|
|
106
|
+
column,
|
|
107
|
+
target,
|
|
108
|
+
kind,
|
|
109
|
+
reason: "caseMismatch",
|
|
110
|
+
...(suggestion === undefined ? {} : { suggestion }),
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
if (resolution.kind === "exact" && isMissingAnchor(target, resolution, settings.placeholders)) {
|
|
115
|
+
return [{ file, line, column, target, kind, reason: "missingAnchor" }];
|
|
116
|
+
}
|
|
117
|
+
return [];
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Check every `.md` and `.mdx` document under `rootDir`, except those the config excludes, for
|
|
122
|
+
* references whose targets don't exist, or exist only under different letter case (decided
|
|
123
|
+
* case-exactly on every filesystem), and for `#fragment`s that name no heading or anchor in the
|
|
124
|
+
* `.md` or `.mdx` document they point into, or in their own document when they have no path; never
|
|
125
|
+
* against an `.mdx` document whose MDX may render anchors parsing can't see (see
|
|
126
|
+
* `knownDocumentAnchors`). Each reference gives at most one finding.
|
|
127
|
+
*
|
|
128
|
+
* Relative references resolve from the document's directory and a leading `/` from the repository
|
|
129
|
+
* root (the nearest `.git` at or above `rootDir`, or the config's `rootDir`). Findings are sorted by
|
|
130
|
+
* file, line, and column, with `file` relative to the repository root.
|
|
131
|
+
*
|
|
132
|
+
* @throws {ConfigError} when the config is invalid, before any document is read
|
|
133
|
+
* @throws {DocumentParseError} when a document can't be parsed
|
|
134
|
+
* @throws {ReadError} when a document or directory can't be read
|
|
135
|
+
*/
|
|
136
|
+
const checkDocReferences = ({ rootDir, config }) => {
|
|
137
|
+
const settings = (0, settings_1.parseDocsSettings)(config);
|
|
138
|
+
const checkedDir = (0, resolver_1.canonicalCase)(path.resolve(rootDir));
|
|
139
|
+
const repoRoot = (0, resolver_1.canonicalCase)((0, repo_root_1.findRepoRootFromDir)(checkedDir, { rootDir: settings.rootDir, cwd: checkedDir }) ?? checkedDir);
|
|
140
|
+
return (0, discover_1.discoverDocuments)(checkedDir)
|
|
141
|
+
.map(resolver_1.canonicalCase)
|
|
142
|
+
.filter(document => !(0, resolver_1.isIgnoredPath)(path.relative(repoRoot, document), settings.exclude))
|
|
143
|
+
.flatMap(document => checkDocument(document, repoRoot, settings))
|
|
144
|
+
.sort(finding_1.compareFindings);
|
|
145
|
+
};
|
|
146
|
+
exports.checkDocReferences = checkDocReferences;
|
|
147
|
+
//# sourceMappingURL=check-doc-references.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type ReferenceKind } from "./resolver";
|
|
2
|
+
/**
|
|
3
|
+
* Decide whether a markdown reference looks like a repository path worth resolving.
|
|
4
|
+
*
|
|
5
|
+
* The non-configurable exclusions are categorically not repo-relative paths: URLs with a scheme,
|
|
6
|
+
* container/registry image references, home-directory paths, globs, shell variables, multi-word
|
|
7
|
+
* command spans, Storybook `?path=` links, and bare `#anchor`s. The configurable `ignore` list is applied by the resolver,
|
|
8
|
+
* because globs match the repo-relative path, which depends on where the reference resolves.
|
|
9
|
+
*
|
|
10
|
+
* Beyond those, the shape of the target decides. The target is the resolver's `referenceTarget`: a
|
|
11
|
+
* URL's `?query` and `#fragment` are stripped and it is percent-decoded (`guide%2Emd` has an
|
|
12
|
+
* extension), while inline code only loses a `#fragment`.
|
|
13
|
+
* - a `/` and a file extension (`docs/guide.md`): always checked;
|
|
14
|
+
* - a `/` and no extension (`libs/react/components/`, `docs/guides`): checked with `directories`;
|
|
15
|
+
* - no `/` and a file extension, in a link, image, or definition URL (`[x](README.md)`): always
|
|
16
|
+
* checked, since a URL with no `/` and no scheme is unambiguously a path relative to the
|
|
17
|
+
* document;
|
|
18
|
+
* - no `/` and a file extension, in inline code (`` `README.md` ``, `` `package.json` ``):
|
|
19
|
+
* checked with `bareFilenames`, since most inline-code spans with no `/` are package names,
|
|
20
|
+
* versions, or commands rather than paths (see `bareFilenames`'s doc comment in `settings.ts`);
|
|
21
|
+
* - no `/` and no extension, either way (`` `README` ``, `[x](README)`): checked only when both
|
|
22
|
+
* `bareFilenames` and `directories` are on.
|
|
23
|
+
* An extension is a final `.` and 1-8 letters or digits, so a bare `v1.2` or `console.log` counts
|
|
24
|
+
* as a filename too.
|
|
25
|
+
* The resolver then expects a file for an extension, a directory for a trailing `/`, and either
|
|
26
|
+
* otherwise (including a stemless dot-name such as `.github`, which is still checked here).
|
|
27
|
+
*
|
|
28
|
+
* Placeholder references (`docs/<name>.md`, `path/to/file.ts`) are passed on: the resolver matches
|
|
29
|
+
* them as wildcards, or skips illustrative `path/to/` ones, under the configured conventions. The file-path test is applied to the target, not
|
|
30
|
+
* to what its placeholders expand to, so a placeholder counts as having an extension only when a literal `.ext` ends it
|
|
31
|
+
* (`<name>.md` does, `<file>` and `file.<ext>` don't).
|
|
32
|
+
*/
|
|
33
|
+
export type SkipReason = "empty" | "anchorOnly" | "multiWord" | "registryImage" | "urlScheme" | "homePath" | "shellVariable" | "glob" | "storybookPath" | "notAFilePath";
|
|
34
|
+
export type Classification = {
|
|
35
|
+
readonly check: true;
|
|
36
|
+
} | {
|
|
37
|
+
readonly check: false;
|
|
38
|
+
readonly reason: SkipReason;
|
|
39
|
+
};
|
|
40
|
+
/** The doc-references settings that widen which reference shapes are checked. */
|
|
41
|
+
export interface ClassifyOptions {
|
|
42
|
+
readonly directories: boolean;
|
|
43
|
+
readonly bareFilenames: boolean;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether a reference is checked as a repo path, or the reason it is skipped: empty, anchor-only,
|
|
47
|
+
* URL-, shell- or glob-shaped, a registry image, a Storybook path, or not shaped like a file path.
|
|
48
|
+
*/
|
|
49
|
+
export declare const classifyReference: (reference: string | undefined, referenceKind: ReferenceKind, options?: ClassifyOptions) => Classification;
|