@techninja/clearstack 0.3.17 → 0.3.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/BUILD_LOG.md +11 -1
- package/docs/CONVENTIONS.md +43 -4
- package/docs/PLATFORM_STACKING.md +486 -0
- package/docs/STATE_AND_ROUTING.md +54 -49
- package/lib/check.js +13 -53
- package/lib/init.js +5 -0
- package/lib/package-gen.js +1 -0
- package/lib/platform-files.js +113 -0
- package/lib/platform.js +52 -0
- package/lib/spec-config.js +55 -0
- package/lib/spec-utils.js +5 -2
- package/lib/update.js +8 -0
- package/package.json +1 -1
- package/templates/fullstack/src/store/realtimeSync.js +4 -4
- package/templates/shared/.configs/eslint.config.js +1 -0
- package/templates/shared/.github/workflows/release.yml +53 -0
- package/templates/shared/docs/clearstack/CONVENTIONS.md +43 -4
- package/templates/shared/docs/clearstack/STATE_AND_ROUTING.md +54 -49
- package/templates/shared/scripts/release.js +81 -0
- package/templates/shared/docs/clearstack/BUILD_LOG.md +0 -349
|
@@ -253,6 +253,43 @@ This applies to any property with `value: []` and an async `connect`.
|
|
|
253
253
|
The `connect` callback sets the value and calls `invalidate()`, but the
|
|
254
254
|
first render happens before that resolves.
|
|
255
255
|
|
|
256
|
+
#### Async Init with Child Component Props
|
|
257
|
+
|
|
258
|
+
When a parent loads data during `connect` and passes it to a child via
|
|
259
|
+
template props, the child may never see the change. Hybrids' change
|
|
260
|
+
detection requires observing old → new. If the prop is set before the
|
|
261
|
+
child mounts, there's no "old" value to compare against.
|
|
262
|
+
|
|
263
|
+
```javascript
|
|
264
|
+
// ❌ BAD — resultCount set during connect, before child mounts
|
|
265
|
+
_init: {
|
|
266
|
+
value: false,
|
|
267
|
+
connect: (host, _key, invalidate) => {
|
|
268
|
+
loadData(host).then(() => {
|
|
269
|
+
host.resultCount = 14;
|
|
270
|
+
invalidate(); // child mounts with 14, never sees a change
|
|
271
|
+
});
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The fix: defer the prop update to after the first render frame so the
|
|
277
|
+
child is mounted and can observe the transition:
|
|
278
|
+
|
|
279
|
+
```javascript
|
|
280
|
+
// ✅ GOOD — child mounts with default (0), then sees 0 → 14
|
|
281
|
+
connect: (host, _key, invalidate) => {
|
|
282
|
+
loadData(host).then(() => {
|
|
283
|
+
invalidate();
|
|
284
|
+
requestAnimationFrame(() => { host.resultCount = loadedCount; });
|
|
285
|
+
});
|
|
286
|
+
},
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Pre-populate external caches during `loadData` so the child has data
|
|
290
|
+
ready when the deferred update triggers. The 0 → 14 transition happens
|
|
291
|
+
within a single frame — no flicker.
|
|
292
|
+
|
|
256
293
|
---
|
|
257
294
|
|
|
258
295
|
## Routing
|
|
@@ -410,37 +447,16 @@ For live data updates, the backend pushes events via **Server-Sent Events
|
|
|
410
447
|
|
|
411
448
|
### Frontend: SSE Listener
|
|
412
449
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
import { store } from 'hybrids';
|
|
417
|
-
|
|
418
|
-
export function connectRealtime(url, modelMap) {
|
|
419
|
-
const source = new EventSource(url);
|
|
420
|
-
|
|
421
|
-
source.addEventListener('update', (event) => {
|
|
422
|
-
const { type } = JSON.parse(event.data);
|
|
423
|
-
const Model = modelMap[type];
|
|
424
|
-
if (Model) store.clear(Model); // full clear triggers re-fetch
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
source.addEventListener('error', () => {
|
|
428
|
-
source.close();
|
|
429
|
-
setTimeout(() => connectRealtime(url, modelMap), 5000);
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
return () => source.close();
|
|
433
|
-
}
|
|
434
|
-
```
|
|
450
|
+
`src/utils/realtimeSync.js` connects to the SSE endpoint, listens for
|
|
451
|
+
`update` events, and calls `store.clear(Model)` to invalidate the cache.
|
|
452
|
+
Any component bound via `store()` re-fetches automatically.
|
|
435
453
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
from the API. This is the mechanism that makes multi-user realtime work —
|
|
439
|
-
when user A creates a task, user B's task list updates automatically.
|
|
454
|
+
The listener reconnects on error (5s delay) and returns a disconnect
|
|
455
|
+
function for cleanup in the router shell's `connect` descriptor.
|
|
440
456
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
457
|
+
`store.clear(Model)` is also called by form submit handlers after a
|
|
458
|
+
successful save. The SSE event that follows is redundant for the local
|
|
459
|
+
user but ensures other connected clients update.
|
|
444
460
|
|
|
445
461
|
### Backend: SSE Endpoint
|
|
446
462
|
|
|
@@ -449,31 +465,20 @@ contract.
|
|
|
449
465
|
|
|
450
466
|
### Wiring It Up
|
|
451
467
|
|
|
452
|
-
In the router shell
|
|
468
|
+
In the router shell, use a `connect` descriptor to start SSE and return
|
|
469
|
+
the disconnect function. Map entity types to store models:
|
|
453
470
|
|
|
454
471
|
```javascript
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
tag: 'app-router',
|
|
460
|
-
stack: router(HomeView, { url: '/' }),
|
|
461
|
-
connection: {
|
|
462
|
-
value: undefined,
|
|
463
|
-
connect(host) {
|
|
464
|
-
const disconnect = connectRealtime('/api/events', {
|
|
465
|
-
user: UserModel,
|
|
466
|
-
});
|
|
467
|
-
return disconnect;
|
|
468
|
-
},
|
|
472
|
+
connection: {
|
|
473
|
+
value: undefined,
|
|
474
|
+
connect(host) {
|
|
475
|
+
return connectRealtime('/api/events', { user: UserModel });
|
|
469
476
|
},
|
|
470
|
-
|
|
471
|
-
});
|
|
477
|
+
},
|
|
472
478
|
```
|
|
473
479
|
|
|
474
|
-
When the backend sends `{ type: "user"
|
|
475
|
-
|
|
476
|
-
re-fetches automatically.
|
|
480
|
+
When the backend sends `{ type: "user" }` over SSE, the store cache for
|
|
481
|
+
`UserModel` is cleared and any bound component re-fetches automatically.
|
|
477
482
|
|
|
478
483
|
### Debouncing Batch Operations
|
|
479
484
|
|
package/lib/check.js
CHANGED
|
@@ -1,64 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Spec compliance checker —
|
|
2
|
+
* Spec compliance checker — check orchestration and resolution.
|
|
3
|
+
* Config lives in spec-config.js, utilities in spec-utils.js.
|
|
3
4
|
* @module lib/check
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import {
|
|
7
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
7
8
|
import { resolve } from 'node:path';
|
|
8
9
|
import { checkFileLines, runCmd, countFiles, checkImports } from './spec-utils.js';
|
|
10
|
+
import { loadConfig, buildCmds, detectRunner } from './spec-config.js';
|
|
9
11
|
|
|
10
12
|
export { checkFileLines, runCmd, countFiles, findFiles, elapsed, checkImports } from './spec-utils.js';
|
|
11
|
-
|
|
12
|
-
/** Detect the project's package manager runner (npx, pnpm exec, yarn). */
|
|
13
|
-
function detectRunner(projectDir) {
|
|
14
|
-
if (existsSync(resolve(projectDir, 'pnpm-lock.yaml'))) return 'pnpm exec';
|
|
15
|
-
if (existsSync(resolve(projectDir, 'yarn.lock'))) return 'yarn';
|
|
16
|
-
return 'npx';
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** @param {string} src */
|
|
20
|
-
function parseEnv(src) {
|
|
21
|
-
const env = {};
|
|
22
|
-
for (const line of src.split('\n')) {
|
|
23
|
-
const m = line.match(/^([^#=]+)=(.*)$/);
|
|
24
|
-
if (m) env[m[1].trim()] = m[2].trim();
|
|
25
|
-
}
|
|
26
|
-
return env;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Load spec config from project .env.
|
|
31
|
-
* @param {string} projectDir
|
|
32
|
-
*/
|
|
33
|
-
export function loadConfig(projectDir) {
|
|
34
|
-
const envPath = resolve(projectDir, '.env');
|
|
35
|
-
const env = existsSync(envPath) ? parseEnv(readFileSync(envPath, 'utf-8')) : {};
|
|
36
|
-
return {
|
|
37
|
-
codeMax: parseInt(env.SPEC_CODE_MAX_LINES) || 150,
|
|
38
|
-
docsMax: parseInt(env.SPEC_DOCS_MAX_LINES) || 500,
|
|
39
|
-
codeExt: (env.SPEC_CODE_EXTENSIONS || '.js,.css').split(','),
|
|
40
|
-
docsExt: (env.SPEC_DOCS_EXTENSIONS || '.md').split(','),
|
|
41
|
-
ignore: (env.SPEC_IGNORE_DIRS || 'node_modules,src/vendor,.git,.configs').split(','),
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Build check commands for the detected package manager. */
|
|
46
|
-
export function buildCmds(projectDir) {
|
|
47
|
-
const runner = detectRunner(projectDir);
|
|
48
|
-
const audit = runner === 'pnpm exec' ? 'pnpm audit --prod' : 'npm audit --omit=dev';
|
|
49
|
-
return {
|
|
50
|
-
lint: `${runner} eslint --config .configs/eslint.config.js . --fix`,
|
|
51
|
-
stylelint: `${runner} stylelint --config .configs/.stylelintrc.json "src/**/*.css" --fix`,
|
|
52
|
-
prettier: `${runner} prettier --config .configs/.prettierrc --write src scripts`,
|
|
53
|
-
mdlint: `${runner} markdownlint-cli2 --config .configs/.markdownlint.jsonc --fix "docs/**/*.md" "*.md"`,
|
|
54
|
-
audit,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
13
|
+
export { loadConfig, buildCmds, detectRunner } from './spec-config.js';
|
|
58
14
|
|
|
59
15
|
/** @typedef {{ key: string, name: string, parent?: string, run: () => boolean }} Check */
|
|
60
16
|
|
|
61
|
-
/** Find all jsconfig.json files — main config +
|
|
17
|
+
/** Find all jsconfig.json files — main config + subdirectories. */
|
|
62
18
|
function findTypeConfigs(dir, runner) {
|
|
63
19
|
const main = resolve(dir, '.configs/jsconfig.json');
|
|
64
20
|
const configs = [];
|
|
@@ -74,8 +30,10 @@ function findTypeConfigs(dir, runner) {
|
|
|
74
30
|
}));
|
|
75
31
|
}
|
|
76
32
|
|
|
77
|
-
/**
|
|
78
|
-
*
|
|
33
|
+
/**
|
|
34
|
+
* Build the unified checks array. Children have a `parent` key.
|
|
35
|
+
* @returns {Check[]}
|
|
36
|
+
*/
|
|
79
37
|
export function buildChecks(dir, cfg, cmds) {
|
|
80
38
|
const js = () => countFiles(dir, ['.js'], cfg.ignore);
|
|
81
39
|
const css = () => countFiles(dir, ['.css'], cfg.ignore);
|
|
@@ -91,7 +49,9 @@ export function buildChecks(dir, cfg, cmds) {
|
|
|
91
49
|
{ key: 'prettier', name: 'Prettier', parent: 'format',
|
|
92
50
|
run: () => runCmd('Prettier', cmds.prettier, dir, `${js()} files`) },
|
|
93
51
|
{ key: 'code', name: `Code (max ${cfg.codeMax} lines)`,
|
|
94
|
-
run: () => checkFileLines(dir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)
|
|
52
|
+
run: () => checkFileLines(dir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`, { exclude: cfg.testPattern }) },
|
|
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 }) },
|
|
95
55
|
{ key: 'docs', name: `Docs (max ${cfg.docsMax} lines)`,
|
|
96
56
|
run: () => checkFileLines(dir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)`) },
|
|
97
57
|
{ key: 'imports', name: 'Import map aliases (no ../ imports)',
|
package/lib/init.js
CHANGED
|
@@ -7,6 +7,7 @@ import { copyFileSync, existsSync, readFileSync } from 'node:fs';
|
|
|
7
7
|
import { basename, resolve } from 'node:path';
|
|
8
8
|
import { copyTemplates } from './copy.js';
|
|
9
9
|
import { writePackageJson } from './package-gen.js';
|
|
10
|
+
import { detectPlatforms, initPlatform } from './platform.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* @typedef {Object} InitOptions
|
|
@@ -75,6 +76,10 @@ export async function init(pkgRoot, opts = {}) {
|
|
|
75
76
|
console.log(' ✓ .env.local (edit this, .env has defaults)');
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
// Platform stacking: detect and scaffold platform layers
|
|
80
|
+
const platforms = detectPlatforms(dest);
|
|
81
|
+
for (const platform of platforms) initPlatform(platform, dest);
|
|
82
|
+
|
|
78
83
|
console.log(`\n✅ Project scaffolded at ${dest}`);
|
|
79
84
|
console.log(' npm install');
|
|
80
85
|
console.log(' npm run dev\n');
|
package/lib/package-gen.js
CHANGED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform file operations — vendor, sync, init, update.
|
|
3
|
+
* Detection lives in platform.js.
|
|
4
|
+
* @module lib/platform-files
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { cpSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
8
|
+
import { resolve, join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Copy src → dest recursively (always overwrite).
|
|
12
|
+
* @param {string} src
|
|
13
|
+
* @param {string} dest
|
|
14
|
+
* @param {string} label
|
|
15
|
+
*/
|
|
16
|
+
function copyDir(src, dest, label) {
|
|
17
|
+
if (!existsSync(src)) return 0;
|
|
18
|
+
mkdirSync(dest, { recursive: true });
|
|
19
|
+
cpSync(src, dest, { recursive: true });
|
|
20
|
+
console.log(` ✓ Synced: ${label}`);
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Copy entries from src → dest, skipping files that already exist.
|
|
26
|
+
* @param {string} src
|
|
27
|
+
* @param {string} dest
|
|
28
|
+
* @param {string} label
|
|
29
|
+
*/
|
|
30
|
+
function copySkipExisting(src, dest, label) {
|
|
31
|
+
if (!existsSync(src)) return 0;
|
|
32
|
+
mkdirSync(dest, { recursive: true });
|
|
33
|
+
let count = 0;
|
|
34
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
35
|
+
const destPath = join(dest, entry.name);
|
|
36
|
+
if (existsSync(destPath)) {
|
|
37
|
+
console.log(` ⏭ Skipped: ${label}/${entry.name} (exists)`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
cpSync(join(src, entry.name), destPath, { recursive: entry.isDirectory() });
|
|
41
|
+
console.log(` ✓ Created: ${label}/${entry.name}`);
|
|
42
|
+
count++;
|
|
43
|
+
}
|
|
44
|
+
return count;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Copy src → dest recursively, overwriting files but merging directories.
|
|
49
|
+
* @param {string} src
|
|
50
|
+
* @param {string} dest
|
|
51
|
+
* @param {string} label
|
|
52
|
+
*/
|
|
53
|
+
function copyMerge(src, dest, label) {
|
|
54
|
+
if (!existsSync(src)) return 0;
|
|
55
|
+
mkdirSync(dest, { recursive: true });
|
|
56
|
+
let count = 0;
|
|
57
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
58
|
+
const srcPath = join(src, entry.name);
|
|
59
|
+
const destPath = join(dest, entry.name);
|
|
60
|
+
if (entry.isDirectory()) {
|
|
61
|
+
count += copyMerge(srcPath, destPath, `${label}/${entry.name}`);
|
|
62
|
+
} else {
|
|
63
|
+
cpSync(srcPath, destPath);
|
|
64
|
+
console.log(` ✓ ${label}/${entry.name}`);
|
|
65
|
+
count++;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return count;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Vendor platform files → src/vendor/<prefix>/ (always overwrite). */
|
|
72
|
+
export function vendorPlatform(platform, projectDir) {
|
|
73
|
+
const { manifest, pkgDir } = platform;
|
|
74
|
+
return copyDir(
|
|
75
|
+
resolve(pkgDir, manifest.vendor),
|
|
76
|
+
resolve(projectDir, manifest.vendorDir),
|
|
77
|
+
`${manifest.prefix} → ${manifest.vendorDir}/`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Sync platform docs → docs/<prefix>/ (always overwrite). */
|
|
82
|
+
export function syncPlatformDocs(platform, projectDir) {
|
|
83
|
+
const { manifest, pkgDir } = platform;
|
|
84
|
+
if (!manifest.docs) return 0;
|
|
85
|
+
return copyDir(
|
|
86
|
+
resolve(pkgDir, manifest.docs),
|
|
87
|
+
resolve(projectDir, `docs/${manifest.prefix}`),
|
|
88
|
+
`docs/${manifest.prefix}/`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Run full platform init: templates, vendor, docs, scripts, api. */
|
|
93
|
+
export function initPlatform(platform, projectDir) {
|
|
94
|
+
const { manifest, pkgDir, name } = platform;
|
|
95
|
+
console.log(`\n📦 Platform detected: ${name} (${manifest.prefix})\n`);
|
|
96
|
+
copyMerge(resolve(pkgDir, manifest.templates), projectDir, 'templates');
|
|
97
|
+
vendorPlatform(platform, projectDir);
|
|
98
|
+
syncPlatformDocs(platform, projectDir);
|
|
99
|
+
if (manifest.scripts) {
|
|
100
|
+
copySkipExisting(resolve(pkgDir, manifest.scripts), resolve(projectDir, 'scripts'), 'scripts');
|
|
101
|
+
}
|
|
102
|
+
if (manifest.api) {
|
|
103
|
+
copySkipExisting(resolve(pkgDir, manifest.api), resolve(projectDir, 'api'), 'api');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Run platform update: re-vendor + sync docs only. */
|
|
108
|
+
export function updatePlatform(platform, projectDir) {
|
|
109
|
+
const { name, manifest } = platform;
|
|
110
|
+
console.log(`\n📦 Platform: ${name} (${manifest.prefix})`);
|
|
111
|
+
vendorPlatform(platform, projectDir);
|
|
112
|
+
syncPlatformDocs(platform, projectDir);
|
|
113
|
+
}
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform stacking — detect platforms in project dependencies.
|
|
3
|
+
* File operations live in platform-files.js.
|
|
4
|
+
* @module lib/platform
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
vendorPlatform, syncPlatformDocs, initPlatform, updatePlatform,
|
|
12
|
+
} from './platform-files.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} PlatformManifest
|
|
16
|
+
* @property {string} prefix
|
|
17
|
+
* @property {string} vendorDir
|
|
18
|
+
* @property {string} configFile
|
|
19
|
+
* @property {string} templates
|
|
20
|
+
* @property {string} vendor
|
|
21
|
+
* @property {string} [docs]
|
|
22
|
+
* @property {string} [scripts]
|
|
23
|
+
* @property {string} [api]
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** @typedef {{ name: string, pkgDir: string, manifest: PlatformManifest }} DetectedPlatform */
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Detect platforms in the project's dependencies.
|
|
30
|
+
* @param {string} projectDir
|
|
31
|
+
* @returns {DetectedPlatform[]}
|
|
32
|
+
*/
|
|
33
|
+
export function detectPlatforms(projectDir) {
|
|
34
|
+
const pkgPath = resolve(projectDir, 'package.json');
|
|
35
|
+
if (!existsSync(pkgPath)) return [];
|
|
36
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
37
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
38
|
+
/** @type {DetectedPlatform[]} */
|
|
39
|
+
const platforms = [];
|
|
40
|
+
for (const name of Object.keys(allDeps)) {
|
|
41
|
+
const depPkg = resolve(projectDir, 'node_modules', name, 'package.json');
|
|
42
|
+
if (!existsSync(depPkg)) continue;
|
|
43
|
+
const dep = JSON.parse(readFileSync(depPkg, 'utf-8'));
|
|
44
|
+
if (!dep.clearstack?.platform) continue;
|
|
45
|
+
platforms.push({
|
|
46
|
+
name,
|
|
47
|
+
pkgDir: resolve(projectDir, 'node_modules', name),
|
|
48
|
+
manifest: dep.clearstack.platform,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return platforms;
|
|
52
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec config — environment detection, .env parsing, command building.
|
|
3
|
+
* @module lib/spec-config
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
|
|
9
|
+
/** Detect the project's package manager runner (npx, pnpm exec, yarn). */
|
|
10
|
+
export function detectRunner(projectDir) {
|
|
11
|
+
if (existsSync(resolve(projectDir, 'pnpm-lock.yaml'))) return 'pnpm exec';
|
|
12
|
+
if (existsSync(resolve(projectDir, 'yarn.lock'))) return 'yarn';
|
|
13
|
+
return 'npx';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @param {string} src */
|
|
17
|
+
function parseEnv(src) {
|
|
18
|
+
const env = {};
|
|
19
|
+
for (const line of src.split('\n')) {
|
|
20
|
+
const m = line.match(/^([^#=]+)=(.*)$/);
|
|
21
|
+
if (m) env[m[1].trim()] = m[2].trim();
|
|
22
|
+
}
|
|
23
|
+
return env;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load spec config from project .env.
|
|
28
|
+
* @param {string} projectDir
|
|
29
|
+
*/
|
|
30
|
+
export function loadConfig(projectDir) {
|
|
31
|
+
const envPath = resolve(projectDir, '.env');
|
|
32
|
+
const env = existsSync(envPath) ? parseEnv(readFileSync(envPath, 'utf-8')) : {};
|
|
33
|
+
return {
|
|
34
|
+
codeMax: parseInt(env.SPEC_CODE_MAX_LINES) || 150,
|
|
35
|
+
testMax: parseInt(env.SPEC_TEST_MAX_LINES) || 300,
|
|
36
|
+
docsMax: parseInt(env.SPEC_DOCS_MAX_LINES) || 500,
|
|
37
|
+
codeExt: (env.SPEC_CODE_EXTENSIONS || '.js,.css').split(','),
|
|
38
|
+
docsExt: (env.SPEC_DOCS_EXTENSIONS || '.md').split(','),
|
|
39
|
+
testPattern: env.SPEC_TEST_PATTERN || '.test.js',
|
|
40
|
+
ignore: (env.SPEC_IGNORE_DIRS || 'node_modules,src/vendor,.git,.configs').split(','),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Build check commands for the detected package manager. */
|
|
45
|
+
export function buildCmds(projectDir) {
|
|
46
|
+
const runner = detectRunner(projectDir);
|
|
47
|
+
const audit = runner === 'pnpm exec' ? 'pnpm audit --prod' : 'npm audit --omit=dev';
|
|
48
|
+
return {
|
|
49
|
+
lint: `${runner} eslint --config .configs/eslint.config.js . --fix`,
|
|
50
|
+
stylelint: `${runner} stylelint --config .configs/.stylelintrc.json "src/**/*.css" --fix`,
|
|
51
|
+
prettier: `${runner} prettier --config .configs/.prettierrc --write src scripts`,
|
|
52
|
+
mdlint: `${runner} markdownlint-cli2 --config .configs/.markdownlint.jsonc --fix "docs/**/*.md" "*.md"`,
|
|
53
|
+
audit,
|
|
54
|
+
};
|
|
55
|
+
}
|
package/lib/spec-utils.js
CHANGED
|
@@ -59,11 +59,14 @@ export function countFiles(root, extensions, ignoreDirs, dirs, filter) {
|
|
|
59
59
|
* @param {number} max
|
|
60
60
|
* @param {string[]} ignoreDirs
|
|
61
61
|
* @param {string} label
|
|
62
|
+
* @param {{ exclude?: string, include?: string }} [filter]
|
|
62
63
|
* @returns {boolean}
|
|
63
64
|
*/
|
|
64
|
-
export function checkFileLines(root, extensions, max, ignoreDirs, label) {
|
|
65
|
+
export function checkFileLines(root, extensions, max, ignoreDirs, label, filter) {
|
|
65
66
|
const start = performance.now();
|
|
66
|
-
|
|
67
|
+
let files = findFiles(root, extensions, ignoreDirs, root);
|
|
68
|
+
if (filter?.exclude) files = files.filter((f) => !f.endsWith(filter.exclude));
|
|
69
|
+
if (filter?.include) files = files.filter((f) => f.endsWith(filter.include));
|
|
67
70
|
const violations = [];
|
|
68
71
|
for (const file of files) {
|
|
69
72
|
const lines = readFileSync(resolve(root, file), 'utf-8').trimEnd().split('\n').length;
|
package/lib/update.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
9
9
|
import { resolve, join } from 'node:path';
|
|
10
|
+
import { detectPlatforms, updatePlatform } from './platform.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Sync a source directory to a destination.
|
|
@@ -87,6 +88,13 @@ export async function update(pkgRoot, opts = {}) {
|
|
|
87
88
|
mkdirSync(resolve(process.cwd(), 'docs/clearstack'), { recursive: true });
|
|
88
89
|
writeFileSync(versionPath, pkg.version + '\n');
|
|
89
90
|
|
|
91
|
+
// Platform stacking: re-vendor + sync platform docs
|
|
92
|
+
const platforms = detectPlatforms(process.cwd());
|
|
93
|
+
for (const platform of platforms) {
|
|
94
|
+
updatePlatform(platform, process.cwd());
|
|
95
|
+
total++;
|
|
96
|
+
}
|
|
97
|
+
|
|
90
98
|
console.log(`\n docs/app-spec/ — untouched (your project specs are safe)`);
|
|
91
99
|
|
|
92
100
|
if (total === 0) {
|
package/package.json
CHANGED
|
@@ -33,13 +33,13 @@ export function connectRealtime(url, modelMap) {
|
|
|
33
33
|
console.log(`[SSE] ${type} ${action} — clearing store cache`);
|
|
34
34
|
try {
|
|
35
35
|
store.clear([Model]);
|
|
36
|
-
} catch {
|
|
37
|
-
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.warn(`[SSE] clear list failed for ${type}:`, e.message);
|
|
38
38
|
}
|
|
39
39
|
try {
|
|
40
40
|
store.clear(Model);
|
|
41
|
-
} catch {
|
|
42
|
-
|
|
41
|
+
} catch (e) {
|
|
42
|
+
console.warn(`[SSE] clear singular failed for ${type}:`, e.message);
|
|
43
43
|
}
|
|
44
44
|
}, 300);
|
|
45
45
|
});
|
|
@@ -39,6 +39,7 @@ export default [
|
|
|
39
39
|
'unused-imports/no-unused-imports': 'error',
|
|
40
40
|
'unused-imports/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
|
41
41
|
'no-console': 'off',
|
|
42
|
+
'no-empty': ['error', { allowEmptyCatch: false }],
|
|
42
43
|
|
|
43
44
|
// JSDoc enforcement
|
|
44
45
|
'jsdoc/require-jsdoc': [
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
id-token: write
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
release:
|
|
14
|
+
name: Publish to npm + GitHub Release
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v6
|
|
19
|
+
with:
|
|
20
|
+
fetch-depth: 0
|
|
21
|
+
|
|
22
|
+
- uses: actions/setup-node@v6
|
|
23
|
+
with:
|
|
24
|
+
node-version: 24
|
|
25
|
+
registry-url: https://registry.npmjs.org
|
|
26
|
+
|
|
27
|
+
- run: npm ci
|
|
28
|
+
|
|
29
|
+
- name: Publish to npm
|
|
30
|
+
run: npm publish --provenance --access public
|
|
31
|
+
env:
|
|
32
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
33
|
+
|
|
34
|
+
- name: Generate changelog from commits
|
|
35
|
+
id: changelog
|
|
36
|
+
run: |
|
|
37
|
+
PREV_TAG=$(git tag --sort=-v:refname | sed -n '2p')
|
|
38
|
+
if [ -z "$PREV_TAG" ]; then
|
|
39
|
+
LOG=$(git log --pretty=format:"- %s (%h)" HEAD)
|
|
40
|
+
else
|
|
41
|
+
LOG=$(git log --pretty=format:"- %s (%h)" "$PREV_TAG"..HEAD)
|
|
42
|
+
fi
|
|
43
|
+
echo "log<<EOF" >> "$GITHUB_OUTPUT"
|
|
44
|
+
echo "$LOG" >> "$GITHUB_OUTPUT"
|
|
45
|
+
echo "EOF" >> "$GITHUB_OUTPUT"
|
|
46
|
+
|
|
47
|
+
- name: Create GitHub Release
|
|
48
|
+
uses: softprops/action-gh-release@v2
|
|
49
|
+
with:
|
|
50
|
+
body: |
|
|
51
|
+
## Changes
|
|
52
|
+
${{ steps.changelog.outputs.log }}
|
|
53
|
+
generate_release_notes: true
|
|
@@ -197,6 +197,20 @@ function toggle(host) {
|
|
|
197
197
|
}
|
|
198
198
|
```
|
|
199
199
|
|
|
200
|
+
```javascript
|
|
201
|
+
// BAD — silent catch hides the failure completely
|
|
202
|
+
try {
|
|
203
|
+
store.clear([Model]);
|
|
204
|
+
} catch {
|
|
205
|
+
/* list may not exist */
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Never use empty `catch` blocks.** If you catch to prevent crashing,
|
|
210
|
+
you must `console.warn` or `console.error` so the failure is visible.
|
|
211
|
+
Silent catches turn bugs into ghosts — things "just don't work" with
|
|
212
|
+
zero console output to trace.
|
|
213
|
+
|
|
200
214
|
### ✅ Do
|
|
201
215
|
|
|
202
216
|
```javascript
|
|
@@ -215,6 +229,15 @@ function toggle(host) {
|
|
|
215
229
|
}
|
|
216
230
|
```
|
|
217
231
|
|
|
232
|
+
```javascript
|
|
233
|
+
// GOOD — catch to prevent crash, but log so failures are visible
|
|
234
|
+
try {
|
|
235
|
+
store.clear([Model]);
|
|
236
|
+
} catch (e) {
|
|
237
|
+
console.warn('[store] clear failed:', e.message);
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
218
241
|
---
|
|
219
242
|
|
|
220
243
|
### ✅ Always Do This
|
|
@@ -251,10 +274,24 @@ export const fullName = (user) => `${user.firstName} ${user.lastName}`;
|
|
|
251
274
|
|
|
252
275
|
---
|
|
253
276
|
|
|
254
|
-
## File Size:
|
|
277
|
+
## File Size: Why 150 Lines
|
|
278
|
+
|
|
279
|
+
The 150-line limit isn't about the number — it's about **context cost**.
|
|
280
|
+
A 400-line file isn't hard to scroll through, but it's expensive to
|
|
281
|
+
_hold in mind_. Humans can only reason about a limited scope at once.
|
|
282
|
+
LLMs face the same constraint differently: every token spent reading a
|
|
283
|
+
bloated file is a token not spent on the actual task. Small files mean
|
|
284
|
+
context is spent on _building_, not on _understanding what you're
|
|
285
|
+
looking at_.
|
|
286
|
+
|
|
287
|
+
When every file is small enough to comprehend in one pass, both humans
|
|
288
|
+
and LLMs can generate, review, and modify code without re-reading
|
|
289
|
+
unrelated sections. Refactors become safe because the blast radius of
|
|
290
|
+
any change is one small file. The limit forces decomposition into
|
|
291
|
+
concept-sized units — not arbitrary chunks, but files where each one
|
|
292
|
+
answers a single question.
|
|
255
293
|
|
|
256
|
-
|
|
257
|
-
light.** When a file passes 120 lines:
|
|
294
|
+
Treat **~120 lines as a yellow light.** When a file passes 120 lines:
|
|
258
295
|
|
|
259
296
|
1. Add a `// SPLIT CANDIDATE:` comment noting where a logical split could happen
|
|
260
297
|
2. Continue working — don't split mid-feature
|
|
@@ -270,7 +307,9 @@ function moveObj(o, dx, dy) { ... }
|
|
|
270
307
|
### When a File Exceeds 150 Lines
|
|
271
308
|
|
|
272
309
|
The **only correct response** is to split the file into two or more files.
|
|
273
|
-
|
|
310
|
+
The concepts have outgrown the container — find the seam between them
|
|
311
|
+
and give each its own file. Never do any of the following to reduce
|
|
312
|
+
line count:
|
|
274
313
|
|
|
275
314
|
- Remove or shorten JSDoc comments
|
|
276
315
|
- Collapse multi-line expressions onto one line
|