@1agh/maude 0.20.0 → 0.21.0

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.
Files changed (69) hide show
  1. package/README.md +7 -0
  2. package/cli/bin/maude.mjs +5 -1
  3. package/cli/commands/design-link.test.mjs +207 -0
  4. package/cli/commands/design.mjs +42 -12
  5. package/cli/commands/doctor.mjs +350 -0
  6. package/cli/commands/doctor.test.mjs +185 -0
  7. package/cli/commands/help.mjs +24 -0
  8. package/cli/commands/hub.mjs +231 -0
  9. package/cli/commands/hub.test.mjs +87 -0
  10. package/cli/lib/config-lint.mjs +141 -0
  11. package/cli/lib/config-lint.test.mjs +117 -0
  12. package/cli/lib/design-link.mjs +216 -0
  13. package/cli/lib/hubs-config.mjs +123 -0
  14. package/cli/lib/hubs-config.test.mjs +100 -0
  15. package/cli/lib/preflight.mjs +232 -0
  16. package/cli/lib/stack-detect.mjs +344 -0
  17. package/cli/lib/stack-detect.test.mjs +121 -0
  18. package/package.json +16 -8
  19. package/plugins/design/dependencies.json +147 -0
  20. package/plugins/design/dependencies.schema.json +107 -0
  21. package/plugins/design/dev-server/ai-banner.tsx +188 -0
  22. package/plugins/design/dev-server/annotations-context-toolbar.tsx +5 -5
  23. package/plugins/design/dev-server/annotations-layer.tsx +52 -12
  24. package/plugins/design/dev-server/api.ts +17 -1
  25. package/plugins/design/dev-server/artboard-marquee.tsx +2 -2
  26. package/plugins/design/dev-server/bin/preflight.sh +32 -0
  27. package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
  28. package/plugins/design/dev-server/canvas-lib.tsx +33 -7
  29. package/plugins/design/dev-server/canvas-shell.tsx +127 -9
  30. package/plugins/design/dev-server/client/app.jsx +72 -0
  31. package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
  32. package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
  33. package/plugins/design/dev-server/collab/index.ts +47 -0
  34. package/plugins/design/dev-server/collab/persistence.ts +123 -0
  35. package/plugins/design/dev-server/collab/protocol.ts +108 -0
  36. package/plugins/design/dev-server/collab/registry.ts +110 -0
  37. package/plugins/design/dev-server/collab/room.ts +215 -0
  38. package/plugins/design/dev-server/comments-overlay.tsx +29 -0
  39. package/plugins/design/dev-server/contextual-toolbar.tsx +1 -1
  40. package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
  41. package/plugins/design/dev-server/dist/client.bundle.js +75 -3
  42. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
  43. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
  44. package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
  45. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  46. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  47. package/plugins/design/dev-server/http.ts +64 -1
  48. package/plugins/design/dev-server/marquee-overlay.tsx +2 -2
  49. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  50. package/plugins/design/dev-server/runtime-bundle.ts +8 -0
  51. package/plugins/design/dev-server/server.ts +78 -11
  52. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  53. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  54. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  55. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  56. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  57. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  58. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  59. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  60. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  61. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  62. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  63. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  64. package/plugins/design/dev-server/use-collab.tsx +478 -0
  65. package/plugins/design/dev-server/ws.ts +123 -7
  66. package/plugins/design/templates/_shell.html +35 -1
  67. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  68. package/plugins/flow/dependencies.json +143 -0
  69. package/plugins/flow/dependencies.schema.json +107 -0
@@ -0,0 +1,232 @@
1
+ // Preflight checker — loads a plugin's dependencies.json and runs each
2
+ // dependency's `check.command` (or `check.mcp` probe) to report
3
+ // presence / version / missing-ness.
4
+ //
5
+ // Used by:
6
+ // - cli/commands/doctor.mjs (the unified diagnostic CLI)
7
+ // - plugins/flow/commands/init.md (the wired pre-flight Step 1)
8
+ // - plugins/design/dev-server/bin/preflight.sh (which re-shells to node
9
+ // for the actual checks — keeps detection logic in one place).
10
+ //
11
+ // Pure-ish: no process.exit; returns an envelope. printReport() is here
12
+ // because every consumer wants the same table format.
13
+
14
+ import { spawnSync } from 'node:child_process';
15
+ import { readFile } from 'node:fs/promises';
16
+ import { resolve } from 'node:path';
17
+
18
+ const SEM_RE = /(\d+)\.(\d+)\.(\d+)/;
19
+
20
+ function parseVersion(text) {
21
+ const m = text.match(SEM_RE);
22
+ if (!m) return null;
23
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
24
+ }
25
+
26
+ function compareVersions(a, b) {
27
+ for (let i = 0; i < 3; i++) {
28
+ if (a[i] > b[i]) return 1;
29
+ if (a[i] < b[i]) return -1;
30
+ }
31
+ return 0;
32
+ }
33
+
34
+ function checkVersion(stdout, minVersion) {
35
+ if (!minVersion) return { ok: true };
36
+ const have = parseVersion(stdout);
37
+ const want = parseVersion(minVersion);
38
+ if (!have) return { ok: false, reason: `could not parse version from "${stdout.trim()}"` };
39
+ if (!want) return { ok: true };
40
+ if (compareVersions(have, want) < 0) {
41
+ return {
42
+ ok: false,
43
+ reason: `have ${have.join('.')} need ≥ ${want.join('.')}`,
44
+ have: have.join('.'),
45
+ };
46
+ }
47
+ return { ok: true, have: have.join('.') };
48
+ }
49
+
50
+ async function runCommand(command) {
51
+ const r = spawnSync('bash', ['-c', command], { encoding: 'utf8', timeout: 5000 });
52
+ return {
53
+ status: r.status === null ? -1 : r.status,
54
+ stdout: (r.stdout || '').toString(),
55
+ stderr: (r.stderr || '').toString(),
56
+ };
57
+ }
58
+
59
+ export async function checkDependency(dep) {
60
+ // MCP probes — we can't actually call MCP tools from a plain Node script;
61
+ // they're surfaced through Claude Code's tool layer. Mark as `unknown`
62
+ // and let the agent layer fill it in (the agent has visibility into
63
+ // available MCP tools).
64
+ if (dep.type === 'mcp') {
65
+ return {
66
+ id: dep.id,
67
+ status: 'unknown',
68
+ hardness: dep.hardness,
69
+ detail: `MCP probe — not checkable from a non-agent process. Tool: ${dep.check.mcp}${dep.check.tool ? `/${dep.check.tool}` : ''}`,
70
+ };
71
+ }
72
+
73
+ const command = dep.check.command || `${dep.id} --version`;
74
+ const expectExit = dep.check.expectExit ?? 0;
75
+ const result = await runCommand(command);
76
+
77
+ if (result.status !== expectExit) {
78
+ return {
79
+ id: dep.id,
80
+ status: 'missing',
81
+ hardness: dep.hardness,
82
+ detail:
83
+ result.status === -1 ? 'command timed out or failed to spawn' : `exit ${result.status}`,
84
+ install: dep.install,
85
+ };
86
+ }
87
+
88
+ const versionCheck = checkVersion(result.stdout, dep.check.minVersion);
89
+ if (!versionCheck.ok) {
90
+ return {
91
+ id: dep.id,
92
+ status: 'outdated',
93
+ hardness: dep.hardness,
94
+ detail: versionCheck.reason,
95
+ install: dep.install,
96
+ };
97
+ }
98
+
99
+ return {
100
+ id: dep.id,
101
+ status: 'ok',
102
+ hardness: dep.hardness,
103
+ version: versionCheck.have || result.stdout.trim().split('\n')[0],
104
+ };
105
+ }
106
+
107
+ export async function loadManifest(manifestPath) {
108
+ const raw = await readFile(resolve(manifestPath), 'utf8');
109
+ return JSON.parse(raw);
110
+ }
111
+
112
+ export async function checkAll(manifestPath) {
113
+ const manifest = await loadManifest(manifestPath);
114
+ const results = [];
115
+ for (const dep of manifest.dependencies) {
116
+ // eslint-disable-next-line no-await-in-loop
117
+ results.push(await checkDependency(dep));
118
+ }
119
+ const hardFailing = results.filter((r) => r.hardness === 'hard' && r.status !== 'ok');
120
+ const softFailing = results.filter((r) => r.hardness === 'soft' && r.status === 'missing');
121
+ return {
122
+ plugin: manifest.plugin,
123
+ results,
124
+ summary: {
125
+ total: results.length,
126
+ ok: results.filter((r) => r.status === 'ok').length,
127
+ missing: results.filter((r) => r.status === 'missing').length,
128
+ outdated: results.filter((r) => r.status === 'outdated').length,
129
+ unknown: results.filter((r) => r.status === 'unknown').length,
130
+ hardFailing: hardFailing.length,
131
+ softFailing: softFailing.length,
132
+ allHardPass: hardFailing.length === 0,
133
+ },
134
+ };
135
+ }
136
+
137
+ const STATUS_GLYPH = {
138
+ ok: '✓',
139
+ missing: '✗',
140
+ outdated: '⚠',
141
+ unknown: '?',
142
+ };
143
+
144
+ export function formatTable(envelope, { color = false } = {}) {
145
+ const lines = [];
146
+ const tag = (s) => (color ? colorFor(s) : s);
147
+ for (const r of envelope.results) {
148
+ const glyph = STATUS_GLYPH[r.status] || '?';
149
+ const label = r.id.padEnd(20);
150
+ let rhs = '';
151
+ if (r.status === 'ok') rhs = r.version || '';
152
+ else if (r.status === 'missing') {
153
+ const hint = (r.install && (r.install[process.platform] || r.install.preferred)) || '';
154
+ rhs = `missing${hint ? ` — ${hint}` : ''}`;
155
+ } else if (r.status === 'outdated') {
156
+ rhs = `outdated (${r.detail})`;
157
+ } else if (r.status === 'unknown') {
158
+ rhs = r.detail || '';
159
+ }
160
+ lines.push(` ${tag(glyph)} ${label} ${rhs}`);
161
+ }
162
+ return lines.join('\n');
163
+ }
164
+
165
+ function colorFor(g) {
166
+ if (g === '✓') return `${g}`;
167
+ if (g === '✗') return `${g}`;
168
+ if (g === '⚠') return `${g}`;
169
+ return g;
170
+ }
171
+
172
+ // ─── CLI entry — `node cli/lib/preflight.mjs --plugin design --json` ──────
173
+
174
+ function parseFlags(args) {
175
+ const out = { plugin: null, mode: 'text' };
176
+ for (let i = 0; i < args.length; i++) {
177
+ const a = args[i];
178
+ if (a === '--plugin') out.plugin = args[++i];
179
+ else if (a === '--json') out.mode = 'json';
180
+ else if (a === '--shell-export') out.mode = 'shell-export';
181
+ else if (a === '--quiet') out.mode = 'quiet';
182
+ else if (a === '--warn-only') out.mode = 'warn-only';
183
+ }
184
+ return out;
185
+ }
186
+
187
+ function manifestPathForPlugin(pluginName) {
188
+ return resolve(`plugins/${pluginName}/dependencies.json`);
189
+ }
190
+
191
+ async function main(argv) {
192
+ const flags = parseFlags(argv.slice(2));
193
+ if (!flags.plugin) {
194
+ process.stderr.write('preflight.mjs: pass --plugin <design|flow>\n');
195
+ process.exit(2);
196
+ }
197
+ const env = await checkAll(manifestPathForPlugin(flags.plugin));
198
+ if (flags.mode === 'json') {
199
+ process.stdout.write(`${JSON.stringify(env, null, 2)}\n`);
200
+ } else if (flags.mode === 'shell-export') {
201
+ const missing = env.results
202
+ .filter((r) => r.status === 'missing')
203
+ .map((r) => r.id)
204
+ .join(',');
205
+ process.stdout.write(`export DEPS_OK=${env.summary.allHardPass ? 1 : 0}\n`);
206
+ process.stdout.write(`export DEPS_MISSING="${missing}"\n`);
207
+ process.stdout.write(`export DEPS_PLUGIN="${env.plugin}"\n`);
208
+ } else if (flags.mode === 'quiet' || flags.mode === 'warn-only') {
209
+ const missingHard = env.results.filter(
210
+ (r) => r.hardness === 'hard' && r.status !== 'ok' && r.status !== 'unknown'
211
+ );
212
+ if (missingHard.length > 0) {
213
+ const ids = missingHard.map((r) => r.id).join(', ');
214
+ process.stdout.write(`MISSING HARD DEPS: ${ids}. Run \`maude doctor --fix\` to install.\n`);
215
+ }
216
+ } else {
217
+ process.stdout.write(`Dependencies (plugin: ${env.plugin}):\n`);
218
+ process.stdout.write(`${formatTable(env, { color: process.stdout.isTTY })}\n`);
219
+ process.stdout.write(
220
+ `\nSummary: ${env.summary.ok}/${env.summary.total} ok, ${env.summary.hardFailing} hard failing, ${env.summary.softFailing} soft missing.\n`
221
+ );
222
+ }
223
+ process.exit(env.summary.allHardPass ? 0 : 1);
224
+ }
225
+
226
+ // Entry guard — only run main() when invoked directly.
227
+ if (import.meta.url === `file://${process.argv[1]}`) {
228
+ main(process.argv).catch((err) => {
229
+ process.stderr.write(`preflight: ${err.message}\n`);
230
+ process.exit(1);
231
+ });
232
+ }
@@ -0,0 +1,344 @@
1
+ // Pure detection of project stack + quality-gate hints.
2
+ //
3
+ // Read-only. Never throws. Returns "unknown" / null when nothing matches.
4
+ //
5
+ // Port of /flow:init Step 2 bash (plugins/flow/commands/init.md lines 71–181)
6
+ // with one extension: when the root lacks a marker (tsconfig.json, next.config.*,
7
+ // …) AND pnpm-workspace.yaml exists, glance one level into workspace packages.
8
+ // Without that, a monorepo where every workspace is TypeScript still reports
9
+ // `language: javascript` because the root only has a JS package.json.
10
+ //
11
+ // Consumers: cli/commands/doctor.mjs and the drift-aware /flow:init re-run.
12
+
13
+ import { readFile, readdir, stat } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+
16
+ async function fileExists(path) {
17
+ try {
18
+ await stat(path);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ async function anyExists(repoRoot, names) {
26
+ for (const n of names) {
27
+ // eslint-disable-next-line no-await-in-loop
28
+ if (await fileExists(join(repoRoot, n))) return n;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ async function listWorkspaceDirs(repoRoot) {
34
+ // Cheap depth-1 scan triggered only when pnpm-workspace.yaml exists.
35
+ // We do not parse the YAML — common monorepo layouts (`packages/*`,
36
+ // `apps/*`, `plugins/*`, `site/`) cover ~all cases. Returns absolute paths.
37
+ if (!(await fileExists(join(repoRoot, 'pnpm-workspace.yaml')))) return [];
38
+ const out = [];
39
+ // Candidate directories that, depending on layout, are either
40
+ // (a) the workspace member itself (e.g. ./site)
41
+ // (b) a container of members (e.g. ./packages/foo, ./plugins/design)
42
+ // We include BOTH the candidate path and its first-level subdirs in the
43
+ // scan target list, so layouts like this repo's `site/tsconfig.json` are
44
+ // reached even though `site/` is itself the member.
45
+ const candidates = ['packages', 'apps', 'plugins', 'site', 'scripts', 'examples'];
46
+ for (const c of candidates) {
47
+ const dir = join(repoRoot, c);
48
+ // eslint-disable-next-line no-await-in-loop
49
+ if (!(await fileExists(dir))) continue;
50
+ out.push(dir);
51
+ try {
52
+ // eslint-disable-next-line no-await-in-loop
53
+ const ents = await readdir(dir, { withFileTypes: true });
54
+ for (const e of ents) {
55
+ if (e.isDirectory()) out.push(join(dir, e.name));
56
+ }
57
+ } catch {
58
+ /* ignore */
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ async function anyExistsDeep(repoRoot, names, workspaceDirs) {
65
+ // First check root, then one level into each workspace dir.
66
+ const root = await anyExists(repoRoot, names);
67
+ if (root) return root;
68
+ for (const wd of workspaceDirs) {
69
+ for (const n of names) {
70
+ // eslint-disable-next-line no-await-in-loop
71
+ if (await fileExists(join(wd, n))) return `${wd.slice(repoRoot.length + 1)}/${n}`;
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+
77
+ async function readPkgJson(repoRoot) {
78
+ try {
79
+ const raw = await readFile(join(repoRoot, 'package.json'), 'utf8');
80
+ return { raw, parsed: JSON.parse(raw) };
81
+ } catch {
82
+ return { raw: '', parsed: null };
83
+ }
84
+ }
85
+
86
+ export async function detectStack(repoRoot) {
87
+ const ws = await listWorkspaceDirs(repoRoot);
88
+ const { raw: pkgRaw, parsed: pkg } = await readPkgJson(repoRoot);
89
+
90
+ // Package manager — lock files at root.
91
+ let packageManager = 'unknown';
92
+ if (await fileExists(join(repoRoot, 'pnpm-lock.yaml'))) packageManager = 'pnpm';
93
+ else if (await fileExists(join(repoRoot, 'yarn.lock'))) packageManager = 'yarn';
94
+ else if (await fileExists(join(repoRoot, 'package-lock.json'))) packageManager = 'npm';
95
+ else if (await fileExists(join(repoRoot, 'bun.lockb'))) packageManager = 'bun';
96
+ else if (await fileExists(join(repoRoot, 'Cargo.toml'))) packageManager = 'cargo';
97
+ else if (await fileExists(join(repoRoot, 'go.mod'))) packageManager = 'go';
98
+ else if (
99
+ (await fileExists(join(repoRoot, 'pyproject.toml'))) ||
100
+ (await fileExists(join(repoRoot, 'requirements.txt')))
101
+ )
102
+ packageManager = 'pip';
103
+ else if (await fileExists(join(repoRoot, 'pom.xml'))) packageManager = 'maven';
104
+ else if (
105
+ (await fileExists(join(repoRoot, 'build.gradle'))) ||
106
+ (await fileExists(join(repoRoot, 'build.gradle.kts')))
107
+ )
108
+ packageManager = 'gradle';
109
+
110
+ // Language — deep-scan tsconfig before falling back to package.json → js.
111
+ let language = 'unknown';
112
+ if (await anyExistsDeep(repoRoot, ['tsconfig.json'], ws)) language = 'typescript';
113
+ else if (await fileExists(join(repoRoot, 'Cargo.toml'))) language = 'rust';
114
+ else if (await fileExists(join(repoRoot, 'go.mod'))) language = 'go';
115
+ else if (
116
+ (await fileExists(join(repoRoot, 'pyproject.toml'))) ||
117
+ (await fileExists(join(repoRoot, 'requirements.txt')))
118
+ )
119
+ language = 'python';
120
+ else if (
121
+ (await fileExists(join(repoRoot, 'pom.xml'))) ||
122
+ (await fileExists(join(repoRoot, 'build.gradle'))) ||
123
+ (await fileExists(join(repoRoot, 'build.gradle.kts')))
124
+ )
125
+ language = 'java';
126
+ else if (await fileExists(join(repoRoot, 'package.json'))) language = 'javascript';
127
+
128
+ // Framework — root first, then workspace.
129
+ let framework = 'unknown';
130
+ if (await anyExistsDeep(repoRoot, ['next.config.js', 'next.config.ts', 'next.config.mjs'], ws))
131
+ framework = 'next.js';
132
+ else if (await anyExistsDeep(repoRoot, ['vite.config.js', 'vite.config.ts'], ws))
133
+ framework = 'vite';
134
+ else if (await anyExistsDeep(repoRoot, ['app.json', 'app.config.js', 'app.config.ts'], ws))
135
+ framework = 'expo';
136
+ else if (await anyExistsDeep(repoRoot, ['svelte.config.js'], ws)) framework = 'sveltekit';
137
+ else if (await anyExistsDeep(repoRoot, ['remix.config.js'], ws)) framework = 'remix';
138
+ else if (await anyExistsDeep(repoRoot, ['astro.config.mjs'], ws)) framework = 'astro';
139
+ else if (await anyExistsDeep(repoRoot, ['nuxt.config.ts', 'nuxt.config.js'], ws))
140
+ framework = 'nuxt';
141
+
142
+ // Monorepo + build tool.
143
+ let monorepo = false;
144
+ let buildTool = 'none';
145
+ if (await fileExists(join(repoRoot, 'turbo.json'))) {
146
+ monorepo = true;
147
+ buildTool = 'turbo';
148
+ } else if (await fileExists(join(repoRoot, 'nx.json'))) {
149
+ monorepo = true;
150
+ buildTool = 'nx';
151
+ } else if (await fileExists(join(repoRoot, 'lerna.json'))) {
152
+ monorepo = true;
153
+ buildTool = 'lerna';
154
+ } else if (await fileExists(join(repoRoot, 'rush.json'))) {
155
+ monorepo = true;
156
+ buildTool = 'rush';
157
+ } else if (await fileExists(join(repoRoot, 'pnpm-workspace.yaml'))) {
158
+ monorepo = true;
159
+ } else if (await fileExists(join(repoRoot, 'Makefile'))) {
160
+ buildTool = 'make';
161
+ } else if (
162
+ (await fileExists(join(repoRoot, 'BUILD.bazel'))) ||
163
+ (await fileExists(join(repoRoot, 'WORKSPACE')))
164
+ ) {
165
+ buildTool = 'bazel';
166
+ }
167
+
168
+ // CI.
169
+ let ci = 'unknown';
170
+ if (await fileExists(join(repoRoot, '.github/workflows'))) ci = 'github-actions';
171
+ else if (await fileExists(join(repoRoot, '.gitlab-ci.yml'))) ci = 'gitlab-ci';
172
+ else if (await fileExists(join(repoRoot, 'Jenkinsfile'))) ci = 'jenkins';
173
+ else if (await fileExists(join(repoRoot, 'azure-pipelines.yml'))) ci = 'azure-devops';
174
+ else if (await fileExists(join(repoRoot, '.circleci/config.yml'))) ci = 'circleci';
175
+ else if (await fileExists(join(repoRoot, 'bitbucket-pipelines.yml'))) ci = 'bitbucket';
176
+
177
+ // Tests — substring match on raw package.json so it picks up references in
178
+ // scripts (`"vitest run"`) as well as devDependencies (matches the bash port).
179
+ let tests = 'unknown';
180
+ if (pkgRaw) {
181
+ if (pkgRaw.includes('"vitest"')) tests = 'vitest';
182
+ else if (pkgRaw.includes('"jest"')) tests = 'jest';
183
+ else if (pkgRaw.includes('"@playwright/test"')) tests = 'playwright';
184
+ else if (pkgRaw.includes('"cypress"')) tests = 'cypress';
185
+ }
186
+ if (await fileExists(join(repoRoot, 'go.mod'))) tests = 'go-test';
187
+ if (await fileExists(join(repoRoot, 'Cargo.toml'))) tests = 'cargo-test';
188
+ if (await fileExists(join(repoRoot, 'pytest.ini'))) tests = 'pytest';
189
+ else if (await fileExists(join(repoRoot, 'pyproject.toml'))) {
190
+ try {
191
+ const py = await readFile(join(repoRoot, 'pyproject.toml'), 'utf8');
192
+ if (py.includes('pytest')) tests = 'pytest';
193
+ } catch {
194
+ /* ignore */
195
+ }
196
+ }
197
+
198
+ // CSS approach.
199
+ let css = 'unknown';
200
+ if (pkgRaw) {
201
+ if (pkgRaw.includes('"tailwindcss"')) css = 'tailwind';
202
+ else if (pkgRaw.includes('"styled-components"')) css = 'styled-components';
203
+ else if (pkgRaw.includes('"@emotion/')) css = 'emotion';
204
+ else if (pkgRaw.includes('"@vanilla-extract/')) css = 'vanilla-extract';
205
+ else if (
206
+ (await fileExists(join(repoRoot, 'postcss.config.js'))) ||
207
+ (await fileExists(join(repoRoot, 'postcss.config.cjs')))
208
+ )
209
+ css = 'css-modules';
210
+ }
211
+
212
+ // Router — framework-dependent.
213
+ let router = 'unknown';
214
+ switch (framework) {
215
+ case 'next.js': {
216
+ const appAtRoot =
217
+ (await fileExists(join(repoRoot, 'app'))) || (await fileExists(join(repoRoot, 'src/app')));
218
+ if (appAtRoot) {
219
+ router = 'next-app';
220
+ } else {
221
+ // Workspace pass — first member with an `app/` or `src/app/` wins.
222
+ for (const wd of ws) {
223
+ if (
224
+ // eslint-disable-next-line no-await-in-loop
225
+ (await fileExists(join(wd, 'app'))) ||
226
+ // eslint-disable-next-line no-await-in-loop
227
+ (await fileExists(join(wd, 'src/app')))
228
+ ) {
229
+ router = 'next-app';
230
+ break;
231
+ }
232
+ }
233
+ if (router === 'unknown') router = 'next-pages';
234
+ }
235
+ break;
236
+ }
237
+ case 'remix':
238
+ router = 'react-router';
239
+ break;
240
+ case 'sveltekit':
241
+ router = 'sveltekit-router';
242
+ break;
243
+ case 'expo':
244
+ router = 'expo-router';
245
+ break;
246
+ default:
247
+ if (pkgRaw?.includes('"@tanstack/router')) router = 'tanstack-router';
248
+ else if (pkgRaw?.includes('"react-router')) router = 'react-router';
249
+ }
250
+
251
+ // _ avoids unused-warning for `pkg` (kept around in case future detectors
252
+ // need parsed access).
253
+ void pkg;
254
+
255
+ return {
256
+ language,
257
+ framework,
258
+ packageManager,
259
+ buildTool,
260
+ monorepo,
261
+ ci,
262
+ tests,
263
+ css,
264
+ router,
265
+ };
266
+ }
267
+
268
+ // ─── Quality-gate detection ────────────────────────────────────────────────
269
+ //
270
+ // detectQualityGates(repoRoot) → { lint, format, typecheck, tests, build } | null
271
+ //
272
+ // Strategy: prefer the user's declared `scripts.<name>` (they know how their
273
+ // project is wired) over inferring from tool presence. Only fall back to tool
274
+ // presence when no script is declared. Always emits the package-manager
275
+ // prefix so the command can be eval'd directly.
276
+
277
+ function pmPrefix(packageManager) {
278
+ if (packageManager === 'pnpm') return 'pnpm';
279
+ if (packageManager === 'yarn') return 'yarn';
280
+ if (packageManager === 'bun') return 'bun';
281
+ return 'npm run'; // default — works for npm and unknown package managers
282
+ }
283
+
284
+ export async function detectQualityGates(repoRoot) {
285
+ const { parsed: pkg } = await readPkgJson(repoRoot);
286
+ if (!pkg) return null;
287
+ const scripts = pkg.scripts || {};
288
+
289
+ // Pick the package manager from lockfile (same logic as detectStack but
290
+ // standalone — this function is composable and may be called separately).
291
+ let pm = 'npm';
292
+ if (await fileExists(join(repoRoot, 'pnpm-lock.yaml'))) pm = 'pnpm';
293
+ else if (await fileExists(join(repoRoot, 'yarn.lock'))) pm = 'yarn';
294
+ else if (await fileExists(join(repoRoot, 'bun.lockb'))) pm = 'bun';
295
+ const prefix = pmPrefix(pm);
296
+
297
+ const out = {};
298
+
299
+ // lint.
300
+ if (scripts.lint) out.lint = `${prefix} lint`;
301
+ else if (
302
+ (await fileExists(join(repoRoot, 'biome.json'))) ||
303
+ (await fileExists(join(repoRoot, 'biome.jsonc')))
304
+ )
305
+ out.lint = `${prefix === 'npm run' ? 'npx' : prefix} biome check .`;
306
+ else if (
307
+ (await fileExists(join(repoRoot, 'eslint.config.js'))) ||
308
+ (await fileExists(join(repoRoot, 'eslint.config.mjs'))) ||
309
+ (await fileExists(join(repoRoot, '.eslintrc.cjs'))) ||
310
+ (await fileExists(join(repoRoot, '.eslintrc.js'))) ||
311
+ (await fileExists(join(repoRoot, '.eslintrc.json')))
312
+ )
313
+ out.lint = `${prefix === 'npm run' ? 'npx' : prefix} eslint .`;
314
+
315
+ // format.
316
+ if (scripts.format) out.format = `${prefix} format`;
317
+ else if (
318
+ (await fileExists(join(repoRoot, 'biome.json'))) ||
319
+ (await fileExists(join(repoRoot, 'biome.jsonc')))
320
+ )
321
+ out.format = `${prefix === 'npm run' ? 'npx' : prefix} biome format --write .`;
322
+ else if (
323
+ (await fileExists(join(repoRoot, '.prettierrc'))) ||
324
+ (await fileExists(join(repoRoot, '.prettierrc.json'))) ||
325
+ (await fileExists(join(repoRoot, '.prettierrc.js'))) ||
326
+ (await fileExists(join(repoRoot, 'prettier.config.js'))) ||
327
+ (await fileExists(join(repoRoot, 'prettier.config.mjs')))
328
+ )
329
+ out.format = `${prefix === 'npm run' ? 'npx' : prefix} prettier --write .`;
330
+
331
+ // typecheck.
332
+ if (scripts.typecheck) out.typecheck = `${prefix} typecheck`;
333
+ else if (await fileExists(join(repoRoot, 'tsconfig.json')))
334
+ out.typecheck = `${prefix === 'npm run' ? 'npx' : prefix === 'pnpm' ? 'pnpm exec' : prefix} tsc --noEmit`;
335
+
336
+ // tests.
337
+ if (scripts.test) out.tests = `${prefix} test`;
338
+
339
+ // build.
340
+ if (scripts.build) out.build = `${prefix} build`;
341
+
342
+ if (Object.keys(out).length === 0) return null;
343
+ return out;
344
+ }
@@ -0,0 +1,121 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { test } from 'node:test';
6
+ import { detectQualityGates, detectStack } from './stack-detect.mjs';
7
+
8
+ function fresh() {
9
+ return mkdtempSync(join(tmpdir(), 'stackdetect-'));
10
+ }
11
+ function file(root, rel, content = '') {
12
+ const path = join(root, rel);
13
+ const parts = rel.split('/');
14
+ if (parts.length > 1) mkdirSync(join(root, parts.slice(0, -1).join('/')), { recursive: true });
15
+ writeFileSync(path, content);
16
+ }
17
+
18
+ test('empty repo — all unknown, quality null', async () => {
19
+ const root = fresh();
20
+ const stack = await detectStack(root);
21
+ assert.equal(stack.language, 'unknown');
22
+ assert.equal(stack.framework, 'unknown');
23
+ assert.equal(stack.packageManager, 'unknown');
24
+ assert.equal(stack.monorepo, false);
25
+ assert.equal(await detectQualityGates(root), null);
26
+ });
27
+
28
+ test('Next.js + biome + tsconfig — language=ts, framework=next.js, router=next-app, quality has lint/format/typecheck', async () => {
29
+ const root = fresh();
30
+ file(root, 'package.json', JSON.stringify({ name: 'x', scripts: { build: 'next build' } }));
31
+ file(root, 'pnpm-lock.yaml');
32
+ file(root, 'tsconfig.json', '{}');
33
+ file(root, 'next.config.mjs');
34
+ file(root, 'biome.json', '{}');
35
+ file(root, 'app/page.tsx');
36
+
37
+ const stack = await detectStack(root);
38
+ assert.equal(stack.language, 'typescript');
39
+ assert.equal(stack.framework, 'next.js');
40
+ assert.equal(stack.router, 'next-app');
41
+ assert.equal(stack.packageManager, 'pnpm');
42
+
43
+ const q = await detectQualityGates(root);
44
+ assert.equal(q.lint, 'pnpm biome check .');
45
+ assert.equal(q.format, 'pnpm biome format --write .');
46
+ assert.equal(q.typecheck, 'pnpm exec tsc --noEmit');
47
+ assert.equal(q.build, 'pnpm build');
48
+ assert.equal(q.tests, undefined);
49
+ });
50
+
51
+ test('Expo + prettier — framework=expo, router=expo-router, format=prettier', async () => {
52
+ const root = fresh();
53
+ file(
54
+ root,
55
+ 'package.json',
56
+ JSON.stringify({ name: 'x', scripts: { test: 'jest' }, devDependencies: { jest: '*' } })
57
+ );
58
+ file(root, 'yarn.lock');
59
+ file(root, 'app.json', '{}');
60
+ file(root, '.prettierrc', '{}');
61
+
62
+ const stack = await detectStack(root);
63
+ assert.equal(stack.framework, 'expo');
64
+ assert.equal(stack.router, 'expo-router');
65
+ assert.equal(stack.packageManager, 'yarn');
66
+ assert.equal(stack.tests, 'jest');
67
+
68
+ const q = await detectQualityGates(root);
69
+ assert.equal(q.format, 'yarn prettier --write .');
70
+ assert.equal(q.tests, 'yarn test');
71
+ });
72
+
73
+ test('pnpm monorepo with workspace-only tsconfig — TS detected via deep scan', async () => {
74
+ const root = fresh();
75
+ file(root, 'package.json', JSON.stringify({ name: 'monorepo' }));
76
+ file(root, 'pnpm-workspace.yaml', '');
77
+ file(root, 'pnpm-lock.yaml');
78
+ file(root, 'packages/web/package.json', '{}');
79
+ file(root, 'packages/web/tsconfig.json', '{}');
80
+
81
+ const stack = await detectStack(root);
82
+ assert.equal(stack.language, 'typescript');
83
+ assert.equal(stack.monorepo, true);
84
+ });
85
+
86
+ test('package.json with only scripts.format — single quality gate emitted', async () => {
87
+ const root = fresh();
88
+ file(root, 'package.json', JSON.stringify({ name: 'x', scripts: { format: 'biome format .' } }));
89
+
90
+ const q = await detectQualityGates(root);
91
+ assert.deepEqual(q, { format: 'npm run format' });
92
+ });
93
+
94
+ test('npm (no lockfile signals) — pmPrefix falls back to `npm run` for scripts, `npx` for tool fallbacks', async () => {
95
+ const root = fresh();
96
+ file(root, 'package.json', JSON.stringify({ name: 'x' }));
97
+ file(root, 'tsconfig.json', '{}');
98
+
99
+ const q = await detectQualityGates(root);
100
+ // No scripts → tsconfig fallback should use npx tsc.
101
+ assert.equal(q.typecheck, 'npx tsc --noEmit');
102
+ });
103
+
104
+ test('vitest in devDependencies — tests=vitest via substring match', async () => {
105
+ const root = fresh();
106
+ file(root, 'package.json', JSON.stringify({ name: 'x', devDependencies: { vitest: '^2' } }));
107
+ file(root, 'package-lock.json');
108
+
109
+ const stack = await detectStack(root);
110
+ assert.equal(stack.tests, 'vitest');
111
+ assert.equal(stack.packageManager, 'npm');
112
+ });
113
+
114
+ test('CI detection — .github/workflows wins', async () => {
115
+ const root = fresh();
116
+ file(root, 'package.json', JSON.stringify({ name: 'x' }));
117
+ mkdirSync(join(root, '.github/workflows'), { recursive: true });
118
+
119
+ const stack = await detectStack(root);
120
+ assert.equal(stack.ci, 'github-actions');
121
+ });