@techninja/clearstack 0.4.17 → 0.4.20
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/watch-runner.js +1 -0
- package/lib/watch-ui.js +39 -77
- package/lib/watch-violations.js +66 -0
- package/lib/watch.js +8 -1
- package/package.json +2 -1
package/lib/watch-runner.js
CHANGED
|
@@ -42,6 +42,7 @@ export function runKeys(specRows, keys, ctx) {
|
|
|
42
42
|
const elapsed = ((Date.now() - t0) / 1000).toFixed(1) + 's';
|
|
43
43
|
row.result = typeof raw === 'boolean' ? { pass: raw } : raw;
|
|
44
44
|
row.pass = row.result.pass;
|
|
45
|
+
if (row.pass) row.ranAt = Date.now();
|
|
45
46
|
row.detail = row.pass
|
|
46
47
|
? `${row.result.detail ?? (row.result.files ? `${row.result.files} files` : '')} (${elapsed})`
|
|
47
48
|
: (row.result.violations?.length
|
package/lib/watch-ui.js
CHANGED
|
@@ -1,79 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Spec watch UI — rendering
|
|
2
|
+
* Spec watch UI — rendering and violation display.
|
|
3
|
+
* Split candidate detection and violation extraction live in watch-violations.js.
|
|
3
4
|
* Widget construction lives in watch-widgets.js.
|
|
4
5
|
* @module lib/watch-ui
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
8
|
-
import { resolve } from 'node:path';
|
|
9
8
|
import { screen, statusBox, divider, logBox, getSpin, getCopyNote } from './watch-widgets.js';
|
|
10
9
|
|
|
11
10
|
export { setQuit } from './watch-widgets.js';
|
|
11
|
+
export { splitCandidates, extractViolations } from './watch-violations.js';
|
|
12
12
|
|
|
13
|
-
// ──
|
|
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
|
-
const errors = row.result.errors;
|
|
68
|
-
// Prettier: [warn] src/file.js — expand each into its own entry
|
|
69
|
-
const prettierFiles = errors.map((l) => l.match(/^\[warn\]\s+(.+\.\w+)$/)?.[1]).filter(Boolean);
|
|
70
|
-
if (prettierFiles.length) return prettierFiles.map((f) => [f, 'formatting — run npm run format']);
|
|
71
|
-
return [[row.label ?? row.name ?? row.key, errors.slice(0, 5).join('\n')]];
|
|
72
|
-
}
|
|
73
|
-
return [];
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ── Render ────────────────────────────────────────────────────────────────────
|
|
13
|
+
// ── Row rendering ─────────────────────────────────────────────────────────────
|
|
77
14
|
|
|
78
15
|
/** @param {object} r */
|
|
79
16
|
function rowIcon(r) {
|
|
@@ -85,11 +22,40 @@ function rowIcon(r) {
|
|
|
85
22
|
}
|
|
86
23
|
if (r.pass === null) return '{grey-fg}' + getSpin() + '{/}';
|
|
87
24
|
if (r.pass) return '{green-fg}ok{/}';
|
|
25
|
+
if (r.keyBinding) return '{grey-fg}–{/} ';
|
|
88
26
|
return '{red-fg}!{/} ';
|
|
89
27
|
}
|
|
90
28
|
|
|
29
|
+
/** @param {number} ms @returns {string} */
|
|
30
|
+
function timeAgo(ms) {
|
|
31
|
+
const s = Math.floor((Date.now() - ms) / 1000);
|
|
32
|
+
if (s < 60) return `${s}s ago`;
|
|
33
|
+
const m = Math.floor(s / 60);
|
|
34
|
+
if (m < 60) return `${m}m ago`;
|
|
35
|
+
return `${Math.floor(m / 60)}h ago`;
|
|
36
|
+
}
|
|
37
|
+
|
|
91
38
|
/** @param {object} r @param {number} w */
|
|
92
|
-
const rowLine = (r, w) => {
|
|
39
|
+
const rowLine = (r, w) => {
|
|
40
|
+
const n = (r.label ?? r.name ?? r.key).padEnd(w);
|
|
41
|
+
const isIdle = r.pass === false && r.keyBinding;
|
|
42
|
+
const name = (!isIdle && r.pass === false) ? `{red-fg}${n}{/}` : n;
|
|
43
|
+
const detail = isIdle
|
|
44
|
+
? `{grey-fg}${r.ranAt ? timeAgo(r.ranAt) : `press ${r.keyBinding} to run`}{/}`
|
|
45
|
+
: (r.detail ? `{grey-fg}${r.detail}{/}` : '');
|
|
46
|
+
return ` ${rowIcon(r)} ${name} ${detail}`;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ── Status box helpers ────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/** @param {object[]} rows @param {string} lastCheck @param {string[]} watchDirs */
|
|
52
|
+
function statusContent(rows, lastCheck, watchDirs) {
|
|
53
|
+
const w = Math.max(...rows.map((r) => (r.label ?? r.name ?? r.key).length));
|
|
54
|
+
return [...rows.map((r) => rowLine(r, w)), '',
|
|
55
|
+
`{grey-fg} watching ${watchDirs.join(', ')} last check: ${lastCheck}{/}${getCopyNote()}`];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
93
59
|
|
|
94
60
|
/**
|
|
95
61
|
* Render the dashboard.
|
|
@@ -99,14 +65,11 @@ const rowLine = (r, w) => { const n = (r.label ?? r.name ?? r.key).padEnd(w); re
|
|
|
99
65
|
* @param {string[][]} violations
|
|
100
66
|
*/
|
|
101
67
|
export function render(rows, lastCheck, watchDirs, violations) {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
statusLines.push(`{grey-fg} watching ${watchDirs.join(', ')} last check: ${lastCheck}{/}${getCopyNote()}`);
|
|
106
|
-
statusBox.setContent(statusLines.join('\n'));
|
|
107
|
-
statusBox.height = statusLines.length;
|
|
68
|
+
const lines = statusContent(rows, lastCheck, watchDirs);
|
|
69
|
+
statusBox.setContent(lines.join('\n'));
|
|
70
|
+
statusBox.height = lines.length;
|
|
108
71
|
|
|
109
|
-
const statusHeight =
|
|
72
|
+
const statusHeight = lines.length + 1;
|
|
110
73
|
divider.top = statusHeight;
|
|
111
74
|
logBox.top = statusHeight + 1;
|
|
112
75
|
|
|
@@ -139,7 +102,6 @@ export function destroyScreen() {
|
|
|
139
102
|
|
|
140
103
|
/** Refresh just the status box — used for transient notes like copy confirmation. */
|
|
141
104
|
export function renderNote(rows, lastCheck, watchDirs) {
|
|
142
|
-
|
|
143
|
-
statusBox.setContent([...rows.map((r) => rowLine(r, w)), '', `{grey-fg} watching ${watchDirs.join(', ')} last check: ${lastCheck}{/}${getCopyNote()}`].join('\n'));
|
|
105
|
+
statusBox.setContent(statusContent(rows, lastCheck, watchDirs).join('\n'));
|
|
144
106
|
screen.render();
|
|
145
107
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Violation extraction and split candidate detection for the spec watch dashboard.
|
|
3
|
+
* @module lib/watch-violations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Suggest split seams for a file that exceeds the line limit.
|
|
11
|
+
* Prefers `// SPLIT CANDIDATE:` comments, falls back to heuristics.
|
|
12
|
+
* @param {string} filePath absolute path
|
|
13
|
+
* @returns {string[]}
|
|
14
|
+
*/
|
|
15
|
+
export function splitCandidates(filePath) {
|
|
16
|
+
if (!existsSync(filePath)) return [];
|
|
17
|
+
const lines = readFileSync(filePath, 'utf-8').split('\n');
|
|
18
|
+
const explicit = [];
|
|
19
|
+
lines.forEach((l, i) => {
|
|
20
|
+
const m = l.match(/\/\/\s*SPLIT CANDIDATE:\s*(.+)/i);
|
|
21
|
+
if (m) explicit.push(` L${i + 1}: ${m[1].trim()}`);
|
|
22
|
+
});
|
|
23
|
+
if (explicit.length) return explicit;
|
|
24
|
+
|
|
25
|
+
const seams = [];
|
|
26
|
+
lines.forEach((l, i) => {
|
|
27
|
+
// Only exported functions/classes are meaningful split boundaries
|
|
28
|
+
if (/^export (async function|function|class)/.test(l)) seams.push(i + 1);
|
|
29
|
+
});
|
|
30
|
+
if (seams.length < 2) return [];
|
|
31
|
+
const suggestions = [];
|
|
32
|
+
for (let i = 0; i < seams.length - 1 && suggestions.length < 3; i++) {
|
|
33
|
+
const start = seams[i], end = seams[i + 1] - 1;
|
|
34
|
+
if (end - start < 10) continue;
|
|
35
|
+
// Peek at the function name for a more useful suggestion
|
|
36
|
+
const nameMatch = lines[start - 1]?.match(/^export (?:async )?function (\w+)/);
|
|
37
|
+
const hint = nameMatch ? `→ ${nameMatch[1]}()` : 'consider extracting';
|
|
38
|
+
suggestions.push(` L${start}-${end}: ${hint}`);
|
|
39
|
+
}
|
|
40
|
+
return suggestions;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Process a failed check row into violation tuples for the log panel.
|
|
45
|
+
* @param {object} row
|
|
46
|
+
* @param {string} projectDir
|
|
47
|
+
* @returns {string[][]}
|
|
48
|
+
*/
|
|
49
|
+
export function extractViolations(row, projectDir) {
|
|
50
|
+
if (row.pass) return [];
|
|
51
|
+
if (row.result?.violations?.length) {
|
|
52
|
+
return row.result.violations.map((v) => {
|
|
53
|
+
if (v.spec !== undefined) return [v.file, `import '${v.spec}' → use #prefix/ alias`];
|
|
54
|
+
const candidates = splitCandidates(resolve(projectDir, v.file));
|
|
55
|
+
return [v.file, `${v.lines} lines (max ${v.max}, +${v.lines - v.max} over)`, ...candidates];
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (row.result?.errors?.length) {
|
|
59
|
+
const errors = row.result.errors;
|
|
60
|
+
// Prettier: [warn] src/file.js — expand each into its own entry
|
|
61
|
+
const prettierFiles = errors.map((l) => l.match(/^\[warn\]\s+(.+\.\w+)$/)?.[1]).filter(Boolean);
|
|
62
|
+
if (prettierFiles.length) return prettierFiles.map((f) => [f, 'formatting — run npm run format']);
|
|
63
|
+
return [[row.label ?? row.name ?? row.key, errors.slice(0, 5).join('\n')]];
|
|
64
|
+
}
|
|
65
|
+
return [];
|
|
66
|
+
}
|
package/lib/watch.js
CHANGED
|
@@ -17,7 +17,7 @@ import { screen, outer } from './watch-widgets.js';
|
|
|
17
17
|
/**
|
|
18
18
|
*
|
|
19
19
|
*/
|
|
20
|
-
function toRow(check) { return { ...check, pass: null, detail: '', result: null }; }
|
|
20
|
+
function toRow(check) { return { ...check, pass: check.keyBinding ? false : null, detail: '', result: null }; }
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
*
|
|
@@ -78,6 +78,13 @@ export async function startWatch(projectDir) {
|
|
|
78
78
|
render(rows, lastCheck.value, watchDirs, currentViolations.value);
|
|
79
79
|
run(new Set(specRows.filter((r) => !r.keyBinding).map((r) => r.key)));
|
|
80
80
|
|
|
81
|
+
// Refresh idle key-bound rows so "X ago" detail stays current.
|
|
82
|
+
const idleTimer = setInterval(() => {
|
|
83
|
+
if (specRows.some((r) => r.keyBinding && r.pass === false && r.ranAt))
|
|
84
|
+
render(rows, lastCheck.value, watchDirs, currentViolations.value);
|
|
85
|
+
}, 30_000);
|
|
86
|
+
idleTimer.unref();
|
|
87
|
+
|
|
81
88
|
setupFix(specRows, cmds, ctx, pending, timers, run);
|
|
82
89
|
|
|
83
90
|
setupLifecycle({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@techninja/clearstack",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A no-build web component framework specification — scaffold, validate, and evolve spec-compliant projects",
|
|
6
6
|
"bin": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"start": "node src/server.js",
|
|
25
25
|
"dev": "node --watch --env-file=.env --env-file=.env.local src/server.js",
|
|
26
|
+
"docs": "node scripts/sync-docs.js",
|
|
26
27
|
"setup": "node scripts/vendor-deps.js && node scripts/build-icons.js",
|
|
27
28
|
"test": "node scripts/test.js",
|
|
28
29
|
"spec": "node --env-file=.env scripts/spec.js",
|