dotmd-cli 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/src/util.mjs ADDED
@@ -0,0 +1,55 @@
1
+ import path from 'node:path';
2
+
3
+ export function escapeTable(value) {
4
+ return String(value).replace(/\|/g, '\\|');
5
+ }
6
+
7
+ export function asString(value) {
8
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
9
+ }
10
+
11
+ export function capitalize(value) {
12
+ return value.charAt(0).toUpperCase() + value.slice(1);
13
+ }
14
+
15
+ export function toSlug(plan) {
16
+ return path.basename(plan.path, '.md');
17
+ }
18
+
19
+ export function truncate(text, max) {
20
+ if (text.length <= max) return text;
21
+ return text.slice(0, max - 3) + '...';
22
+ }
23
+
24
+ export function normalizeStringList(value) {
25
+ if (Array.isArray(value)) {
26
+ return value.map(item => String(item).trim()).filter(Boolean);
27
+ }
28
+ if (typeof value === 'string' && value.trim()) {
29
+ return [value.trim()];
30
+ }
31
+ return [];
32
+ }
33
+
34
+ export function normalizeBlockers(blockers) {
35
+ if (Array.isArray(blockers)) {
36
+ return blockers.map(item => String(item));
37
+ }
38
+ if (typeof blockers === 'string' && blockers.trim()) {
39
+ return [blockers.trim()];
40
+ }
41
+ return [];
42
+ }
43
+
44
+ export function mergeUniqueStrings(...lists) {
45
+ return [...new Set(lists.flat().filter(Boolean))];
46
+ }
47
+
48
+ export function toRepoPath(absolutePath, repoRoot) {
49
+ return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
50
+ }
51
+
52
+ export function die(message) {
53
+ process.stderr.write(`${message}\n`);
54
+ process.exitCode = 1;
55
+ }
@@ -0,0 +1,157 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { asString } from './util.mjs';
4
+ import { getGitLastModified } from './git.mjs';
5
+ import { toRepoPath } from './util.mjs';
6
+
7
+ const NOW = new Date();
8
+
9
+ export function validateDoc(doc, frontmatter, headingTitle, config) {
10
+ if (!doc.status) {
11
+ doc.errors.push({ path: doc.path, level: 'error', message: 'Missing frontmatter `status`.' });
12
+ } else if (!config.validStatuses.has(doc.status)) {
13
+ doc.errors.push({ path: doc.path, level: 'error', message: `Invalid status \`${doc.status}\`.` });
14
+ }
15
+
16
+ if (!config.lifecycle.skipWarningsFor.has(doc.status) && !doc.updated) {
17
+ doc.errors.push({ path: doc.path, level: 'error', message: 'Missing frontmatter `updated` for non-archived doc.' });
18
+ }
19
+
20
+ if (doc.auditLevel && doc.auditLevel !== 'none' && !doc.audited) {
21
+ doc.errors.push({ path: doc.path, level: 'error', message: '`audit_level` is set without `audited`.' });
22
+ }
23
+
24
+ if (doc.auditLevel === 'none' && doc.audited) {
25
+ doc.errors.push({ path: doc.path, level: 'error', message: '`audit_level: none` cannot be combined with `audited`.' });
26
+ }
27
+
28
+ if (Object.prototype.hasOwnProperty.call(frontmatter, 'blockers') && !Array.isArray(frontmatter.blockers)) {
29
+ doc.errors.push({ path: doc.path, level: 'error', message: '`blockers` must be a YAML list when present.' });
30
+ }
31
+
32
+ if (Object.prototype.hasOwnProperty.call(frontmatter, 'surfaces') && !Array.isArray(frontmatter.surfaces)) {
33
+ doc.errors.push({ path: doc.path, level: 'error', message: '`surfaces` must be a YAML list when present.' });
34
+ }
35
+
36
+ if (Object.prototype.hasOwnProperty.call(frontmatter, 'modules') && !Array.isArray(frontmatter.modules)) {
37
+ doc.errors.push({ path: doc.path, level: 'error', message: '`modules` must be a YAML list when present.' });
38
+ }
39
+
40
+ if (config.moduleRequiredStatuses.has(doc.status) && !doc.module) {
41
+ doc.errors.push({ path: doc.path, level: 'error', message: '`module` is required for active/ready/planned/blocked docs; use a real module, `platform`, or `none`.' });
42
+ }
43
+
44
+ if (config.validSurfaces) {
45
+ for (const surface of doc.surfaces) {
46
+ if (!config.validSurfaces.has(surface)) {
47
+ doc.warnings.push({ path: doc.path, level: 'warning', message: `Unknown surface \`${surface}\`; expected a known surface taxonomy value.` });
48
+ }
49
+ }
50
+ }
51
+
52
+ if (!headingTitle && !asString(frontmatter.title)) {
53
+ doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `title` and no H1 found for fallback.' });
54
+ }
55
+
56
+ if (!config.lifecycle.skipWarningsFor.has(doc.status) && !asString(frontmatter.summary) && !doc.summary) {
57
+ doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `summary` and no blockquote fallback found.' });
58
+ }
59
+
60
+ if (['active', 'ready', 'planned', 'blocked'].includes(doc.status) && !asString(frontmatter.current_state)) {
61
+ doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `current_state`; index output is using a fallback or placeholder.' });
62
+ }
63
+
64
+ if (['active', 'ready', 'planned'].includes(doc.status) && !asString(frontmatter.next_step)) {
65
+ doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `next_step`; command output will omit a clear immediate action.' });
66
+ }
67
+
68
+ // Validate reference fields resolve to existing files
69
+ const docDir = path.dirname(path.join(config.repoRoot, doc.path));
70
+ const allRefFields = [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])];
71
+ for (const field of allRefFields) {
72
+ for (const relPath of (doc.refFields[field] || [])) {
73
+ const resolved = path.resolve(docDir, relPath);
74
+ if (!existsSync(resolved)) {
75
+ doc.errors.push({ path: doc.path, level: 'error', message: `${field} entry \`${relPath}\` does not resolve to an existing file.` });
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ export function checkBidirectionalReferences(docs, config) {
82
+ const warnings = [];
83
+ const biFields = config.referenceFields.bidirectional || [];
84
+ if (!biFields.length) return { warnings, errors: [] };
85
+
86
+ const refMap = new Map();
87
+ for (const doc of docs) {
88
+ const docDir = path.dirname(path.join(config.repoRoot, doc.path));
89
+ const refs = new Set();
90
+ for (const field of biFields) {
91
+ for (const relPath of (doc.refFields[field] || [])) {
92
+ const resolved = path.resolve(docDir, relPath);
93
+ refs.add(toRepoPath(resolved, config.repoRoot));
94
+ }
95
+ }
96
+ refMap.set(doc.path, refs);
97
+ }
98
+
99
+ for (const [docPath, refs] of refMap) {
100
+ for (const targetPath of refs) {
101
+ const targetRefs = refMap.get(targetPath);
102
+ if (targetRefs && !targetRefs.has(docPath)) {
103
+ warnings.push({ path: docPath, level: 'warning',
104
+ message: `references \`${targetPath}\` in ${biFields.join('/')}, but that doc does not reference back.` });
105
+ }
106
+ }
107
+ }
108
+
109
+ return { warnings, errors: [] };
110
+ }
111
+
112
+ export function checkGitStaleness(docs, config) {
113
+ const warnings = [];
114
+ for (const doc of docs) {
115
+ if (config.lifecycle.skipStaleFor.has(doc.status)) continue;
116
+ if (!doc.updated) continue;
117
+
118
+ const gitDate = getGitLastModified(doc.path, config.repoRoot);
119
+ if (!gitDate) continue;
120
+
121
+ const gitDay = Math.floor(new Date(gitDate).getTime() / 86400000);
122
+ const fmDay = Math.floor(new Date(doc.updated).getTime() / 86400000);
123
+
124
+ if (gitDay > fmDay) {
125
+ warnings.push({
126
+ path: doc.path,
127
+ level: 'warning',
128
+ message: `frontmatter \`updated: ${doc.updated}\` is behind git history (last committed ${gitDate.slice(0, 10)}).`,
129
+ });
130
+ }
131
+ }
132
+ return warnings;
133
+ }
134
+
135
+ export function computeDaysSinceUpdate(updated) {
136
+ if (!updated) return null;
137
+ const parsed = new Date(updated);
138
+ if (Number.isNaN(parsed.getTime())) return null;
139
+
140
+ const diffMs = NOW.getTime() - parsed.getTime();
141
+ if (diffMs < 0) return 0;
142
+ return Math.floor(diffMs / (1000 * 60 * 60 * 24));
143
+ }
144
+
145
+ export function computeIsStale(status, updated, config) {
146
+ const staleAfterDays = config.staleDaysByStatus[status] ?? null;
147
+ if (staleAfterDays == null) return false;
148
+
149
+ const daysSinceUpdate = computeDaysSinceUpdate(updated);
150
+ if (daysSinceUpdate == null) return false;
151
+ return daysSinceUpdate > staleAfterDays;
152
+ }
153
+
154
+ export function computeChecklistCompletionRate(checklist) {
155
+ if (!checklist.total) return null;
156
+ return Number((checklist.completed / checklist.total).toFixed(4));
157
+ }