prelude-context 1.5.0 → 1.7.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 (39) hide show
  1. package/README.md +63 -4
  2. package/dist/bin/prelude.js +2 -0
  3. package/dist/bin/prelude.js.map +1 -1
  4. package/dist/src/commands/export.d.ts.map +1 -1
  5. package/dist/src/commands/export.js +5 -3
  6. package/dist/src/commands/export.js.map +1 -1
  7. package/dist/src/commands/init.d.ts.map +1 -1
  8. package/dist/src/commands/init.js +151 -0
  9. package/dist/src/commands/init.js.map +1 -1
  10. package/dist/src/commands/validate.d.ts +3 -0
  11. package/dist/src/commands/validate.d.ts.map +1 -0
  12. package/dist/src/commands/validate.js +203 -0
  13. package/dist/src/commands/validate.js.map +1 -0
  14. package/dist/src/constants.d.ts +1 -1
  15. package/dist/src/constants.d.ts.map +1 -1
  16. package/dist/src/constants.js +11 -1
  17. package/dist/src/constants.js.map +1 -1
  18. package/dist/src/core/claude-md-parser.d.ts +33 -0
  19. package/dist/src/core/claude-md-parser.d.ts.map +1 -0
  20. package/dist/src/core/claude-md-parser.js +410 -0
  21. package/dist/src/core/claude-md-parser.js.map +1 -0
  22. package/dist/src/core/exporter.d.ts +2 -1
  23. package/dist/src/core/exporter.d.ts.map +1 -1
  24. package/dist/src/core/exporter.js +159 -1
  25. package/dist/src/core/exporter.js.map +1 -1
  26. package/dist/src/core/infer.d.ts.map +1 -1
  27. package/dist/src/core/infer.js +936 -52
  28. package/dist/src/core/infer.js.map +1 -1
  29. package/dist/src/core/source-scanner.d.ts.map +1 -1
  30. package/dist/src/core/source-scanner.js +152 -8
  31. package/dist/src/core/source-scanner.js.map +1 -1
  32. package/dist/src/schema/stack.d.ts +3 -3
  33. package/dist/src/schema/stack.js +1 -1
  34. package/dist/src/schema/stack.js.map +1 -1
  35. package/dist/src/utils/fs.d.ts.map +1 -1
  36. package/dist/src/utils/fs.js +4 -1
  37. package/dist/src/utils/fs.js.map +1 -1
  38. package/package.json +11 -10
  39. package/schemas/stack.schema.json +1 -1
@@ -6,6 +6,134 @@ import { scanSources } from './source-scanner.js';
6
6
  // --- ADD THESE CONSTANTS ---
7
7
  const PRELUDE_VERSION = "1.0.0";
8
8
  const SCHEMA_URL = "https://adjective.us/prelude/schemas/v1";
9
+ // --------------------------
10
+ // --- Minimal pyproject.toml parser ---
11
+ function setNested(obj, table, key, value) {
12
+ const parts = table ? table.split('.') : [];
13
+ let current = obj;
14
+ for (const part of parts) {
15
+ if (!(part in current))
16
+ current[part] = {};
17
+ current = current[part];
18
+ }
19
+ current[key] = value;
20
+ }
21
+ function parsePyprojectToml(content) {
22
+ const result = {};
23
+ let currentTable = '';
24
+ let collectingArray = null;
25
+ let collectingKey = '';
26
+ for (const line of content.split('\n')) {
27
+ const trimmed = line.trim();
28
+ if (!trimmed || trimmed.startsWith('#')) {
29
+ if (collectingArray !== null)
30
+ continue;
31
+ continue;
32
+ }
33
+ // Handle multiline array continuation
34
+ if (collectingArray !== null) {
35
+ if (trimmed === ']') {
36
+ setNested(result, currentTable, collectingKey, collectingArray);
37
+ collectingArray = null;
38
+ continue;
39
+ }
40
+ // Inline table in array: {key = "value", ...}
41
+ if (trimmed.startsWith('{')) {
42
+ const obj = {};
43
+ const pairPattern = /(\w+)\s*=\s*["']([^"']*)["']/g;
44
+ let m;
45
+ while ((m = pairPattern.exec(trimmed)) !== null) {
46
+ obj[m[1]] = m[2];
47
+ }
48
+ if (Object.keys(obj).length > 0)
49
+ collectingArray.push(obj);
50
+ continue;
51
+ }
52
+ // String in array: "value",
53
+ const strMatch = trimmed.match(/^["']([^"']*)["']/);
54
+ if (strMatch) {
55
+ collectingArray.push(strMatch[1]);
56
+ }
57
+ continue;
58
+ }
59
+ // Table header: [section] or [section.subsection]
60
+ const tableMatch = trimmed.match(/^\[([^\]]+)\]$/);
61
+ if (tableMatch) {
62
+ currentTable = tableMatch[1];
63
+ continue;
64
+ }
65
+ // Key = value
66
+ const kvMatch = trimmed.match(/^([a-zA-Z_-][a-zA-Z0-9_.-]*)\s*=\s*(.*)$/);
67
+ if (!kvMatch)
68
+ continue;
69
+ const key = kvMatch[1].trim();
70
+ const rawValue = kvMatch[2].trim();
71
+ if (rawValue === '[') {
72
+ collectingArray = [];
73
+ collectingKey = key;
74
+ }
75
+ else if (rawValue.startsWith('[')) {
76
+ // Inline array
77
+ const items = [];
78
+ // Check for inline tables in array
79
+ const inlineTablePattern = /\{([^}]+)\}/g;
80
+ let itMatch;
81
+ while ((itMatch = inlineTablePattern.exec(rawValue)) !== null) {
82
+ const obj = {};
83
+ const pairPattern = /(\w+)\s*=\s*["']([^"']*)["']/g;
84
+ let m;
85
+ while ((m = pairPattern.exec(itMatch[1])) !== null) {
86
+ obj[m[1]] = m[2];
87
+ }
88
+ if (Object.keys(obj).length > 0)
89
+ items.push(obj);
90
+ }
91
+ if (items.length === 0) {
92
+ // Plain string array
93
+ const strPattern = /["']([^"']*)["']/g;
94
+ let m;
95
+ while ((m = strPattern.exec(rawValue)) !== null) {
96
+ items.push(m[1]);
97
+ }
98
+ }
99
+ setNested(result, currentTable, key, items);
100
+ }
101
+ else if (rawValue.startsWith('{')) {
102
+ // Inline table
103
+ const obj = {};
104
+ const pairPattern = /(\w+)\s*=\s*["']([^"']*)["']/g;
105
+ let m;
106
+ while ((m = pairPattern.exec(rawValue)) !== null) {
107
+ obj[m[1]] = m[2];
108
+ }
109
+ setNested(result, currentTable, key, obj);
110
+ }
111
+ else if (rawValue.startsWith('"') || rawValue.startsWith("'")) {
112
+ const quote = rawValue[0];
113
+ const endIdx = rawValue.indexOf(quote, 1);
114
+ if (endIdx > 0) {
115
+ setNested(result, currentTable, key, rawValue.slice(1, endIdx));
116
+ }
117
+ }
118
+ else if (rawValue === 'true' || rawValue === 'false') {
119
+ setNested(result, currentTable, key, rawValue === 'true');
120
+ }
121
+ else if (/^\d+$/.test(rawValue)) {
122
+ setNested(result, currentTable, key, parseInt(rawValue, 10));
123
+ }
124
+ }
125
+ return result;
126
+ }
127
+ function extractPyDepName(dep) {
128
+ return dep.split(/[>=<!~\[;@ ]/)[0].trim().toLowerCase();
129
+ }
130
+ function parseRequirementsTxt(content) {
131
+ return content.split('\n')
132
+ .map(line => line.trim())
133
+ .filter(line => line && !line.startsWith('#') && !line.startsWith('-') && !line.startsWith('git+'))
134
+ .map(line => extractPyDepName(line))
135
+ .filter(name => name.length > 0);
136
+ }
9
137
  async function scanMonorepoPackages(rootDir) {
10
138
  const packages = [];
11
139
  // Check common monorepo locations
@@ -95,26 +223,12 @@ export async function inferProjectMetadata(rootDir) {
95
223
  if (hasPackageJson) {
96
224
  projectData = await readJSON(packageJsonPath);
97
225
  }
98
- // Try to read README for better description
99
- let description = projectData.description || 'No description provided';
100
- const readmePath = join(rootDir, 'README.md');
101
- if (await fileExists(readmePath)) {
102
- try {
103
- const readme = await readFile(readmePath, 'utf-8');
104
- const lines = readme.split('\n').filter(l => l.trim());
105
- // Try to get first meaningful paragraph
106
- const firstParagraph = lines.find(l => !l.startsWith('#') && l.length > 20);
107
- if (firstParagraph && (!projectData.description || projectData.description === '')) {
108
- description = firstParagraph.slice(0, 200);
109
- }
110
- }
111
- catch { }
112
- }
113
- const name = projectData.name || basename(rootDir);
114
- const projectVersion = projectData.version; // Use the renamed field
115
- const repository = projectData.repository?.url || projectData.repository;
116
- const license = projectData.license;
117
- const homepage = projectData.homepage;
226
+ let name = projectData.name || '';
227
+ let description = projectData.description || '';
228
+ let projectVersion = projectData.version;
229
+ let repository = projectData.repository?.url || projectData.repository;
230
+ let license = projectData.license;
231
+ let homepage = projectData.homepage;
118
232
  // Detect team info from package.json
119
233
  const team = [];
120
234
  if (projectData.author) {
@@ -128,12 +242,110 @@ export async function inferProjectMetadata(rootDir) {
128
242
  if (projectData.contributors) {
129
243
  team.push(...projectData.contributors);
130
244
  }
245
+ // Try pyproject.toml for Python projects
246
+ const pyprojectPath = join(rootDir, 'pyproject.toml');
247
+ if (await fileExists(pyprojectPath)) {
248
+ try {
249
+ const pyContent = await readFile(pyprojectPath, 'utf-8');
250
+ const pyproject = parsePyprojectToml(pyContent);
251
+ const proj = pyproject?.project || {};
252
+ if (!name && proj.name)
253
+ name = proj.name;
254
+ if (!description && proj.description)
255
+ description = proj.description;
256
+ if (!projectVersion && proj.version)
257
+ projectVersion = proj.version;
258
+ // License from pyproject.toml
259
+ if (!license) {
260
+ if (typeof proj.license === 'string')
261
+ license = proj.license;
262
+ else if (proj.license?.text)
263
+ license = proj.license.text;
264
+ }
265
+ // Authors from pyproject.toml
266
+ if (team.length === 0 && Array.isArray(proj.authors)) {
267
+ for (const author of proj.authors) {
268
+ if (typeof author === 'object' && author.name) {
269
+ team.push({ name: author.name, email: author.email });
270
+ }
271
+ }
272
+ }
273
+ // Python version as runtime info
274
+ if (proj['requires-python']) {
275
+ // Store for later use but don't set here — stack handles runtime
276
+ }
277
+ }
278
+ catch { }
279
+ }
280
+ // Try Cargo.toml for Rust projects
281
+ const cargoProjectPath = join(rootDir, 'Cargo.toml');
282
+ if (await fileExists(cargoProjectPath)) {
283
+ try {
284
+ const cargoContent = await readFile(cargoProjectPath, 'utf-8');
285
+ const cargo = parsePyprojectToml(cargoContent);
286
+ const pkg = cargo?.package || {};
287
+ if (!name && pkg.name)
288
+ name = pkg.name;
289
+ if (!description && pkg.description)
290
+ description = pkg.description;
291
+ if (!projectVersion && pkg.version)
292
+ projectVersion = pkg.version;
293
+ if (!license && pkg.license)
294
+ license = pkg.license;
295
+ if (!repository && pkg.repository)
296
+ repository = pkg.repository;
297
+ if (!homepage && pkg.homepage)
298
+ homepage = pkg.homepage;
299
+ if (team.length === 0 && Array.isArray(pkg.authors)) {
300
+ for (const author of pkg.authors) {
301
+ if (typeof author === 'string') {
302
+ team.push({ name: author.replace(/<.*>/, '').trim() });
303
+ }
304
+ }
305
+ }
306
+ }
307
+ catch { }
308
+ }
309
+ // Try go.mod for Go projects (module name only)
310
+ const goModProjectPath = join(rootDir, 'go.mod');
311
+ if (await fileExists(goModProjectPath) && !name) {
312
+ try {
313
+ const goModContent = await readFile(goModProjectPath, 'utf-8');
314
+ const moduleMatch = goModContent.match(/^module\s+(\S+)/m);
315
+ if (moduleMatch) {
316
+ // Use the last segment of the module path as name
317
+ const parts = moduleMatch[1].split('/');
318
+ name = parts[parts.length - 1];
319
+ }
320
+ }
321
+ catch { }
322
+ }
323
+ // Fall back to directory name for name
324
+ if (!name)
325
+ name = basename(rootDir);
326
+ // Fall back to README for description
327
+ if (!description) {
328
+ const readmePath = join(rootDir, 'README.md');
329
+ if (await fileExists(readmePath)) {
330
+ try {
331
+ const readme = await readFile(readmePath, 'utf-8');
332
+ const lines = readme.split('\n').filter(l => l.trim());
333
+ const firstParagraph = lines.find(l => !l.startsWith('#') && !l.startsWith('!') && !l.startsWith('[') && !l.startsWith('<') && !l.startsWith('---') && l.length > 20);
334
+ if (firstParagraph) {
335
+ description = firstParagraph.slice(0, 200);
336
+ }
337
+ }
338
+ catch { }
339
+ }
340
+ }
341
+ if (!description)
342
+ description = 'No description provided';
131
343
  return {
132
344
  $schema: `${SCHEMA_URL}/project.schema.json`,
133
345
  version: PRELUDE_VERSION,
134
346
  name,
135
347
  description,
136
- projectVersion, // Correctly use renamed field
348
+ projectVersion,
137
349
  createdAt: getCurrentTimestamp(),
138
350
  updatedAt: getCurrentTimestamp(),
139
351
  repository,
@@ -503,24 +715,220 @@ export async function inferStack(rootDir) {
503
715
  // Check for Python project
504
716
  const requirementsPath = join(rootDir, 'requirements.txt');
505
717
  const pyprojectPath = join(rootDir, 'pyproject.toml');
506
- if (await fileExists(requirementsPath) || await fileExists(pyprojectPath)) {
718
+ if (await fileExists(pyprojectPath) || await fileExists(requirementsPath)) {
507
719
  stack.language = 'Python';
508
- stack.packageManager = await fileExists(pyprojectPath) ? 'poetry' : 'pip';
509
- // Check for common Python frameworks
720
+ const allPyDeps = new Set();
721
+ // Parse pyproject.toml
722
+ if (await fileExists(pyprojectPath)) {
723
+ try {
724
+ const pyContent = await readFile(pyprojectPath, 'utf-8');
725
+ const pyproject = parsePyprojectToml(pyContent);
726
+ // Package manager detection
727
+ const buildBackend = pyproject?.['build-system']?.['build-backend'] || '';
728
+ if (buildBackend.includes('poetry')) {
729
+ stack.packageManager = 'poetry';
730
+ }
731
+ else if (await fileExists(join(rootDir, 'uv.lock'))) {
732
+ stack.packageManager = 'uv';
733
+ }
734
+ else if (await fileExists(join(rootDir, 'poetry.lock'))) {
735
+ stack.packageManager = 'poetry';
736
+ }
737
+ else if (await fileExists(join(rootDir, 'Pipfile')) || await fileExists(join(rootDir, 'Pipfile.lock'))) {
738
+ stack.packageManager = 'pipenv';
739
+ }
740
+ else {
741
+ stack.packageManager = 'pip';
742
+ }
743
+ // Python version
744
+ const requiresPython = pyproject?.project?.['requires-python'];
745
+ if (requiresPython) {
746
+ stack.runtime = `Python ${requiresPython}`;
747
+ }
748
+ // Collect deps from [project.dependencies]
749
+ const projDeps = pyproject?.project?.dependencies || [];
750
+ const depsRecord = {};
751
+ for (const dep of projDeps) {
752
+ const depName = extractPyDepName(dep);
753
+ const version = dep.slice(depName.length).replace(/^\[.*?\]/, '').trim() || '*';
754
+ depsRecord[depName] = version;
755
+ allPyDeps.add(depName);
756
+ }
757
+ if (Object.keys(depsRecord).length > 0) {
758
+ stack.dependencies = depsRecord;
759
+ }
760
+ // Collect dev/optional deps from [project.optional-dependencies]
761
+ const optDeps = pyproject?.project?.['optional-dependencies'] || {};
762
+ const devDepsRecord = {};
763
+ for (const [, groupDeps] of Object.entries(optDeps)) {
764
+ if (Array.isArray(groupDeps)) {
765
+ for (const dep of groupDeps) {
766
+ if (typeof dep === 'string') {
767
+ const depName = extractPyDepName(dep);
768
+ const version = dep.slice(depName.length).replace(/^\[.*?\]/, '').trim() || '*';
769
+ devDepsRecord[depName] = version;
770
+ allPyDeps.add(depName);
771
+ }
772
+ }
773
+ }
774
+ }
775
+ if (Object.keys(devDepsRecord).length > 0) {
776
+ stack.devDependencies = devDepsRecord;
777
+ }
778
+ // Also check Poetry-style deps under [tool.poetry.dependencies]
779
+ const poetryDeps = pyproject?.tool?.poetry?.dependencies;
780
+ if (poetryDeps && typeof poetryDeps === 'object') {
781
+ for (const depName of Object.keys(poetryDeps)) {
782
+ if (depName !== 'python')
783
+ allPyDeps.add(depName.toLowerCase());
784
+ }
785
+ }
786
+ }
787
+ catch { }
788
+ }
789
+ // Parse requirements.txt as fallback/supplement
510
790
  if (await fileExists(requirementsPath)) {
511
- const requirements = await readFile(requirementsPath, 'utf-8');
512
- const frameworks = [];
513
- if (requirements.includes('django'))
514
- frameworks.push('Django');
515
- if (requirements.includes('flask'))
516
- frameworks.push('Flask');
517
- if (requirements.includes('fastapi'))
518
- frameworks.push('FastAPI');
519
- if (requirements.includes('tornado'))
520
- frameworks.push('Tornado');
521
- if (requirements.includes('pyramid'))
522
- frameworks.push('Pyramid');
523
- stack.frameworks = frameworks;
791
+ try {
792
+ const reqContent = await readFile(requirementsPath, 'utf-8');
793
+ const reqDeps = parseRequirementsTxt(reqContent);
794
+ for (const dep of reqDeps)
795
+ allPyDeps.add(dep);
796
+ if (!stack.dependencies || Object.keys(stack.dependencies).length === 0) {
797
+ const depsRecord = {};
798
+ for (const dep of reqDeps)
799
+ depsRecord[dep] = '*';
800
+ stack.dependencies = depsRecord;
801
+ stack.packageManager = stack.packageManager || 'pip';
802
+ }
803
+ }
804
+ catch { }
805
+ }
806
+ // === FRAMEWORKS ===
807
+ const pyFrameworks = [];
808
+ // Web frameworks
809
+ if (allPyDeps.has('django'))
810
+ pyFrameworks.push('Django');
811
+ if (allPyDeps.has('flask'))
812
+ pyFrameworks.push('Flask');
813
+ if (allPyDeps.has('fastapi'))
814
+ pyFrameworks.push('FastAPI');
815
+ if (allPyDeps.has('starlette'))
816
+ pyFrameworks.push('Starlette');
817
+ if (allPyDeps.has('tornado'))
818
+ pyFrameworks.push('Tornado');
819
+ if (allPyDeps.has('pyramid'))
820
+ pyFrameworks.push('Pyramid');
821
+ if (allPyDeps.has('sanic'))
822
+ pyFrameworks.push('Sanic');
823
+ if (allPyDeps.has('falcon'))
824
+ pyFrameworks.push('Falcon');
825
+ if (allPyDeps.has('litestar'))
826
+ pyFrameworks.push('Litestar');
827
+ // CLI frameworks
828
+ if (allPyDeps.has('typer'))
829
+ pyFrameworks.push('Typer');
830
+ if (allPyDeps.has('click'))
831
+ pyFrameworks.push('Click');
832
+ // ML/AI frameworks
833
+ if (allPyDeps.has('langchain'))
834
+ pyFrameworks.push('LangChain');
835
+ if (allPyDeps.has('transformers'))
836
+ pyFrameworks.push('Hugging Face Transformers');
837
+ if (allPyDeps.has('torch') || allPyDeps.has('pytorch'))
838
+ pyFrameworks.push('PyTorch');
839
+ if (allPyDeps.has('tensorflow'))
840
+ pyFrameworks.push('TensorFlow');
841
+ if (allPyDeps.has('scikit-learn') || allPyDeps.has('sklearn'))
842
+ pyFrameworks.push('scikit-learn');
843
+ // Data
844
+ if (allPyDeps.has('pandas'))
845
+ pyFrameworks.push('pandas');
846
+ if (allPyDeps.has('numpy'))
847
+ pyFrameworks.push('NumPy');
848
+ if (pyFrameworks.length > 0) {
849
+ stack.frameworks = pyFrameworks;
850
+ stack.framework = pyFrameworks[0];
851
+ }
852
+ // === TESTING ===
853
+ const pyTesting = [];
854
+ if (allPyDeps.has('pytest'))
855
+ pyTesting.push('pytest');
856
+ if (allPyDeps.has('pytest-cov'))
857
+ pyTesting.push('pytest-cov');
858
+ if (allPyDeps.has('hypothesis'))
859
+ pyTesting.push('Hypothesis');
860
+ if (allPyDeps.has('tox'))
861
+ pyTesting.push('tox');
862
+ if (allPyDeps.has('nox'))
863
+ pyTesting.push('nox');
864
+ if (allPyDeps.has('coverage'))
865
+ pyTesting.push('coverage');
866
+ if (pyTesting.length > 0) {
867
+ stack.testingFrameworks = pyTesting;
868
+ }
869
+ // === BUILD TOOLS ===
870
+ const pyBuildTools = [];
871
+ if (allPyDeps.has('setuptools'))
872
+ pyBuildTools.push('setuptools');
873
+ if (allPyDeps.has('wheel'))
874
+ pyBuildTools.push('wheel');
875
+ if (allPyDeps.has('cython'))
876
+ pyBuildTools.push('Cython');
877
+ if (allPyDeps.has('maturin'))
878
+ pyBuildTools.push('Maturin');
879
+ if (pyBuildTools.length > 0) {
880
+ stack.buildTools = pyBuildTools;
881
+ }
882
+ // === ORM/DATABASE ===
883
+ if (allPyDeps.has('sqlalchemy'))
884
+ stack.orm = 'SQLAlchemy';
885
+ else if (allPyDeps.has('tortoise-orm'))
886
+ stack.orm = 'Tortoise ORM';
887
+ else if (allPyDeps.has('peewee'))
888
+ stack.orm = 'Peewee';
889
+ else if (allPyDeps.has('mongoengine'))
890
+ stack.orm = 'MongoEngine';
891
+ else if (allPyDeps.has('django'))
892
+ stack.orm = 'Django ORM';
893
+ const pyDatabases = [];
894
+ if (allPyDeps.has('psycopg2') || allPyDeps.has('psycopg2-binary') || allPyDeps.has('psycopg') || allPyDeps.has('asyncpg'))
895
+ pyDatabases.push('PostgreSQL');
896
+ if (allPyDeps.has('pymongo') || allPyDeps.has('motor'))
897
+ pyDatabases.push('MongoDB');
898
+ if (allPyDeps.has('redis') || allPyDeps.has('aioredis'))
899
+ pyDatabases.push('Redis');
900
+ if (allPyDeps.has('pymysql') || allPyDeps.has('mysqlclient'))
901
+ pyDatabases.push('MySQL');
902
+ if (allPyDeps.has('aiosqlite'))
903
+ pyDatabases.push('SQLite');
904
+ if (allPyDeps.has('supabase'))
905
+ pyDatabases.push('Supabase');
906
+ if (pyDatabases.length > 0) {
907
+ stack.database = pyDatabases.join(', ');
908
+ }
909
+ // === DEPLOYMENT ===
910
+ if (!stack.deployment) {
911
+ if (await fileExists(join(rootDir, 'Dockerfile')))
912
+ stack.deployment = 'Docker';
913
+ else if (await fileExists(join(rootDir, 'Procfile')))
914
+ stack.deployment = 'Heroku';
915
+ else if (await fileExists(join(rootDir, 'fly.toml')))
916
+ stack.deployment = 'Fly.io';
917
+ else if (await fileExists(join(rootDir, 'render.yaml')))
918
+ stack.deployment = 'Render';
919
+ }
920
+ // === CI/CD ===
921
+ if (!stack.cicd || stack.cicd.length === 0) {
922
+ const cicd = [];
923
+ if (await fileExists(join(rootDir, '.github/workflows')))
924
+ cicd.push('GitHub Actions');
925
+ if (await fileExists(join(rootDir, '.gitlab-ci.yml')))
926
+ cicd.push('GitLab CI');
927
+ if (await fileExists(join(rootDir, '.circleci')))
928
+ cicd.push('CircleCI');
929
+ if (await fileExists(join(rootDir, 'tox.ini')))
930
+ cicd.push('tox');
931
+ stack.cicd = cicd;
524
932
  }
525
933
  }
526
934
  // Check for Rust project
@@ -528,12 +936,255 @@ export async function inferStack(rootDir) {
528
936
  if (await fileExists(cargoPath)) {
529
937
  stack.language = 'Rust';
530
938
  stack.packageManager = 'cargo';
939
+ try {
940
+ const cargoContent = await readFile(cargoPath, 'utf-8');
941
+ const cargo = parsePyprojectToml(cargoContent); // TOML parser works for Cargo.toml too
942
+ // Rust edition as runtime
943
+ const edition = cargo?.package?.edition;
944
+ if (edition) {
945
+ stack.runtime = `Rust Edition ${edition}`;
946
+ }
947
+ // Collect all deps
948
+ const cargoDeps = cargo?.dependencies || {};
949
+ const cargoDevDeps = cargo?.['dev-dependencies'] || {};
950
+ const cargoBuildDeps = cargo?.['build-dependencies'] || {};
951
+ const allCrateDeps = new Set([
952
+ ...Object.keys(cargoDeps).map((s) => s.toLowerCase()),
953
+ ...Object.keys(cargoDevDeps).map((s) => s.toLowerCase()),
954
+ ...Object.keys(cargoBuildDeps).map((s) => s.toLowerCase()),
955
+ ]);
956
+ // Store deps
957
+ const rustDepsRecord = {};
958
+ for (const [name, spec] of Object.entries(cargoDeps)) {
959
+ rustDepsRecord[name] = typeof spec === 'string' ? spec : '*';
960
+ }
961
+ if (Object.keys(rustDepsRecord).length > 0)
962
+ stack.dependencies = rustDepsRecord;
963
+ const rustDevDepsRecord = {};
964
+ for (const [name, spec] of Object.entries(cargoDevDeps)) {
965
+ rustDevDepsRecord[name] = typeof spec === 'string' ? spec : '*';
966
+ }
967
+ if (Object.keys(rustDevDepsRecord).length > 0)
968
+ stack.devDependencies = rustDevDepsRecord;
969
+ // === FRAMEWORKS ===
970
+ const rustFrameworks = [];
971
+ // Web frameworks
972
+ if (allCrateDeps.has('actix-web'))
973
+ rustFrameworks.push('Actix Web');
974
+ if (allCrateDeps.has('axum'))
975
+ rustFrameworks.push('Axum');
976
+ if (allCrateDeps.has('rocket'))
977
+ rustFrameworks.push('Rocket');
978
+ if (allCrateDeps.has('warp'))
979
+ rustFrameworks.push('Warp');
980
+ if (allCrateDeps.has('tide'))
981
+ rustFrameworks.push('Tide');
982
+ // Async runtime
983
+ if (allCrateDeps.has('tokio'))
984
+ rustFrameworks.push('Tokio');
985
+ if (allCrateDeps.has('async-std'))
986
+ rustFrameworks.push('async-std');
987
+ // CLI
988
+ if (allCrateDeps.has('clap'))
989
+ rustFrameworks.push('Clap');
990
+ if (allCrateDeps.has('structopt'))
991
+ rustFrameworks.push('StructOpt');
992
+ // Serialization
993
+ if (allCrateDeps.has('serde'))
994
+ rustFrameworks.push('Serde');
995
+ // GUI
996
+ if (allCrateDeps.has('tauri'))
997
+ rustFrameworks.push('Tauri');
998
+ if (allCrateDeps.has('egui'))
999
+ rustFrameworks.push('egui');
1000
+ if (allCrateDeps.has('iced'))
1001
+ rustFrameworks.push('Iced');
1002
+ if (rustFrameworks.length > 0) {
1003
+ stack.frameworks = rustFrameworks;
1004
+ stack.framework = rustFrameworks[0];
1005
+ }
1006
+ // === TESTING ===
1007
+ const rustTesting = [];
1008
+ if (allCrateDeps.has('criterion'))
1009
+ rustTesting.push('Criterion (benchmarks)');
1010
+ if (allCrateDeps.has('proptest'))
1011
+ rustTesting.push('proptest');
1012
+ if (allCrateDeps.has('quickcheck'))
1013
+ rustTesting.push('quickcheck');
1014
+ if (allCrateDeps.has('mockall'))
1015
+ rustTesting.push('mockall');
1016
+ if (allCrateDeps.has('rstest'))
1017
+ rustTesting.push('rstest');
1018
+ // Rust has built-in testing
1019
+ rustTesting.unshift('cargo test (built-in)');
1020
+ stack.testingFrameworks = rustTesting;
1021
+ // === ORM/DATABASE ===
1022
+ if (allCrateDeps.has('diesel'))
1023
+ stack.orm = 'Diesel';
1024
+ else if (allCrateDeps.has('sea-orm') || allCrateDeps.has('sea_orm'))
1025
+ stack.orm = 'SeaORM';
1026
+ else if (allCrateDeps.has('sqlx'))
1027
+ stack.orm = 'SQLx';
1028
+ const rustDatabases = [];
1029
+ if (allCrateDeps.has('tokio-postgres') || allCrateDeps.has('sqlx') || allCrateDeps.has('diesel'))
1030
+ rustDatabases.push('PostgreSQL');
1031
+ if (allCrateDeps.has('mongodb'))
1032
+ rustDatabases.push('MongoDB');
1033
+ if (allCrateDeps.has('redis'))
1034
+ rustDatabases.push('Redis');
1035
+ if (allCrateDeps.has('rusqlite'))
1036
+ rustDatabases.push('SQLite');
1037
+ if (rustDatabases.length > 0)
1038
+ stack.database = rustDatabases.join(', ');
1039
+ // === BUILD TOOLS ===
1040
+ const rustBuildTools = [];
1041
+ if (allCrateDeps.has('maturin'))
1042
+ rustBuildTools.push('Maturin');
1043
+ if (allCrateDeps.has('wasm-bindgen'))
1044
+ rustBuildTools.push('wasm-bindgen');
1045
+ if (allCrateDeps.has('napi') || allCrateDeps.has('napi-derive'))
1046
+ rustBuildTools.push('napi-rs');
1047
+ if (rustBuildTools.length > 0)
1048
+ stack.buildTools = rustBuildTools;
1049
+ // === DEPLOYMENT ===
1050
+ if (!stack.deployment) {
1051
+ if (await fileExists(join(rootDir, 'Dockerfile')))
1052
+ stack.deployment = 'Docker';
1053
+ else if (await fileExists(join(rootDir, 'fly.toml')))
1054
+ stack.deployment = 'Fly.io';
1055
+ else if (await fileExists(join(rootDir, 'shuttle.toml')) || allCrateDeps.has('shuttle-runtime'))
1056
+ stack.deployment = 'Shuttle';
1057
+ }
1058
+ // === CI/CD ===
1059
+ if (!stack.cicd || stack.cicd.length === 0) {
1060
+ const cicd = [];
1061
+ if (await fileExists(join(rootDir, '.github/workflows')))
1062
+ cicd.push('GitHub Actions');
1063
+ if (await fileExists(join(rootDir, '.gitlab-ci.yml')))
1064
+ cicd.push('GitLab CI');
1065
+ stack.cicd = cicd;
1066
+ }
1067
+ }
1068
+ catch { }
531
1069
  }
532
1070
  // Check for Go project
533
1071
  const goModPath = join(rootDir, 'go.mod');
534
1072
  if (await fileExists(goModPath)) {
535
1073
  stack.language = 'Go';
536
1074
  stack.packageManager = 'go';
1075
+ try {
1076
+ const goModContent = await readFile(goModPath, 'utf-8');
1077
+ // Parse Go version
1078
+ const goVersionMatch = goModContent.match(/^go\s+(\S+)/m);
1079
+ if (goVersionMatch) {
1080
+ stack.runtime = `Go ${goVersionMatch[1]}`;
1081
+ }
1082
+ // Parse module path
1083
+ const moduleMatch = goModContent.match(/^module\s+(\S+)/m);
1084
+ const modulePath = moduleMatch?.[1] || '';
1085
+ // Parse require block
1086
+ const allGoMods = new Set();
1087
+ const depsRecord = {};
1088
+ // Single-line requires: require github.com/foo/bar v1.2.3
1089
+ const singleReqs = goModContent.matchAll(/^require\s+(\S+)\s+(\S+)/gm);
1090
+ for (const m of singleReqs) {
1091
+ const modName = m[1].toLowerCase();
1092
+ allGoMods.add(modName);
1093
+ depsRecord[m[1]] = m[2];
1094
+ }
1095
+ // Block requires
1096
+ const requireBlocks = goModContent.matchAll(/require\s*\(([\s\S]*?)\)/g);
1097
+ for (const block of requireBlocks) {
1098
+ const lines = block[1].split('\n');
1099
+ for (const line of lines) {
1100
+ const depMatch = line.trim().match(/^(\S+)\s+(\S+)/);
1101
+ if (depMatch && !depMatch[1].startsWith('//')) {
1102
+ allGoMods.add(depMatch[1].toLowerCase());
1103
+ depsRecord[depMatch[1]] = depMatch[2];
1104
+ }
1105
+ }
1106
+ }
1107
+ if (Object.keys(depsRecord).length > 0)
1108
+ stack.dependencies = depsRecord;
1109
+ // Helper: check if any go module path contains a substring
1110
+ const hasGoMod = (name) => [...allGoMods].some(m => m.includes(name));
1111
+ // === FRAMEWORKS ===
1112
+ const goFrameworks = [];
1113
+ if (hasGoMod('gin-gonic/gin'))
1114
+ goFrameworks.push('Gin');
1115
+ if (hasGoMod('labstack/echo'))
1116
+ goFrameworks.push('Echo');
1117
+ if (hasGoMod('gofiber/fiber'))
1118
+ goFrameworks.push('Fiber');
1119
+ if (hasGoMod('go-chi/chi'))
1120
+ goFrameworks.push('Chi');
1121
+ if (hasGoMod('gorilla/mux'))
1122
+ goFrameworks.push('Gorilla Mux');
1123
+ if (hasGoMod('beego'))
1124
+ goFrameworks.push('Beego');
1125
+ if (hasGoMod('grpc'))
1126
+ goFrameworks.push('gRPC');
1127
+ // CLI
1128
+ if (hasGoMod('spf13/cobra'))
1129
+ goFrameworks.push('Cobra');
1130
+ if (hasGoMod('urfave/cli'))
1131
+ goFrameworks.push('urfave/cli');
1132
+ // Config
1133
+ if (hasGoMod('spf13/viper'))
1134
+ goFrameworks.push('Viper');
1135
+ if (goFrameworks.length > 0) {
1136
+ stack.frameworks = goFrameworks;
1137
+ stack.framework = goFrameworks[0];
1138
+ }
1139
+ // === TESTING ===
1140
+ const goTesting = ['go test (built-in)'];
1141
+ if (hasGoMod('stretchr/testify'))
1142
+ goTesting.push('Testify');
1143
+ if (hasGoMod('onsi/ginkgo'))
1144
+ goTesting.push('Ginkgo');
1145
+ if (hasGoMod('onsi/gomega'))
1146
+ goTesting.push('Gomega');
1147
+ stack.testingFrameworks = goTesting;
1148
+ // === ORM/DATABASE ===
1149
+ if (hasGoMod('gorm.io'))
1150
+ stack.orm = 'GORM';
1151
+ else if (hasGoMod('ent/ent'))
1152
+ stack.orm = 'Ent';
1153
+ else if (hasGoMod('sqlc'))
1154
+ stack.orm = 'sqlc';
1155
+ else if (hasGoMod('jmoiron/sqlx'))
1156
+ stack.orm = 'sqlx';
1157
+ const goDatabases = [];
1158
+ if (hasGoMod('lib/pq') || hasGoMod('jackc/pgx') || hasGoMod('pgx'))
1159
+ goDatabases.push('PostgreSQL');
1160
+ if (hasGoMod('go.mongodb.org') || hasGoMod('mongo-driver'))
1161
+ goDatabases.push('MongoDB');
1162
+ if (hasGoMod('go-redis/redis') || hasGoMod('redis/go-redis'))
1163
+ goDatabases.push('Redis');
1164
+ if (hasGoMod('mattn/go-sqlite3'))
1165
+ goDatabases.push('SQLite');
1166
+ if (hasGoMod('go-sql-driver/mysql'))
1167
+ goDatabases.push('MySQL');
1168
+ if (goDatabases.length > 0)
1169
+ stack.database = goDatabases.join(', ');
1170
+ // === DEPLOYMENT ===
1171
+ if (!stack.deployment) {
1172
+ if (await fileExists(join(rootDir, 'Dockerfile')))
1173
+ stack.deployment = 'Docker';
1174
+ else if (await fileExists(join(rootDir, 'fly.toml')))
1175
+ stack.deployment = 'Fly.io';
1176
+ }
1177
+ // === CI/CD ===
1178
+ if (!stack.cicd || stack.cicd.length === 0) {
1179
+ const cicd = [];
1180
+ if (await fileExists(join(rootDir, '.github/workflows')))
1181
+ cicd.push('GitHub Actions');
1182
+ if (await fileExists(join(rootDir, '.gitlab-ci.yml')))
1183
+ cicd.push('GitLab CI');
1184
+ stack.cicd = cicd;
1185
+ }
1186
+ }
1187
+ catch { }
537
1188
  }
538
1189
  return stack;
539
1190
  }
@@ -608,23 +1259,141 @@ export async function inferArchitecture(rootDir) {
608
1259
  const hasPackages = relativeDirs.some(d => d === 'packages');
609
1260
  const hasApps = relativeDirs.some(d => d === 'apps');
610
1261
  const hasServices = relativeDirs.some(d => d === 'services');
611
- if (hasPackages || hasApps) {
612
- architecture.type = 'monorepo';
613
- }
614
- else if (hasServices) {
615
- architecture.type = 'microservices';
1262
+ // Check for Python CLI entry points from pyproject.toml
1263
+ let hasPythonScripts = false;
1264
+ let hasPythonWebFramework = false;
1265
+ const pyprojectArchPath = join(rootDir, 'pyproject.toml');
1266
+ if (await fileExists(pyprojectArchPath)) {
1267
+ try {
1268
+ const pyContent = await readFile(pyprojectArchPath, 'utf-8');
1269
+ const pyproject = parsePyprojectToml(pyContent);
1270
+ // CLI entry points from [project.scripts]
1271
+ const scripts = pyproject?.project?.scripts;
1272
+ if (scripts && typeof scripts === 'object' && Object.keys(scripts).length > 0) {
1273
+ hasPythonScripts = true;
1274
+ for (const [cmdName, entryPoint] of Object.entries(scripts)) {
1275
+ if (typeof entryPoint === 'string') {
1276
+ architecture.entryPoints = architecture.entryPoints || [];
1277
+ architecture.entryPoints.push({
1278
+ file: entryPoint,
1279
+ purpose: `CLI command: ${cmdName}`
1280
+ });
1281
+ }
1282
+ }
1283
+ }
1284
+ // Check for web framework deps
1285
+ const deps = pyproject?.project?.dependencies || [];
1286
+ const depNames = deps.map(extractPyDepName);
1287
+ hasPythonWebFramework = depNames.some(d => ['django', 'flask', 'fastapi', 'starlette', 'sanic', 'tornado', 'falcon', 'litestar'].includes(d));
1288
+ }
1289
+ catch { }
616
1290
  }
617
- else if (hasApp && hasSrc) {
618
- architecture.type = 'fullstack';
1291
+ // Python-specific entry points
1292
+ if (await fileExists(join(rootDir, 'manage.py'))) {
1293
+ architecture.entryPoints = architecture.entryPoints || [];
1294
+ architecture.entryPoints.push({ file: 'manage.py', purpose: 'Django management' });
619
1295
  }
620
- else if (hasLib && !hasApp) {
621
- architecture.type = 'library';
1296
+ // Rust architecture detection
1297
+ let hasRustBin = false;
1298
+ let hasRustLib = false;
1299
+ let hasRustWebFramework = false;
1300
+ let hasRustCliFramework = false;
1301
+ const cargoArchPath = join(rootDir, 'Cargo.toml');
1302
+ if (await fileExists(cargoArchPath)) {
1303
+ try {
1304
+ const cargoContent = await readFile(cargoArchPath, 'utf-8');
1305
+ const cargo = parsePyprojectToml(cargoContent);
1306
+ hasRustBin = await fileExists(join(rootDir, 'src/main.rs'));
1307
+ hasRustLib = await fileExists(join(rootDir, 'src/lib.rs'));
1308
+ const cargoDeps = Object.keys(cargo?.dependencies || {}).map(s => s.toLowerCase());
1309
+ hasRustWebFramework = cargoDeps.some(d => ['actix-web', 'axum', 'rocket', 'warp', 'tide'].includes(d));
1310
+ hasRustCliFramework = cargoDeps.some(d => ['clap', 'structopt'].includes(d));
1311
+ // Rust entry points
1312
+ if (hasRustBin) {
1313
+ architecture.entryPoints = architecture.entryPoints || [];
1314
+ architecture.entryPoints.push({ file: 'src/main.rs', purpose: 'Binary entry point' });
1315
+ }
1316
+ if (hasRustLib) {
1317
+ architecture.entryPoints = architecture.entryPoints || [];
1318
+ architecture.entryPoints.push({ file: 'src/lib.rs', purpose: 'Library entry point' });
1319
+ }
1320
+ }
1321
+ catch { }
622
1322
  }
623
- else if (await fileExists(join(rootDir, 'bin'))) {
624
- architecture.type = 'cli';
1323
+ // Go architecture detection
1324
+ let hasGoCmd = false;
1325
+ let hasGoWebFramework = false;
1326
+ let hasGoCliFramework = false;
1327
+ const goModArchPath = join(rootDir, 'go.mod');
1328
+ if (await fileExists(goModArchPath)) {
1329
+ try {
1330
+ const goModContent = await readFile(goModArchPath, 'utf-8');
1331
+ hasGoCmd = relativeDirs.some(d => d === 'cmd' || d.startsWith('cmd/'));
1332
+ const goMainExists = await fileExists(join(rootDir, 'main.go'));
1333
+ const goModLower = goModContent.toLowerCase();
1334
+ hasGoWebFramework = ['gin-gonic', 'labstack/echo', 'gofiber/fiber', 'go-chi/chi', 'gorilla/mux'].some(f => goModLower.includes(f));
1335
+ hasGoCliFramework = ['spf13/cobra', 'urfave/cli'].some(f => goModLower.includes(f));
1336
+ // Go entry points
1337
+ if (hasGoCmd) {
1338
+ architecture.entryPoints = architecture.entryPoints || [];
1339
+ architecture.entryPoints.push({ file: 'cmd/', purpose: 'CLI entry points' });
1340
+ }
1341
+ else if (goMainExists) {
1342
+ architecture.entryPoints = architecture.entryPoints || [];
1343
+ architecture.entryPoints.push({ file: 'main.go', purpose: 'Application entry' });
1344
+ }
1345
+ }
1346
+ catch { }
625
1347
  }
626
- else if (hasPages || hasApp) {
627
- architecture.type = 'frontend';
1348
+ // === Score-based architecture type detection ===
1349
+ // Each signal contributes to a score for each type
1350
+ const typeScores = {
1351
+ monorepo: 0, microservices: 0, cli: 0, backend: 0,
1352
+ fullstack: 0, library: 0, frontend: 0
1353
+ };
1354
+ // Monorepo signals
1355
+ if (hasPackages || hasApps)
1356
+ typeScores.monorepo += 10;
1357
+ // Microservices signals
1358
+ if (hasServices)
1359
+ typeScores.microservices += 8;
1360
+ // CLI signals
1361
+ if (hasPythonScripts && !hasPythonWebFramework)
1362
+ typeScores.cli += 8;
1363
+ if (hasRustCliFramework && hasRustBin)
1364
+ typeScores.cli += 8;
1365
+ if (hasGoCliFramework || hasGoCmd)
1366
+ typeScores.cli += 8;
1367
+ if (await fileExists(join(rootDir, 'bin')))
1368
+ typeScores.cli += 5;
1369
+ // Backend signals
1370
+ if (hasPythonWebFramework)
1371
+ typeScores.backend += 8;
1372
+ if (hasRustWebFramework)
1373
+ typeScores.backend += 8;
1374
+ if (hasGoWebFramework)
1375
+ typeScores.backend += 8;
1376
+ if (relativeDirs.some(d => d.includes('api')))
1377
+ typeScores.backend += 2;
1378
+ // Fullstack signals
1379
+ if (hasApp && hasSrc)
1380
+ typeScores.fullstack += 5;
1381
+ if (hasPythonWebFramework && relativeDirs.some(d => d.includes('dashboard') || d.includes('frontend')))
1382
+ typeScores.fullstack += 7;
1383
+ // Library signals
1384
+ if (hasLib && !hasApp && !hasPages)
1385
+ typeScores.library += 5;
1386
+ if (hasRustLib && !hasRustBin)
1387
+ typeScores.library += 8;
1388
+ // Frontend signals
1389
+ if (hasPages || (hasApp && !hasSrc && !hasServices))
1390
+ typeScores.frontend += 5;
1391
+ // Pick highest score, with fallback
1392
+ const sortedTypes = Object.entries(typeScores)
1393
+ .filter(([, score]) => score > 0)
1394
+ .sort((a, b) => b[1] - a[1]);
1395
+ if (sortedTypes.length > 0) {
1396
+ architecture.type = sortedTypes[0][0];
628
1397
  }
629
1398
  else {
630
1399
  architecture.type = 'backend';
@@ -700,6 +1469,12 @@ export async function inferArchitecture(rootDir) {
700
1469
  patterns.push('Feature-based organization');
701
1470
  if (relativeDirs.some(d => d.includes('modules')))
702
1471
  patterns.push('Module pattern');
1472
+ if (relativeDirs.some(d => d.includes('agents')))
1473
+ patterns.push('Agent-based architecture');
1474
+ if (relativeDirs.some(d => d.includes('missions') || d.includes('pipelines')))
1475
+ patterns.push('Pipeline pattern');
1476
+ if (relativeDirs.some(d => d.includes('models') && !d.includes('node_modules')))
1477
+ patterns.push('Data models');
703
1478
  architecture.patterns = patterns;
704
1479
  // Detect conventions
705
1480
  const conventions = [];
@@ -718,9 +1493,51 @@ export async function inferArchitecture(rootDir) {
718
1493
  if (await fileExists(join(rootDir, '.husky'))) {
719
1494
  conventions.push('Git hooks (Husky)');
720
1495
  }
1496
+ // Python conventions
1497
+ if (await fileExists(pyprojectArchPath)) {
1498
+ try {
1499
+ const pyContent = await readFile(pyprojectArchPath, 'utf-8');
1500
+ const pyproject = parsePyprojectToml(pyContent);
1501
+ if (pyproject?.tool?.ruff)
1502
+ conventions.push('Ruff code linting');
1503
+ if (pyproject?.tool?.black)
1504
+ conventions.push('Black code formatting');
1505
+ if (pyproject?.tool?.mypy)
1506
+ conventions.push('mypy type checking');
1507
+ if (pyproject?.tool?.isort)
1508
+ conventions.push('isort import sorting');
1509
+ if (pyproject?.tool?.pytest)
1510
+ conventions.push('pytest configuration');
1511
+ }
1512
+ catch { }
1513
+ }
1514
+ if (await fileExists(join(rootDir, '.flake8'))) {
1515
+ conventions.push('flake8 code linting');
1516
+ }
1517
+ if (await fileExists(join(rootDir, 'mypy.ini')) || await fileExists(join(rootDir, '.mypy.ini'))) {
1518
+ conventions.push('mypy type checking');
1519
+ }
1520
+ // Rust conventions
1521
+ if (await fileExists(cargoArchPath)) {
1522
+ if (await fileExists(join(rootDir, 'rustfmt.toml')) || await fileExists(join(rootDir, '.rustfmt.toml'))) {
1523
+ conventions.push('rustfmt code formatting');
1524
+ }
1525
+ if (await fileExists(join(rootDir, 'clippy.toml')) || await fileExists(join(rootDir, '.clippy.toml'))) {
1526
+ conventions.push('Clippy linting');
1527
+ }
1528
+ // Rust always has these by convention
1529
+ conventions.push('cargo fmt / cargo clippy');
1530
+ }
1531
+ // Go conventions
1532
+ if (await fileExists(goModArchPath)) {
1533
+ if (await fileExists(join(rootDir, '.golangci.yml')) || await fileExists(join(rootDir, '.golangci.yaml'))) {
1534
+ conventions.push('golangci-lint');
1535
+ }
1536
+ conventions.push('gofmt / go vet');
1537
+ }
721
1538
  architecture.conventions = conventions;
722
- // Detect entry points
723
- const entryPoints = [];
1539
+ // Detect entry points (preserve any already detected, e.g. from pyproject.toml)
1540
+ const entryPoints = architecture.entryPoints || [];
724
1541
  if (await fileExists(join(rootDir, 'src/index.ts')))
725
1542
  entryPoints.push({ file: 'src/index.ts', purpose: 'Main entry point' });
726
1543
  else if (await fileExists(join(rootDir, 'src/index.tsx')))
@@ -847,6 +1664,73 @@ export async function inferConstraints(rootDir) {
847
1664
  formatter: 'Prettier'
848
1665
  };
849
1666
  }
1667
+ // Python code style tools
1668
+ const pyprojectConstraintsPath = join(rootDir, 'pyproject.toml');
1669
+ if (await fileExists(pyprojectConstraintsPath)) {
1670
+ try {
1671
+ const pyContent = await readFile(pyprojectConstraintsPath, 'utf-8');
1672
+ const pyproject = parsePyprojectToml(pyContent);
1673
+ // Linter
1674
+ if (pyproject?.tool?.ruff) {
1675
+ constraints.codeStyle = {
1676
+ ...constraints.codeStyle,
1677
+ linter: 'Ruff'
1678
+ };
1679
+ const targetVersion = pyproject.tool.ruff['target-version'];
1680
+ if (targetVersion && typeof targetVersion === 'string') {
1681
+ // py311 → 3.11, py39 → 3.9
1682
+ const nums = targetVersion.replace('py', '');
1683
+ const major = nums[0];
1684
+ const minor = nums.slice(1);
1685
+ if (major && minor) {
1686
+ constraints.mustUse?.push(`Python ${major}.${minor}+`);
1687
+ }
1688
+ }
1689
+ const lineLength = pyproject.tool.ruff['line-length'];
1690
+ if (lineLength) {
1691
+ constraints.preferences?.push({
1692
+ category: 'Code Style',
1693
+ preference: `Line length: ${lineLength}`,
1694
+ rationale: 'Ruff configuration'
1695
+ });
1696
+ }
1697
+ }
1698
+ if (pyproject?.tool?.black) {
1699
+ constraints.codeStyle = {
1700
+ ...constraints.codeStyle,
1701
+ formatter: 'Black'
1702
+ };
1703
+ }
1704
+ if (pyproject?.tool?.mypy) {
1705
+ constraints.mustUse?.push('mypy for type checking');
1706
+ }
1707
+ // Python version constraint (only if not already added from Ruff target-version)
1708
+ const requiresPython = pyproject?.project?.['requires-python'];
1709
+ if (requiresPython && !constraints.mustUse?.some(m => m.startsWith('Python '))) {
1710
+ constraints.mustUse?.push(`Python ${requiresPython}`);
1711
+ }
1712
+ // Testing from pyproject
1713
+ const deps = pyproject?.project?.dependencies || [];
1714
+ const optDeps = pyproject?.project?.['optional-dependencies'] || {};
1715
+ const allDepNames = new Set([
1716
+ ...deps.map(extractPyDepName),
1717
+ ...Object.values(optDeps).flat().filter((d) => typeof d === 'string').map(extractPyDepName)
1718
+ ]);
1719
+ if (allDepNames.has('pytest') || pyproject?.tool?.pytest) {
1720
+ constraints.testing = {
1721
+ required: true,
1722
+ strategy: 'pytest'
1723
+ };
1724
+ }
1725
+ }
1726
+ catch { }
1727
+ }
1728
+ if (await fileExists(join(rootDir, '.flake8'))) {
1729
+ constraints.codeStyle = {
1730
+ ...constraints.codeStyle,
1731
+ linter: constraints.codeStyle?.linter || 'flake8'
1732
+ };
1733
+ }
850
1734
  // Check for TypeScript
851
1735
  if (await fileExists(join(rootDir, 'tsconfig.json'))) {
852
1736
  constraints.mustUse?.push('TypeScript for type safety');