@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.
Files changed (3) hide show
  1. package/README.md +8 -0
  2. package/package.json +33 -0
  3. package/src/index.js +196 -0
package/README.md ADDED
@@ -0,0 +1,8 @@
1
+ # @recallnet/docs-governance-policy
2
+
3
+ Shared helpers for docs governance rules:
4
+
5
+ - load `docs/docs-policy.json`
6
+ - match policy globs
7
+ - resolve review-policy defaults
8
+ - compute reachability roots and orphan allowlists
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
+ }