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.
- package/CHANGELOG.md +65 -0
- package/package.json +4 -2
- package/src/commands/doctor.js +4 -6
- package/src/commands/init.js +9 -6
- package/src/commands/scan.js +126 -0
- package/src/commands/setup-state.js +113 -0
- package/src/commands/status.js +6 -4
- package/src/commands/upgrade.js +32 -20
- package/src/core/drift-checks.js +2 -1
- package/src/core/file-categorizer.js +38 -0
- package/src/core/merger.js +14 -7
- package/src/core/migration.js +77 -1
- package/src/core/project-scanner/detectors/ci.js +51 -0
- package/src/core/project-scanner/detectors/deployment.js +32 -0
- package/src/core/project-scanner/detectors/env-variables.js +78 -0
- package/src/core/project-scanner/detectors/external-apis.js +45 -0
- package/src/core/project-scanner/detectors/frameworks.js +56 -0
- package/src/core/project-scanner/detectors/language.js +131 -0
- package/src/core/project-scanner/detectors/linting.js +58 -0
- package/src/core/project-scanner/detectors/monorepo.js +93 -0
- package/src/core/project-scanner/detectors/orm.js +72 -0
- package/src/core/project-scanner/detectors/package-manager.js +57 -0
- package/src/core/project-scanner/detectors/readme.js +123 -0
- package/src/core/project-scanner/detectors/scripts.js +128 -0
- package/src/core/project-scanner/detectors/spec-docs.js +110 -0
- package/src/core/project-scanner/detectors/testing.js +88 -0
- package/src/core/project-scanner/index.js +126 -0
- package/src/core/project-scanner/manifests.js +120 -0
- package/src/core/remover.js +6 -3
- package/src/core/scaffolder.js +1 -0
- package/src/core/setup-state.js +213 -0
- package/src/index.js +39 -0
- package/templates/commands/setup.md +512 -113
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import YAML from 'yaml';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import { fileExists, readFile } from '../../../utils/file.js';
|
|
5
|
+
import { readPackageJson } from '../manifests.js';
|
|
6
|
+
|
|
7
|
+
async function readYamlWorkspacePackages(projectRoot) {
|
|
8
|
+
const filePath = path.join(projectRoot, 'pnpm-workspace.yaml');
|
|
9
|
+
if (!(await fileExists(filePath))) return null;
|
|
10
|
+
try {
|
|
11
|
+
const raw = await readFile(filePath);
|
|
12
|
+
const parsed = YAML.parse(raw);
|
|
13
|
+
if (parsed && Array.isArray(parsed.packages)) return parsed.packages;
|
|
14
|
+
} catch {
|
|
15
|
+
/* missing or unreadable — non-fatal */
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function expandPackagePaths(projectRoot, patterns) {
|
|
21
|
+
const paths = [];
|
|
22
|
+
for (const pattern of patterns) {
|
|
23
|
+
if (pattern.endsWith('/*')) {
|
|
24
|
+
const parent = pattern.slice(0, -2);
|
|
25
|
+
const parentPath = path.join(projectRoot, parent);
|
|
26
|
+
try {
|
|
27
|
+
const entries = await fs.readdir(parentPath, { withFileTypes: true });
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (entry.isDirectory()) paths.push(path.posix.join(parent, entry.name));
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// directory missing — pnpm-workspace.yaml lists a path the user hasn't created
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
paths.push(pattern);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return paths;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default async function detectMonorepo(projectRoot) {
|
|
42
|
+
const results = [];
|
|
43
|
+
const sources = [];
|
|
44
|
+
let packagePaths = [];
|
|
45
|
+
let tool = null;
|
|
46
|
+
|
|
47
|
+
const pnpmPackages = await readYamlWorkspacePackages(projectRoot);
|
|
48
|
+
if (pnpmPackages) {
|
|
49
|
+
tool = 'pnpm';
|
|
50
|
+
sources.push('pnpm-workspace.yaml');
|
|
51
|
+
packagePaths = await expandPackagePaths(projectRoot, pnpmPackages);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!tool) {
|
|
55
|
+
const pkg = await readPackageJson(projectRoot);
|
|
56
|
+
if (pkg && pkg.workspaces) {
|
|
57
|
+
const patterns = Array.isArray(pkg.workspaces)
|
|
58
|
+
? pkg.workspaces
|
|
59
|
+
: Array.isArray(pkg.workspaces.packages)
|
|
60
|
+
? pkg.workspaces.packages
|
|
61
|
+
: [];
|
|
62
|
+
if (patterns.length > 0) {
|
|
63
|
+
tool = 'npm-or-yarn';
|
|
64
|
+
sources.push('package.json');
|
|
65
|
+
packagePaths = await expandPackagePaths(projectRoot, patterns);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const markers = [
|
|
71
|
+
{ file: 'lerna.json', tool: 'lerna' },
|
|
72
|
+
{ file: 'nx.json', tool: 'nx' },
|
|
73
|
+
{ file: 'turbo.json', tool: 'turbo' },
|
|
74
|
+
{ file: 'rush.json', tool: 'rush' },
|
|
75
|
+
];
|
|
76
|
+
for (const { file, tool: markerTool } of markers) {
|
|
77
|
+
if (await fileExists(path.join(projectRoot, file))) {
|
|
78
|
+
if (!tool) tool = markerTool;
|
|
79
|
+
sources.push(file);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!tool) return [];
|
|
84
|
+
|
|
85
|
+
results.push({
|
|
86
|
+
field: 'monorepo',
|
|
87
|
+
value: { isMonorepo: true, tool, packagePaths },
|
|
88
|
+
confidence: 'high',
|
|
89
|
+
source: sources.join(', '),
|
|
90
|
+
candidates: null,
|
|
91
|
+
});
|
|
92
|
+
return results;
|
|
93
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { fileExists } from '../../../utils/file.js';
|
|
4
|
+
import { getAllDeps } from '../manifests.js';
|
|
5
|
+
|
|
6
|
+
async function findFile(projectRoot, patterns) {
|
|
7
|
+
for (const p of patterns) {
|
|
8
|
+
if (await fileExists(path.join(projectRoot, p))) return p;
|
|
9
|
+
}
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function findDrizzleConfig(projectRoot) {
|
|
14
|
+
try {
|
|
15
|
+
const entries = await fs.readdir(projectRoot, { withFileTypes: true });
|
|
16
|
+
const match = entries.find(
|
|
17
|
+
(e) => e.isFile() && /^drizzle\.config\.(js|ts|mjs|cjs)$/.test(e.name)
|
|
18
|
+
);
|
|
19
|
+
return match ? match.name : null;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SIMPLE_ORMS = [
|
|
26
|
+
{ deps: ['sequelize', '@sequelize/core'], name: 'Sequelize', source: 'package.json', from: 'js' },
|
|
27
|
+
{ deps: ['typeorm'], name: 'TypeORM', source: 'package.json', from: 'js' },
|
|
28
|
+
{ deps: ['mongoose'], name: 'Mongoose', source: 'package.json', from: 'js' },
|
|
29
|
+
{ deps: ['sqlalchemy', 'SQLAlchemy'], name: 'SQLAlchemy', source: 'pyproject.toml', from: 'py' },
|
|
30
|
+
{ deps: ['tortoise-orm'], name: 'Tortoise ORM', source: 'pyproject.toml', from: 'py' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function ormResult(name, schemaFile, source) {
|
|
34
|
+
return {
|
|
35
|
+
field: 'orm',
|
|
36
|
+
value: { name, schemaFile: schemaFile ?? null },
|
|
37
|
+
confidence: 'high',
|
|
38
|
+
source,
|
|
39
|
+
candidates: null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default async function detectOrm(projectRoot) {
|
|
44
|
+
const { js, py } = await getAllDeps(projectRoot);
|
|
45
|
+
const results = [];
|
|
46
|
+
|
|
47
|
+
if (js['prisma'] !== undefined || js['@prisma/client'] !== undefined) {
|
|
48
|
+
const schemaFile = await findFile(projectRoot, ['prisma/schema.prisma']);
|
|
49
|
+
results.push(ormResult('Prisma', schemaFile, schemaFile || 'package.json'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (js['drizzle-orm'] !== undefined) {
|
|
53
|
+
const schemaFile = await findDrizzleConfig(projectRoot);
|
|
54
|
+
results.push(ormResult('Drizzle', schemaFile, schemaFile || 'package.json'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const { deps, name, source, from } of SIMPLE_ORMS) {
|
|
58
|
+
const map = from === 'js' ? js : py;
|
|
59
|
+
if (deps.some((d) => map[d] !== undefined)) {
|
|
60
|
+
results.push(ormResult(name, null, source));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const alembicIni = (await fileExists(path.join(projectRoot, 'alembic.ini')))
|
|
65
|
+
? 'alembic.ini'
|
|
66
|
+
: null;
|
|
67
|
+
if (py['alembic'] !== undefined || alembicIni) {
|
|
68
|
+
results.push(ormResult('Alembic', alembicIni, alembicIni || 'pyproject.toml'));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return results;
|
|
72
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileExists } from '../../../utils/file.js';
|
|
3
|
+
|
|
4
|
+
const LOCKFILES = [
|
|
5
|
+
{ file: 'pnpm-lock.yaml', manager: 'pnpm', group: 'js' },
|
|
6
|
+
{ file: 'yarn.lock', manager: 'yarn', group: 'js' },
|
|
7
|
+
{ file: 'package-lock.json', manager: 'npm', group: 'js' },
|
|
8
|
+
{ file: 'bun.lock', manager: 'bun', group: 'js' },
|
|
9
|
+
{ file: 'bun.lockb', manager: 'bun', group: 'js' },
|
|
10
|
+
{ file: 'poetry.lock', manager: 'poetry', group: 'python' },
|
|
11
|
+
{ file: 'uv.lock', manager: 'uv', group: 'python' },
|
|
12
|
+
{ file: 'Pipfile.lock', manager: 'pipenv', group: 'python' },
|
|
13
|
+
{ file: 'Cargo.lock', manager: 'cargo', group: 'rust' },
|
|
14
|
+
{ file: 'Gemfile.lock', manager: 'bundler', group: 'ruby' },
|
|
15
|
+
{ file: 'go.sum', manager: 'go', group: 'go' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export default async function detectPackageManager(projectRoot) {
|
|
19
|
+
const present = [];
|
|
20
|
+
for (const entry of LOCKFILES) {
|
|
21
|
+
if (await fileExists(path.join(projectRoot, entry.file))) {
|
|
22
|
+
present.push(entry);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (present.length === 0) return [];
|
|
26
|
+
|
|
27
|
+
const byGroup = new Map();
|
|
28
|
+
for (const entry of present) {
|
|
29
|
+
if (!byGroup.has(entry.group)) byGroup.set(entry.group, []);
|
|
30
|
+
byGroup.get(entry.group).push(entry);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const results = [];
|
|
34
|
+
for (const group of byGroup.values()) {
|
|
35
|
+
if (group.length === 1) {
|
|
36
|
+
const { file, manager } = group[0];
|
|
37
|
+
results.push({
|
|
38
|
+
field: 'packageManager',
|
|
39
|
+
value: manager,
|
|
40
|
+
confidence: 'high',
|
|
41
|
+
source: file,
|
|
42
|
+
candidates: null,
|
|
43
|
+
});
|
|
44
|
+
} else {
|
|
45
|
+
const managers = group.map((g) => g.manager);
|
|
46
|
+
const sources = group.map((g) => g.file);
|
|
47
|
+
results.push({
|
|
48
|
+
field: 'packageManager',
|
|
49
|
+
value: group[0].manager,
|
|
50
|
+
confidence: 'medium',
|
|
51
|
+
source: sources.join(', '),
|
|
52
|
+
candidates: managers,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return results;
|
|
57
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { fileExists, readFile } from '../../../utils/file.js';
|
|
4
|
+
|
|
5
|
+
const README_CANDIDATES = ['README.md', 'README.rst', 'README.txt'];
|
|
6
|
+
const SETUP_HEADING = /installation|getting\s*started|setup|quick\s*start/i;
|
|
7
|
+
const SETUP_CAP = 2000;
|
|
8
|
+
const MIN_DESCRIPTION_LENGTH = 20;
|
|
9
|
+
// Cap full-file read to keep a pathological 50MB README from blocking the scan.
|
|
10
|
+
const MAX_README_BYTES = 512 * 1024;
|
|
11
|
+
const BADGE_PATTERNS = [/^\s*\[!\[.*?\]\(.*?\)\]\(.*?\)\s*$/, /^\s*!\[.*?\]\(.*?\)\s*$/];
|
|
12
|
+
|
|
13
|
+
function stripHtmlComments(text) {
|
|
14
|
+
return text.replace(/<!--[\s\S]*?-->/g, '');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function stripHtmlTags(text) {
|
|
18
|
+
return text.replace(/<[^>]+>/g, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isSkippable(line) {
|
|
22
|
+
if (line.trim() === '') return true;
|
|
23
|
+
return BADGE_PATTERNS.some((p) => p.test(line));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stripLeadingBadges(lines) {
|
|
27
|
+
const result = [...lines];
|
|
28
|
+
while (result.length > 0 && isSkippable(result[0])) {
|
|
29
|
+
result.shift();
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function firstNonEmptyParagraph(lines, startIndex) {
|
|
35
|
+
let i = startIndex;
|
|
36
|
+
while (i < lines.length && isSkippable(lines[i])) i++;
|
|
37
|
+
if (i >= lines.length) return null;
|
|
38
|
+
const paragraph = [];
|
|
39
|
+
while (i < lines.length && lines[i].trim() !== '') {
|
|
40
|
+
if (/^#{1,6}\s/.test(lines[i])) break;
|
|
41
|
+
if (isSkippable(lines[i])) {
|
|
42
|
+
i++;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
paragraph.push(lines[i]);
|
|
46
|
+
i++;
|
|
47
|
+
}
|
|
48
|
+
const text = paragraph.join(' ').trim();
|
|
49
|
+
return text.length > 0 ? text : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function findH1Index(lines) {
|
|
53
|
+
return lines.findIndex((line) => /^#\s+/.test(line));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractSetupInstructions(lines) {
|
|
57
|
+
const startIdx = lines.findIndex(
|
|
58
|
+
(line) => /^#{1,6}\s/.test(line) && SETUP_HEADING.test(line.replace(/^#+\s+/, ''))
|
|
59
|
+
);
|
|
60
|
+
if (startIdx === -1) return null;
|
|
61
|
+
const body = [];
|
|
62
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
63
|
+
if (/^#{1,6}\s/.test(lines[i])) break;
|
|
64
|
+
body.push(lines[i]);
|
|
65
|
+
}
|
|
66
|
+
const text = body.join('\n').trim();
|
|
67
|
+
if (text.length === 0) return null;
|
|
68
|
+
return text.length > SETUP_CAP ? text.slice(0, SETUP_CAP) : text;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default async function detectReadme(projectRoot) {
|
|
72
|
+
let sourceFile = null;
|
|
73
|
+
let content = null;
|
|
74
|
+
for (const candidate of README_CANDIDATES) {
|
|
75
|
+
const p = path.join(projectRoot, candidate);
|
|
76
|
+
if (await fileExists(p)) {
|
|
77
|
+
sourceFile = candidate;
|
|
78
|
+
const stat = await fs.stat(p);
|
|
79
|
+
if (stat.size > MAX_README_BYTES) {
|
|
80
|
+
// Oversized README — read just the head; description/setup headings are near the top.
|
|
81
|
+
const handle = await fs.open(p, 'r');
|
|
82
|
+
try {
|
|
83
|
+
const buf = Buffer.alloc(MAX_README_BYTES);
|
|
84
|
+
const { bytesRead } = await handle.read(buf, 0, MAX_README_BYTES, 0);
|
|
85
|
+
content = buf.subarray(0, bytesRead).toString('utf-8');
|
|
86
|
+
} finally {
|
|
87
|
+
await handle.close();
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
content = await readFile(p);
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!sourceFile) return [];
|
|
96
|
+
|
|
97
|
+
const cleaned = stripHtmlTags(stripHtmlComments(content));
|
|
98
|
+
const rawLines = cleaned.split(/\r?\n/);
|
|
99
|
+
const lines = stripLeadingBadges(rawLines);
|
|
100
|
+
|
|
101
|
+
const h1Index = findH1Index(lines);
|
|
102
|
+
const descStart = h1Index === -1 ? 0 : h1Index + 1;
|
|
103
|
+
let projectDescription = firstNonEmptyParagraph(lines, descStart);
|
|
104
|
+
if (projectDescription && projectDescription.length < MIN_DESCRIPTION_LENGTH) {
|
|
105
|
+
projectDescription = null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const setupInstructions = extractSetupInstructions(lines);
|
|
109
|
+
|
|
110
|
+
return [
|
|
111
|
+
{
|
|
112
|
+
field: 'readme',
|
|
113
|
+
value: {
|
|
114
|
+
projectDescription,
|
|
115
|
+
setupInstructions,
|
|
116
|
+
fullPath: sourceFile,
|
|
117
|
+
},
|
|
118
|
+
confidence: 'medium',
|
|
119
|
+
source: sourceFile,
|
|
120
|
+
candidates: null,
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import YAML from 'yaml';
|
|
3
|
+
import { fileExists, readFile } from '../../../utils/file.js';
|
|
4
|
+
import { readPackageJson } from '../manifests.js';
|
|
5
|
+
|
|
6
|
+
const ALL_SCRIPTS_CAP = 50;
|
|
7
|
+
|
|
8
|
+
function pickScript(scripts, candidates) {
|
|
9
|
+
for (const key of candidates) {
|
|
10
|
+
if (scripts[key]) return { key, value: scripts[key] };
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function firstTestScript(scripts) {
|
|
16
|
+
if (scripts['test']) return { key: 'test', value: scripts['test'] };
|
|
17
|
+
if (scripts['test:unit']) return { key: 'test:unit', value: scripts['test:unit'] };
|
|
18
|
+
const key = Object.keys(scripts).find((k) => k.startsWith('test:'));
|
|
19
|
+
return key ? { key, value: scripts[key] } : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function readMakefileTargets(projectRoot) {
|
|
23
|
+
const filePath = path.join(projectRoot, 'Makefile');
|
|
24
|
+
if (!(await fileExists(filePath))) return [];
|
|
25
|
+
try {
|
|
26
|
+
const raw = await readFile(filePath);
|
|
27
|
+
const targets = new Set();
|
|
28
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
29
|
+
const match = line.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*:(?!=)/);
|
|
30
|
+
if (match) targets.add(match[1]);
|
|
31
|
+
}
|
|
32
|
+
return Array.from(targets);
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readTaskfile(projectRoot) {
|
|
39
|
+
const filePath = path.join(projectRoot, 'Taskfile.yml');
|
|
40
|
+
if (!(await fileExists(filePath))) return [];
|
|
41
|
+
try {
|
|
42
|
+
const raw = await readFile(filePath);
|
|
43
|
+
const parsed = YAML.parse(raw);
|
|
44
|
+
if (parsed && parsed.tasks && typeof parsed.tasks === 'object') {
|
|
45
|
+
return Object.keys(parsed.tasks);
|
|
46
|
+
}
|
|
47
|
+
return [];
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readJustfile(projectRoot) {
|
|
54
|
+
const filePath = path.join(projectRoot, 'justfile');
|
|
55
|
+
if (!(await fileExists(filePath))) return [];
|
|
56
|
+
try {
|
|
57
|
+
const raw = await readFile(filePath);
|
|
58
|
+
const recipes = new Set();
|
|
59
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
60
|
+
const match = line.match(/^([a-zA-Z][a-zA-Z0-9_-]*)\s*(?::|\s+[a-zA-Z])/);
|
|
61
|
+
if (match) recipes.add(match[1]);
|
|
62
|
+
}
|
|
63
|
+
return Array.from(recipes);
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default async function detectScripts(projectRoot) {
|
|
70
|
+
const pkg = await readPackageJson(projectRoot);
|
|
71
|
+
const scripts = pkg && pkg.scripts ? pkg.scripts : {};
|
|
72
|
+
|
|
73
|
+
const dev = pickScript(scripts, ['dev', 'start', 'serve']);
|
|
74
|
+
const test = firstTestScript(scripts);
|
|
75
|
+
const build = pickScript(scripts, ['build', 'compile']);
|
|
76
|
+
const lint = pickScript(scripts, ['lint', 'check']);
|
|
77
|
+
|
|
78
|
+
let truncated = false;
|
|
79
|
+
let allScripts = {};
|
|
80
|
+
const entries = Object.entries(scripts);
|
|
81
|
+
if (entries.length > ALL_SCRIPTS_CAP) {
|
|
82
|
+
truncated = true;
|
|
83
|
+
for (const [k, v] of entries.slice(0, ALL_SCRIPTS_CAP)) allScripts[k] = v;
|
|
84
|
+
} else {
|
|
85
|
+
allScripts = { ...scripts };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const sources = [];
|
|
89
|
+
if (pkg) sources.push('package.json');
|
|
90
|
+
|
|
91
|
+
const makefileTargets = await readMakefileTargets(projectRoot);
|
|
92
|
+
const taskfileTasks = await readTaskfile(projectRoot);
|
|
93
|
+
const justfileRecipes = await readJustfile(projectRoot);
|
|
94
|
+
if (makefileTargets.length > 0) sources.push('Makefile');
|
|
95
|
+
if (taskfileTasks.length > 0) sources.push('Taskfile.yml');
|
|
96
|
+
if (justfileRecipes.length > 0) sources.push('justfile');
|
|
97
|
+
|
|
98
|
+
if (
|
|
99
|
+
!pkg &&
|
|
100
|
+
makefileTargets.length === 0 &&
|
|
101
|
+
taskfileTasks.length === 0 &&
|
|
102
|
+
justfileRecipes.length === 0
|
|
103
|
+
) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const value = {
|
|
108
|
+
dev: dev ? { key: dev.key, command: dev.value } : null,
|
|
109
|
+
test: test ? { key: test.key, command: test.value } : null,
|
|
110
|
+
build: build ? { key: build.key, command: build.value } : null,
|
|
111
|
+
lint: lint ? { key: lint.key, command: lint.value } : null,
|
|
112
|
+
allScripts,
|
|
113
|
+
makefileTargets,
|
|
114
|
+
taskfileTasks,
|
|
115
|
+
justfileRecipes,
|
|
116
|
+
};
|
|
117
|
+
if (truncated) value.truncated = true;
|
|
118
|
+
|
|
119
|
+
return [
|
|
120
|
+
{
|
|
121
|
+
field: 'scripts',
|
|
122
|
+
value,
|
|
123
|
+
confidence: 'high',
|
|
124
|
+
source: sources.join(', '),
|
|
125
|
+
candidates: null,
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { readFile } from '../../../utils/file.js';
|
|
4
|
+
|
|
5
|
+
const EXCLUDES = new Set([
|
|
6
|
+
'node_modules',
|
|
7
|
+
'.git',
|
|
8
|
+
'.claude',
|
|
9
|
+
'dist',
|
|
10
|
+
'build',
|
|
11
|
+
'.next',
|
|
12
|
+
'target',
|
|
13
|
+
'vendor',
|
|
14
|
+
'.venv',
|
|
15
|
+
'__pycache__',
|
|
16
|
+
'.cache',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const MAX_FILES = 200;
|
|
20
|
+
const MAX_DEPTH = 3;
|
|
21
|
+
const HEADING_CHAR_CAP = 80;
|
|
22
|
+
const ROOT_PATTERNS = [/^PRD/i, /^SPEC/i, /^REQUIREMENTS/i];
|
|
23
|
+
|
|
24
|
+
async function rootMatches(projectRoot) {
|
|
25
|
+
const matches = [];
|
|
26
|
+
try {
|
|
27
|
+
const entries = await fs.readdir(projectRoot, { withFileTypes: true });
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (!entry.isFile()) continue;
|
|
30
|
+
if (ROOT_PATTERNS.some((p) => p.test(entry.name))) {
|
|
31
|
+
matches.push(entry.name);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
/* missing or unreadable — non-fatal */
|
|
36
|
+
}
|
|
37
|
+
return matches;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function walkDocs(dir, projectRoot, depth, results) {
|
|
41
|
+
if (depth > MAX_DEPTH || results.length >= MAX_FILES) return;
|
|
42
|
+
let entries;
|
|
43
|
+
try {
|
|
44
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
45
|
+
} catch {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
if (results.length >= MAX_FILES) return;
|
|
50
|
+
if (EXCLUDES.has(entry.name)) continue;
|
|
51
|
+
const fullPath = path.join(dir, entry.name);
|
|
52
|
+
if (entry.isDirectory()) {
|
|
53
|
+
await walkDocs(fullPath, projectRoot, depth + 1, results);
|
|
54
|
+
} else if (entry.isFile() && /\.(md|txt)$/i.test(entry.name)) {
|
|
55
|
+
results.push(path.relative(projectRoot, fullPath).split(path.sep).join('/'));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function firstHeading(projectRoot, relativePath) {
|
|
61
|
+
try {
|
|
62
|
+
const raw = await readFile(path.join(projectRoot, relativePath));
|
|
63
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
64
|
+
const line = rawLine.trim();
|
|
65
|
+
if (line === '') continue;
|
|
66
|
+
const match = line.match(/^#\s+(.+)$/);
|
|
67
|
+
if (match) return match[1].slice(0, HEADING_CHAR_CAP);
|
|
68
|
+
return line.slice(0, HEADING_CHAR_CAP);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
/* missing or unreadable — non-fatal */
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export default async function detectSpecDocs(projectRoot) {
|
|
77
|
+
const docs = [];
|
|
78
|
+
const rootNames = await rootMatches(projectRoot);
|
|
79
|
+
for (const name of rootNames) docs.push(name);
|
|
80
|
+
|
|
81
|
+
const docsDir = path.join(projectRoot, 'docs');
|
|
82
|
+
try {
|
|
83
|
+
const stat = await fs.stat(docsDir);
|
|
84
|
+
if (stat.isDirectory()) {
|
|
85
|
+
await walkDocs(docsDir, projectRoot, 1, docs);
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
/* missing or unreadable — non-fatal */
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (docs.length === 0) return [];
|
|
92
|
+
|
|
93
|
+
const unique = Array.from(new Set(docs));
|
|
94
|
+
const value = await Promise.all(
|
|
95
|
+
unique.map(async (relative) => ({
|
|
96
|
+
path: relative,
|
|
97
|
+
firstHeading: await firstHeading(projectRoot, relative),
|
|
98
|
+
}))
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return [
|
|
102
|
+
{
|
|
103
|
+
field: 'specDocs',
|
|
104
|
+
value,
|
|
105
|
+
confidence: 'high',
|
|
106
|
+
source: 'project tree',
|
|
107
|
+
candidates: null,
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileExists } from '../../../utils/file.js';
|
|
3
|
+
import { getAllDeps, readPyprojectToml } from '../manifests.js';
|
|
4
|
+
|
|
5
|
+
const CONFIG_VARIANTS = {
|
|
6
|
+
vitest: ['vitest.config.js', 'vitest.config.ts', 'vitest.config.mjs', 'vitest.config.mts'],
|
|
7
|
+
jest: ['jest.config.js', 'jest.config.ts', 'jest.config.mjs', 'jest.config.cjs'],
|
|
8
|
+
playwright: ['playwright.config.js', 'playwright.config.ts', 'playwright.config.mjs'],
|
|
9
|
+
cypress: ['cypress.config.js', 'cypress.config.ts', 'cypress.config.mjs'],
|
|
10
|
+
mocha: ['.mocharc.js', '.mocharc.json', '.mocharc.yml', '.mocharc.cjs'],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
async function findConfig(projectRoot, variants) {
|
|
14
|
+
for (const name of variants) {
|
|
15
|
+
if (await fileExists(path.join(projectRoot, name))) return name;
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function hasPytestConfig(projectRoot) {
|
|
21
|
+
if (await fileExists(path.join(projectRoot, 'pytest.ini'))) return 'pytest.ini';
|
|
22
|
+
const pyproject = await readPyprojectToml(projectRoot);
|
|
23
|
+
if (pyproject && pyproject.tool && pyproject.tool.pytest) {
|
|
24
|
+
return 'pyproject.toml';
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default async function detectTesting(projectRoot) {
|
|
30
|
+
const { js, py } = await getAllDeps(projectRoot);
|
|
31
|
+
const results = [];
|
|
32
|
+
|
|
33
|
+
const jsFrameworkOrder = ['vitest', 'jest', 'mocha'];
|
|
34
|
+
for (const name of jsFrameworkOrder) {
|
|
35
|
+
if (js[name] !== undefined) {
|
|
36
|
+
const configFile = await findConfig(projectRoot, CONFIG_VARIANTS[name] || []);
|
|
37
|
+
results.push({
|
|
38
|
+
field: 'testing',
|
|
39
|
+
value: {
|
|
40
|
+
framework: name,
|
|
41
|
+
configFile,
|
|
42
|
+
browserTesting: false,
|
|
43
|
+
},
|
|
44
|
+
confidence: 'high',
|
|
45
|
+
source: configFile || 'package.json',
|
|
46
|
+
candidates: null,
|
|
47
|
+
});
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (js['playwright'] !== undefined || js['@playwright/test'] !== undefined) {
|
|
53
|
+
const configFile = await findConfig(projectRoot, CONFIG_VARIANTS.playwright);
|
|
54
|
+
results.push({
|
|
55
|
+
field: 'testing',
|
|
56
|
+
value: { framework: 'playwright', configFile, browserTesting: true },
|
|
57
|
+
confidence: 'high',
|
|
58
|
+
source: configFile || 'package.json',
|
|
59
|
+
candidates: null,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (js['cypress'] !== undefined) {
|
|
64
|
+
const configFile = await findConfig(projectRoot, CONFIG_VARIANTS.cypress);
|
|
65
|
+
results.push({
|
|
66
|
+
field: 'testing',
|
|
67
|
+
value: { framework: 'cypress', configFile, browserTesting: true },
|
|
68
|
+
confidence: 'high',
|
|
69
|
+
source: configFile || 'package.json',
|
|
70
|
+
candidates: null,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const pytestPresent =
|
|
75
|
+
py['pytest'] !== undefined || Object.keys(py).some((k) => k.startsWith('pytest-'));
|
|
76
|
+
if (pytestPresent) {
|
|
77
|
+
const configFile = await hasPytestConfig(projectRoot);
|
|
78
|
+
results.push({
|
|
79
|
+
field: 'testing',
|
|
80
|
+
value: { framework: 'pytest', configFile, browserTesting: false },
|
|
81
|
+
confidence: 'high',
|
|
82
|
+
source: configFile || 'pyproject.toml',
|
|
83
|
+
candidates: null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return results;
|
|
88
|
+
}
|