@techninja/clearstack 0.3.37 → 0.3.39
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/bin/cli.js +24 -4
- package/docs/OG_METADATA_SPEC.md +98 -51
- package/docs/SPEC_WORKFLOW.md +116 -0
- package/docs/app-spec/SPEC_WATCH_PROPOSAL.md +80 -0
- package/lib/build-og-images.js +122 -0
- package/lib/build-og.js +17 -41
- package/lib/check.js +10 -10
- package/lib/og-default-template.js +48 -0
- package/lib/og-image-template.js +111 -0
- package/lib/og-template.js +2 -2
- package/lib/report.js +106 -0
- package/lib/spec-config.js +7 -3
- package/lib/spec-utils.js +38 -36
- package/package.json +3 -3
- package/templates/shared/docs/clearstack/OG_METADATA_SPEC.md +98 -51
- package/templates/shared/docs/clearstack/SPEC_WORKFLOW.md +116 -0
package/lib/check.js
CHANGED
|
@@ -26,7 +26,7 @@ function findTypeConfigs(dir, runner) {
|
|
|
26
26
|
}
|
|
27
27
|
return configs.map((c) => ({
|
|
28
28
|
key: c.key, name: `JSDoc types — ${c.label}`, parent: 'types',
|
|
29
|
-
run: () => runCmd(`Types (${c.label})`, `${runner} tsc --project ${c.path}`, dir),
|
|
29
|
+
run: () => runCmd(`Types (${c.label})`, `${runner} tsc --project ${c.path}`, dir).pass,
|
|
30
30
|
}));
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -41,24 +41,24 @@ export function buildChecks(dir, cfg, cmds) {
|
|
|
41
41
|
const runner = detectRunner(dir);
|
|
42
42
|
return [
|
|
43
43
|
{ key: 'es', name: 'ESLint', parent: 'lint',
|
|
44
|
-
run: () => runCmd('ESLint', cmds.lint, dir, `${js()} files`) },
|
|
44
|
+
run: () => runCmd('ESLint', cmds.lint, dir, `${js()} files`).pass },
|
|
45
45
|
{ key: 'css', name: 'Stylelint', parent: 'lint',
|
|
46
|
-
run: () => runCmd('Stylelint', cmds.stylelint, dir, `${css()} files`) },
|
|
46
|
+
run: () => runCmd('Stylelint', cmds.stylelint, dir, `${css()} files`).pass },
|
|
47
47
|
{ key: 'md', name: 'Markdown lint', parent: 'lint',
|
|
48
|
-
run: () => runCmd('Markdown', cmds.mdlint, dir, `${md()} files`) },
|
|
48
|
+
run: () => runCmd('Markdown', cmds.mdlint, dir, `${md()} files`).pass },
|
|
49
49
|
{ key: 'prettier', name: 'Prettier', parent: 'format',
|
|
50
|
-
run: () => runCmd('Prettier', cmds.prettier, dir, `${js()} files`) },
|
|
50
|
+
run: () => runCmd('Prettier', cmds.prettier, dir, `${js()} files`).pass },
|
|
51
51
|
{ key: 'code', name: `Code (max ${cfg.codeMax} lines)`,
|
|
52
|
-
run: () => checkFileLines(dir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`, { exclude: cfg.testPattern }) },
|
|
52
|
+
run: () => checkFileLines(dir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`, { exclude: cfg.testPattern }).pass },
|
|
53
53
|
{ key: 'tests', name: `Tests (max ${cfg.testMax} lines)`,
|
|
54
|
-
run: () => checkFileLines(dir, cfg.codeExt, cfg.testMax, cfg.ignore, `Tests (max ${cfg.testMax} lines)`, { include: cfg.testPattern }) },
|
|
54
|
+
run: () => checkFileLines(dir, cfg.codeExt, cfg.testMax, cfg.ignore, `Tests (max ${cfg.testMax} lines)`, { include: cfg.testPattern }).pass },
|
|
55
55
|
{ key: 'docs', name: `Docs (max ${cfg.docsMax} lines)`,
|
|
56
|
-
run: () => checkFileLines(dir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)`) },
|
|
56
|
+
run: () => checkFileLines(dir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)`).pass },
|
|
57
57
|
{ key: 'imports', name: 'Import map aliases (no ../ imports)',
|
|
58
|
-
run: () => checkImports(dir, cfg.ignore, 'Import map aliases (no ../ imports)') },
|
|
58
|
+
run: () => checkImports(dir, cfg.ignore, 'Import map aliases (no ../ imports)').pass },
|
|
59
59
|
...findTypeConfigs(dir, runner),
|
|
60
60
|
{ key: 'audit', name: 'Security audit',
|
|
61
|
-
run: () => runCmd('Security audit', cmds.audit, dir) },
|
|
61
|
+
run: () => runCmd('Security audit', cmds.audit, dir).pass },
|
|
62
62
|
];
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in default OG image template — used when no project template exists.
|
|
3
|
+
* @module lib/og-default-template
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @param {number} w @param {number} h */
|
|
7
|
+
export function builtinTemplate(w, h) {
|
|
8
|
+
return `<!DOCTYPE html>
|
|
9
|
+
<html><head><meta charset="utf-8">
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--bg: {bg}; --surface: {surface}; --primary: {primary};
|
|
13
|
+
--accent: {accent}; --text: {text}; --text-muted: {textMuted};
|
|
14
|
+
}
|
|
15
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
16
|
+
body {
|
|
17
|
+
width: ${w}px; height: ${h}px;
|
|
18
|
+
display: flex; align-items: center; justify-content: center;
|
|
19
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
20
|
+
background: var(--bg); color: var(--text); overflow: hidden;
|
|
21
|
+
}
|
|
22
|
+
.card {
|
|
23
|
+
display: flex; width: 100%; height: 100%; padding: 60px;
|
|
24
|
+
flex-direction: column; justify-content: center; gap: 24px;
|
|
25
|
+
background: linear-gradient(135deg, var(--bg) 0%, var(--surface) 100%);
|
|
26
|
+
}
|
|
27
|
+
.card-with-image { flex-direction: row; align-items: center; }
|
|
28
|
+
.content { flex: 1; display: flex; flex-direction: column; gap: 20px; }
|
|
29
|
+
.title { font-size: 56px; font-weight: 800; line-height: 1.1; color: var(--primary); }
|
|
30
|
+
.description { font-size: 26px; line-height: 1.4; color: var(--text-muted); }
|
|
31
|
+
.logo { position: absolute; bottom: 40px; right: 60px; opacity: 0.7; height: 40px; }
|
|
32
|
+
.hero { width: 360px; height: 360px; border-radius: 24px; object-fit: cover; flex-shrink: 0; }
|
|
33
|
+
.badge { font-size: 18px; text-transform: uppercase; letter-spacing: 2px; color: var(--accent); font-weight: 600; }
|
|
34
|
+
.emoji { font-size: 80px; }
|
|
35
|
+
</style></head>
|
|
36
|
+
<body>
|
|
37
|
+
<div class="card {cardClass}">
|
|
38
|
+
<div class="content">
|
|
39
|
+
{badgeHtml}
|
|
40
|
+
{emojiHtml}
|
|
41
|
+
<div class="title">{title}</div>
|
|
42
|
+
<div class="description">{description}</div>
|
|
43
|
+
</div>
|
|
44
|
+
{imageHtml}
|
|
45
|
+
</div>
|
|
46
|
+
{logoHtml}
|
|
47
|
+
</body></html>`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OG image template resolver — finds the right template for a route,
|
|
3
|
+
* renders it with interpolated data, ready for Playwright screenshot.
|
|
4
|
+
* @module lib/og-image-template
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import { interpolate } from './og-template.js';
|
|
10
|
+
import { builtinTemplate } from './og-default-template.js';
|
|
11
|
+
|
|
12
|
+
const WIDTH = 1200;
|
|
13
|
+
const HEIGHT = 630;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve template HTML for a route.
|
|
17
|
+
* Priority: route-specific → project default → built-in.
|
|
18
|
+
* @param {string} projectDir
|
|
19
|
+
* @param {string} [templateName]
|
|
20
|
+
*/
|
|
21
|
+
export function resolveTemplate(projectDir, templateName) {
|
|
22
|
+
if (templateName) {
|
|
23
|
+
const custom = resolve(projectDir, `src/og-templates/${templateName}.html`);
|
|
24
|
+
if (existsSync(custom)) return readFileSync(custom, 'utf-8');
|
|
25
|
+
}
|
|
26
|
+
const projectDefault = resolve(projectDir, 'src/og-templates/default.html');
|
|
27
|
+
if (existsSync(projectDefault)) return readFileSync(projectDefault, 'utf-8');
|
|
28
|
+
return builtinTemplate(WIDTH, HEIGHT);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Render a template with data context, inlining local assets as data URIs.
|
|
33
|
+
* @param {string} template
|
|
34
|
+
* @param {Record<string, unknown>} data
|
|
35
|
+
* @param {string} projectDir
|
|
36
|
+
*/
|
|
37
|
+
export function renderTemplate(template, data, projectDir) {
|
|
38
|
+
let html = interpolate(template, data);
|
|
39
|
+
html = html.replace(/src="\/([^"]+)"/g, (_, p) => {
|
|
40
|
+
const abs = resolve(projectDir, 'src', p);
|
|
41
|
+
if (!existsSync(abs)) return `src=""`;
|
|
42
|
+
const ext = p.split('.').pop();
|
|
43
|
+
const mime = ext === 'svg' ? 'image/svg+xml' : `image/${ext}`;
|
|
44
|
+
return `src="data:${mime};base64,${readFileSync(abs).toString('base64')}"`;
|
|
45
|
+
});
|
|
46
|
+
return html;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Load CSS variables from a project's token file if available. */
|
|
50
|
+
export function loadTokens(projectDir) {
|
|
51
|
+
const paths = ['src/styles/tokens.css', 'src/styles/base.css', 'src/styles/global.css'];
|
|
52
|
+
for (const p of paths) {
|
|
53
|
+
const full = resolve(projectDir, p);
|
|
54
|
+
if (!existsSync(full)) continue;
|
|
55
|
+
const css = readFileSync(full, 'utf-8');
|
|
56
|
+
const vars = {};
|
|
57
|
+
for (const m of css.matchAll(/--([^:]+):\s*([^;]+)/g)) vars[m[1].trim()] = m[2].trim();
|
|
58
|
+
if (Object.keys(vars).length) return vars;
|
|
59
|
+
}
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the full data context for template rendering.
|
|
65
|
+
* @param {{ title: string, description?: string, image?: string }} config
|
|
66
|
+
* @param {Record<string, unknown>} itemData
|
|
67
|
+
* @param {{ tokens?: Record<string, string>, logo?: string, siteName?: string }} site
|
|
68
|
+
*/
|
|
69
|
+
export function buildContext(config, itemData, site) {
|
|
70
|
+
const t = site.tokens || {};
|
|
71
|
+
const title = interpolate(config.title, itemData);
|
|
72
|
+
const desc = config.description ? interpolate(config.description, itemData) : '';
|
|
73
|
+
const image = config.image ? interpolate(config.image, itemData) : '';
|
|
74
|
+
const item = itemData.item || {};
|
|
75
|
+
const emoji = item.emoji || itemData.emoji || '';
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
...itemData, ...item,
|
|
79
|
+
title: truncate(title, 60),
|
|
80
|
+
description: truncate(desc, 160),
|
|
81
|
+
bg: t['color-bg'] || '#0f172a',
|
|
82
|
+
surface: t['color-surface'] || '#1e293b',
|
|
83
|
+
primary: t['color-primary'] || '#818cf8',
|
|
84
|
+
accent: t['color-accent'] || '#34d399',
|
|
85
|
+
text: t['color-text'] || '#e2e8f0',
|
|
86
|
+
textMuted: t['color-text-muted'] || '#a8b8cc',
|
|
87
|
+
emoji, image,
|
|
88
|
+
variantsFormatted: formatNum(item.expected_variants),
|
|
89
|
+
uniqueFormatted: formatNum(item.estimated_unique_variants),
|
|
90
|
+
imageHtml: image ? `<img class="hero" src="${image}">` : '',
|
|
91
|
+
cardClass: image ? 'card-with-image' : '',
|
|
92
|
+
logoHtml: site.logo ? `<img class="logo" src="${site.logo}">` : '',
|
|
93
|
+
badgeHtml: site.siteName ? `<div class="badge">${site.siteName}</div>` : '',
|
|
94
|
+
emojiHtml: emoji ? `<div class="emoji">${emoji}</div>` : '',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Format large numbers with K/M suffix. */
|
|
99
|
+
function formatNum(n) {
|
|
100
|
+
if (!n) return '—';
|
|
101
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
102
|
+
if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
|
|
103
|
+
return String(n);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @param {string} str @param {number} max */
|
|
107
|
+
function truncate(str, max) {
|
|
108
|
+
return str.length > max ? str.slice(0, max - 1) + '…' : str;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export { WIDTH, HEIGHT };
|
package/lib/og-template.js
CHANGED
|
@@ -65,9 +65,9 @@ export function injectMeta(html, meta) {
|
|
|
65
65
|
* @returns {string}
|
|
66
66
|
*/
|
|
67
67
|
export function interpolate(template, data) {
|
|
68
|
-
return template.replace(/\{([
|
|
68
|
+
return template.replace(/\{([\w.]+)\}/g, (match, path) => {
|
|
69
69
|
const val = path.split('.').reduce((obj, key) => obj?.[key], data);
|
|
70
|
-
return val !== null && val !== undefined ? String(val) :
|
|
70
|
+
return val !== null && val !== undefined ? String(val) : match;
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
|
package/lib/report.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entropy report — runs the full spec pipeline, collects structured results.
|
|
3
|
+
* Uses the same check functions as `spec all` but in quiet mode for data.
|
|
4
|
+
* @module lib/report
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { runCmd, checkFileLines, checkImports } from './spec-utils.js';
|
|
8
|
+
import { loadConfig, buildCmds, detectRunner } from './spec-config.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Run full spec pipeline and return structured JSON results.
|
|
12
|
+
* @param {string} dir
|
|
13
|
+
* @returns {{ checks: object[], summary: object }}
|
|
14
|
+
*/
|
|
15
|
+
export function collect(dir) {
|
|
16
|
+
const cfg = loadConfig(dir);
|
|
17
|
+
const cmds = buildCmds(dir);
|
|
18
|
+
const runner = detectRunner(dir);
|
|
19
|
+
const q = { quiet: true };
|
|
20
|
+
|
|
21
|
+
// Phase 1: lint/format fixes (mutating, like spec all)
|
|
22
|
+
const lint = runCmd('ESLint', cmds.lint, dir, undefined, q);
|
|
23
|
+
const stylelint = runCmd('Stylelint', cmds.stylelint, dir, undefined, q);
|
|
24
|
+
const prettier = runCmd('Prettier', cmds.prettier, dir, undefined, q);
|
|
25
|
+
const mdlint = runCmd('Markdown', cmds.mdlint, dir, undefined, q);
|
|
26
|
+
|
|
27
|
+
// Phase 2: measure post-fix state
|
|
28
|
+
const code = checkFileLines(dir, cfg.codeExt, cfg.codeMax, cfg.ignore, 'Code', { exclude: cfg.testPattern, quiet: true });
|
|
29
|
+
const tests = checkFileLines(dir, cfg.codeExt, cfg.testMax, cfg.ignore, 'Tests', { include: cfg.testPattern, quiet: true });
|
|
30
|
+
const docs = checkFileLines(dir, cfg.docsExt, cfg.docsMax, cfg.ignore, 'Docs', { quiet: true });
|
|
31
|
+
const imports = checkImports(dir, cfg.ignore, 'Imports', q);
|
|
32
|
+
|
|
33
|
+
// Phase 3: types
|
|
34
|
+
const types = runCmd('Types', `${runner} tsc --project .configs/jsconfig.json`, dir, undefined, q);
|
|
35
|
+
|
|
36
|
+
// Phase 4: audit
|
|
37
|
+
const audit = runCmd('Audit', cmds.audit, dir, undefined, q);
|
|
38
|
+
|
|
39
|
+
const checks = [lint, stylelint, mdlint, prettier, code, tests, docs, imports, types, audit];
|
|
40
|
+
const allLineViolations = [...(code.violations || []), ...(tests.violations || []), ...(docs.violations || [])];
|
|
41
|
+
const totalFiles = (code.files || 0) + (tests.files || 0) + (docs.files || 0);
|
|
42
|
+
const excessLines = allLineViolations.reduce((s, v) => s + (v.lines - v.max), 0);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
checks,
|
|
46
|
+
summary: {
|
|
47
|
+
totalFiles,
|
|
48
|
+
totalChecks: checks.length,
|
|
49
|
+
passed: checks.filter((c) => c.pass).length,
|
|
50
|
+
fileViolations: allLineViolations.length,
|
|
51
|
+
importViolations: imports.violations?.length || 0,
|
|
52
|
+
excessLines,
|
|
53
|
+
lintErrors: (lint.errors?.length || 0) + (stylelint.errors?.length || 0),
|
|
54
|
+
typeErrors: types.errors?.length || 0,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run report and print human-friendly output (or JSON with --json flag).
|
|
61
|
+
* @param {string} projectDir
|
|
62
|
+
* @param {{ json?: boolean }} [opts]
|
|
63
|
+
*/
|
|
64
|
+
export function report(projectDir, opts = {}) {
|
|
65
|
+
console.log('\n🔧 Running full spec pipeline...\n');
|
|
66
|
+
const { checks, summary } = collect(projectDir);
|
|
67
|
+
|
|
68
|
+
if (opts.json) {
|
|
69
|
+
console.log(JSON.stringify({ checks, summary }, null, 2));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const pct = summary.totalFiles > 0
|
|
74
|
+
? ((summary.totalFiles - summary.fileViolations) / summary.totalFiles * 100).toFixed(1)
|
|
75
|
+
: '100.0';
|
|
76
|
+
|
|
77
|
+
console.log('📊 Clearstack Entropy Report\n');
|
|
78
|
+
console.log(` Checks: ${summary.passed}/${summary.totalChecks} passed`);
|
|
79
|
+
console.log(` Project size: ${summary.totalFiles} files`);
|
|
80
|
+
console.log(` Compliance: ${pct}% of files within line limits\n`);
|
|
81
|
+
|
|
82
|
+
for (const c of checks) {
|
|
83
|
+
const icon = c.pass ? '✅' : '❌';
|
|
84
|
+
console.log(` ${icon} ${c.label} (${c.time})`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (summary.excessLines > 0) {
|
|
88
|
+
const violations = [...(checks[4].violations || []), ...(checks[5].violations || []), ...(checks[6].violations || [])];
|
|
89
|
+
violations.sort((a, b) => (b.lines - b.max) - (a.lines - a.max));
|
|
90
|
+
console.log(`\n Total excess: ${summary.excessLines} lines over limits across ${summary.fileViolations} file(s)`);
|
|
91
|
+
console.log(' Top offenders:');
|
|
92
|
+
for (const v of violations.slice(0, 5)) console.log(` ${v.file}: ${v.lines} lines (+${v.lines - v.max})`);
|
|
93
|
+
const unowned = violations.filter((v) => /\b(vendor|dist|build)\b/.test(v.file));
|
|
94
|
+
if (unowned.length) console.log(`\n 💡 ${unowned.length} in vendor/dist — consider adding to SPEC_IGNORE_DIRS`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (summary.lintErrors > 0) console.log(`\n ⚠️ ${summary.lintErrors} unfixable lint error(s) remain`);
|
|
98
|
+
if (summary.typeErrors > 0) console.log(` ⚠️ ${summary.typeErrors} type error(s)`);
|
|
99
|
+
if (summary.importViolations > 0) console.log(` ⚠️ ${summary.importViolations} parent (../) import(s) need aliases`);
|
|
100
|
+
|
|
101
|
+
if (summary.passed === summary.totalChecks) {
|
|
102
|
+
console.log('\n ✨ All clear — zero entropy drift.\n');
|
|
103
|
+
} else {
|
|
104
|
+
console.log('');
|
|
105
|
+
}
|
|
106
|
+
}
|
package/lib/spec-config.js
CHANGED
|
@@ -13,8 +13,9 @@ export function detectRunner(projectDir) {
|
|
|
13
13
|
return 'npx';
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/** @param {string} src */
|
|
16
|
+
/** @param {string} src @returns {Record<string, string>} */
|
|
17
17
|
function parseEnv(src) {
|
|
18
|
+
/** @type {Record<string, string>} */
|
|
18
19
|
const env = {};
|
|
19
20
|
for (const line of src.split('\n')) {
|
|
20
21
|
const m = line.match(/^([^#=]+)=(.*)$/);
|
|
@@ -24,12 +25,15 @@ function parseEnv(src) {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
|
-
* Load spec config from project .env.
|
|
28
|
+
* Load spec config from project .env, layering .env.local on top.
|
|
28
29
|
* @param {string} projectDir
|
|
29
30
|
*/
|
|
30
31
|
export function loadConfig(projectDir) {
|
|
31
32
|
const envPath = resolve(projectDir, '.env');
|
|
32
|
-
const
|
|
33
|
+
const localPath = resolve(projectDir, '.env.local');
|
|
34
|
+
const base = existsSync(envPath) ? parseEnv(readFileSync(envPath, 'utf-8')) : {};
|
|
35
|
+
const local = existsSync(localPath) ? parseEnv(readFileSync(localPath, 'utf-8')) : {};
|
|
36
|
+
const env = { ...base, ...local };
|
|
33
37
|
return {
|
|
34
38
|
codeMax: parseInt(env.SPEC_CODE_MAX_LINES) || 150,
|
|
35
39
|
testMax: parseInt(env.SPEC_TEST_MAX_LINES) || 300,
|
package/lib/spec-utils.js
CHANGED
|
@@ -35,23 +35,16 @@ export function findFiles(dir, extensions, ignoreDirs, root = dir) {
|
|
|
35
35
|
* @param {string} root
|
|
36
36
|
* @param {string[]} extensions
|
|
37
37
|
* @param {string[]} ignoreDirs
|
|
38
|
-
* @param {string[]} [dirs] - Subdirectories to scope to
|
|
39
|
-
* @param {RegExp} [filter] - Optional regex filter on filenames
|
|
40
38
|
* @returns {number}
|
|
41
39
|
*/
|
|
42
|
-
export function countFiles(root, extensions, ignoreDirs
|
|
43
|
-
|
|
44
|
-
if (dirs) {
|
|
45
|
-
files = dirs.reduce((acc, d) => {
|
|
46
|
-
try { return acc.concat(findFiles(resolve(root, d), extensions, ignoreDirs, root)); }
|
|
47
|
-
catch { return acc; }
|
|
48
|
-
}, []);
|
|
49
|
-
} else {
|
|
50
|
-
files = findFiles(root, extensions, ignoreDirs, root);
|
|
51
|
-
}
|
|
52
|
-
return filter ? files.filter((f) => filter.test(f)).length : files.length;
|
|
40
|
+
export function countFiles(root, extensions, ignoreDirs) {
|
|
41
|
+
return findFiles(root, extensions, ignoreDirs, root).length;
|
|
53
42
|
}
|
|
54
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
|
+
|
|
55
48
|
/**
|
|
56
49
|
* Check file line counts with timing.
|
|
57
50
|
* @param {string} root
|
|
@@ -59,8 +52,8 @@ export function countFiles(root, extensions, ignoreDirs, dirs, filter) {
|
|
|
59
52
|
* @param {number} max
|
|
60
53
|
* @param {string[]} ignoreDirs
|
|
61
54
|
* @param {string} label
|
|
62
|
-
* @param {{ exclude?: string, include?: string }} [filter]
|
|
63
|
-
* @returns {
|
|
55
|
+
* @param {{ exclude?: string, include?: string, quiet?: boolean }} [filter]
|
|
56
|
+
* @returns {CheckResult & { violations?: LineViolation[] }}
|
|
64
57
|
*/
|
|
65
58
|
export function checkFileLines(root, extensions, max, ignoreDirs, label, filter) {
|
|
66
59
|
const start = performance.now();
|
|
@@ -74,12 +67,14 @@ export function checkFileLines(root, extensions, max, ignoreDirs, label, filter)
|
|
|
74
67
|
}
|
|
75
68
|
const time = elapsed(start);
|
|
76
69
|
if (violations.length === 0) {
|
|
77
|
-
console.log(` ✅ ${label} (${files.length} files, ${time})`);
|
|
78
|
-
return true;
|
|
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})`);
|
|
79
76
|
}
|
|
80
|
-
|
|
81
|
-
for (const v of violations) console.log(` ${v.file}: ${v.lines} lines (max ${v.max})`);
|
|
82
|
-
return false;
|
|
77
|
+
return { pass: false, label, time, files: files.length, violations };
|
|
83
78
|
}
|
|
84
79
|
|
|
85
80
|
/**
|
|
@@ -88,9 +83,10 @@ export function checkFileLines(root, extensions, max, ignoreDirs, label, filter)
|
|
|
88
83
|
* @param {string} root
|
|
89
84
|
* @param {string[]} ignoreDirs
|
|
90
85
|
* @param {string} label
|
|
91
|
-
* @
|
|
86
|
+
* @param {{ quiet?: boolean }} [opts]
|
|
87
|
+
* @returns {CheckResult & { violations?: ImportViolation[] }}
|
|
92
88
|
*/
|
|
93
|
-
export function checkImports(root, ignoreDirs, label) {
|
|
89
|
+
export function checkImports(root, ignoreDirs, label, opts) {
|
|
94
90
|
const start = performance.now();
|
|
95
91
|
const files = findFiles(root, ['.js'], ignoreDirs, root)
|
|
96
92
|
.filter((f) => f.startsWith('src/') && !f.includes('vendor/') && !f.includes('api/') && !f.endsWith('.test.js') && !f.endsWith('server.js'));
|
|
@@ -106,39 +102,45 @@ export function checkImports(root, ignoreDirs, label) {
|
|
|
106
102
|
}
|
|
107
103
|
const time = elapsed(start);
|
|
108
104
|
if (violations.length === 0) {
|
|
109
|
-
console.log(` ✅ ${label} (${files.length} files, ${time})`);
|
|
110
|
-
return true;
|
|
105
|
+
if (!opts?.quiet) console.log(` ✅ ${label} (${files.length} files, ${time})`);
|
|
106
|
+
return { pass: true, label, time, files: files.length };
|
|
111
107
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 };
|
|
115
113
|
}
|
|
116
114
|
|
|
115
|
+
|
|
117
116
|
/**
|
|
118
117
|
* Run a shell command, report pass/fail with timing.
|
|
119
118
|
* @param {string} label
|
|
120
119
|
* @param {string} cmd
|
|
121
120
|
* @param {string} cwd
|
|
122
121
|
* @param {string} [stats]
|
|
123
|
-
* @
|
|
122
|
+
* @param {{ quiet?: boolean }} [opts]
|
|
123
|
+
* @returns {CheckResult}
|
|
124
124
|
*/
|
|
125
|
-
export function runCmd(label, cmd, cwd, stats) {
|
|
125
|
+
export function runCmd(label, cmd, cwd, stats, opts) {
|
|
126
126
|
const start = performance.now();
|
|
127
127
|
const suffix = (s) => s ? ` (${s}, ${elapsed(start)})` : ` (${elapsed(start)})`;
|
|
128
128
|
try {
|
|
129
129
|
execSync(cmd, { cwd, stdio: 'pipe', encoding: 'utf-8' });
|
|
130
|
-
console.log(` ✅ ${label}${suffix(stats)}`);
|
|
131
|
-
return true;
|
|
130
|
+
if (!opts?.quiet) console.log(` ✅ ${label}${suffix(stats)}`);
|
|
131
|
+
return { pass: true, label, time: elapsed(start), files: parseInt(stats) || undefined };
|
|
132
132
|
} catch (err) {
|
|
133
133
|
const out = (err.stdout || '') + (err.stderr || '');
|
|
134
134
|
const ownErrors = out.trim().split('\n')
|
|
135
135
|
.filter((l) => l.trim() && !l.includes('node_modules'));
|
|
136
136
|
if (ownErrors.length === 0) {
|
|
137
|
-
console.log(` ✅ ${label}${suffix(stats)}`);
|
|
138
|
-
return true;
|
|
137
|
+
if (!opts?.quiet) console.log(` ✅ ${label}${suffix(stats)}`);
|
|
138
|
+
return { pass: true, label, time: elapsed(start) };
|
|
139
|
+
}
|
|
140
|
+
if (!opts?.quiet) {
|
|
141
|
+
console.log(` ❌ ${label}${suffix(stats)}`);
|
|
142
|
+
for (const line of ownErrors) console.log(` ${line}`);
|
|
139
143
|
}
|
|
140
|
-
|
|
141
|
-
for (const line of ownErrors) console.log(` ${line}`);
|
|
142
|
-
return false;
|
|
144
|
+
return { pass: false, label, time: elapsed(start), errors: ownErrors };
|
|
143
145
|
}
|
|
144
146
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@techninja/clearstack",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.39",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A no-build web component framework specification — scaffold, validate, and evolve spec-compliant projects",
|
|
6
6
|
"bin": {
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"express": "^5.2.1",
|
|
65
65
|
"hybrids": "^9.1.22",
|
|
66
66
|
"lucide-static": "^1.7.0",
|
|
67
|
-
"markdownlint-cli2": "^0.
|
|
68
|
-
"playwright": "^1.
|
|
67
|
+
"markdownlint-cli2": "^0.12.1",
|
|
68
|
+
"playwright": "^1.61.0",
|
|
69
69
|
"prettier": "^3.8.1",
|
|
70
70
|
"stylelint": "^17.6.0",
|
|
71
71
|
"stylelint-config-standard": "^40.0.0",
|