@recallnet/docs-governance-policy 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 +33 -0
- package/src/index.js +196 -0
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@recallnet/docs-governance-policy",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared policy loader and path resolution helpers for docs governance remark plugins.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/recallnet/remark.git",
|
|
10
|
+
"directory": "packages/docs-governance-policy"
|
|
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-plugin",
|
|
19
|
+
"docs",
|
|
20
|
+
"governance",
|
|
21
|
+
"frontmatter"
|
|
22
|
+
],
|
|
23
|
+
"exports": "./src/index.js",
|
|
24
|
+
"files": [
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20.0.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const DOCS_POLICY_KEY = "docs_policy/v1";
|
|
5
|
+
export const DEFAULT_POLICY_PATH = "docs/docs-policy.json";
|
|
6
|
+
|
|
7
|
+
export function normalizePath(pathValue) {
|
|
8
|
+
return String(pathValue).replaceAll("\\", "/").replace(/^\.\//, "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// @context decision !high [verified:2026-03-25] — Keep policy loading deliberately thin in the shared package.
|
|
12
|
+
// Schema-heavy validation can layer on later, but the portable core should just load stable repo policy semantics.
|
|
13
|
+
export function loadDocsPolicy(options = {}) {
|
|
14
|
+
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
|
|
15
|
+
const policyPath = options.policyPath ?? DEFAULT_POLICY_PATH;
|
|
16
|
+
const absolutePolicyPath = resolve(cwd, policyPath);
|
|
17
|
+
const document = JSON.parse(readFileSync(absolutePolicyPath, "utf8"));
|
|
18
|
+
const policy = document?.[DOCS_POLICY_KEY];
|
|
19
|
+
|
|
20
|
+
if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
|
|
21
|
+
throw new Error(`Expected '${DOCS_POLICY_KEY}' object in ${policyPath}.`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
cwd,
|
|
26
|
+
policyPath: absolutePolicyPath,
|
|
27
|
+
policy,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function wildcardPatternToRegex(pattern) {
|
|
32
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
33
|
+
return new RegExp(`^${escaped}$`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function matchesDocsPolicyPattern(pathValue, pattern) {
|
|
37
|
+
if (pattern === "*") {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (pattern.endsWith("/**")) {
|
|
42
|
+
const prefix = pattern.slice(0, -3);
|
|
43
|
+
return pathValue === prefix || pathValue.startsWith(`${prefix}/`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (pattern.includes("*")) {
|
|
47
|
+
return wildcardPatternToRegex(pattern).test(pathValue);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return pathValue === pattern;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isDocsPathInScope(pathValue, policy) {
|
|
54
|
+
const normalizedPath = normalizePath(pathValue);
|
|
55
|
+
const inScopePaths = Array.isArray(policy?.in_scope_paths) ? policy.in_scope_paths : [];
|
|
56
|
+
return inScopePaths.some((pattern) => matchesDocsPolicyPattern(normalizedPath, pattern));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isMarkdownPath(pathValue) {
|
|
60
|
+
return normalizePath(pathValue).toLowerCase().endsWith(".md");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// @context decision:tradeoff !high [verified:2026-03-25] — Path defaults use longest-match wins.
|
|
64
|
+
// That preserves the old docs policy behavior without forcing every consumer to hand-order special cases.
|
|
65
|
+
export function resolveDocsReviewPolicy(pathValue, policy) {
|
|
66
|
+
const freshness = policy?.freshness;
|
|
67
|
+
if (!freshness || !Array.isArray(freshness.path_defaults)) {
|
|
68
|
+
return typeof freshness?.default_review_policy === "string"
|
|
69
|
+
? freshness.default_review_policy
|
|
70
|
+
: null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let bestMatch = null;
|
|
74
|
+
for (const entry of freshness.path_defaults) {
|
|
75
|
+
if (!entry || typeof entry.path !== "string" || typeof entry.review_policy !== "string") {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!matchesDocsPolicyPattern(pathValue, entry.path)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!bestMatch || entry.path.length > bestMatch.path.length) {
|
|
84
|
+
bestMatch = entry;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return bestMatch?.review_policy ?? freshness.default_review_policy ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function resolveReviewPolicyConfig(reviewPolicyId, policy) {
|
|
92
|
+
const reviewPolicies = Array.isArray(policy?.freshness?.review_policies)
|
|
93
|
+
? policy.freshness.review_policies
|
|
94
|
+
: [];
|
|
95
|
+
|
|
96
|
+
return reviewPolicies.find((entry) => entry?.id === reviewPolicyId) ?? null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// @context requirement !high [verified:2026-03-25] — Repo scans exclude `.git` and `node_modules`.
|
|
100
|
+
// Governance checks need deterministic source files, not VCS internals or dependency trees.
|
|
101
|
+
export function listRepositoryFiles(cwd) {
|
|
102
|
+
const files = [];
|
|
103
|
+
const queue = [""];
|
|
104
|
+
|
|
105
|
+
while (queue.length > 0) {
|
|
106
|
+
const relDir = queue.shift();
|
|
107
|
+
const absoluteDir = resolve(cwd, relDir);
|
|
108
|
+
const entries = readdirSync(absoluteDir, { withFileTypes: true })
|
|
109
|
+
.map((entry) => entry.name)
|
|
110
|
+
.sort((left, right) => left.localeCompare(right, "en"));
|
|
111
|
+
|
|
112
|
+
for (const name of entries) {
|
|
113
|
+
if (name === ".git" || name === "node_modules") {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const childRel = relDir ? `${relDir}/${name}` : name;
|
|
118
|
+
const childAbs = resolve(cwd, childRel);
|
|
119
|
+
const entryStat = statSync(childAbs);
|
|
120
|
+
|
|
121
|
+
if (entryStat.isDirectory()) {
|
|
122
|
+
queue.push(childRel);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (entryStat.isFile()) {
|
|
127
|
+
files.push(normalizePath(childRel));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return files;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function collectInScopeMarkdownFiles(cwd, policy) {
|
|
136
|
+
const frontmatterExcludeGlobs = Array.isArray(policy?.frontmatter_exclude_globs)
|
|
137
|
+
? policy.frontmatter_exclude_globs
|
|
138
|
+
: [];
|
|
139
|
+
|
|
140
|
+
return listRepositoryFiles(cwd)
|
|
141
|
+
.filter(isMarkdownPath)
|
|
142
|
+
.filter((pathValue) => isDocsPathInScope(pathValue, policy))
|
|
143
|
+
.filter(
|
|
144
|
+
(pathValue) =>
|
|
145
|
+
!frontmatterExcludeGlobs.some((pattern) => matchesDocsPolicyPattern(pathValue, pattern))
|
|
146
|
+
)
|
|
147
|
+
.sort((left, right) => left.localeCompare(right, "en"));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function computeReachabilityRoots(policy, inScopeMarkdownSet) {
|
|
151
|
+
const fromRequiredDocTypes = Array.isArray(policy?.required_doc_types)
|
|
152
|
+
? policy.required_doc_types.map((entry) => normalizePath(entry?.path ?? ""))
|
|
153
|
+
: [];
|
|
154
|
+
const fromRootAllowlist = Array.isArray(policy?.root_docs_allowlist)
|
|
155
|
+
? policy.root_docs_allowlist.map(normalizePath)
|
|
156
|
+
: [];
|
|
157
|
+
|
|
158
|
+
return [...new Set([...fromRequiredDocTypes, ...fromRootAllowlist])]
|
|
159
|
+
.filter(isMarkdownPath)
|
|
160
|
+
.filter((pathValue) => inScopeMarkdownSet.has(pathValue))
|
|
161
|
+
.sort((left, right) => left.localeCompare(right, "en"));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function matchesGlob(pathValue, pattern) {
|
|
165
|
+
const regexStr = pattern
|
|
166
|
+
.replace(/\./g, "\\.")
|
|
167
|
+
.replace(/\*\*/g, "\0")
|
|
168
|
+
.replace(/\*/g, "[^/]*")
|
|
169
|
+
.replace(/\0/g, ".*");
|
|
170
|
+
return new RegExp(`^${regexStr}$`).test(pathValue);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// @context history !high [verified:2026-03-25] — Orphan allowlisting mirrors the legacy checker.
|
|
174
|
+
// Root exceptions, migration debt, and orphan-exclude globs stay combined so reachability can roll out incrementally.
|
|
175
|
+
export function computeOrphanAllowlist(policy, inScopeMarkdownSet) {
|
|
176
|
+
const fromRootLevelExceptions = Array.isArray(policy?.root_level_exceptions)
|
|
177
|
+
? policy.root_level_exceptions.map(normalizePath)
|
|
178
|
+
: [];
|
|
179
|
+
const fromLegacyAllowlist = Array.isArray(policy?.migration_debt?.legacy_in_scope_allowlist)
|
|
180
|
+
? policy.migration_debt.legacy_in_scope_allowlist.map(normalizePath)
|
|
181
|
+
: [];
|
|
182
|
+
const orphanExcludeGlobs = Array.isArray(policy?.orphan_exclude_globs)
|
|
183
|
+
? policy.orphan_exclude_globs
|
|
184
|
+
: [];
|
|
185
|
+
const fromGlobExcludes =
|
|
186
|
+
orphanExcludeGlobs.length > 0
|
|
187
|
+
? [...inScopeMarkdownSet].filter((pathValue) =>
|
|
188
|
+
orphanExcludeGlobs.some((pattern) => matchesGlob(pathValue, pattern))
|
|
189
|
+
)
|
|
190
|
+
: [];
|
|
191
|
+
|
|
192
|
+
return [...new Set([...fromRootLevelExceptions, ...fromLegacyAllowlist, ...fromGlobExcludes])]
|
|
193
|
+
.filter(isMarkdownPath)
|
|
194
|
+
.filter((pathValue) => inScopeMarkdownSet.has(pathValue))
|
|
195
|
+
.sort((left, right) => left.localeCompare(right, "en"));
|
|
196
|
+
}
|