openclew 0.2.0 → 0.3.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/lib/search.js ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * openclew search <query> — search docs by keyword.
3
+ *
4
+ * Searches metadata line (category, keywords, type, status) and
5
+ * L1 fields (subject, doc_brief) across all refdocs and logs.
6
+ * Returns results sorted by relevance score.
7
+ *
8
+ * Zero dependencies — Node 16+ standard library only.
9
+ */
10
+
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+
14
+ // ── Parsers (JS port of generate-index.py) ─────────────────────────
15
+
16
+ function parseMetadataLine(content) {
17
+ const meta = {};
18
+ const firstLine = content.split("\n", 1)[0].trim();
19
+ if (!firstLine.startsWith("openclew@")) return meta;
20
+
21
+ const parts = firstLine.split(" · ");
22
+ for (const part of parts) {
23
+ const trimmed = part.trim();
24
+ if (trimmed.startsWith("openclew@")) {
25
+ meta.version = trimmed.split("@")[1];
26
+ continue;
27
+ }
28
+ const colonIdx = trimmed.indexOf(":");
29
+ if (colonIdx > 0) {
30
+ const key = trimmed.slice(0, colonIdx).trim().toLowerCase();
31
+ const value = trimmed.slice(colonIdx + 1).trim();
32
+ meta[key] = value;
33
+ }
34
+ }
35
+ return meta;
36
+ }
37
+
38
+ function parseL1(content) {
39
+ const meta = {};
40
+ const match = content.match(
41
+ /<!--\s*L1_START\s*-->([\s\S]+?)<!--\s*L1_END\s*-->/
42
+ );
43
+ if (!match) return meta;
44
+
45
+ const block = match[1];
46
+ const subjectMatch = block.match(/\*\*subject:\*\*\s*(.+)/);
47
+ if (subjectMatch) meta.subject = subjectMatch[1].trim();
48
+
49
+ const briefMatch = block.match(/\*\*doc_brief:\*\*\s*(.+)/);
50
+ if (briefMatch) meta.doc_brief = briefMatch[1].trim();
51
+
52
+ return meta;
53
+ }
54
+
55
+ function parseL1Legacy(content) {
56
+ const meta = {};
57
+ const match = content.match(
58
+ /<!--\s*L1_START\s*-->([\s\S]+?)<!--\s*L1_END\s*-->/
59
+ );
60
+ if (!match) return meta;
61
+
62
+ for (const line of match[1].split("\n")) {
63
+ const trimmed = line.trim();
64
+ if (!trimmed || trimmed.startsWith("#")) continue;
65
+ const colonIdx = trimmed.indexOf(":");
66
+ if (colonIdx > 0) {
67
+ const key = trimmed.slice(0, colonIdx).trim().toLowerCase();
68
+ const value = trimmed.slice(colonIdx + 1).trim();
69
+ meta[key] = value;
70
+ }
71
+ }
72
+ return meta;
73
+ }
74
+
75
+ function parseFile(filepath) {
76
+ let content;
77
+ try {
78
+ content = fs.readFileSync(filepath, "utf-8");
79
+ } catch {
80
+ return null;
81
+ }
82
+
83
+ const metaLine = parseMetadataLine(content);
84
+ const l1 = parseL1(content);
85
+
86
+ if (l1.subject) {
87
+ return { ...metaLine, ...l1 };
88
+ }
89
+
90
+ const legacy = parseL1Legacy(content);
91
+ if (Object.keys(legacy).length) {
92
+ return { ...metaLine, ...legacy };
93
+ }
94
+
95
+ return null;
96
+ }
97
+
98
+ // ── Collector ───────────────────────────────────────────────────────
99
+
100
+ function collectDocs(docDir) {
101
+ const docs = [];
102
+
103
+ // Refdocs: doc/_*.md
104
+ if (fs.existsSync(docDir)) {
105
+ for (const entry of fs.readdirSync(docDir).sort()) {
106
+ if (entry.startsWith("_") && entry.endsWith(".md") && entry !== "_INDEX.md") {
107
+ const filepath = path.join(docDir, entry);
108
+ const meta = parseFile(filepath);
109
+ if (meta) docs.push({ filepath, filename: entry, kind: "refdoc", meta });
110
+ }
111
+ }
112
+ }
113
+
114
+ // Logs: doc/log/*.md
115
+ const logDir = path.join(docDir, "log");
116
+ if (fs.existsSync(logDir)) {
117
+ for (const entry of fs.readdirSync(logDir).sort().reverse()) {
118
+ if (entry.endsWith(".md")) {
119
+ const filepath = path.join(logDir, entry);
120
+ const meta = parseFile(filepath);
121
+ if (meta) docs.push({ filepath, filename: entry, kind: "log", meta });
122
+ }
123
+ }
124
+ }
125
+
126
+ return docs;
127
+ }
128
+
129
+ // ── Search engine ───────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Score a document against query terms.
133
+ * Higher score = more relevant.
134
+ *
135
+ * Weights: subject (3), doc_brief (2), keywords (2), category (1.5), type (1), status (0.5)
136
+ */
137
+ function scoreDoc(doc, queryTerms) {
138
+ const fields = [
139
+ { value: doc.meta.subject || "", weight: 3 },
140
+ { value: doc.meta.doc_brief || "", weight: 2 },
141
+ { value: doc.meta.keywords || "", weight: 2 },
142
+ { value: doc.meta.category || "", weight: 1.5 },
143
+ { value: doc.meta.type || "", weight: 1 },
144
+ { value: doc.meta.status || "", weight: 0.5 },
145
+ ];
146
+
147
+ let score = 0;
148
+ for (const term of queryTerms) {
149
+ const termLower = term.toLowerCase();
150
+ for (const field of fields) {
151
+ const valueLower = field.value.toLowerCase();
152
+ if (valueLower.includes(termLower)) {
153
+ score += field.weight;
154
+ // Bonus for exact word match (not substring)
155
+ if (new RegExp(`\\b${termLower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(field.value)) {
156
+ score += field.weight * 0.5;
157
+ }
158
+ }
159
+ }
160
+ }
161
+ return score;
162
+ }
163
+
164
+ /**
165
+ * Search docs matching query. Returns sorted results.
166
+ *
167
+ * @param {string} docDir - Path to doc/ directory
168
+ * @param {string} query - Search query (space-separated terms, AND logic)
169
+ * @returns {Array<{filepath, filename, kind, meta, score}>}
170
+ */
171
+ function searchDocs(docDir, query) {
172
+ const docs = collectDocs(docDir);
173
+ const queryTerms = query.trim().split(/\s+/).filter(Boolean);
174
+ if (!queryTerms.length) return [];
175
+
176
+ const results = [];
177
+ for (const doc of docs) {
178
+ const score = scoreDoc(doc, queryTerms);
179
+ if (score > 0) {
180
+ results.push({ ...doc, score });
181
+ }
182
+ }
183
+
184
+ results.sort((a, b) => b.score - a.score);
185
+ return results;
186
+ }
187
+
188
+ // ── CLI runner ──────────────────────────────────────────────────────
189
+
190
+ function run() {
191
+ const args = process.argv.slice(2);
192
+ const cmdIndex = args.indexOf("search");
193
+ const queryArgs = cmdIndex >= 0 ? args.slice(cmdIndex + 1) : args.slice(1);
194
+ const query = queryArgs.join(" ");
195
+
196
+ if (!query) {
197
+ console.error('Usage: openclew search <query>');
198
+ console.error('');
199
+ console.error('Examples:');
200
+ console.error(' openclew search auth # find docs about authentication');
201
+ console.error(' openclew search "API design" # multi-word search');
202
+ process.exit(1);
203
+ }
204
+
205
+ const projectRoot = process.cwd();
206
+ const docDir = path.join(projectRoot, "doc");
207
+ if (!fs.existsSync(docDir)) {
208
+ console.error("No doc/ directory found. Run 'openclew init' first.");
209
+ process.exit(1);
210
+ }
211
+
212
+ const results = searchDocs(docDir, query);
213
+
214
+ if (!results.length) {
215
+ console.log(`No docs matching "${query}".`);
216
+ process.exit(0);
217
+ }
218
+
219
+ console.log(`Found ${results.length} doc${results.length > 1 ? "s" : ""} matching "${query}":\n`);
220
+
221
+ for (const r of results) {
222
+ const relPath = path.relative(projectRoot, r.filepath);
223
+ const icon = r.kind === "refdoc" ? "📘" : "📝";
224
+ const subject = r.meta.subject || r.filename;
225
+ const brief = r.meta.doc_brief || "";
226
+ const status = r.meta.status ? ` [${r.meta.status}]` : "";
227
+
228
+ console.log(`${icon} ${subject}${status}`);
229
+ console.log(` ${relPath}`);
230
+ if (brief) console.log(` ${brief}`);
231
+ console.log("");
232
+ }
233
+ }
234
+
235
+ // Export for MCP server + tests
236
+ module.exports = { searchDocs, collectDocs, parseFile, parseMetadataLine, parseL1, parseL1Legacy };
237
+
238
+ // Run as CLI (invoked via dispatcher or directly)
239
+ const calledAsSearch = process.argv.includes("search");
240
+ if (require.main === module || calledAsSearch) {
241
+ run();
242
+ }
package/lib/status.js ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * openclew status — documentation health dashboard.
3
+ *
4
+ * Shows stats, docs missing doc_brief, stale docs, and distribution.
5
+ * Zero dependencies — Node 16+ standard library only.
6
+ */
7
+
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const { collectDocs, parseFile } = require("./search");
11
+ const { parseLegacyDoc } = require("./migrate");
12
+
13
+ function run() {
14
+ const projectRoot = process.cwd();
15
+ const docDir = path.join(projectRoot, "doc");
16
+
17
+ if (!fs.existsSync(docDir)) {
18
+ console.error("No doc/ directory found. Run 'openclew init' first.");
19
+ process.exit(1);
20
+ }
21
+
22
+ const docs = collectDocs(docDir);
23
+ const refdocs = docs.filter((d) => d.kind === "refdoc");
24
+ const logs = docs.filter((d) => d.kind === "log");
25
+
26
+ // ── Overview ──────────────────────────────────────────────────
27
+ console.log("openclew status\n");
28
+ console.log(` Refdocs: ${refdocs.length}`);
29
+ console.log(` Logs: ${logs.length}`);
30
+ console.log(` Total: ${docs.length}`);
31
+ console.log("");
32
+
33
+ // ── Legacy format detection ──────────────────────────────────
34
+ let legacyCount = 0;
35
+ for (const d of docs) {
36
+ try {
37
+ const content = fs.readFileSync(d.filepath, "utf-8");
38
+ const parsed = parseLegacyDoc(content);
39
+ if (parsed.isLegacy) legacyCount++;
40
+ } catch {}
41
+ }
42
+ if (legacyCount > 0) {
43
+ console.log(`Legacy format: ${legacyCount} docs need migration`);
44
+ console.log(` Run 'openclew migrate' to preview, 'openclew migrate --write' to apply.\n`);
45
+ }
46
+
47
+ // ── Missing doc_brief ─────────────────────────────────────────
48
+ const missingBrief = docs.filter(
49
+ (d) => !d.meta.doc_brief || d.meta.doc_brief === ""
50
+ );
51
+ if (missingBrief.length) {
52
+ console.log(`Missing doc_brief (${missingBrief.length}):`);
53
+ for (const d of missingBrief) {
54
+ const relPath = path.relative(projectRoot, d.filepath);
55
+ const subject = d.meta.subject || d.filename;
56
+ console.log(` - ${relPath} (${subject})`);
57
+ }
58
+ console.log("");
59
+ }
60
+
61
+ // ── Missing subject ───────────────────────────────────────────
62
+ const missingSubject = docs.filter(
63
+ (d) => !d.meta.subject || d.meta.subject === ""
64
+ );
65
+ if (missingSubject.length) {
66
+ console.log(`Missing subject (${missingSubject.length}):`);
67
+ for (const d of missingSubject) {
68
+ const relPath = path.relative(projectRoot, d.filepath);
69
+ console.log(` - ${relPath}`);
70
+ }
71
+ console.log("");
72
+ }
73
+
74
+ // ── Stale refdocs (updated > 30 days ago) ─────────────────────
75
+ const now = new Date();
76
+ const staleThresholdMs = 30 * 24 * 60 * 60 * 1000;
77
+ const staleRefdocs = refdocs.filter((d) => {
78
+ const updated = d.meta.updated || d.meta.created;
79
+ if (!updated) return true; // No date = stale
80
+ const docDate = new Date(updated);
81
+ return !isNaN(docDate.getTime()) && now - docDate > staleThresholdMs;
82
+ });
83
+ if (staleRefdocs.length) {
84
+ console.log(`Stale refdocs (not updated in 30+ days): ${staleRefdocs.length}`);
85
+ for (const d of staleRefdocs) {
86
+ const relPath = path.relative(projectRoot, d.filepath);
87
+ const updated = d.meta.updated || d.meta.created || "no date";
88
+ const subject = d.meta.subject || d.filename;
89
+ console.log(` - ${relPath} (${subject}, last: ${updated})`);
90
+ }
91
+ console.log("");
92
+ }
93
+
94
+ // ── Status distribution ───────────────────────────────────────
95
+ const statusCounts = {};
96
+ for (const d of docs) {
97
+ const st = d.meta.status || "—";
98
+ statusCounts[st] = (statusCounts[st] || 0) + 1;
99
+ }
100
+ const statusEntries = Object.entries(statusCounts).sort((a, b) => b[1] - a[1]);
101
+ if (statusEntries.length) {
102
+ console.log("Status distribution:");
103
+ for (const [status, count] of statusEntries) {
104
+ console.log(` ${status}: ${count}`);
105
+ }
106
+ console.log("");
107
+ }
108
+
109
+ // ── Category distribution ─────────────────────────────────────
110
+ const catCounts = {};
111
+ for (const d of docs) {
112
+ const cat = d.meta.category || "—";
113
+ if (cat && cat !== "—") catCounts[cat] = (catCounts[cat] || 0) + 1;
114
+ }
115
+ const catEntries = Object.entries(catCounts).sort((a, b) => b[1] - a[1]);
116
+ if (catEntries.length) {
117
+ console.log("Category distribution:");
118
+ for (const [cat, count] of catEntries) {
119
+ console.log(` ${cat}: ${count}`);
120
+ }
121
+ console.log("");
122
+ }
123
+
124
+ // ── Health score ──────────────────────────────────────────────
125
+ const total = docs.length;
126
+ if (total === 0) {
127
+ console.log("Health: no docs yet. Run 'openclew new' to create one.");
128
+ return;
129
+ }
130
+
131
+ const withBrief = total - missingBrief.length;
132
+ const withSubject = total - missingSubject.length;
133
+ const freshRefdocs = refdocs.length - staleRefdocs.length;
134
+ const healthPct = Math.round(
135
+ ((withBrief + withSubject + freshRefdocs) /
136
+ (total + total + Math.max(refdocs.length, 1))) *
137
+ 100
138
+ );
139
+
140
+ console.log(`Health: ${healthPct}%`);
141
+ if (healthPct === 100) {
142
+ console.log(" All docs have subject + doc_brief, no stale refdocs.");
143
+ }
144
+ }
145
+
146
+ module.exports = { run };
147
+
148
+ const calledAsStatus = process.argv.includes("status");
149
+ if (require.main === module || calledAsStatus) {
150
+ run();
151
+ }