@techninja/clearstack 0.4.4 → 0.4.6

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 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 dev # http://localhost:3000
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 start # Start server
75
- npm run dev # Start with --watch
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
- const { check } = await import('../lib/check.js');
65
- await check(process.cwd(), subs.join(' ') || undefined, { verbose: !!flags.verbose });
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 { checkFileLines, runCmd, countFiles, checkImports } from './spec-utils.js';
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 { checkFileLines, runCmd, countFiles, findFiles, elapsed, checkImports } from './spec-utils.js';
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).pass,
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`).pass },
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`).pass },
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`).pass },
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`).pass },
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 }).pass },
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: (runOpts) => checkI18n(dir, cfg.ignore, 'i18n readiness', runOpts).pass },
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 }).pass },
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)`).pass },
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)').pass },
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).pass },
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 ok = matched.every((c) => c.run(opts));
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 passed = results.filter(Boolean).length;
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
+ }
@@ -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,62 @@ export function loadConfig(projectDir) {
45
47
  };
46
48
  }
47
49
 
48
- /** Build check commands for the detected package manager. */
49
- export function buildCmds(projectDir) {
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
+ fix: {
100
+ es: `${runner} eslint --config .configs/eslint.config.js . --fix`,
101
+ css: `${runner} stylelint --config .configs/.stylelintrc.json "src/**/*.css" --fix`,
102
+ prettier: `${runner} prettier --config .configs/.prettierrc --write src scripts`,
103
+ md: `${runner} markdownlint-cli2 --config .configs/.markdownlint.jsonc --fix "docs/**/*.md" "*.md"`,
104
+ },
105
+ };
56
106
  return {
57
107
  lint: `${runner} eslint --config .configs/eslint.config.js . --fix`,
58
108
  stylelint: `${runner} stylelint --config .configs/.stylelintrc.json "src/**/*.css" --fix`,
@@ -4,25 +4,27 @@
4
4
  */
5
5
 
6
6
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
7
- import { execSync } from 'node:child_process';
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 out = execSync('npx hybrids extract ./src', {
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(out);
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 { findFiles, elapsed } from './spec-utils.js';
10
- import { tryHybridsExtract, loadLocales } from './spec-i18n-locales.js';
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
- const INIT_RE = /loadLocale|localize\s*\(/;
19
- const TPL_RE = /html`[\s\S]*?`/g;
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, hasInit = false;
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 (!opts?.quiet) {
70
- const icon = patternParts.length ? '\u2705' : '\u26a0\ufe0f ';
71
- const pattern = patternParts.length ? patternParts.join(', ') : 'none detected';
72
- const langs = languages.length ? languages.join(', ') : 'en only';
73
- const init = `${hasInit ? 'yes' : 'not detected'} \u00b7 Overrides: ${hasOverrides ? 'yes' : 'no'} \u00b7 Languages: ${langs}`;
74
-
75
- let coverage = '';
76
- if (extractedKeys && locales.length) {
77
- const total = extractedKeys.length;
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 = extractedKeys.filter((k) => loc.keys.has(k)).length;
80
- const pct = total > 0 ? Math.round((translated / total) * 100) : 0;
81
- const missing = total - translated;
82
- coverage += `\n ${loc.lang}: ${translated}/${total} (${pct}%)${missing > 0 ? ` \u2014 ${missing} missing` : ' \u2714'}`;
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
- console.log(` ${icon} ${label} (${time})\n Pattern: ${pattern}\n Init: ${init}${coverage}${unwrapped}`);
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
- if (opts?.verbose && extractedKeys && locales.length) {
110
- for (const loc of locales) {
111
- const missing = extractedKeys.filter((k) => !loc.keys.has(k));
112
- if (missing.length) {
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
  }
@@ -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 — file scanning, command running, timing.
3
- * Used by both the clearstack CLI and the POC spec runner.
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
- /** Recursively find files matching extensions, skipping ignored dirs. */
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 ownErrors = out.trim().split('\n')
135
- .filter((l) => l.trim() && !l.includes('node_modules'));
136
- if (ownErrors.length === 0) {
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 ownErrors) console.log(` ${line}`);
47
+ for (const line of display) console.log(` ${line}`);
143
48
  }
144
- return { pass: false, label, time: elapsed(start), errors: ownErrors };
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
+ }
@@ -0,0 +1,145 @@
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
+ 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 ────────────────────────────────────────────────────────────────────
77
+
78
+ /** @param {object} r */
79
+ function rowIcon(r) {
80
+ if (r.key === 'server') {
81
+ if (r.status === 'running') return '{green-fg}ok{/}';
82
+ if (r.status === 'crashed') return '{red-fg}!{/} ';
83
+ if (r.status === 'restarting') return '{yellow-fg}~{/} ';
84
+ return '{grey-fg}' + getSpin() + '{/}';
85
+ }
86
+ if (r.pass === null) return '{grey-fg}' + getSpin() + '{/}';
87
+ if (r.pass) return '{green-fg}ok{/}';
88
+ return '{red-fg}!{/} ';
89
+ }
90
+
91
+ /** @param {object} r @param {number} w */
92
+ const rowLine = (r, w) => { const n = (r.label ?? r.name ?? r.key).padEnd(w); return ` ${rowIcon(r)} ${r.pass === false ? `{red-fg}${n}{/}` : n} ${r.detail ? `{grey-fg}${r.detail}{/}` : ''}`; };
93
+
94
+ /**
95
+ * Render the dashboard.
96
+ * @param {object[]} rows
97
+ * @param {string} lastCheck
98
+ * @param {string[]} watchDirs
99
+ * @param {string[][]} violations
100
+ */
101
+ export function render(rows, lastCheck, watchDirs, violations) {
102
+ const w = Math.max(...rows.map((r) => (r.label ?? r.name ?? r.key).length));
103
+ const statusLines = rows.map((r) => rowLine(r, w));
104
+ statusLines.push('');
105
+ statusLines.push(`{grey-fg} watching ${watchDirs.join(', ')} last check: ${lastCheck}{/}${getCopyNote()}`);
106
+ statusBox.setContent(statusLines.join('\n'));
107
+ statusBox.height = statusLines.length;
108
+
109
+ const statusHeight = statusLines.length + 1;
110
+ divider.top = statusHeight;
111
+ logBox.top = statusHeight + 1;
112
+
113
+ if (violations.length) {
114
+ const logLines = ['{yellow-fg}─── Copy below into your LLM session ───{/}'];
115
+ for (const [file, detail, ...candidates] of violations) {
116
+ logLines.push('');
117
+ logLines.push(`{cyan-fg}${file}{/}`);
118
+ logLines.push(detail);
119
+ if (candidates.length) {
120
+ logLines.push('{grey-fg}Split candidates:{/}');
121
+ for (const c of candidates) logLines.push(`{grey-fg}${c}{/}`);
122
+ }
123
+ }
124
+ logBox.setContent(logLines.join('\n'));
125
+ logBox.show();
126
+ divider.show();
127
+ } else {
128
+ logBox.hide();
129
+ divider.hide();
130
+ }
131
+
132
+ screen.render();
133
+ }
134
+
135
+ /** Tear down the blessed screen cleanly. */
136
+ export function destroyScreen() {
137
+ screen.destroy();
138
+ }
139
+
140
+ /** Refresh just the status box — used for transient notes like copy confirmation. */
141
+ export function renderNote(rows, lastCheck, watchDirs) {
142
+ const w = Math.max(...rows.map((r) => (r.label ?? r.name ?? r.key).length));
143
+ statusBox.setContent([...rows.map((r) => rowLine(r, w)), '', `{grey-fg} watching ${watchDirs.join(', ')} last check: ${lastCheck}{/}${getCopyNote()}`].join('\n'));
144
+ screen.render();
145
+ }
@@ -0,0 +1,86 @@
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 f fix {/}', 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
+ let _onFix = () => {};
62
+ export const setOnFix = (fn) => { _onFix = fn; };
63
+
64
+ screen.key(['C-c', 'q'], () => _quit());
65
+ screen.key(['pageup', 'up'], () => { logBox.scroll(-1); screen.render(); });
66
+ screen.key(['pagedown', 'down'], () => { logBox.scroll(1); screen.render(); });
67
+ screen.key(['f'], () => _onFix());
68
+ screen.key(['c'], () => {
69
+ const raw = logBox.getContent()
70
+ .replace(/\{[^}]+\}/g, '') // blessed tags
71
+ .replace(/\x1b\[[\d;]*m/g, '') // ANSI escape codes
72
+ .split('\n').slice(1).join('\n') // drop header line
73
+ .trim();
74
+ if (!raw) return;
75
+ const [cmd, ...args] = process.platform === 'darwin'
76
+ ? ['pbcopy']
77
+ : ['xclip', '-selection', 'clipboard'];
78
+ const proc = execFile(cmd, args);
79
+ proc.stdin.end(raw);
80
+ proc.on('close', (code) => {
81
+ _copyNote = code === 0 ? '{green-fg} ✓ copied{/}' : '{red-fg} ✗ copy failed{/}';
82
+ _onCopyNote();
83
+ setTimeout(() => { _copyNote = ''; _onCopyNote(); }, 1500);
84
+ });
85
+ proc.on('error', () => { _copyNote = '{red-fg} ✗ clipboard unavailable{/}'; _onCopyNote(); });
86
+ });
package/lib/watch.js ADDED
@@ -0,0 +1,145 @@
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 { execSync } from 'node:child_process';
11
+ import { loadConfig, buildCmds, makeExtMap, FAST_CHECKS } from './spec-config.js';
12
+ import { buildChecks } from './check.js';
13
+ import { render, extractViolations, renderNote } from './watch-ui.js';
14
+ import { spawnServer, detectServerCmd } from './server-proc.js';
15
+ import { setupLifecycle } from './watch-lifecycle.js';
16
+ import { setOnFix } from './watch-widgets.js';
17
+
18
+ /** Adapt a Check from buildChecks into a watch row (adds pass/detail/result state). */
19
+ function toRow(check) {
20
+ return { ...check, pass: null, detail: '', result: null };
21
+ }
22
+
23
+ /**
24
+ * Start the spec watch dashboard.
25
+ * @param {string} projectDir
26
+ */
27
+ export async function startWatch(projectDir) {
28
+ const cfg = loadConfig(projectDir);
29
+ const cmds = buildCmds(projectDir, { watch: true });
30
+ const checks = await buildChecks(projectDir, cfg, cmds);
31
+ const specRows = checks.map(toRow);
32
+ const checksForExt = makeExtMap(checks);
33
+
34
+ const watchDirs = ['src', 'scripts', 'docs'].filter((d) =>
35
+ existsSync(resolve(projectDir, d)),
36
+ );
37
+
38
+ let lastCheck = 'never';
39
+ let fastTimer = null;
40
+ let slowTimer = null;
41
+ /** @type {Set<string>} */
42
+ const fastPending = new Set();
43
+ /** @type {Set<string>} */
44
+ const slowPending = new Set();
45
+
46
+ // Render immediately — server spawns and checks run async behind it
47
+ const serverCmd = detectServerCmd(projectDir, cfg);
48
+
49
+ /** @type {import('./server-proc.js').ServerRow|null} */
50
+ const serverRow = serverCmd ? { key: 'server', label: 'server', status: 'starting', pass: null, detail: 'starting…', kill: () => {} } : null;
51
+ const rows = serverRow ? [serverRow, ...specRows] : specRows;
52
+
53
+ /**
54
+ *
55
+ */
56
+ function spawnAndWatch() {
57
+ const proc = spawnServer(serverCmd, projectDir, cfg.rawEnv, () => {
58
+ serverRow.status = proc.status;
59
+ serverRow.pass = proc.pass;
60
+ serverRow.detail = proc.detail;
61
+ serverRow.kill = proc.kill;
62
+ render(rows, lastCheck, watchDirs, currentViolations);
63
+ });
64
+ }
65
+ if (serverCmd) spawnAndWatch();
66
+
67
+ /** @type {string[][]} */
68
+ let currentViolations = [];
69
+ render(rows, lastCheck, watchDirs, currentViolations);
70
+
71
+ /**
72
+ *
73
+ */
74
+ function runKeys(keys) {
75
+ const toRun = specRows.filter((r) => keys.has(r.key));
76
+ if (!toRun.length) return;
77
+ let i = 0;
78
+ /**
79
+ *
80
+ */
81
+ async function next() {
82
+ if (i >= toRun.length) {
83
+ const now = new Date();
84
+ lastCheck = `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
85
+ render(rows, lastCheck, watchDirs, currentViolations);
86
+ return;
87
+ }
88
+ const row = toRun[i++];
89
+ const raw = await row.run({ quiet: true });
90
+ row.result = typeof raw === 'boolean' ? { pass: raw } : raw;
91
+ row.pass = row.result.pass;
92
+ row.detail = row.pass
93
+ ? (row.result.detail ?? (row.result.files ? `${row.result.files} files` : ''))
94
+ : (row.result.violations?.length
95
+ ? `${row.result.violations.length} violation(s)`
96
+ : (() => { const errs = row.result.errors ?? []; const n = errs.filter((l) => /\.(js|ts|css|md)/.test(l)).length || errs.length; return `${n} error(s)`; })());
97
+ // Rebuild violations from all currently-failed rows
98
+ currentViolations = specRows.flatMap((r) => extractViolations(r, projectDir));
99
+ render(rows, lastCheck, watchDirs, currentViolations);
100
+ setImmediate(next);
101
+ }
102
+ setImmediate(next);
103
+ }
104
+
105
+ /**
106
+ *
107
+ */
108
+ function runFast() { const keys = new Set(fastPending); fastPending.clear(); runKeys(keys); }
109
+ /**
110
+ *
111
+ */
112
+ function runSlow() { const keys = new Set(slowPending); slowPending.clear(); runKeys(keys); }
113
+
114
+ /**
115
+ *
116
+ */
117
+ function schedule(ext) {
118
+ for (const k of checksForExt(ext)) {
119
+ if (FAST_CHECKS.has(k)) fastPending.add(k);
120
+ else slowPending.add(k);
121
+ }
122
+ if (fastPending.size) { clearTimeout(fastTimer); fastTimer = setTimeout(runFast, 50); }
123
+ if (slowPending.size) { clearTimeout(slowTimer); slowTimer = setTimeout(runSlow, 1500); }
124
+ }
125
+
126
+ runKeys(new Set(specRows.map((r) => r.key)));
127
+
128
+ // f key — run fix commands for all currently-failing fixable checks
129
+ setOnFix(() => {
130
+ const fixable = specRows.filter((r) => !r.pass && cmds.fix?.[r.key]);
131
+ if (!fixable.length) return;
132
+ for (const row of fixable) {
133
+ try { execSync(cmds.fix[row.key], { cwd: projectDir, stdio: 'pipe' }); } catch { /* linter may exit 1 even after fixing */ }
134
+ slowPending.add(row.key);
135
+ }
136
+ clearTimeout(slowTimer);
137
+ slowTimer = setTimeout(runSlow, 100);
138
+ });
139
+
140
+ setupLifecycle({
141
+ serverRow, specRows, projectDir, watchDirs,
142
+ schedule,
143
+ renderNote: () => renderNote(rows, lastCheck, watchDirs),
144
+ });
145
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techninja/clearstack",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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",