stet-cli 0.1.2 → 0.1.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.3 — 2026-11-14
4
+ - Stack detection now finds projects in subfolders (e.g. backend/, frontend/, apps/web) and languages from file types, so monorepos with Python + JavaScript are detected correctly.
5
+ - stet init creates one test lock per sub-project (e.g. `cd backend && pytest -q`).
6
+
3
7
  ## 0.1.2 — 2026-11-13
4
8
  - Published on npm as stet-cli (the command is still `stet`).
5
9
 
package/lib/core.js CHANGED
@@ -77,37 +77,83 @@ function matches(file, globs) {
77
77
  const splitList = (v) => (Array.isArray(v) ? v : String(v || '').split(',')).map((s) => s.trim()).filter(Boolean);
78
78
 
79
79
  // ---------- stack detection ----------
80
- function detect(root) {
81
- const ex = (f) => fs.existsSync(path.join(root, f));
82
- const rd = (f) => { try { return fs.readFileSync(path.join(root, f), 'utf8'); } catch { return ''; } };
83
- const s = { langs: [], frameworks: [], test: null, run: [], deps: [], entry: [] };
80
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'coverage', '__pycache__', '.venv', 'venv', 'env', 'vendor', 'target', '.stet', '.idea', '.vscode']);
81
+ const MANIFESTS = ['package.json', 'pyproject.toml', 'requirements.txt', 'setup.py', 'Pipfile', 'go.mod', 'Cargo.toml', 'Gemfile', 'pom.xml', 'build.gradle', 'composer.json', 'pubspec.yaml'];
82
+ const EXT_LANG = { py: 'Python', ts: 'TypeScript', tsx: 'TypeScript', js: 'JavaScript', jsx: 'JavaScript', go: 'Go', rs: 'Rust', rb: 'Ruby', java: 'Java', kt: 'Kotlin', swift: 'Swift', php: 'PHP', cs: 'C#', dart: 'Dart' };
83
+
84
+ // Folders (root + up to 2 levels deep) that contain a project manifest, e.g. "", "backend", "apps/web".
85
+ function projectDirs(root) {
86
+ const out = [];
87
+ const walk = (rel, depth) => {
88
+ const abs = path.join(root, rel);
89
+ let entries = [];
90
+ try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; }
91
+ if (entries.some((e) => e.isFile() && MANIFESTS.includes(e.name))) out.push(rel);
92
+ if (depth >= 2) return;
93
+ for (const e of entries) if (e.isDirectory() && !SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) walk(rel ? rel + '/' + e.name : e.name, depth + 1);
94
+ };
95
+ walk('', 0);
96
+ return out;
97
+ }
98
+
99
+ function detectIn(root, rel, s) {
100
+ const ex = (f) => fs.existsSync(path.join(root, rel, f));
101
+ const rd = (f) => { try { return fs.readFileSync(path.join(root, rel, f), 'utf8'); } catch { return ''; } };
102
+ const where = rel ? ` (${rel}/)` : '';
103
+ const cd = (cmd) => (rel ? `cd ${rel} && ${cmd}` : cmd);
104
+ const addLang = (l) => { if (!s.langs.includes(l + where)) s.langs.push(l + where); };
105
+ const addFw = (f) => { if (!s.frameworks.includes(f)) s.frameworks.push(f); };
106
+ const addTest = (cmd) => { if (!s.tests.includes(cd(cmd))) s.tests.push(cd(cmd)); };
107
+ const p = (f) => (rel ? rel + '/' + f : f);
84
108
  if (ex('package.json')) {
85
109
  let pj = {}; try { pj = JSON.parse(rd('package.json')); } catch {}
86
110
  const deps = { ...(pj.dependencies || {}), ...(pj.devDependencies || {}) };
87
111
  s.deps.push(...Object.keys(pj.dependencies || {}));
88
- s.langs.push(deps.typescript || ex('tsconfig.json') ? 'TypeScript' : 'JavaScript');
89
- for (const [k, n] of [['next', 'Next.js'], ['react', 'React'], ['vue', 'Vue'], ['svelte', 'Svelte'], ['express', 'Express'], ['@nestjs/core', 'NestJS'], ['fastify', 'Fastify'], ['hono', 'Hono'], ['vite', 'Vite'], ['prisma', 'Prisma'], ['drizzle-orm', 'Drizzle'], ['@supabase/supabase-js', 'Supabase'], ['stripe', 'Stripe'], ['tailwindcss', 'Tailwind']]) if (deps[k]) s.frameworks.push(n);
90
- const pm = ex('pnpm-lock.yaml') ? 'pnpm' : ex('yarn.lock') ? 'yarn' : ex('bun.lockb') || ex('bun.lock') ? 'bun' : 'npm';
112
+ const emptyWorkspaceRoot = !rel && !Object.keys(deps).length && !Object.keys(pj.scripts || {}).length;
113
+ if (!emptyWorkspaceRoot) addLang(deps.typescript || ex('tsconfig.json') ? 'TypeScript' : 'JavaScript');
114
+ for (const [k, n] of [['next', 'Next.js'], ['react', 'React'], ['vue', 'Vue'], ['svelte', 'Svelte'], ['express', 'Express'], ['@nestjs/core', 'NestJS'], ['fastify', 'Fastify'], ['hono', 'Hono'], ['vite', 'Vite'], ['prisma', 'Prisma'], ['drizzle-orm', 'Drizzle'], ['@supabase/supabase-js', 'Supabase'], ['stripe', 'Stripe'], ['tailwindcss', 'Tailwind']]) if (deps[k]) addFw(n);
115
+ const rootEx = (f) => ex(f) || fs.existsSync(path.join(root, f));
116
+ const pm = rootEx('pnpm-lock.yaml') ? 'pnpm' : rootEx('yarn.lock') ? 'yarn' : rootEx('bun.lockb') || rootEx('bun.lock') ? 'bun' : 'npm';
91
117
  const sc = pj.scripts || {};
92
- if (sc.test && !/no test specified/.test(sc.test)) s.test = `${pm} test`;
93
- for (const k of ['dev', 'start', 'build', 'lint', 'typecheck']) if (sc[k]) s.run.push(`${pm} run ${k}`);
94
- if (pj.main) s.entry.push(pj.main);
95
- if (pj.bin) s.entry.push(...(typeof pj.bin === 'string' ? [pj.bin] : Object.values(pj.bin)));
118
+ if (sc.test && !/no test specified/.test(sc.test)) addTest(`${pm} test`);
119
+ for (const k of ['dev', 'start', 'build', 'lint', 'typecheck']) if (sc[k]) s.run.push(cd(`${pm} run ${k}`));
120
+ if (pj.main) s.entry.push(p(pj.main));
121
+ if (pj.bin) s.entry.push(...(typeof pj.bin === 'string' ? [pj.bin] : Object.values(pj.bin)).map(p));
96
122
  }
97
- if (ex('pyproject.toml') || ex('requirements.txt') || ex('setup.py')) {
98
- s.langs.push('Python');
99
- const txt = (rd('pyproject.toml') + '\n' + rd('requirements.txt')).toLowerCase();
100
- for (const [k, n] of [['django', 'Django'], ['fastapi', 'FastAPI'], ['flask', 'Flask'], ['sqlalchemy', 'SQLAlchemy'], ['pydantic', 'Pydantic'], ['pandas', 'pandas'], ['celery', 'Celery']]) if (txt.includes(k)) s.frameworks.push(n);
101
- s.deps.push(...nonEmpty(rd('requirements.txt')).filter((l) => !l.startsWith('#')).map((l) => l.split(/[=<>~! ;\[]/)[0]));
102
- if (!s.test && (ex('tests') || ex('pytest.ini') || txt.includes('pytest'))) s.test = 'pytest -q';
103
- if (ex('manage.py')) { s.run.push('python manage.py runserver'); s.entry.push('manage.py'); }
123
+ if (ex('pyproject.toml') || ex('requirements.txt') || ex('setup.py') || ex('Pipfile')) {
124
+ addLang('Python');
125
+ const txt = (rd('pyproject.toml') + '\n' + rd('requirements.txt') + '\n' + rd('Pipfile')).toLowerCase();
126
+ for (const [k, n] of [['django', 'Django'], ['fastapi', 'FastAPI'], ['flask', 'Flask'], ['sqlalchemy', 'SQLAlchemy'], ['pydantic', 'Pydantic'], ['pandas', 'pandas'], ['celery', 'Celery']]) if (txt.includes(k)) addFw(n);
127
+ s.deps.push(...nonEmpty(rd('requirements.txt')).filter((l) => !l.trim().startsWith('#') && !l.trim().startsWith('-')).map((l) => l.trim().split(/[=<>~! ;\[]/)[0]));
128
+ if (ex('tests') || ex('test') || ex('pytest.ini') || ex('conftest.py') || txt.includes('[tool.pytest')) addTest('pytest -q');
129
+ if (ex('manage.py')) { s.run.push(cd('python manage.py runserver')); s.entry.push(p('manage.py')); }
130
+ }
131
+ if (ex('go.mod')) { addLang('Go'); addTest('go test ./...'); }
132
+ if (ex('Cargo.toml')) { addLang('Rust'); addTest('cargo test'); }
133
+ if (ex('Gemfile')) addLang('Ruby');
134
+ if (ex('pom.xml') || ex('build.gradle')) addLang('Java');
135
+ if (ex('composer.json')) addLang('PHP');
136
+ if (ex('pubspec.yaml')) addLang('Dart');
137
+ if (ex('Dockerfile') || ex('docker-compose.yml') || ex('docker-compose.yaml') || ex('compose.yaml')) addFw('Docker');
138
+ for (const f of ['main.py', 'app.py', 'server.js', 'index.js', 'src/server.js', 'src/server.ts', 'src/app.js', 'src/index.ts', 'src/main.ts', 'src/index.js', 'src/app.ts', 'app/main.py', 'app/page.tsx', 'src/main.tsx', 'main.go', 'src/main.rs']) if (ex(f) && !s.entry.includes(p(f))) s.entry.push(p(f));
139
+ if (!rel && ex('Makefile')) s.run.push(...nonEmpty(rd('Makefile')).filter((l) => /^[a-zA-Z][\w-]*:/.test(l)).slice(0, 6).map((l) => 'make ' + l.split(':')[0]));
140
+ }
141
+
142
+ function detect(root) {
143
+ const s = { langs: [], frameworks: [], tests: [], test: null, run: [], deps: [], entry: [] };
144
+ for (const rel of projectDirs(root)) detectIn(root, rel, s);
145
+ if (!s.frameworks.includes('Docker') && ['Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', 'compose.yaml', 'compose.yml'].some((f) => fs.existsSync(path.join(root, f)))) s.frameworks.push('Docker');
146
+ // Fallback: languages with real source files but no manifest we recognise.
147
+ const files = names(tgit(['ls-files', '--cached', '--others', '--exclude-standard'], root));
148
+ const count = {};
149
+ for (const f of files) {
150
+ if (f.split('/').some((seg) => SKIP_DIRS.has(seg))) continue;
151
+ const l = EXT_LANG[f.split('.').pop().toLowerCase()];
152
+ if (l) count[l] = (count[l] || 0) + 1;
104
153
  }
105
- if (ex('go.mod')) { s.langs.push('Go'); s.test = s.test || 'go test ./...'; }
106
- if (ex('Cargo.toml')) { s.langs.push('Rust'); s.test = s.test || 'cargo test'; }
107
- if (ex('Gemfile')) s.langs.push('Ruby');
108
- if (ex('Dockerfile') || ex('docker-compose.yml') || ex('compose.yaml')) s.frameworks.push('Docker');
109
- for (const f of ['main.py', 'app.py', 'server.js', 'index.js', 'src/server.js', 'src/server.ts', 'src/app.js', 'src/index.ts', 'src/main.ts', 'src/index.js', 'src/app.ts', 'app/page.tsx', 'src/main.tsx', 'main.go', 'src/main.rs']) if (ex(f) && !s.entry.includes(f)) s.entry.push(f);
110
- if (ex('Makefile')) s.run.push(...nonEmpty(rd('Makefile')).filter((l) => /^[a-zA-Z][\w-]*:/.test(l)).slice(0, 6).map((l) => 'make ' + l.split(':')[0]));
154
+ for (const [l, n] of Object.entries(count)) if (n >= 3 && !s.langs.some((x) => x.split(' (')[0] === l)) s.langs.push(l);
155
+ s.deps = [...new Set(s.deps.filter(Boolean))];
156
+ s.test = s.tests.length ? s.tests.join(' && ') : null;
111
157
  return s;
112
158
  }
113
159
 
@@ -155,7 +201,7 @@ ${name} is ...
155
201
 
156
202
  ## Stack
157
203
  - ${stack}
158
- ${st.test ? `- Tests: \`${st.test}\`\n` : ''}${st.run.length ? `- Run: ${st.run.map((x) => '`' + x + '`').join(', ')}\n` : ''}
204
+ ${st.tests.length ? `- Tests: ${st.tests.map((x) => '`' + x + '`').join(', ')}\n` : ''}${st.run.length ? `- Run: ${st.run.map((x) => '`' + x + '`').join(', ')}\n` : ''}
159
205
  ## Always
160
206
  - Read this charter, the current scope and the locked behaviours before changing code.
161
207
  - Make the smallest change that solves the task. Match the existing style and patterns.
@@ -253,7 +299,10 @@ function init(r, { name, mcp } = {}) {
253
299
  const put = (f, s) => { if (fs.existsSync(r.p(f))) kept.push(f); else { r.write(f, s); made.push(f); } };
254
300
  put('config.json', JSON.stringify({ name: nm, version: 1, protect: [], ignore: [], maxFileKB: 64, checkTimeoutSec: 600 }, null, 2) + '\n');
255
301
  put('charter.md', charterTemplate(nm, st));
256
- put('locks.json', JSON.stringify(st.test ? [{ id: 'L1', name: 'Full test suite passes', cmd: st.test, protect: [], added: today() }] : [], null, 2) + '\n');
302
+ put('locks.json', JSON.stringify(st.tests.map((cmd, i) => {
303
+ const m = cmd.match(/^cd (\S+) && /);
304
+ return { id: 'L' + (i + 1), name: m ? `Test suite passes (${m[1]}/)` : 'Full test suite passes', cmd, protect: [], added: today() };
305
+ }), null, 2) + '\n');
257
306
  put('scope.json', '{}\n');
258
307
  put('decisions.md', `# Decision log — ${nm}\n\nOne entry per decision. Newest at the bottom. Managed with \`stet decide\`.\n\n## D-001 · ${today()} · Adopted Stet to keep AI agents in scope\nAI agents read AGENTS.md. Locked behaviours are checked with \`stet check\` before work is called done.\n`);
259
308
  put('.gitignore', '.cache/\ncontext.md\n');
@@ -495,7 +544,7 @@ Generated by Stet ${VERSION} on ${today()}. Regenerate any time with \`stet hand
495
544
  ${charter}
496
545
 
497
546
  ## 2. Run it
498
- ${st.run.length || st.test ? [...st.run, ...(st.test ? [st.test + ' # tests'] : [])].map((x) => '- `' + x + '`').join('\n') : '- No run scripts detected. Add them to the charter.'}
547
+ ${st.run.length || st.tests.length ? [...st.run, ...st.tests.map((t) => t + ' # tests')].map((x) => '- `' + x + '`').join('\n') : '- No run scripts detected. Add them to the charter.'}
499
548
  ${envKeys.length ? `\nEnvironment variables needed (names only, from the example env file):\n${envKeys.map((k) => '`' + k + '`').join(' · ')}` : ''}
500
549
 
501
550
  ## 3. Architecture at a glance
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stet-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Keep AI coding agents from breaking what already works. Charter, locked behaviours, scope guard, decision log and git-synced context for Claude Code, Cursor, Codex and any chat.",
5
5
  "bin": {
6
6
  "stet": "bin/stet.js"