@techninja/clearstack 0.4.3 → 0.4.5
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 +14 -3
- package/bin/cli.js +8 -3
- package/docs/I18N.md +372 -111
- package/lib/check.js +54 -21
- package/lib/server-proc.js +150 -0
- package/lib/spec-config.js +48 -4
- package/lib/spec-i18n-locales.js +86 -0
- package/lib/spec-i18n.js +60 -62
- package/lib/spec-scan.js +101 -0
- package/lib/spec-utils.js +12 -107
- package/lib/watch-lifecycle.js +47 -0
- package/lib/watch-ui.js +148 -0
- package/lib/watch-widgets.js +82 -0
- package/lib/watch.js +131 -0
- package/package.json +7 -2
- package/templates/shared/docs/clearstack/I18N.md +372 -111
package/lib/spec-scan.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File scanning utilities — find, count, check lines, check imports.
|
|
3
|
+
* @module lib/spec-scan
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
7
|
+
import { resolve, extname, relative } from 'node:path';
|
|
8
|
+
import { elapsed } from './spec-utils.js';
|
|
9
|
+
|
|
10
|
+
/** @typedef {import('./spec-utils.js').CheckResult} CheckResult */
|
|
11
|
+
/** @typedef {{ file: string, lines: number, max: number }} LineViolation */
|
|
12
|
+
/** @typedef {{ file: string, spec: string }} ImportViolation */
|
|
13
|
+
|
|
14
|
+
/** Recursively find files matching extensions, skipping ignored dirs. */
|
|
15
|
+
export function findFiles(dir, extensions, ignoreDirs, root = dir) {
|
|
16
|
+
const results = [];
|
|
17
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
18
|
+
const full = resolve(dir, entry.name);
|
|
19
|
+
const rel = relative(root, full);
|
|
20
|
+
if (entry.isDirectory()) {
|
|
21
|
+
if (ignoreDirs.some((ig) => entry.name === ig || rel === ig || rel.startsWith(ig + '/'))) continue;
|
|
22
|
+
results.push(...findFiles(full, extensions, ignoreDirs, root));
|
|
23
|
+
} else if (extensions.includes(extname(entry.name))) {
|
|
24
|
+
results.push(rel);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return results;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} root
|
|
32
|
+
* @param {string[]} extensions
|
|
33
|
+
* @param {string[]} ignoreDirs
|
|
34
|
+
* @returns {number}
|
|
35
|
+
*/
|
|
36
|
+
export function countFiles(root, extensions, ignoreDirs) {
|
|
37
|
+
return findFiles(root, extensions, ignoreDirs, root).length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} root
|
|
42
|
+
* @param {string[]} extensions
|
|
43
|
+
* @param {number} max
|
|
44
|
+
* @param {string[]} ignoreDirs
|
|
45
|
+
* @param {string} label
|
|
46
|
+
* @param {{ exclude?: string, include?: string, localeMax?: number, quiet?: boolean }} [filter]
|
|
47
|
+
* @returns {CheckResult & { violations?: LineViolation[] }}
|
|
48
|
+
*/
|
|
49
|
+
export function checkFileLines(root, extensions, max, ignoreDirs, label, filter) {
|
|
50
|
+
const start = performance.now();
|
|
51
|
+
let files = findFiles(root, extensions, ignoreDirs, root);
|
|
52
|
+
if (filter?.exclude) files = files.filter((f) => !f.endsWith(filter.exclude));
|
|
53
|
+
if (filter?.include) files = files.filter((f) => f.endsWith(filter.include));
|
|
54
|
+
const localeMax = filter?.localeMax || max * 5;
|
|
55
|
+
const violations = [];
|
|
56
|
+
for (const file of files) {
|
|
57
|
+
const lines = readFileSync(resolve(root, file), 'utf-8').trimEnd().split('\n').length;
|
|
58
|
+
const limit = file.includes('/locales/') ? localeMax : max;
|
|
59
|
+
if (lines > limit) violations.push({ file, lines, max: limit });
|
|
60
|
+
}
|
|
61
|
+
const time = elapsed(start);
|
|
62
|
+
if (violations.length === 0) {
|
|
63
|
+
if (!filter?.quiet) console.log(` ✅ ${label} (${files.length} files, ${time})`);
|
|
64
|
+
return { pass: true, label, time, files: files.length };
|
|
65
|
+
}
|
|
66
|
+
if (!filter?.quiet) {
|
|
67
|
+
console.log(` ❌ ${label} — ${violations.length} violation(s):`);
|
|
68
|
+
for (const v of violations) console.log(` ${v.file}: ${v.lines} lines (max ${v.max})`);
|
|
69
|
+
}
|
|
70
|
+
return { pass: false, label, time, files: files.length, violations };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} root
|
|
75
|
+
* @param {string[]} ignoreDirs
|
|
76
|
+
* @param {string} label
|
|
77
|
+
* @param {{ quiet?: boolean }} [opts]
|
|
78
|
+
* @returns {CheckResult & { violations?: ImportViolation[] }}
|
|
79
|
+
*/
|
|
80
|
+
export function checkImports(root, ignoreDirs, label, opts) {
|
|
81
|
+
const start = performance.now();
|
|
82
|
+
const files = findFiles(root, ['.js'], ignoreDirs, root)
|
|
83
|
+
.filter((f) => f.startsWith('src/') && !f.includes('vendor/') && !f.includes('api/') && !f.endsWith('.test.js') && !f.endsWith('server.js'));
|
|
84
|
+
const violations = [];
|
|
85
|
+
const importRe = /(?:^|\n)\s*import\s.*?from\s+['"](\.\.[^'"]*)['"]/g;
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
const src = readFileSync(resolve(root, file), 'utf-8');
|
|
88
|
+
let m;
|
|
89
|
+
while ((m = importRe.exec(src)) !== null) violations.push({ file, spec: m[1] });
|
|
90
|
+
}
|
|
91
|
+
const time = elapsed(start);
|
|
92
|
+
if (violations.length === 0) {
|
|
93
|
+
if (!opts?.quiet) console.log(` ✅ ${label} (${files.length} files, ${time})`);
|
|
94
|
+
return { pass: true, label, time, files: files.length };
|
|
95
|
+
}
|
|
96
|
+
if (!opts?.quiet) {
|
|
97
|
+
console.log(` ❌ ${label} — ${violations.length} violation(s):`);
|
|
98
|
+
for (const v of violations) console.log(` ${v.file}: import '${v.spec}' → use #prefix/ alias`);
|
|
99
|
+
}
|
|
100
|
+
return { pass: false, label, time, files: files.length, violations };
|
|
101
|
+
}
|
package/lib/spec-utils.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared spec utilities —
|
|
3
|
-
*
|
|
2
|
+
* Shared spec utilities — timing and command running.
|
|
3
|
+
* File scanning lives in spec-scan.js.
|
|
4
4
|
* @module lib/spec-utils
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { readFileSync, readdirSync } from 'node:fs';
|
|
8
|
-
import { resolve, extname, relative } from 'node:path';
|
|
9
7
|
import { execSync } from 'node:child_process';
|
|
10
8
|
|
|
11
9
|
/** @param {number} start */
|
|
@@ -14,104 +12,7 @@ export function elapsed(start) {
|
|
|
14
12
|
return d < 1000 ? `${Math.round(d)}ms` : `${(d / 1000).toFixed(1)}s`;
|
|
15
13
|
}
|
|
16
14
|
|
|
17
|
-
/**
|
|
18
|
-
export function findFiles(dir, extensions, ignoreDirs, root = dir) {
|
|
19
|
-
const results = [];
|
|
20
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
21
|
-
const full = resolve(dir, entry.name);
|
|
22
|
-
const rel = relative(root, full);
|
|
23
|
-
if (entry.isDirectory()) {
|
|
24
|
-
if (ignoreDirs.some((ig) => entry.name === ig || rel === ig || rel.startsWith(ig + '/'))) continue;
|
|
25
|
-
results.push(...findFiles(full, extensions, ignoreDirs, root));
|
|
26
|
-
} else if (extensions.includes(extname(entry.name))) {
|
|
27
|
-
results.push(rel);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return results;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Count files matching extensions.
|
|
35
|
-
* @param {string} root
|
|
36
|
-
* @param {string[]} extensions
|
|
37
|
-
* @param {string[]} ignoreDirs
|
|
38
|
-
* @returns {number}
|
|
39
|
-
*/
|
|
40
|
-
export function countFiles(root, extensions, ignoreDirs) {
|
|
41
|
-
return findFiles(root, extensions, ignoreDirs, root).length;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** @typedef {{ pass: boolean, label: string, time: string, files?: number, errors?: string[] }} CheckResult */
|
|
45
|
-
/** @typedef {{ file: string, lines: number, max: number }} LineViolation */
|
|
46
|
-
/** @typedef {{ file: string, spec: string }} ImportViolation */
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Check file line counts with timing.
|
|
50
|
-
* @param {string} root
|
|
51
|
-
* @param {string[]} extensions
|
|
52
|
-
* @param {number} max
|
|
53
|
-
* @param {string[]} ignoreDirs
|
|
54
|
-
* @param {string} label
|
|
55
|
-
* @param {{ exclude?: string, include?: string, quiet?: boolean }} [filter]
|
|
56
|
-
* @returns {CheckResult & { violations?: LineViolation[] }}
|
|
57
|
-
*/
|
|
58
|
-
export function checkFileLines(root, extensions, max, ignoreDirs, label, filter) {
|
|
59
|
-
const start = performance.now();
|
|
60
|
-
let files = findFiles(root, extensions, ignoreDirs, root);
|
|
61
|
-
if (filter?.exclude) files = files.filter((f) => !f.endsWith(filter.exclude));
|
|
62
|
-
if (filter?.include) files = files.filter((f) => f.endsWith(filter.include));
|
|
63
|
-
const violations = [];
|
|
64
|
-
for (const file of files) {
|
|
65
|
-
const lines = readFileSync(resolve(root, file), 'utf-8').trimEnd().split('\n').length;
|
|
66
|
-
if (lines > max) violations.push({ file, lines, max });
|
|
67
|
-
}
|
|
68
|
-
const time = elapsed(start);
|
|
69
|
-
if (violations.length === 0) {
|
|
70
|
-
if (!filter?.quiet) console.log(` ✅ ${label} (${files.length} files, ${time})`);
|
|
71
|
-
return { pass: true, label, time, files: files.length };
|
|
72
|
-
}
|
|
73
|
-
if (!filter?.quiet) {
|
|
74
|
-
console.log(` ❌ ${label} — ${violations.length} violation(s):`);
|
|
75
|
-
for (const v of violations) console.log(` ${v.file}: ${v.lines} lines (max ${v.max})`);
|
|
76
|
-
}
|
|
77
|
-
return { pass: false, label, time, files: files.length, violations };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Check for relative parent imports (`../`) in browser-facing JS files.
|
|
82
|
-
* Same-directory (`./`) and server/test files are allowed.
|
|
83
|
-
* @param {string} root
|
|
84
|
-
* @param {string[]} ignoreDirs
|
|
85
|
-
* @param {string} label
|
|
86
|
-
* @param {{ quiet?: boolean }} [opts]
|
|
87
|
-
* @returns {CheckResult & { violations?: ImportViolation[] }}
|
|
88
|
-
*/
|
|
89
|
-
export function checkImports(root, ignoreDirs, label, opts) {
|
|
90
|
-
const start = performance.now();
|
|
91
|
-
const files = findFiles(root, ['.js'], ignoreDirs, root)
|
|
92
|
-
.filter((f) => f.startsWith('src/') && !f.includes('vendor/') && !f.includes('api/') && !f.endsWith('.test.js') && !f.endsWith('server.js'));
|
|
93
|
-
const violations = [];
|
|
94
|
-
const importRe = /(?:^|\n)\s*import\s.*?from\s+['"](\.\.[^'"]*)['"]|(?:^|\n)\s*import\s+['"](\.\.[^'"]*)['"]|(?:^|\n)\s*export\s.*?from\s+['"](\.\.[^'"]*)['"/]/g;
|
|
95
|
-
for (const file of files) {
|
|
96
|
-
const src = readFileSync(resolve(root, file), 'utf-8');
|
|
97
|
-
let m;
|
|
98
|
-
while ((m = importRe.exec(src)) !== null) {
|
|
99
|
-
const spec = m[1] || m[2] || m[3];
|
|
100
|
-
violations.push({ file, spec });
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
const time = elapsed(start);
|
|
104
|
-
if (violations.length === 0) {
|
|
105
|
-
if (!opts?.quiet) console.log(` ✅ ${label} (${files.length} files, ${time})`);
|
|
106
|
-
return { pass: true, label, time, files: files.length };
|
|
107
|
-
}
|
|
108
|
-
if (!opts?.quiet) {
|
|
109
|
-
console.log(` ❌ ${label} — ${violations.length} violation(s):`);
|
|
110
|
-
for (const v of violations) console.log(` ${v.file}: import '${v.spec}' → use #prefix/ alias`);
|
|
111
|
-
}
|
|
112
|
-
return { pass: false, label, time, files: files.length, violations };
|
|
113
|
-
}
|
|
114
|
-
|
|
15
|
+
/** @typedef {{ pass: boolean, label: string, time: string, detail?: string, files?: number, errors?: string[], violations?: object[] }} CheckResult */
|
|
115
16
|
|
|
116
17
|
/**
|
|
117
18
|
* Run a shell command, report pass/fail with timing.
|
|
@@ -131,16 +32,20 @@ export function runCmd(label, cmd, cwd, stats, opts) {
|
|
|
131
32
|
return { pass: true, label, time: elapsed(start), files: parseInt(stats) || undefined };
|
|
132
33
|
} catch (err) {
|
|
133
34
|
const out = (err.stdout || '') + (err.stderr || '');
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
35
|
+
const lines = out.trim().split('\n').filter((l) => l.trim());
|
|
36
|
+
const ownErrors = lines.filter((l) => !/[/\\]node_modules[/\\]/.test(l));
|
|
37
|
+
const isPrettier = cmd.includes('prettier');
|
|
38
|
+
const display = isPrettier
|
|
39
|
+
? ownErrors.filter((l) => l.startsWith('[warn]') && !l.includes('Run Prettier'))
|
|
40
|
+
: ownErrors;
|
|
41
|
+
if (display.length === 0) {
|
|
137
42
|
if (!opts?.quiet) console.log(` ✅ ${label}${suffix(stats)}`);
|
|
138
43
|
return { pass: true, label, time: elapsed(start) };
|
|
139
44
|
}
|
|
140
45
|
if (!opts?.quiet) {
|
|
141
46
|
console.log(` ❌ ${label}${suffix(stats)}`);
|
|
142
|
-
for (const line of
|
|
47
|
+
for (const line of display) console.log(` ${line}`);
|
|
143
48
|
}
|
|
144
|
-
return { pass: false, label, time: elapsed(start), errors:
|
|
49
|
+
return { pass: false, label, time: elapsed(start), errors: display };
|
|
145
50
|
}
|
|
146
51
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watch lifecycle — quit, self-restart, and fs.watch setup.
|
|
3
|
+
* @module lib/watch-lifecycle
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { watch } from 'node:fs';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { resolve, extname } from 'node:path';
|
|
9
|
+
import { destroyScreen, setQuit } from './watch-ui.js';
|
|
10
|
+
import { setOnCopyNote } from './watch-widgets.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Register quit + restart handlers and start fs.watch on project and lib dirs.
|
|
14
|
+
* @param {{ serverRow: object|null, specRows: object[], projectDir: string, watchDirs: string[], schedule: (ext: string) => void, renderNote: () => void }} ctx
|
|
15
|
+
*/
|
|
16
|
+
export function setupLifecycle(ctx) {
|
|
17
|
+
const { serverRow, specRows, projectDir, watchDirs, schedule, renderNote } = ctx;
|
|
18
|
+
|
|
19
|
+
const quit = () => {
|
|
20
|
+
serverRow?.kill();
|
|
21
|
+
destroyScreen();
|
|
22
|
+
const passing = specRows.filter((r) => r.pass).length;
|
|
23
|
+
console.log(`Spec watch stopped. Final state: ${passing}/${specRows.length} checks passing`);
|
|
24
|
+
process.exit(0);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const restart = () => {
|
|
28
|
+
serverRow?.kill();
|
|
29
|
+
destroyScreen();
|
|
30
|
+
console.log('\nSpec files changed — run `npm run spec watch` to restart.');
|
|
31
|
+
process.exit(0);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
setQuit(quit);
|
|
35
|
+
setOnCopyNote(renderNote);
|
|
36
|
+
process.on('SIGINT', quit);
|
|
37
|
+
|
|
38
|
+
for (const dir of watchDirs) {
|
|
39
|
+
watch(resolve(projectDir, dir), { recursive: true }, (_event, filename) => {
|
|
40
|
+
if (filename) schedule(extname(filename));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const dir of ['lib', 'bin'].filter((d) => existsSync(resolve(projectDir, d)))) {
|
|
45
|
+
watch(resolve(projectDir, dir), { recursive: true }, restart);
|
|
46
|
+
}
|
|
47
|
+
}
|
package/lib/watch-ui.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec watch UI — rendering, split candidates, violation extraction.
|
|
3
|
+
* Widget construction lives in watch-widgets.js.
|
|
4
|
+
* @module lib/watch-ui
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import { screen, statusBox, divider, logBox, getSpin, getCopyNote } from './watch-widgets.js';
|
|
10
|
+
|
|
11
|
+
export { setQuit } from './watch-widgets.js';
|
|
12
|
+
|
|
13
|
+
// ── Split candidate detection ─────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Suggest split seams for a file that exceeds the line limit.
|
|
17
|
+
* Prefers `// SPLIT CANDIDATE:` comments, falls back to heuristics.
|
|
18
|
+
* @param {string} filePath absolute path
|
|
19
|
+
* @returns {string[]}
|
|
20
|
+
*/
|
|
21
|
+
export function splitCandidates(filePath) {
|
|
22
|
+
if (!existsSync(filePath)) return [];
|
|
23
|
+
const lines = readFileSync(filePath, 'utf-8').split('\n');
|
|
24
|
+
const explicit = [];
|
|
25
|
+
lines.forEach((l, i) => {
|
|
26
|
+
const m = l.match(/\/\/\s*SPLIT CANDIDATE:\s*(.+)/i);
|
|
27
|
+
if (m) explicit.push(` L${i + 1}: ${m[1].trim()}`);
|
|
28
|
+
});
|
|
29
|
+
if (explicit.length) return explicit;
|
|
30
|
+
|
|
31
|
+
const seams = [];
|
|
32
|
+
lines.forEach((l, i) => {
|
|
33
|
+
// Only exported functions/classes are meaningful split boundaries
|
|
34
|
+
if (/^export (async function|function|class)/.test(l)) seams.push(i + 1);
|
|
35
|
+
});
|
|
36
|
+
if (seams.length < 2) return [];
|
|
37
|
+
const suggestions = [];
|
|
38
|
+
for (let i = 0; i < seams.length - 1 && suggestions.length < 3; i++) {
|
|
39
|
+
const start = seams[i], end = seams[i + 1] - 1;
|
|
40
|
+
if (end - start < 10) continue;
|
|
41
|
+
// Peek at the function name for a more useful suggestion
|
|
42
|
+
const nameMatch = lines[start - 1]?.match(/^export (?:async )?function (\w+)/);
|
|
43
|
+
const hint = nameMatch ? `→ ${nameMatch[1]}()` : 'consider extracting';
|
|
44
|
+
suggestions.push(` L${start}-${end}: ${hint}`);
|
|
45
|
+
}
|
|
46
|
+
return suggestions;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Violation extraction ──────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Process a failed check row into violation tuples for the log panel.
|
|
53
|
+
* @param {object} row
|
|
54
|
+
* @param {string} projectDir
|
|
55
|
+
* @returns {string[][]}
|
|
56
|
+
*/
|
|
57
|
+
export function extractViolations(row, projectDir) {
|
|
58
|
+
if (row.pass) return [];
|
|
59
|
+
if (row.result?.violations?.length) {
|
|
60
|
+
return row.result.violations.map((v) => {
|
|
61
|
+
if (v.spec !== undefined) return [v.file, `import '${v.spec}' → use #prefix/ alias`];
|
|
62
|
+
const candidates = splitCandidates(resolve(projectDir, v.file));
|
|
63
|
+
return [v.file, `${v.lines} lines (max ${v.max}, +${v.lines - v.max} over)`, ...candidates];
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (row.result?.errors?.length) {
|
|
67
|
+
return [[(row.label ?? row.name ?? row.key), row.result.errors.slice(0, 3).join('\n')]];
|
|
68
|
+
}
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Render ────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/** @param {object} r */
|
|
75
|
+
function rowIcon(r) {
|
|
76
|
+
if (r.key === 'server') {
|
|
77
|
+
if (r.status === 'running') return '{green-fg}ok{/}';
|
|
78
|
+
if (r.status === 'crashed') return '{red-fg}!{/} ';
|
|
79
|
+
if (r.status === 'restarting') return '{yellow-fg}~{/} ';
|
|
80
|
+
return '{grey-fg}' + getSpin() + '{/}';
|
|
81
|
+
}
|
|
82
|
+
if (r.pass === null) return '{grey-fg}' + getSpin() + '{/}';
|
|
83
|
+
if (r.pass) return '{green-fg}ok{/}';
|
|
84
|
+
return '{red-fg}!{/} ';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Render the dashboard.
|
|
89
|
+
* @param {object[]} rows
|
|
90
|
+
* @param {string} lastCheck
|
|
91
|
+
* @param {string[]} watchDirs
|
|
92
|
+
* @param {string[][]} violations
|
|
93
|
+
*/
|
|
94
|
+
export function render(rows, lastCheck, watchDirs, violations) {
|
|
95
|
+
const labelWidth = Math.max(...rows.map((r) => (r.label ?? r.name ?? r.key).length));
|
|
96
|
+
const statusLines = rows.map((r) => {
|
|
97
|
+
const name = (r.label ?? r.name ?? r.key).padEnd(labelWidth);
|
|
98
|
+
const styled = r.pass === false ? `{red-fg}${name}{/}` : name;
|
|
99
|
+
const detail = r.detail ? `{grey-fg}${r.detail}{/}` : '';
|
|
100
|
+
return ` ${rowIcon(r)} ${styled} ${detail}`;
|
|
101
|
+
});
|
|
102
|
+
statusLines.push('');
|
|
103
|
+
statusLines.push(`{grey-fg} watching ${watchDirs.join(', ')} last check: ${lastCheck}{/}${getCopyNote()}`);
|
|
104
|
+
statusBox.setContent(statusLines.join('\n'));
|
|
105
|
+
statusBox.height = statusLines.length;
|
|
106
|
+
|
|
107
|
+
const statusHeight = statusLines.length + 1;
|
|
108
|
+
divider.top = statusHeight;
|
|
109
|
+
logBox.top = statusHeight + 1;
|
|
110
|
+
|
|
111
|
+
if (violations.length) {
|
|
112
|
+
const logLines = ['{yellow-fg}─── Copy below into your LLM session ───{/}'];
|
|
113
|
+
for (const [file, detail, ...candidates] of violations) {
|
|
114
|
+
logLines.push('');
|
|
115
|
+
logLines.push(`{cyan-fg}${file}{/}`);
|
|
116
|
+
logLines.push(detail);
|
|
117
|
+
if (candidates.length) {
|
|
118
|
+
logLines.push('{grey-fg}Split candidates:{/}');
|
|
119
|
+
for (const c of candidates) logLines.push(`{grey-fg}${c}{/}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
logBox.setContent(logLines.join('\n'));
|
|
123
|
+
logBox.show();
|
|
124
|
+
divider.show();
|
|
125
|
+
} else {
|
|
126
|
+
logBox.hide();
|
|
127
|
+
divider.hide();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
screen.render();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Tear down the blessed screen cleanly. */
|
|
134
|
+
export function destroyScreen() {
|
|
135
|
+
screen.destroy();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Refresh just the status box — used for transient notes like copy confirmation. */
|
|
139
|
+
export function renderNote(rows, lastCheck, watchDirs) {
|
|
140
|
+
const labelWidth = Math.max(...rows.map((r) => (r.label ?? r.name ?? r.key).length));
|
|
141
|
+
const lines = [
|
|
142
|
+
...rows.map((r) => { const n = (r.label ?? r.name ?? r.key).padEnd(labelWidth); return ` ${rowIcon(r)} ${r.pass === false ? `{red-fg}${n}{/}` : n} ${r.detail ? `{grey-fg}${r.detail}{/}` : ''}`; }),
|
|
143
|
+
'',
|
|
144
|
+
`{grey-fg} watching ${watchDirs.join(', ')} last check: ${lastCheck}{/}${getCopyNote()}`,
|
|
145
|
+
];
|
|
146
|
+
statusBox.setContent(lines.join('\n'));
|
|
147
|
+
screen.render();
|
|
148
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blessed widget tree for the spec watch dashboard.
|
|
3
|
+
* Constructed once at import time and shared across render calls.
|
|
4
|
+
* @module lib/watch-widgets
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const blessed = require('blessed');
|
|
12
|
+
|
|
13
|
+
export const screen = blessed.screen({ smartCSR: true, title: 'Clearstack Spec Watch' });
|
|
14
|
+
|
|
15
|
+
export const outer = blessed.box({
|
|
16
|
+
top: 0, left: 0, width: '100%', height: '100%',
|
|
17
|
+
border: { type: 'line' },
|
|
18
|
+
label: { text: ' {blue-fg}♥{/blue-fg} Clearstack Spec Watch q quit ↑↓ scroll c copy {/}', side: 'left' },
|
|
19
|
+
tags: true,
|
|
20
|
+
style: { border: { fg: 'cyan' }, label: { fg: 'grey', bold: false } },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const statusBox = blessed.box({
|
|
24
|
+
top: 0, left: 0, width: '100%-2', height: 1,
|
|
25
|
+
tags: true,
|
|
26
|
+
style: { fg: 'white' },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const divider = blessed.line({
|
|
30
|
+
top: 1, left: 0, width: '100%-2', orientation: 'horizontal',
|
|
31
|
+
style: { fg: 'grey' },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const logBox = blessed.scrollablebox({
|
|
35
|
+
top: 2, left: 0, width: '100%-2', bottom: 1,
|
|
36
|
+
tags: true, scrollable: true, alwaysScroll: true,
|
|
37
|
+
scrollbar: { ch: '│', style: { fg: 'grey' } },
|
|
38
|
+
style: { fg: 'white' },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
outer.append(statusBox);
|
|
42
|
+
outer.append(divider);
|
|
43
|
+
outer.append(logBox);
|
|
44
|
+
screen.append(outer);
|
|
45
|
+
|
|
46
|
+
const SPINNER = ['⠋ ', '⠙ ', '⠹ ', '⠸ ', '⠼ ', '⠴ ', '⠦ ', '⠧ ', '⠇ ', '⠏ '];
|
|
47
|
+
let spinFrame = 0;
|
|
48
|
+
export const getSpin = () => SPINNER[spinFrame % SPINNER.length];
|
|
49
|
+
|
|
50
|
+
const spinInterval = setInterval(() => { spinFrame++; screen.render(); }, 80);
|
|
51
|
+
spinInterval.unref(); // don't keep process alive just for the spinner
|
|
52
|
+
|
|
53
|
+
let _quit = () => process.exit(0);
|
|
54
|
+
export const setQuit = (fn) => { _quit = fn; };
|
|
55
|
+
|
|
56
|
+
let _copyNote = '';
|
|
57
|
+
export const getCopyNote = () => _copyNote;
|
|
58
|
+
let _onCopyNote = () => {};
|
|
59
|
+
export const setOnCopyNote = (fn) => { _onCopyNote = fn; };
|
|
60
|
+
|
|
61
|
+
screen.key(['C-c', 'q'], () => _quit());
|
|
62
|
+
screen.key(['pageup', 'up'], () => { logBox.scroll(-1); screen.render(); });
|
|
63
|
+
screen.key(['pagedown', 'down'], () => { logBox.scroll(1); screen.render(); });
|
|
64
|
+
screen.key(['c'], () => {
|
|
65
|
+
const raw = logBox.getContent()
|
|
66
|
+
.replace(/\{[^}]+\}/g, '') // blessed tags
|
|
67
|
+
.replace(/\x1b\[[\d;]*m/g, '') // ANSI escape codes
|
|
68
|
+
.split('\n').slice(1).join('\n') // drop header line
|
|
69
|
+
.trim();
|
|
70
|
+
if (!raw) return;
|
|
71
|
+
const [cmd, ...args] = process.platform === 'darwin'
|
|
72
|
+
? ['pbcopy']
|
|
73
|
+
: ['xclip', '-selection', 'clipboard'];
|
|
74
|
+
const proc = execFile(cmd, args);
|
|
75
|
+
proc.stdin.end(raw);
|
|
76
|
+
proc.on('close', (code) => {
|
|
77
|
+
_copyNote = code === 0 ? '{green-fg} ✓ copied{/}' : '{red-fg} ✗ copy failed{/}';
|
|
78
|
+
_onCopyNote();
|
|
79
|
+
setTimeout(() => { _copyNote = ''; _onCopyNote(); }, 1500);
|
|
80
|
+
});
|
|
81
|
+
proc.on('error', () => { _copyNote = '{red-fg} ✗ clipboard unavailable{/}'; _onCopyNote(); });
|
|
82
|
+
});
|
package/lib/watch.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec watch dashboard — continuous compliance monitoring.
|
|
3
|
+
* Runs affected checks on file change with 500ms debounce.
|
|
4
|
+
* UI rendering lives in watch-ui.js.
|
|
5
|
+
* @module lib/watch
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
import { loadConfig, buildCmds, makeExtMap, FAST_CHECKS } from './spec-config.js';
|
|
11
|
+
import { buildChecks } from './check.js';
|
|
12
|
+
import { render, extractViolations, renderNote } from './watch-ui.js';
|
|
13
|
+
import { spawnServer, detectServerCmd } from './server-proc.js';
|
|
14
|
+
import { setupLifecycle } from './watch-lifecycle.js';
|
|
15
|
+
|
|
16
|
+
/** Adapt a Check from buildChecks into a watch row (adds pass/detail/result state). */
|
|
17
|
+
function toRow(check) {
|
|
18
|
+
return { ...check, pass: null, detail: '', result: null };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Start the spec watch dashboard.
|
|
23
|
+
* @param {string} projectDir
|
|
24
|
+
*/
|
|
25
|
+
export async function startWatch(projectDir) {
|
|
26
|
+
const cfg = loadConfig(projectDir);
|
|
27
|
+
const cmds = buildCmds(projectDir, { watch: true });
|
|
28
|
+
const checks = await buildChecks(projectDir, cfg, cmds);
|
|
29
|
+
const specRows = checks.map(toRow);
|
|
30
|
+
const checksForExt = makeExtMap(checks);
|
|
31
|
+
|
|
32
|
+
const watchDirs = ['src', 'scripts', 'docs'].filter((d) =>
|
|
33
|
+
existsSync(resolve(projectDir, d)),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
let lastCheck = 'never';
|
|
37
|
+
let fastTimer = null;
|
|
38
|
+
let slowTimer = null;
|
|
39
|
+
/** @type {Set<string>} */
|
|
40
|
+
const fastPending = new Set();
|
|
41
|
+
/** @type {Set<string>} */
|
|
42
|
+
const slowPending = new Set();
|
|
43
|
+
|
|
44
|
+
// Render immediately — server spawns and checks run async behind it
|
|
45
|
+
const serverCmd = detectServerCmd(projectDir, cfg);
|
|
46
|
+
|
|
47
|
+
/** @type {import('./server-proc.js').ServerRow|null} */
|
|
48
|
+
const serverRow = serverCmd ? { key: 'server', label: 'server', status: 'starting', pass: null, detail: 'starting…', kill: () => {} } : null;
|
|
49
|
+
const rows = serverRow ? [serverRow, ...specRows] : specRows;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
*
|
|
53
|
+
*/
|
|
54
|
+
function spawnAndWatch() {
|
|
55
|
+
const proc = spawnServer(serverCmd, projectDir, cfg.rawEnv, () => {
|
|
56
|
+
serverRow.status = proc.status;
|
|
57
|
+
serverRow.pass = proc.pass;
|
|
58
|
+
serverRow.detail = proc.detail;
|
|
59
|
+
serverRow.kill = proc.kill;
|
|
60
|
+
render(rows, lastCheck, watchDirs, currentViolations);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (serverCmd) spawnAndWatch();
|
|
64
|
+
|
|
65
|
+
/** @type {string[][]} */
|
|
66
|
+
let currentViolations = [];
|
|
67
|
+
render(rows, lastCheck, watchDirs, currentViolations);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
*
|
|
71
|
+
*/
|
|
72
|
+
function runKeys(keys) {
|
|
73
|
+
const toRun = specRows.filter((r) => keys.has(r.key));
|
|
74
|
+
if (!toRun.length) return;
|
|
75
|
+
let i = 0;
|
|
76
|
+
/**
|
|
77
|
+
*
|
|
78
|
+
*/
|
|
79
|
+
async function next() {
|
|
80
|
+
if (i >= toRun.length) {
|
|
81
|
+
const now = new Date();
|
|
82
|
+
lastCheck = `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
|
|
83
|
+
render(rows, lastCheck, watchDirs, currentViolations);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const row = toRun[i++];
|
|
87
|
+
const raw = await row.run({ quiet: true });
|
|
88
|
+
row.result = typeof raw === 'boolean' ? { pass: raw } : raw;
|
|
89
|
+
row.pass = row.result.pass;
|
|
90
|
+
row.detail = row.pass
|
|
91
|
+
? (row.result.detail ?? (row.result.files ? `${row.result.files} files` : ''))
|
|
92
|
+
: (row.result.violations?.length
|
|
93
|
+
? `${row.result.violations.length} violation(s)`
|
|
94
|
+
: (() => { const errs = row.result.errors ?? []; const n = errs.filter((l) => /\.(js|ts|css|md)/.test(l)).length || errs.length; return `${n} error(s)`; })());
|
|
95
|
+
// Rebuild violations from all currently-failed rows
|
|
96
|
+
currentViolations = specRows.flatMap((r) => extractViolations(r, projectDir));
|
|
97
|
+
render(rows, lastCheck, watchDirs, currentViolations);
|
|
98
|
+
setImmediate(next);
|
|
99
|
+
}
|
|
100
|
+
setImmediate(next);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
*
|
|
105
|
+
*/
|
|
106
|
+
function runFast() { const keys = new Set(fastPending); fastPending.clear(); runKeys(keys); }
|
|
107
|
+
/**
|
|
108
|
+
*
|
|
109
|
+
*/
|
|
110
|
+
function runSlow() { const keys = new Set(slowPending); slowPending.clear(); runKeys(keys); }
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
*
|
|
114
|
+
*/
|
|
115
|
+
function schedule(ext) {
|
|
116
|
+
for (const k of checksForExt(ext)) {
|
|
117
|
+
if (FAST_CHECKS.has(k)) fastPending.add(k);
|
|
118
|
+
else slowPending.add(k);
|
|
119
|
+
}
|
|
120
|
+
if (fastPending.size) { clearTimeout(fastTimer); fastTimer = setTimeout(runFast, 50); }
|
|
121
|
+
if (slowPending.size) { clearTimeout(slowTimer); slowTimer = setTimeout(runSlow, 1500); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
runKeys(new Set(specRows.map((r) => r.key)));
|
|
125
|
+
|
|
126
|
+
setupLifecycle({
|
|
127
|
+
serverRow, specRows, projectDir, watchDirs,
|
|
128
|
+
schedule,
|
|
129
|
+
renderNote: () => renderNote(rows, lastCheck, watchDirs),
|
|
130
|
+
});
|
|
131
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@techninja/clearstack",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A no-build web component framework specification — scaffold, validate, and evolve spec-compliant projects",
|
|
6
6
|
"bin": {
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"templates/",
|
|
13
13
|
"docs/"
|
|
14
14
|
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./lib/check.js",
|
|
17
|
+
"./lib/*": "./lib/*"
|
|
18
|
+
},
|
|
15
19
|
"repository": {
|
|
16
20
|
"type": "git",
|
|
17
21
|
"url": "git+https://github.com/techninja/clearstack.git"
|
|
@@ -50,7 +54,8 @@
|
|
|
50
54
|
],
|
|
51
55
|
"license": "MIT",
|
|
52
56
|
"dependencies": {
|
|
53
|
-
"@inquirer/prompts": "^8.3.2"
|
|
57
|
+
"@inquirer/prompts": "^8.3.2",
|
|
58
|
+
"blessed": "^0.1.81"
|
|
54
59
|
},
|
|
55
60
|
"devDependencies": {
|
|
56
61
|
"@open-wc/testing": "^4.0.0",
|