@recallnet/remark-lint-docs-reachability 0.1.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 +8 -0
- package/package.json +38 -0
- package/src/index.js +189 -0
package/README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# @recallnet/remark-lint-docs-reachability
|
|
2
|
+
|
|
3
|
+
Flags in-scope Markdown documents that are not reachable from policy-defined roots.
|
|
4
|
+
|
|
5
|
+
This package intentionally complements:
|
|
6
|
+
|
|
7
|
+
- `remark-validate-links` for link validity
|
|
8
|
+
- `remark-lint-frontmatter-schema` for frontmatter schema checks
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@recallnet/remark-lint-docs-reachability",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Remark plugin that flags markdown docs not reachable from policy roots.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/recallnet/remark.git",
|
|
10
|
+
"directory": "packages/remark-lint-docs-reachability"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/recallnet/remark",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/recallnet/remark/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"remark",
|
|
18
|
+
"remark-lint",
|
|
19
|
+
"docs",
|
|
20
|
+
"reachability",
|
|
21
|
+
"orphans"
|
|
22
|
+
],
|
|
23
|
+
"exports": "./src/index.js",
|
|
24
|
+
"files": [
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@recallnet/docs-governance-policy": "workspace:*",
|
|
29
|
+
"remark": "^15.0.1",
|
|
30
|
+
"remark-frontmatter": "^5.0.0"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20.0.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, relative, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_POLICY_PATH,
|
|
6
|
+
collectInScopeMarkdownFiles,
|
|
7
|
+
computeOrphanAllowlist,
|
|
8
|
+
computeReachabilityRoots,
|
|
9
|
+
isMarkdownPath,
|
|
10
|
+
loadDocsPolicy,
|
|
11
|
+
normalizePath,
|
|
12
|
+
} from "@recallnet/docs-governance-policy";
|
|
13
|
+
import { remark } from "remark";
|
|
14
|
+
import remarkFrontmatter from "remark-frontmatter";
|
|
15
|
+
|
|
16
|
+
const RULE_ID = "docs-reachability";
|
|
17
|
+
|
|
18
|
+
function isExternalDestination(destination) {
|
|
19
|
+
if (!destination) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (destination.startsWith("//")) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return /^[a-zA-Z][a-zA-Z\\d+.-]*:/.test(destination);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function splitDestination(target) {
|
|
31
|
+
const hashIndex = target.indexOf("#");
|
|
32
|
+
const beforeHash = hashIndex >= 0 ? target.slice(0, hashIndex) : target;
|
|
33
|
+
const queryIndex = beforeHash.indexOf("?");
|
|
34
|
+
return {
|
|
35
|
+
pathRaw: queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// @context decision !high [verified:2026-03-25] — Links that escape the repo are excluded from reachability.
|
|
40
|
+
// Orphan detection is only about repo-owned docs; external link correctness belongs to `remark-validate-links`.
|
|
41
|
+
function resolveTargetPath({ repoRoot, sourcePath, pathRaw }) {
|
|
42
|
+
if (!pathRaw) {
|
|
43
|
+
return sourcePath;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const sourceAbsolute = resolve(repoRoot, sourcePath);
|
|
47
|
+
const targetAbsolute = pathRaw.startsWith("/")
|
|
48
|
+
? resolve(repoRoot, `.${pathRaw}`)
|
|
49
|
+
: resolve(dirname(sourceAbsolute), pathRaw);
|
|
50
|
+
const relPath = normalizePath(relative(repoRoot, targetAbsolute));
|
|
51
|
+
|
|
52
|
+
if (!relPath || relPath === ".") {
|
|
53
|
+
return sourcePath;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (relPath.startsWith("../") || relPath === "..") {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return relPath;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function collectLinks(tree) {
|
|
64
|
+
const links = [];
|
|
65
|
+
|
|
66
|
+
function visit(node) {
|
|
67
|
+
if (!node || typeof node !== "object") {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (node.type === "link" && typeof node.url === "string") {
|
|
72
|
+
links.push(node.url);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (Array.isArray(node.children)) {
|
|
76
|
+
for (const child of node.children) {
|
|
77
|
+
visit(child);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
visit(tree);
|
|
83
|
+
return links;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function computeReachableDocuments(roots, edges) {
|
|
87
|
+
const adjacency = new Map();
|
|
88
|
+
for (const edge of edges) {
|
|
89
|
+
const list = adjacency.get(edge.source) ?? [];
|
|
90
|
+
list.push(edge.target);
|
|
91
|
+
adjacency.set(edge.source, list);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const reachable = new Set();
|
|
95
|
+
const queue = [...roots];
|
|
96
|
+
|
|
97
|
+
while (queue.length > 0) {
|
|
98
|
+
const current = queue.shift();
|
|
99
|
+
if (reachable.has(current)) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
reachable.add(current);
|
|
104
|
+
|
|
105
|
+
for (const target of adjacency.get(current) ?? []) {
|
|
106
|
+
if (!reachable.has(target)) {
|
|
107
|
+
queue.push(target);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return reachable;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// @context decision:tradeoff !high [verified:2026-03-25] — The graph is rebuilt from disk instead of shared remark batch state.
|
|
116
|
+
// That keeps orphan detection deterministic for CI, editor-on-save linting, and one-file invocations.
|
|
117
|
+
export function buildReachabilityReport(options = {}) {
|
|
118
|
+
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
|
|
119
|
+
const policyPath = options.policyPath ?? DEFAULT_POLICY_PATH;
|
|
120
|
+
const { policy } = loadDocsPolicy({ cwd, policyPath });
|
|
121
|
+
const documents = collectInScopeMarkdownFiles(cwd, policy);
|
|
122
|
+
const inScopeMarkdownSet = new Set(documents);
|
|
123
|
+
const parser = remark().use(remarkFrontmatter);
|
|
124
|
+
const edges = [];
|
|
125
|
+
|
|
126
|
+
for (const sourcePath of documents) {
|
|
127
|
+
const tree = parser.parse(readFileSync(resolve(cwd, sourcePath), "utf8"));
|
|
128
|
+
for (const destination of collectLinks(tree)) {
|
|
129
|
+
if (isExternalDestination(destination)) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const { pathRaw } = splitDestination(destination);
|
|
134
|
+
const resolvedTargetPath = resolveTargetPath({
|
|
135
|
+
repoRoot: cwd,
|
|
136
|
+
sourcePath,
|
|
137
|
+
pathRaw,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (!resolvedTargetPath || !isMarkdownPath(resolvedTargetPath)) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!existsSync(resolve(cwd, resolvedTargetPath))) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (inScopeMarkdownSet.has(resolvedTargetPath)) {
|
|
149
|
+
edges.push({ source: sourcePath, target: resolvedTargetPath });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const roots = computeReachabilityRoots(policy, inScopeMarkdownSet);
|
|
155
|
+
const allowlist = computeOrphanAllowlist(policy, inScopeMarkdownSet);
|
|
156
|
+
const reachable = computeReachableDocuments(roots, edges);
|
|
157
|
+
const rootSet = new Set(roots);
|
|
158
|
+
const allowlistSet = new Set(allowlist);
|
|
159
|
+
const orphanDocuments = documents.filter(
|
|
160
|
+
(docPath) => !reachable.has(docPath) && !rootSet.has(docPath) && !allowlistSet.has(docPath)
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
documents,
|
|
165
|
+
edges,
|
|
166
|
+
roots,
|
|
167
|
+
allowlist,
|
|
168
|
+
orphanDocuments,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export default function remarkLintDocsReachability(options = {}) {
|
|
173
|
+
return (_tree, file) => {
|
|
174
|
+
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
|
|
175
|
+
const filePath = file.path ? normalizePath(relative(cwd, file.path)) : "";
|
|
176
|
+
|
|
177
|
+
if (!filePath) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const report = buildReachabilityReport({ cwd, policyPath: options.policyPath });
|
|
182
|
+
if (report.orphanDocuments.includes(filePath)) {
|
|
183
|
+
file.message(`Document is not reachable from policy roots: ${filePath}`, {
|
|
184
|
+
ruleId: RULE_ID,
|
|
185
|
+
source: "@recallnet/remark-lint-docs-reachability",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|