@techninja/clearstack 0.4.4 → 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/lib/check.js +21 -17
- package/lib/server-proc.js +150 -0
- package/lib/spec-config.js +48 -4
- package/lib/spec-i18n-locales.js +8 -6
- package/lib/spec-i18n.js +53 -61
- 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 +3 -2
package/README.md
CHANGED
|
@@ -18,9 +18,11 @@ This entire repository — the specification, the file structure, every componen
|
|
|
18
18
|
npm install -D @techninja/clearstack
|
|
19
19
|
npx clearstack init # scaffold a spec-compliant project
|
|
20
20
|
npm install
|
|
21
|
-
npm run
|
|
21
|
+
npm run spec watch # start dev server + spec dashboard
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
`npm run spec watch` is the recommended way to develop. It spawns your dev server, watches all source files, and runs spec checks continuously — linting, formatting, type checking, line limits, and i18n coverage all in one terminal. Violations surface instantly with file locations and split suggestions ready to copy into your LLM session.
|
|
25
|
+
|
|
24
26
|
## What's In The Box
|
|
25
27
|
|
|
26
28
|
A project/task tracker that exercises every pattern in the spec: API-backed entities, localStorage-only state, realtime sync via SSE, schema-driven endpoints, and a full atomic design component hierarchy — all served as raw ES modules with zero build tools.
|
|
@@ -71,8 +73,9 @@ See [QUICKSTART.md](./docs/QUICKSTART.md) for the full walkthrough.
|
|
|
71
73
|
## Scripts
|
|
72
74
|
|
|
73
75
|
```bash
|
|
74
|
-
npm
|
|
75
|
-
npm
|
|
76
|
+
npm run spec watch # Dev server + continuous spec dashboard (recommended)
|
|
77
|
+
npm start # Start server only
|
|
78
|
+
npm run dev # Start server with --watch
|
|
76
79
|
npm test # Node + browser tests
|
|
77
80
|
npm run lint # ESLint check
|
|
78
81
|
npm run lint:fix # ESLint auto-fix
|
|
@@ -85,6 +88,14 @@ npm run spec docs # Check doc files ≤500 lines
|
|
|
85
88
|
npm run spec update # Sync docs from upstream
|
|
86
89
|
```
|
|
87
90
|
|
|
91
|
+
### Watch dashboard keys
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
q / Ctrl-C quit
|
|
95
|
+
↑ ↓ scroll violations
|
|
96
|
+
c copy violations to clipboard (paste into LLM)
|
|
97
|
+
```
|
|
98
|
+
|
|
88
99
|
## License
|
|
89
100
|
|
|
90
101
|
MIT
|
package/bin/cli.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* clearstack init [-y] [--static|--fullstack] [--port 3000]
|
|
7
7
|
* clearstack update [--force]
|
|
8
8
|
* clearstack build [og|og-images|all] → generate OG pages and/or images
|
|
9
|
-
* clearstack check [code|docs|imports|lint|format|types|audit|all]
|
|
9
|
+
* clearstack check [code|docs|imports|lint|format|types|audit|all|watch]
|
|
10
10
|
* clearstack report --json → structured JSON output for tooling
|
|
11
11
|
* clearstack → interactive menu
|
|
12
12
|
*/
|
|
@@ -61,8 +61,13 @@ async function run(action) {
|
|
|
61
61
|
await update(PKG_ROOT, { force: !!flags.force });
|
|
62
62
|
} else if (action === 'check') {
|
|
63
63
|
const subs = args.filter((a) => a !== cmd && !a.startsWith('-'));
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
if (subs[0] === 'watch') {
|
|
65
|
+
const { startWatch } = await import('../lib/watch.js');
|
|
66
|
+
await startWatch(process.cwd());
|
|
67
|
+
} else {
|
|
68
|
+
const { check } = await import('../lib/check.js');
|
|
69
|
+
await check(process.cwd(), subs.join(' ') || undefined, { verbose: !!flags.verbose });
|
|
70
|
+
}
|
|
66
71
|
} else if (action === 'report') {
|
|
67
72
|
const { report } = await import('../lib/report.js');
|
|
68
73
|
report(process.cwd(), { json: !!flags.json });
|
package/lib/check.js
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
import { existsSync, readdirSync } from 'node:fs';
|
|
8
8
|
import { resolve } from 'node:path';
|
|
9
|
-
import {
|
|
9
|
+
import { runCmd } from './spec-utils.js';
|
|
10
|
+
import { countFiles, checkFileLines, checkImports } from './spec-scan.js';
|
|
10
11
|
import { checkI18n } from './spec-i18n.js';
|
|
11
12
|
import { loadConfig, buildCmds, detectRunner } from './spec-config.js';
|
|
12
13
|
|
|
13
|
-
export {
|
|
14
|
+
export { runCmd, elapsed } from './spec-utils.js';
|
|
15
|
+
export { findFiles, countFiles, checkFileLines, checkImports } from './spec-scan.js';
|
|
14
16
|
export { checkI18n } from './spec-i18n.js';
|
|
15
17
|
export { loadConfig, buildCmds, detectRunner } from './spec-config.js';
|
|
16
18
|
|
|
@@ -38,7 +40,7 @@ async function loadExtensions(projectDir) {
|
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
/** @typedef {{ key: string, name: string, parent?: string, run: (opts?: object) => boolean }} Check */
|
|
43
|
+
/** @typedef {{ key: string, name: string, parent?: string, watchExts?: string[], run: (opts?: object) => (boolean | import('./spec-utils.js').CheckResult | Promise<import('./spec-utils.js').CheckResult>) }} Check */
|
|
42
44
|
|
|
43
45
|
/** Find all jsconfig.json files — main config + subdirectories. */
|
|
44
46
|
function findTypeConfigs(dir, runner) {
|
|
@@ -52,7 +54,7 @@ function findTypeConfigs(dir, runner) {
|
|
|
52
54
|
}
|
|
53
55
|
return configs.map((c) => ({
|
|
54
56
|
key: c.key, name: `JSDoc types — ${c.label}`, parent: 'types',
|
|
55
|
-
run: () => runCmd(`Types (${c.label})`, `${runner} tsc --project ${c.path}`, dir)
|
|
57
|
+
run: (o) => runCmd(`Types (${c.label})`, `${runner} tsc --project ${c.path} --noEmit`, dir, undefined, o),
|
|
56
58
|
}));
|
|
57
59
|
}
|
|
58
60
|
|
|
@@ -68,26 +70,26 @@ export async function buildChecks(dir, cfg, cmds) {
|
|
|
68
70
|
const runner = detectRunner(dir);
|
|
69
71
|
const builtin = [
|
|
70
72
|
{ key: 'es', name: 'ESLint', parent: 'lint',
|
|
71
|
-
run: () => runCmd('ESLint', cmds.lint, dir, `${js()} files
|
|
73
|
+
run: (o) => runCmd('ESLint', cmds.lint, dir, `${js()} files`, o) },
|
|
72
74
|
{ key: 'css', name: 'Stylelint', parent: 'lint',
|
|
73
|
-
run: () => runCmd('Stylelint', cmds.stylelint, dir, `${css()} files
|
|
75
|
+
run: (o) => runCmd('Stylelint', cmds.stylelint, dir, `${css()} files`, o) },
|
|
74
76
|
{ key: 'md', name: 'Markdown lint', parent: 'lint',
|
|
75
|
-
run: () => runCmd('Markdown', cmds.mdlint, dir, `${md()} files
|
|
77
|
+
run: (o) => runCmd('Markdown', cmds.mdlint, dir, `${md()} files`, o) },
|
|
76
78
|
{ key: 'prettier', name: 'Prettier', parent: 'format',
|
|
77
|
-
run: () => runCmd('Prettier', cmds.prettier, dir, `${js()} files
|
|
79
|
+
run: (o) => runCmd('Prettier', cmds.prettier, dir, `${js()} files`, o) },
|
|
78
80
|
{ key: 'lines', name: `Code (max ${cfg.codeMax} lines)`, parent: 'code',
|
|
79
|
-
run: () => checkFileLines(dir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`, { exclude: cfg.testPattern })
|
|
81
|
+
run: (o) => checkFileLines(dir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`, { exclude: cfg.testPattern, ...o }) },
|
|
80
82
|
{ key: 'i18n', name: 'i18n readiness', parent: 'code',
|
|
81
|
-
run: (
|
|
83
|
+
run: (o) => checkI18n(dir, cfg.ignore, 'i18n readiness', o) },
|
|
82
84
|
{ key: 'tests', name: `Tests (max ${cfg.testMax} lines)`,
|
|
83
|
-
run: () => checkFileLines(dir, cfg.codeExt, cfg.testMax, cfg.ignore, `Tests (max ${cfg.testMax} lines)`, { include: cfg.testPattern })
|
|
85
|
+
run: (o) => checkFileLines(dir, cfg.codeExt, cfg.testMax, cfg.ignore, `Tests (max ${cfg.testMax} lines)`, { include: cfg.testPattern, ...o }) },
|
|
84
86
|
{ key: 'docs', name: `Docs (max ${cfg.docsMax} lines)`,
|
|
85
|
-
run: () => checkFileLines(dir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)
|
|
87
|
+
run: (o) => checkFileLines(dir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)`, o) },
|
|
86
88
|
{ key: 'imports', name: 'Import map aliases (no ../ imports)',
|
|
87
|
-
run: () => checkImports(dir, cfg.ignore, 'Import map aliases (no ../ imports)')
|
|
89
|
+
run: (o) => checkImports(dir, cfg.ignore, 'Import map aliases (no ../ imports)', o) },
|
|
88
90
|
...findTypeConfigs(dir, runner),
|
|
89
91
|
{ key: 'audit', name: 'Security audit',
|
|
90
|
-
run: () => runCmd('Security audit', cmds.audit, dir)
|
|
92
|
+
run: (o) => runCmd('Security audit', cmds.audit, dir, undefined, o) },
|
|
91
93
|
];
|
|
92
94
|
const extensions = await loadExtensions(dir);
|
|
93
95
|
if (!extensions.length) return builtin;
|
|
@@ -127,14 +129,16 @@ export async function check(projectDir, scope, opts) {
|
|
|
127
129
|
console.log(`Available: ${[...tops, ...parentKeys(checks)].join(', ')}`);
|
|
128
130
|
process.exit(1);
|
|
129
131
|
}
|
|
130
|
-
const
|
|
132
|
+
const results = await Promise.all(matched.map((c) => c.run(opts)));
|
|
133
|
+
const ok = results.every((r) => typeof r === 'boolean' ? r : r.pass);
|
|
131
134
|
if (!ok) process.exit(1);
|
|
132
135
|
return;
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
console.log('🔍 Clearstack compliance checking now... 💙\n');
|
|
136
|
-
const results = checks.map((c) => c.run(opts));
|
|
137
|
-
const
|
|
139
|
+
const results = await Promise.all(checks.map((c) => c.run(opts)));
|
|
140
|
+
const passes = results.map((r) => typeof r === 'boolean' ? r : r.pass);
|
|
141
|
+
const passed = passes.filter(Boolean).length;
|
|
138
142
|
console.log(`\n${'='.repeat(40)}`);
|
|
139
143
|
console.log(`${passed}/${results.length} checks passed.`);
|
|
140
144
|
if (passed < results.length) process.exit(1);
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev server child process manager for spec watch dashboard.
|
|
3
|
+
* Spawns the server, captures output, and maintains a status row
|
|
4
|
+
* that the dashboard can render without any extra logic.
|
|
5
|
+
* @module lib/server-proc
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn, execSync } from 'node:child_process';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Detect the dev server command. Prefers SPEC_SERVER_CMD from env,
|
|
14
|
+
* then probes common entry points. Returns null for static projects.
|
|
15
|
+
* @param {string} dir @param {object} cfg @returns {string|null}
|
|
16
|
+
*/
|
|
17
|
+
export function detectServerCmd(dir, cfg) {
|
|
18
|
+
if (cfg.serverCmd) return cfg.serverCmd;
|
|
19
|
+
for (const entry of ['src/server.js', 'server.js', 'index.js']) {
|
|
20
|
+
if (existsSync(resolve(dir, entry))) return `node --watch ${entry}`;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Kill whatever process is listening on a given port.
|
|
27
|
+
* No-ops silently if nothing is found.
|
|
28
|
+
* @param {number} port
|
|
29
|
+
*/
|
|
30
|
+
export function killPort(port) {
|
|
31
|
+
try {
|
|
32
|
+
const pid = execSync(`lsof -ti tcp:${port}`, { encoding: 'utf-8' }).trim();
|
|
33
|
+
if (pid) execSync(`kill ${pid}`);
|
|
34
|
+
} catch { /* nothing listening, or kill already gone */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @typedef {'starting'|'running'|'restarting'|'crashed'|'disabled'} ServerStatus */
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {object} ServerRow
|
|
41
|
+
* @property {'server'} key
|
|
42
|
+
* @property {string} label
|
|
43
|
+
* @property {ServerStatus} status
|
|
44
|
+
* @property {boolean|null} pass
|
|
45
|
+
* @property {string} detail
|
|
46
|
+
* @property {string} [_errLine]
|
|
47
|
+
* @property {() => void} kill
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/** Lines from Node --watch we don't want to surface as the detail. */
|
|
51
|
+
const NOISE = /^Debugger|^Waiting for|^\s*$/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Spawn the dev server and return a live ServerRow.
|
|
55
|
+
* The row object is mutated in place as output arrives — the dashboard
|
|
56
|
+
* just reads it on each render cycle.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} cmd e.g. 'node --watch src/server.js'
|
|
59
|
+
* @param {string} cwd project root
|
|
60
|
+
* @param {object} env merged into process.env for the child
|
|
61
|
+
* @param {() => void} onUpdate called whenever status/detail changes
|
|
62
|
+
* @returns {ServerRow}
|
|
63
|
+
*/
|
|
64
|
+
export function spawnServer(cmd, cwd, env, onUpdate) {
|
|
65
|
+
/** @type {ServerRow} */
|
|
66
|
+
const row = {
|
|
67
|
+
key: 'server',
|
|
68
|
+
label: 'server',
|
|
69
|
+
status: 'starting',
|
|
70
|
+
pass: null,
|
|
71
|
+
detail: 'starting…',
|
|
72
|
+
kill: () => {},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const [bin, ...args] = cmd.split(/\s+/);
|
|
76
|
+
const child = spawn(bin, args, {
|
|
77
|
+
cwd,
|
|
78
|
+
env: { ...process.env, ...env, FORCE_COLOR: '0' },
|
|
79
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
row.kill = () => child.kill('SIGTERM');
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
*
|
|
86
|
+
*/
|
|
87
|
+
function onLine(line, isStderr) {
|
|
88
|
+
line = line.trim();
|
|
89
|
+
if (!line || NOISE.test(line)) return;
|
|
90
|
+
|
|
91
|
+
if (/restarting/i.test(line)) {
|
|
92
|
+
row.status = 'restarting';
|
|
93
|
+
row.pass = null;
|
|
94
|
+
row.detail = 'restarting…';
|
|
95
|
+
onUpdate();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// Errors on stderr always override — don't let a stale URL persist
|
|
99
|
+
if (isStderr || /error|EADDR|EACCES|ENOENT/i.test(line)) {
|
|
100
|
+
row.status = 'crashed';
|
|
101
|
+
row.pass = false;
|
|
102
|
+
// Prefer 'Error: ...' lines; only upgrade, never downgrade
|
|
103
|
+
const isRich = /^Error:/i.test(line);
|
|
104
|
+
if (!row._errLine || (isRich && !/^Error:/i.test(row._errLine))) {
|
|
105
|
+
row._errLine = line.slice(0, 60);
|
|
106
|
+
row.detail = row._errLine;
|
|
107
|
+
onUpdate();
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (/https?:\/\//i.test(line) || /listening|started|ready/i.test(line)) {
|
|
112
|
+
row.status = 'running';
|
|
113
|
+
row.pass = true;
|
|
114
|
+
row.detail = line;
|
|
115
|
+
onUpdate();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (row.status !== 'running') {
|
|
119
|
+
row.detail = line.slice(0, 60);
|
|
120
|
+
onUpdate();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
child.stdout?.setEncoding('utf-8');
|
|
125
|
+
child.stderr?.setEncoding('utf-8');
|
|
126
|
+
/** @type {Array<[import('stream').Readable|null, boolean]>} */
|
|
127
|
+
const streams = [[child.stdout, false], [child.stderr, true]];
|
|
128
|
+
for (const [stream, isStderr] of streams) {
|
|
129
|
+
if (!stream) continue;
|
|
130
|
+
let buf = '';
|
|
131
|
+
stream.on('data', (chunk) => {
|
|
132
|
+
buf += chunk;
|
|
133
|
+
const lines = buf.split('\n');
|
|
134
|
+
buf = lines.pop();
|
|
135
|
+
for (const l of lines) onLine(l, isStderr);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
child.on('close', (code, signal) => {
|
|
140
|
+
if (signal === 'SIGTERM') return;
|
|
141
|
+
row.status = 'crashed';
|
|
142
|
+
row.pass = false;
|
|
143
|
+
if (!row.detail || row.detail === 'starting…' || row.detail === 'restarting…') {
|
|
144
|
+
row.detail = `exited (code ${code ?? signal})`;
|
|
145
|
+
}
|
|
146
|
+
onUpdate();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return row;
|
|
150
|
+
}
|
package/lib/spec-config.js
CHANGED
|
@@ -35,6 +35,8 @@ export function loadConfig(projectDir) {
|
|
|
35
35
|
const local = existsSync(localPath) ? parseEnv(readFileSync(localPath, 'utf-8')) : {};
|
|
36
36
|
const env = { ...base, ...local };
|
|
37
37
|
return {
|
|
38
|
+
serverCmd: env.SPEC_SERVER_CMD || null,
|
|
39
|
+
rawEnv: env,
|
|
38
40
|
codeMax: parseInt(env.SPEC_CODE_MAX_LINES) || 150,
|
|
39
41
|
testMax: parseInt(env.SPEC_TEST_MAX_LINES) || 300,
|
|
40
42
|
docsMax: parseInt(env.SPEC_DOCS_MAX_LINES) || 500,
|
|
@@ -45,14 +47,56 @@ export function loadConfig(projectDir) {
|
|
|
45
47
|
};
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Default ext→check-key mapping for built-ins.
|
|
52
|
+
* Extensions override this by declaring a `watchExts` array on their check object.
|
|
53
|
+
* @type {Record<string, string[]>}
|
|
54
|
+
*/
|
|
55
|
+
const EXT_DEFAULTS = {
|
|
56
|
+
'.js': ['lines', 'es', 'prettier', 'frontend', 'imports', 'i18n'],
|
|
57
|
+
'.mjs': ['lines', 'es', 'frontend', 'imports'],
|
|
58
|
+
'.css': ['lines', 'css'],
|
|
59
|
+
'.md': ['docs', 'md'],
|
|
60
|
+
'.json': ['i18n'],
|
|
61
|
+
'.php': [],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Checks that run on pure file reads — no subprocess, sub-millisecond. */
|
|
65
|
+
export const FAST_CHECKS = new Set(['lines', 'docs', 'imports', 'i18n', 'tests']);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build ext→keys map, merging EXT_DEFAULTS with `watchExts` declared on extension checks.
|
|
69
|
+
* @param {object[]} checks
|
|
70
|
+
* @returns {(ext: string) => string[]}
|
|
71
|
+
*/
|
|
72
|
+
export function makeExtMap(checks) {
|
|
73
|
+
const map = new Map(Object.entries(EXT_DEFAULTS).map(([k, v]) => [k, new Set(v)]));
|
|
74
|
+
for (const c of checks) {
|
|
75
|
+
if (!c.watchExts) continue;
|
|
76
|
+
for (const ext of c.watchExts) {
|
|
77
|
+
if (!map.has(ext)) map.set(ext, new Set());
|
|
78
|
+
map.get(ext).add(c.key);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return (ext) => [...(map.get(ext) ?? [])];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} projectDir
|
|
86
|
+
* @param {{ watch?: boolean }} [opts]
|
|
87
|
+
*/
|
|
88
|
+
export function buildCmds(projectDir, opts) {
|
|
50
89
|
const runner = detectRunner(projectDir);
|
|
51
|
-
// pnpm 9.x audit hits a retired npm endpoint (410). Use --ignore-registry-errors
|
|
52
|
-
// to avoid failing on registry issues outside the user's control.
|
|
53
90
|
const audit = runner === 'pnpm exec'
|
|
54
91
|
? 'pnpm audit --prod --ignore-registry-errors'
|
|
55
92
|
: 'npm audit --omit=dev';
|
|
93
|
+
if (opts?.watch) return {
|
|
94
|
+
lint: `${runner} eslint --config .configs/eslint.config.js .`,
|
|
95
|
+
stylelint: `${runner} stylelint --config .configs/.stylelintrc.json "src/**/*.css"`,
|
|
96
|
+
prettier: `${runner} prettier --config .configs/.prettierrc --check src scripts`,
|
|
97
|
+
mdlint: `${runner} markdownlint-cli2 --config .configs/.markdownlint.jsonc "docs/**/*.md" "*.md"`,
|
|
98
|
+
audit,
|
|
99
|
+
};
|
|
56
100
|
return {
|
|
57
101
|
lint: `${runner} eslint --config .configs/eslint.config.js . --fix`,
|
|
58
102
|
stylelint: `${runner} stylelint --config .configs/.stylelintrc.json "src/**/*.css" --fix`,
|
package/lib/spec-i18n-locales.js
CHANGED
|
@@ -4,25 +4,27 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
7
|
-
import {
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
8
9
|
import { resolve } from 'node:path';
|
|
9
10
|
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
10
13
|
/**
|
|
11
14
|
* Try to run `npx hybrids extract ./src` and parse the JSON output.
|
|
12
15
|
* @param {string} root
|
|
13
|
-
* @returns {Record<string, object>|null}
|
|
16
|
+
* @returns {Promise<Record<string, object>|null>}
|
|
14
17
|
*/
|
|
15
|
-
export function tryHybridsExtract(root) {
|
|
18
|
+
export async function tryHybridsExtract(root) {
|
|
16
19
|
const srcDir = resolve(root, 'src');
|
|
17
20
|
if (!existsSync(srcDir)) return null;
|
|
18
21
|
try {
|
|
19
|
-
const
|
|
22
|
+
const { stdout } = await execFileAsync('npx', ['hybrids', 'extract', './src'], {
|
|
20
23
|
cwd: root,
|
|
21
24
|
encoding: 'utf-8',
|
|
22
25
|
timeout: 30000,
|
|
23
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
24
26
|
});
|
|
25
|
-
return JSON.parse(
|
|
27
|
+
return JSON.parse(stdout);
|
|
26
28
|
} catch {
|
|
27
29
|
return null;
|
|
28
30
|
}
|
package/lib/spec-i18n.js
CHANGED
|
@@ -6,17 +6,37 @@
|
|
|
6
6
|
|
|
7
7
|
import { readFileSync, existsSync } from 'node:fs';
|
|
8
8
|
import { resolve } from 'node:path';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { elapsed } from './spec-utils.js';
|
|
10
|
+
import { findFiles } from './spec-scan.js';
|
|
11
|
+
import { tryHybridsExtract, loadLocales, parseLocaleJson } from './spec-i18n-locales.js';
|
|
11
12
|
|
|
12
13
|
/** @typedef {import('./spec-utils.js').CheckResult} CheckResult */
|
|
13
14
|
|
|
14
|
-
const PROSE_RE = />\s*[A-Z][a-z]{2,}[^<$`\\]{3,}\s*[<$]/g;
|
|
15
15
|
const T_RE = /\bt\s*\(/;
|
|
16
16
|
const MSG_RE = /\bmsg`/;
|
|
17
17
|
const LOC_RE = /\blocalize\s*\(/;
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract keys from the DEFAULTS object in a t()-based i18n.js file.
|
|
21
|
+
* @param {string} root
|
|
22
|
+
* @returns {string[]|null}
|
|
23
|
+
*/
|
|
24
|
+
function extractTDefaults(root) {
|
|
25
|
+
const candidates = ['src/utils/i18n.js', 'src/i18n.js', 'utils/i18n.js'];
|
|
26
|
+
for (const rel of candidates) {
|
|
27
|
+
const p = resolve(root, rel);
|
|
28
|
+
if (!existsSync(p)) continue;
|
|
29
|
+
const src = readFileSync(p, 'utf-8');
|
|
30
|
+
const block = src.match(/const DEFAULTS\s*=\s*\{([\s\S]*?)\};/);
|
|
31
|
+
if (!block) continue;
|
|
32
|
+
const keys = [];
|
|
33
|
+
const re = /['"]([\.\w]+)['"]\s*:/g;
|
|
34
|
+
let m;
|
|
35
|
+
while ((m = re.exec(block[1])) !== null) keys.push(m[1]);
|
|
36
|
+
if (keys.length) return keys;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
20
40
|
|
|
21
41
|
/**
|
|
22
42
|
* Check i18n readiness — informational, always passes.
|
|
@@ -24,9 +44,9 @@ const TPL_RE = /html`[\s\S]*?`/g;
|
|
|
24
44
|
* @param {string[]} ignoreDirs
|
|
25
45
|
* @param {string} label
|
|
26
46
|
* @param {{ quiet?: boolean, verbose?: boolean }} [opts]
|
|
27
|
-
* @returns {CheckResult}
|
|
47
|
+
* @returns {Promise<CheckResult>}
|
|
28
48
|
*/
|
|
29
|
-
export function checkI18n(root, ignoreDirs, label, opts) {
|
|
49
|
+
export async function checkI18n(root, ignoreDirs, label, opts) {
|
|
30
50
|
const start = performance.now();
|
|
31
51
|
|
|
32
52
|
const srcFiles = findFiles(root, ['.js'], ignoreDirs, root).filter(
|
|
@@ -37,13 +57,12 @@ export function checkI18n(root, ignoreDirs, label, opts) {
|
|
|
37
57
|
!f.endsWith('.test.js'),
|
|
38
58
|
);
|
|
39
59
|
|
|
40
|
-
let usesT = 0, usesMsg = 0, usesLocalize = 0
|
|
60
|
+
let usesT = 0, usesMsg = 0, usesLocalize = 0;
|
|
41
61
|
for (const file of srcFiles) {
|
|
42
62
|
const src = readFileSync(resolve(root, file), 'utf-8');
|
|
43
63
|
if (T_RE.test(src)) usesT++;
|
|
44
64
|
if (MSG_RE.test(src)) usesMsg++;
|
|
45
65
|
if (LOC_RE.test(src)) usesLocalize++;
|
|
46
|
-
if (/router\/index|app.*init|main\.js/.test(file) && INIT_RE.test(src)) hasInit = true;
|
|
47
66
|
}
|
|
48
67
|
|
|
49
68
|
const localesDir = existsSync(resolve(root, 'src/locales'))
|
|
@@ -53,10 +72,9 @@ export function checkI18n(root, ignoreDirs, label, opts) {
|
|
|
53
72
|
: null;
|
|
54
73
|
|
|
55
74
|
const hasOverrides = localesDir ? existsSync(resolve(localesDir, 'overrides.json')) : false;
|
|
56
|
-
const extracted = tryHybridsExtract(root);
|
|
75
|
+
const extracted = await tryHybridsExtract(root);
|
|
57
76
|
const extractedKeys = extracted ? Object.keys(extracted) : null;
|
|
58
77
|
const locales = loadLocales(localesDir);
|
|
59
|
-
const languages = locales.map((l) => l.lang);
|
|
60
78
|
|
|
61
79
|
const patternParts = [
|
|
62
80
|
usesT ? `t() \u00d7${usesT}` : '',
|
|
@@ -65,61 +83,35 @@ export function checkI18n(root, ignoreDirs, label, opts) {
|
|
|
65
83
|
].filter(Boolean);
|
|
66
84
|
|
|
67
85
|
const time = elapsed(start);
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
86
|
+
const coverage = [];
|
|
87
|
+
if (localesDir) {
|
|
88
|
+
// Key source priority: t() DEFAULTS + overrides → hybrids extract → overrides.json alone
|
|
89
|
+
const tKeys = extractTDefaults(root);
|
|
90
|
+
const overrideKeys = hasOverrides
|
|
91
|
+
? [...parseLocaleJson(resolve(localesDir, 'overrides.json'))]
|
|
92
|
+
: [];
|
|
93
|
+
const masterKeys = tKeys
|
|
94
|
+
? [...new Set([...tKeys, ...overrideKeys])]
|
|
95
|
+
: (extractedKeys ?? (overrideKeys.length ? overrideKeys : null));
|
|
96
|
+
if (masterKeys?.length && locales.length) {
|
|
78
97
|
for (const loc of locales) {
|
|
79
|
-
const translated =
|
|
80
|
-
const pct =
|
|
81
|
-
const missing =
|
|
82
|
-
coverage
|
|
83
|
-
}
|
|
84
|
-
} else if (extractedKeys) {
|
|
85
|
-
coverage = `\n Extracted: ${extractedKeys.length} translatable strings`;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
let unwrapped = '';
|
|
89
|
-
if (!extractedKeys) {
|
|
90
|
-
let hardcodedHits = 0, templateFiles = 0;
|
|
91
|
-
for (const file of srcFiles) {
|
|
92
|
-
const src = readFileSync(resolve(root, file), 'utf-8');
|
|
93
|
-
const templates = src.match(TPL_RE);
|
|
94
|
-
if (templates) {
|
|
95
|
-
templateFiles++;
|
|
96
|
-
for (const tpl of templates) {
|
|
97
|
-
const hits = tpl.match(PROSE_RE);
|
|
98
|
-
if (hits) hardcodedHits += hits.length;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
if (hardcodedHits > 0) {
|
|
103
|
-
unwrapped = `\n Unwrapped: ~${hardcodedHits} prose strings across ${templateFiles} template files`;
|
|
98
|
+
const translated = masterKeys.filter((k) => loc.keys.has(k)).length;
|
|
99
|
+
const pct = Math.round((translated / masterKeys.length) * 100);
|
|
100
|
+
const missing = masterKeys.length - translated;
|
|
101
|
+
coverage.push({ lang: loc.lang, pct, missing, total: masterKeys.length });
|
|
104
102
|
}
|
|
105
103
|
}
|
|
104
|
+
}
|
|
106
105
|
|
|
107
|
-
|
|
106
|
+
const coverageDetail = coverage.length
|
|
107
|
+
? coverage.map((c) => `${c.lang} ${c.pct}%${c.missing ? ` (${c.missing} missing)` : ' ✓'}`).join(' · ')
|
|
108
|
+
: (locales.length === 0 ? 'en only' : '');
|
|
108
109
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
console.log(`\n Missing in ${loc.lang} (${missing.length}):`);
|
|
114
|
-
for (const k of missing.slice(0, 50)) {
|
|
115
|
-
const truncated = k.length > 70 ? k.slice(0, 70) + '\u2026' : k;
|
|
116
|
-
console.log(` \u2022 ${truncated}`);
|
|
117
|
-
}
|
|
118
|
-
if (missing.length > 50) console.log(` \u2026 and ${missing.length - 50} more`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
110
|
+
if (!opts?.quiet) {
|
|
111
|
+
const icon = patternParts.length ? '✅' : '⚠️ ';
|
|
112
|
+
const detail = coverageDetail || (patternParts.length ? patternParts.join(', ') : 'no i18n patterns detected');
|
|
113
|
+
console.log(` ${icon} ${label} — ${detail} (${time})`);
|
|
122
114
|
}
|
|
123
115
|
|
|
124
|
-
return { pass: true, label, time };
|
|
116
|
+
return { pass: true, label, time, detail: coverageDetail };
|
|
125
117
|
}
|
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": {
|
|
@@ -54,7 +54,8 @@
|
|
|
54
54
|
],
|
|
55
55
|
"license": "MIT",
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@inquirer/prompts": "^8.3.2"
|
|
57
|
+
"@inquirer/prompts": "^8.3.2",
|
|
58
|
+
"blessed": "^0.1.81"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@open-wc/testing": "^4.0.0",
|