worclaude 2.5.0 → 2.6.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 (33) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/package.json +4 -2
  3. package/src/commands/doctor.js +4 -6
  4. package/src/commands/init.js +9 -6
  5. package/src/commands/scan.js +126 -0
  6. package/src/commands/setup-state.js +113 -0
  7. package/src/commands/status.js +6 -4
  8. package/src/commands/upgrade.js +32 -20
  9. package/src/core/drift-checks.js +2 -1
  10. package/src/core/file-categorizer.js +38 -0
  11. package/src/core/merger.js +14 -7
  12. package/src/core/migration.js +77 -1
  13. package/src/core/project-scanner/detectors/ci.js +51 -0
  14. package/src/core/project-scanner/detectors/deployment.js +32 -0
  15. package/src/core/project-scanner/detectors/env-variables.js +78 -0
  16. package/src/core/project-scanner/detectors/external-apis.js +45 -0
  17. package/src/core/project-scanner/detectors/frameworks.js +56 -0
  18. package/src/core/project-scanner/detectors/language.js +131 -0
  19. package/src/core/project-scanner/detectors/linting.js +58 -0
  20. package/src/core/project-scanner/detectors/monorepo.js +93 -0
  21. package/src/core/project-scanner/detectors/orm.js +72 -0
  22. package/src/core/project-scanner/detectors/package-manager.js +57 -0
  23. package/src/core/project-scanner/detectors/readme.js +123 -0
  24. package/src/core/project-scanner/detectors/scripts.js +128 -0
  25. package/src/core/project-scanner/detectors/spec-docs.js +110 -0
  26. package/src/core/project-scanner/detectors/testing.js +88 -0
  27. package/src/core/project-scanner/index.js +126 -0
  28. package/src/core/project-scanner/manifests.js +120 -0
  29. package/src/core/remover.js +6 -3
  30. package/src/core/scaffolder.js +1 -0
  31. package/src/core/setup-state.js +213 -0
  32. package/src/index.js +39 -0
  33. package/templates/commands/setup.md +512 -113
@@ -10,6 +10,7 @@ import {
10
10
  scaffoldPluginJson,
11
11
  scaffoldMemoryDocs,
12
12
  } from './scaffolder.js';
13
+ import { workflowRefRelPath } from './file-categorizer.js';
13
14
  import { promptHookConflict } from '../prompts/conflict-resolution.js';
14
15
  import {
15
16
  detectMissingSections,
@@ -124,10 +125,11 @@ async function mergeSkills(projectRoot, existingScan, variables, report, selecti
124
125
  const existsAsFlat = existingScan.existingSkills.includes(`${skill.name}.md`);
125
126
 
126
127
  if (existsAsDir || existsAsFlat) {
127
- // Tier 2: conflict — save as .workflow-ref.md
128
+ // Tier 2: conflict — save under .claude/workflow-ref/ so the live
129
+ // SKILL.md stays authoritative and the ref cannot shadow it.
128
130
  await scaffoldFile(
129
131
  skill.templatePath,
130
- path.join('.claude', 'skills', skill.name, 'SKILL.workflow-ref.md'),
132
+ workflowRefRelPath(`skills/${skill.name}/SKILL.md`),
131
133
  skill.vars,
132
134
  projectRoot
133
135
  );
@@ -147,7 +149,7 @@ async function mergeSkills(projectRoot, existingScan, variables, report, selecti
147
149
 
148
150
  if (routingExistsAsDir || routingExistsAsFlat) {
149
151
  await writeFile(
150
- path.join(projectRoot, skillsDir, 'agent-routing', 'SKILL.workflow-ref.md'),
152
+ path.join(projectRoot, workflowRefRelPath('skills/agent-routing/SKILL.md')),
151
153
  routingContent
152
154
  );
153
155
  report.conflicts.skills.push('agent-routing');
@@ -166,7 +168,7 @@ async function mergeAgents(projectRoot, existingScan, selectedAgents, report) {
166
168
  if (existingScan.existingAgents.includes(filename)) {
167
169
  await scaffoldFile(
168
170
  `agents/universal/${agent}.md`,
169
- path.join(destDir, `${agent}.workflow-ref.md`),
171
+ workflowRefRelPath(`agents/${filename}`),
170
172
  {},
171
173
  projectRoot
172
174
  );
@@ -191,7 +193,7 @@ async function mergeAgents(projectRoot, existingScan, selectedAgents, report) {
191
193
  if (existingScan.existingAgents.includes(filename)) {
192
194
  await scaffoldFile(
193
195
  `agents/optional/${category}/${agent}.md`,
194
- path.join(destDir, `${agent}.workflow-ref.md`),
196
+ workflowRefRelPath(`agents/${filename}`),
195
197
  {},
196
198
  projectRoot
197
199
  );
@@ -216,7 +218,7 @@ async function mergeCommands(projectRoot, existingScan, report) {
216
218
  if (existingScan.existingCommands.includes(filename)) {
217
219
  await scaffoldFile(
218
220
  `commands/${cmd}.md`,
219
- path.join(destDir, `${cmd}.workflow-ref.md`),
221
+ workflowRefRelPath(`commands/${filename}`),
220
222
  {},
221
223
  projectRoot
222
224
  );
@@ -431,7 +433,12 @@ async function mergeAgentsMd(projectRoot, existingScan, variables, report) {
431
433
  await scaffoldFile('core/agents-md.md', 'AGENTS.md', variables, projectRoot);
432
434
  report.agentsMdHandling = 'created';
433
435
  } else {
434
- await scaffoldFile('core/agents-md.md', 'AGENTS.md.workflow-ref', variables, projectRoot);
436
+ await scaffoldFile(
437
+ 'core/agents-md.md',
438
+ workflowRefRelPath('root/AGENTS.md'),
439
+ variables,
440
+ projectRoot
441
+ );
435
442
  report.agentsMdHandling = 'saved-alongside';
436
443
  }
437
444
  }
@@ -1,7 +1,16 @@
1
1
  import path from 'node:path';
2
2
  import { AGENT_CATALOG } from '../data/agents.js';
3
- import { readFile, writeFile, dirExists, moveFile, listFiles } from '../utils/file.js';
3
+ import {
4
+ fileExists,
5
+ readFile,
6
+ writeFile,
7
+ dirExists,
8
+ moveFile,
9
+ listFiles,
10
+ listFilesRecursive,
11
+ } from '../utils/file.js';
4
12
  import { hashFile } from '../utils/hash.js';
13
+ import { WORKFLOW_REF_DIR } from './file-categorizer.js';
5
14
 
6
15
  // --- Version comparison ---
7
16
 
@@ -142,3 +151,70 @@ export async function patchAgentDescriptions(projectRoot, meta, promptFn) {
142
151
 
143
152
  return report;
144
153
  }
154
+
155
+ // --- Workflow-ref relocation (v2.5.1) ---
156
+
157
+ const LEGACY_ROOT_REFS = ['CLAUDE.md.workflow-ref.md', 'AGENTS.md.workflow-ref'];
158
+
159
+ // Legacy ref files could only live inside these subdirs. Scoping the scan
160
+ // here instead of walking all of .claude/ matters because `sessions/` and
161
+ // `learnings/` can accumulate hundreds of files that will never be refs.
162
+ const LEGACY_REF_SUBDIRS = ['commands', 'agents', 'skills'];
163
+
164
+ function legacyClaudeRefTarget(relKey) {
165
+ // relKey is relative to .claude/, e.g. "commands/sync.workflow-ref.md".
166
+ // Lookahead `(?=\.[^.]+$)` ensures ".workflow-ref" is stripped only when
167
+ // followed by a final extension — so "sync.workflow-ref.md" → "sync.md",
168
+ // but a hypothetical "sync.workflow-ref" (no final ext) wouldn't match.
169
+ const parts = relKey.split('/');
170
+ const name = parts.pop();
171
+ parts.push(name.replace(/\.workflow-ref(?=\.[^.]+$)/, ''));
172
+ return path.join(WORKFLOW_REF_DIR, ...parts);
173
+ }
174
+
175
+ export async function migrateWorkflowRefLocation(projectRoot) {
176
+ const report = { moved: 0, names: [], skipped: [] };
177
+ const claudeDir = path.join(projectRoot, '.claude');
178
+ const refDir = path.join(claudeDir, WORKFLOW_REF_DIR);
179
+
180
+ // 1. Scan only the subdirs where legacy `.workflow-ref.md` siblings could
181
+ // live. Walking all of .claude/ would re-stat hundreds of session files
182
+ // on every upgrade for no benefit.
183
+ for (const subdir of LEGACY_REF_SUBDIRS) {
184
+ const scanRoot = path.join(claudeDir, subdir);
185
+ if (!(await dirExists(scanRoot))) continue;
186
+
187
+ const allFiles = await listFilesRecursive(scanRoot);
188
+ for (const fp of allFiles) {
189
+ const rel = path.relative(claudeDir, fp).split(path.sep).join('/');
190
+ if (!rel.endsWith('.workflow-ref.md')) continue;
191
+
192
+ const target = path.join(claudeDir, legacyClaudeRefTarget(rel));
193
+ if (await fileExists(target)) {
194
+ report.skipped.push(rel);
195
+ continue;
196
+ }
197
+ await moveFile(fp, target);
198
+ report.moved++;
199
+ report.names.push(rel);
200
+ }
201
+ }
202
+
203
+ // 2. Legacy root-level refs (CLAUDE.md.workflow-ref.md, AGENTS.md.workflow-ref).
204
+ for (const legacyName of LEGACY_ROOT_REFS) {
205
+ const src = path.join(projectRoot, legacyName);
206
+ if (!(await fileExists(src))) continue;
207
+
208
+ const original = legacyName.replace(/\.workflow-ref(?:\.md)?$/, '');
209
+ const target = path.join(refDir, original);
210
+ if (await fileExists(target)) {
211
+ report.skipped.push(legacyName);
212
+ continue;
213
+ }
214
+ await moveFile(src, target);
215
+ report.moved++;
216
+ report.names.push(legacyName);
217
+ }
218
+
219
+ return report;
220
+ }
@@ -0,0 +1,51 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { fileExists, dirExists } from '../../../utils/file.js';
4
+
5
+ export default async function detectCi(projectRoot) {
6
+ const results = [];
7
+
8
+ const workflowsDir = path.join(projectRoot, '.github', 'workflows');
9
+ if (await dirExists(workflowsDir)) {
10
+ try {
11
+ const entries = await fs.readdir(workflowsDir, { withFileTypes: true });
12
+ const workflows = entries
13
+ .filter((e) => e.isFile() && /\.ya?ml$/.test(e.name))
14
+ .map((e) => e.name)
15
+ .sort();
16
+ if (workflows.length > 0) {
17
+ results.push({
18
+ field: 'ci',
19
+ value: { provider: 'GitHub Actions', workflows },
20
+ confidence: 'high',
21
+ source: '.github/workflows/',
22
+ candidates: null,
23
+ });
24
+ }
25
+ } catch {
26
+ /* missing or unreadable — non-fatal */
27
+ }
28
+ }
29
+
30
+ const other = [
31
+ { file: '.gitlab-ci.yml', provider: 'GitLab CI' },
32
+ { file: '.circleci/config.yml', provider: 'CircleCI' },
33
+ { file: 'azure-pipelines.yml', provider: 'Azure Pipelines' },
34
+ { file: 'Jenkinsfile', provider: 'Jenkins' },
35
+ { file: '.drone.yml', provider: 'Drone' },
36
+ { file: 'bitbucket-pipelines.yml', provider: 'Bitbucket Pipelines' },
37
+ ];
38
+ for (const { file, provider } of other) {
39
+ if (await fileExists(path.join(projectRoot, file))) {
40
+ results.push({
41
+ field: 'ci',
42
+ value: { provider, workflows: [path.basename(file)] },
43
+ confidence: 'high',
44
+ source: file,
45
+ candidates: null,
46
+ });
47
+ }
48
+ }
49
+
50
+ return results;
51
+ }
@@ -0,0 +1,32 @@
1
+ import path from 'node:path';
2
+ import { fileExists } from '../../../utils/file.js';
3
+
4
+ const DEPLOYMENTS = [
5
+ { file: 'vercel.json', target: 'Vercel' },
6
+ { file: 'netlify.toml', target: 'Netlify' },
7
+ { file: 'Dockerfile', target: 'Docker' },
8
+ { file: 'fly.toml', target: 'Fly.io' },
9
+ { file: 'railway.toml', target: 'Railway' },
10
+ { file: 'app.yaml', target: 'Google App Engine' },
11
+ { file: 'serverless.yml', target: 'Serverless Framework' },
12
+ { file: 'render.yaml', target: 'Render' },
13
+ { file: '.platform.app.yaml', target: 'Platform.sh' },
14
+ { file: 'wrangler.toml', target: 'Cloudflare Workers' },
15
+ { file: 'amplify.yml', target: 'AWS Amplify' },
16
+ ];
17
+
18
+ export default async function detectDeployment(projectRoot) {
19
+ const results = [];
20
+ for (const { file, target } of DEPLOYMENTS) {
21
+ if (await fileExists(path.join(projectRoot, file))) {
22
+ results.push({
23
+ field: 'deployment',
24
+ value: target,
25
+ confidence: 'high',
26
+ source: file,
27
+ candidates: null,
28
+ });
29
+ }
30
+ }
31
+ return results;
32
+ }
@@ -0,0 +1,78 @@
1
+ import path from 'node:path';
2
+ import { fileExists, readFile } from '../../../utils/file.js';
3
+ import { getAllDeps, depMatches } from '../manifests.js';
4
+
5
+ const ENV_FILES = ['.env.example', '.env.template', '.env.sample'];
6
+
7
+ const SDK_SERVICES = [
8
+ { sdkPrefix: '@stripe/', service: 'Stripe', envPrefix: 'STRIPE_' },
9
+ { sdkPrefix: 'stripe', service: 'Stripe', envPrefix: 'STRIPE_' },
10
+ { sdkPrefix: '@aws-sdk/', service: 'AWS', envPrefix: 'AWS_' },
11
+ { sdkPrefix: '@sendgrid/', service: 'SendGrid', envPrefix: 'SENDGRID_' },
12
+ { sdkPrefix: 'twilio', service: 'Twilio', envPrefix: 'TWILIO_' },
13
+ { sdkPrefix: 'openai', service: 'OpenAI', envPrefix: 'OPENAI_' },
14
+ { sdkPrefix: '@anthropic-ai/', service: 'Anthropic', envPrefix: 'ANTHROPIC_' },
15
+ { sdkPrefix: '@google-cloud/', service: 'Google Cloud', envPrefix: 'GOOGLE_' },
16
+ { sdkPrefix: '@slack/', service: 'Slack', envPrefix: 'SLACK_' },
17
+ { sdkPrefix: 'postmark', service: 'Postmark', envPrefix: 'POSTMARK_' },
18
+ { sdkPrefix: 'resend', service: 'Resend', envPrefix: 'RESEND_' },
19
+ { sdkPrefix: '@sentry/', service: 'Sentry', envPrefix: 'SENTRY_' },
20
+ ];
21
+
22
+ function parseEnvNames(content) {
23
+ const names = [];
24
+ for (const rawLine of content.split(/\r?\n/)) {
25
+ const line = rawLine.trim();
26
+ if (line === '' || line.startsWith('#')) continue;
27
+ const match = line.match(/^(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=/);
28
+ if (match) names.push(match[1]);
29
+ }
30
+ return names;
31
+ }
32
+
33
+ export default async function detectEnvVariables(projectRoot) {
34
+ let sourceFile = null;
35
+ let content = null;
36
+ for (const f of ENV_FILES) {
37
+ const p = path.join(projectRoot, f);
38
+ if (await fileExists(p)) {
39
+ sourceFile = f;
40
+ content = await readFile(p);
41
+ break;
42
+ }
43
+ }
44
+ if (!sourceFile) return [];
45
+
46
+ const names = parseEnvNames(content);
47
+ if (names.length === 0) {
48
+ return [
49
+ {
50
+ field: 'envVariables',
51
+ value: { names: [], inferredServices: [] },
52
+ confidence: 'high',
53
+ source: sourceFile,
54
+ candidates: null,
55
+ },
56
+ ];
57
+ }
58
+
59
+ const { js, py } = await getAllDeps(projectRoot);
60
+ const allDeps = { ...js, ...py };
61
+ const inferredServices = new Set();
62
+ for (const { sdkPrefix, service, envPrefix } of SDK_SERVICES) {
63
+ const pattern = sdkPrefix.endsWith('/') ? `${sdkPrefix}*` : sdkPrefix;
64
+ if (depMatches(allDeps, pattern) && names.some((n) => n.startsWith(envPrefix))) {
65
+ inferredServices.add(service);
66
+ }
67
+ }
68
+
69
+ return [
70
+ {
71
+ field: 'envVariables',
72
+ value: { names, inferredServices: Array.from(inferredServices) },
73
+ confidence: 'high',
74
+ source: sourceFile,
75
+ candidates: null,
76
+ },
77
+ ];
78
+ }
@@ -0,0 +1,45 @@
1
+ import { getAllDeps, depMatches } from '../manifests.js';
2
+
3
+ const SDK_MAP = [
4
+ { match: 'stripe', service: 'Stripe' },
5
+ { match: '@sendgrid/', service: 'SendGrid' },
6
+ { match: '@aws-sdk/', service: 'AWS' },
7
+ { match: 'twilio', service: 'Twilio' },
8
+ { match: 'openai', service: 'OpenAI' },
9
+ { match: '@anthropic-ai/sdk', service: 'Anthropic' },
10
+ { match: '@google-cloud/', service: 'Google Cloud' },
11
+ { match: '@slack/', service: 'Slack' },
12
+ { match: 'postmark', service: 'Postmark' },
13
+ { match: 'resend', service: 'Resend' },
14
+ { match: 'algoliasearch', service: 'Algolia' },
15
+ { match: 'pusher', service: 'Pusher' },
16
+ { match: 'posthog-js', service: 'PostHog' },
17
+ { match: 'posthog-node', service: 'PostHog' },
18
+ { match: '@sentry/', service: 'Sentry' },
19
+ { match: 'mixpanel', service: 'Mixpanel' },
20
+ { match: 'mixpanel-browser', service: 'Mixpanel' },
21
+ ];
22
+
23
+ export default async function detectExternalApis(projectRoot) {
24
+ const { js, py, hasPackageJson, hasPyproject } = await getAllDeps(projectRoot);
25
+ if (!hasPackageJson && !hasPyproject) return [];
26
+
27
+ const allDeps = { ...js, ...py };
28
+ const services = new Set();
29
+ for (const { match, service } of SDK_MAP) {
30
+ const pattern = match.endsWith('/') ? `${match}*` : match;
31
+ if (depMatches(allDeps, pattern)) services.add(service);
32
+ }
33
+
34
+ if (services.size === 0) return [];
35
+
36
+ return [
37
+ {
38
+ field: 'externalApis',
39
+ value: Array.from(services),
40
+ confidence: 'high',
41
+ source: hasPackageJson ? 'package.json' : 'pyproject.toml',
42
+ candidates: null,
43
+ },
44
+ ];
45
+ }
@@ -0,0 +1,56 @@
1
+ import { getAllDeps } from '../manifests.js';
2
+
3
+ const FRAMEWORKS = [
4
+ { dep: 'next', name: 'Next.js' },
5
+ { dep: 'nuxt', name: 'Nuxt' },
6
+ { dep: 'astro', name: 'Astro' },
7
+ { dep: 'remix', name: 'Remix' },
8
+ { dep: 'sveltekit', name: 'SvelteKit' },
9
+ { dep: '@sveltejs/kit', name: 'SvelteKit' },
10
+ { dep: 'react', name: 'React' },
11
+ { dep: 'vue', name: 'Vue' },
12
+ { dep: 'svelte', name: 'Svelte' },
13
+ { dep: 'solid-js', name: 'SolidJS' },
14
+ { dep: 'express', name: 'Express' },
15
+ { dep: 'fastify', name: 'Fastify' },
16
+ { dep: 'hono', name: 'Hono' },
17
+ { dep: 'koa', name: 'Koa' },
18
+ { dep: '@nestjs/core', name: 'NestJS' },
19
+ { dep: 'fastapi', name: 'FastAPI' },
20
+ { dep: 'starlette', name: 'Starlette' },
21
+ { dep: 'django', name: 'Django' },
22
+ { dep: 'flask', name: 'Flask' },
23
+ ];
24
+
25
+ export default async function detectFrameworks(projectRoot) {
26
+ const { js, py, hasPackageJson, hasPyproject } = await getAllDeps(projectRoot);
27
+ if (!hasPackageJson && !hasPyproject) return [];
28
+
29
+ const detected = [];
30
+ const sources = new Set();
31
+ const seen = new Set();
32
+ for (const { dep, name } of FRAMEWORKS) {
33
+ if (seen.has(name)) continue;
34
+ if (js[dep] !== undefined) {
35
+ detected.push({ name, version: typeof js[dep] === 'string' ? js[dep] : '' });
36
+ sources.add('package.json');
37
+ seen.add(name);
38
+ } else if (py[dep] !== undefined) {
39
+ detected.push({ name, version: typeof py[dep] === 'string' ? py[dep] : '' });
40
+ sources.add('pyproject.toml');
41
+ seen.add(name);
42
+ }
43
+ }
44
+
45
+ if (detected.length === 0) return [];
46
+
47
+ return [
48
+ {
49
+ field: 'frameworks',
50
+ value: detected,
51
+ confidence: 'high',
52
+ source: Array.from(sources).join(', '),
53
+ candidates: null,
54
+ },
55
+ ];
56
+ }
@@ -0,0 +1,131 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { fileExists } from '../../../utils/file.js';
4
+
5
+ async function hasAnyJsFile(projectRoot) {
6
+ try {
7
+ const entries = await fs.readdir(projectRoot, { withFileTypes: true });
8
+ for (const entry of entries) {
9
+ if (entry.isFile() && /\.(js|mjs|cjs)$/.test(entry.name)) return true;
10
+ }
11
+ const srcDir = path.join(projectRoot, 'src');
12
+ if (await fileExists(srcDir)) {
13
+ const srcEntries = await fs.readdir(srcDir, { withFileTypes: true });
14
+ for (const entry of srcEntries) {
15
+ if (entry.isFile() && /\.(js|mjs|cjs)$/.test(entry.name)) return true;
16
+ }
17
+ }
18
+ } catch {
19
+ /* missing or unreadable — non-fatal */
20
+ }
21
+ return false;
22
+ }
23
+
24
+ async function hasTsFiles(projectRoot) {
25
+ try {
26
+ const srcDir = path.join(projectRoot, 'src');
27
+ if (await fileExists(srcDir)) {
28
+ const entries = await fs.readdir(srcDir, { withFileTypes: true });
29
+ for (const entry of entries) {
30
+ if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name)) return true;
31
+ }
32
+ }
33
+ } catch {
34
+ /* missing or unreadable — non-fatal */
35
+ }
36
+ return false;
37
+ }
38
+
39
+ export default async function detectLanguage(projectRoot) {
40
+ const results = [];
41
+ const has = async (f) => fileExists(path.join(projectRoot, f));
42
+
43
+ if (await has('tsconfig.json')) {
44
+ results.push({
45
+ field: 'language',
46
+ value: 'TypeScript',
47
+ confidence: 'high',
48
+ source: 'tsconfig.json',
49
+ candidates: null,
50
+ });
51
+ } else if (await has('jsconfig.json')) {
52
+ results.push({
53
+ field: 'language',
54
+ value: 'JavaScript',
55
+ confidence: 'high',
56
+ source: 'jsconfig.json',
57
+ candidates: null,
58
+ });
59
+ } else if ((await hasAnyJsFile(projectRoot)) && !(await hasTsFiles(projectRoot))) {
60
+ results.push({
61
+ field: 'language',
62
+ value: 'JavaScript',
63
+ confidence: 'high',
64
+ source: '*.js files',
65
+ candidates: null,
66
+ });
67
+ }
68
+
69
+ const pythonFiles = ['pyproject.toml', 'setup.py', 'requirements.txt'];
70
+ for (const f of pythonFiles) {
71
+ if (await has(f)) {
72
+ results.push({
73
+ field: 'language',
74
+ value: 'Python',
75
+ confidence: 'high',
76
+ source: f,
77
+ candidates: null,
78
+ });
79
+ break;
80
+ }
81
+ }
82
+ if (!results.some((r) => r.value === 'Python')) {
83
+ try {
84
+ const entries = await fs.readdir(projectRoot, { withFileTypes: true });
85
+ const reqFile = entries.find((e) => e.isFile() && /^requirements.*\.txt$/.test(e.name));
86
+ if (reqFile) {
87
+ results.push({
88
+ field: 'language',
89
+ value: 'Python',
90
+ confidence: 'high',
91
+ source: reqFile.name,
92
+ candidates: null,
93
+ });
94
+ }
95
+ } catch {
96
+ /* missing or unreadable — non-fatal */
97
+ }
98
+ }
99
+
100
+ if (await has('Cargo.toml')) {
101
+ results.push({
102
+ field: 'language',
103
+ value: 'Rust',
104
+ confidence: 'high',
105
+ source: 'Cargo.toml',
106
+ candidates: null,
107
+ });
108
+ }
109
+
110
+ if (await has('go.mod')) {
111
+ results.push({
112
+ field: 'language',
113
+ value: 'Go',
114
+ confidence: 'high',
115
+ source: 'go.mod',
116
+ candidates: null,
117
+ });
118
+ }
119
+
120
+ if (await has('Gemfile')) {
121
+ results.push({
122
+ field: 'language',
123
+ value: 'Ruby',
124
+ confidence: 'high',
125
+ source: 'Gemfile',
126
+ candidates: null,
127
+ });
128
+ }
129
+
130
+ return results;
131
+ }
@@ -0,0 +1,58 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { fileExists } from '../../../utils/file.js';
4
+ import { readPyprojectToml } from '../manifests.js';
5
+
6
+ const CONFIGS = [
7
+ { tool: 'ESLint', patterns: [/^\.eslintrc(\..+)?$/, /^eslint\.config\.(js|ts|mjs|cjs)$/] },
8
+ { tool: 'Prettier', patterns: [/^\.prettierrc(\..+)?$/, /^prettier\.config\.(js|ts|mjs|cjs)$/] },
9
+ { tool: 'Biome', patterns: [/^biome\.jsonc?$/] },
10
+ { tool: 'Stylelint', patterns: [/^\.stylelintrc(\..+)?$/] },
11
+ { tool: 'RuboCop', patterns: [/^\.rubocop\.ya?ml$/] },
12
+ ];
13
+
14
+ async function rootFiles(projectRoot) {
15
+ try {
16
+ const entries = await fs.readdir(projectRoot, { withFileTypes: true });
17
+ return entries.filter((e) => e.isFile()).map((e) => e.name);
18
+ } catch {
19
+ return [];
20
+ }
21
+ }
22
+
23
+ export default async function detectLinting(projectRoot) {
24
+ const files = await rootFiles(projectRoot);
25
+ const tools = new Set();
26
+ const sources = [];
27
+
28
+ for (const { tool, patterns } of CONFIGS) {
29
+ const match = files.find((f) => patterns.some((p) => p.test(f)));
30
+ if (match) {
31
+ tools.add(tool);
32
+ sources.push(match);
33
+ }
34
+ }
35
+
36
+ if (await fileExists(path.join(projectRoot, 'ruff.toml'))) {
37
+ tools.add('Ruff');
38
+ sources.push('ruff.toml');
39
+ } else {
40
+ const pyproject = await readPyprojectToml(projectRoot);
41
+ if (pyproject && pyproject.tool && pyproject.tool.ruff) {
42
+ tools.add('Ruff');
43
+ sources.push('pyproject.toml');
44
+ }
45
+ }
46
+
47
+ if (tools.size === 0) return [];
48
+
49
+ return [
50
+ {
51
+ field: 'linting',
52
+ value: Array.from(tools),
53
+ confidence: 'high',
54
+ source: sources.join(', '),
55
+ candidates: null,
56
+ },
57
+ ];
58
+ }