prelude-context 1.4.3 → 1.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.
@@ -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,67 @@ 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
+ // Fall back to directory name for name
281
+ if (!name)
282
+ name = basename(rootDir);
283
+ // Fall back to README for description
284
+ if (!description) {
285
+ const readmePath = join(rootDir, 'README.md');
286
+ if (await fileExists(readmePath)) {
287
+ try {
288
+ const readme = await readFile(readmePath, 'utf-8');
289
+ const lines = readme.split('\n').filter(l => l.trim());
290
+ const firstParagraph = lines.find(l => !l.startsWith('#') && !l.startsWith('!') && !l.startsWith('[') && !l.startsWith('<') && !l.startsWith('---') && l.length > 20);
291
+ if (firstParagraph) {
292
+ description = firstParagraph.slice(0, 200);
293
+ }
294
+ }
295
+ catch { }
296
+ }
297
+ }
298
+ if (!description)
299
+ description = 'No description provided';
131
300
  return {
132
301
  $schema: `${SCHEMA_URL}/project.schema.json`,
133
302
  version: PRELUDE_VERSION,
134
303
  name,
135
304
  description,
136
- projectVersion, // Correctly use renamed field
305
+ projectVersion,
137
306
  createdAt: getCurrentTimestamp(),
138
307
  updatedAt: getCurrentTimestamp(),
139
308
  repository,
@@ -503,24 +672,220 @@ export async function inferStack(rootDir) {
503
672
  // Check for Python project
504
673
  const requirementsPath = join(rootDir, 'requirements.txt');
505
674
  const pyprojectPath = join(rootDir, 'pyproject.toml');
506
- if (await fileExists(requirementsPath) || await fileExists(pyprojectPath)) {
675
+ if (await fileExists(pyprojectPath) || await fileExists(requirementsPath)) {
507
676
  stack.language = 'Python';
508
- stack.packageManager = await fileExists(pyprojectPath) ? 'poetry' : 'pip';
509
- // Check for common Python frameworks
677
+ const allPyDeps = new Set();
678
+ // Parse pyproject.toml
679
+ if (await fileExists(pyprojectPath)) {
680
+ try {
681
+ const pyContent = await readFile(pyprojectPath, 'utf-8');
682
+ const pyproject = parsePyprojectToml(pyContent);
683
+ // Package manager detection
684
+ const buildBackend = pyproject?.['build-system']?.['build-backend'] || '';
685
+ if (buildBackend.includes('poetry')) {
686
+ stack.packageManager = 'poetry';
687
+ }
688
+ else if (await fileExists(join(rootDir, 'uv.lock'))) {
689
+ stack.packageManager = 'uv';
690
+ }
691
+ else if (await fileExists(join(rootDir, 'poetry.lock'))) {
692
+ stack.packageManager = 'poetry';
693
+ }
694
+ else if (await fileExists(join(rootDir, 'Pipfile')) || await fileExists(join(rootDir, 'Pipfile.lock'))) {
695
+ stack.packageManager = 'pipenv';
696
+ }
697
+ else {
698
+ stack.packageManager = 'pip';
699
+ }
700
+ // Python version
701
+ const requiresPython = pyproject?.project?.['requires-python'];
702
+ if (requiresPython) {
703
+ stack.runtime = `Python ${requiresPython}`;
704
+ }
705
+ // Collect deps from [project.dependencies]
706
+ const projDeps = pyproject?.project?.dependencies || [];
707
+ const depsRecord = {};
708
+ for (const dep of projDeps) {
709
+ const depName = extractPyDepName(dep);
710
+ const version = dep.slice(depName.length).replace(/^\[.*?\]/, '').trim() || '*';
711
+ depsRecord[depName] = version;
712
+ allPyDeps.add(depName);
713
+ }
714
+ if (Object.keys(depsRecord).length > 0) {
715
+ stack.dependencies = depsRecord;
716
+ }
717
+ // Collect dev/optional deps from [project.optional-dependencies]
718
+ const optDeps = pyproject?.project?.['optional-dependencies'] || {};
719
+ const devDepsRecord = {};
720
+ for (const [, groupDeps] of Object.entries(optDeps)) {
721
+ if (Array.isArray(groupDeps)) {
722
+ for (const dep of groupDeps) {
723
+ if (typeof dep === 'string') {
724
+ const depName = extractPyDepName(dep);
725
+ const version = dep.slice(depName.length).replace(/^\[.*?\]/, '').trim() || '*';
726
+ devDepsRecord[depName] = version;
727
+ allPyDeps.add(depName);
728
+ }
729
+ }
730
+ }
731
+ }
732
+ if (Object.keys(devDepsRecord).length > 0) {
733
+ stack.devDependencies = devDepsRecord;
734
+ }
735
+ // Also check Poetry-style deps under [tool.poetry.dependencies]
736
+ const poetryDeps = pyproject?.tool?.poetry?.dependencies;
737
+ if (poetryDeps && typeof poetryDeps === 'object') {
738
+ for (const depName of Object.keys(poetryDeps)) {
739
+ if (depName !== 'python')
740
+ allPyDeps.add(depName.toLowerCase());
741
+ }
742
+ }
743
+ }
744
+ catch { }
745
+ }
746
+ // Parse requirements.txt as fallback/supplement
510
747
  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;
748
+ try {
749
+ const reqContent = await readFile(requirementsPath, 'utf-8');
750
+ const reqDeps = parseRequirementsTxt(reqContent);
751
+ for (const dep of reqDeps)
752
+ allPyDeps.add(dep);
753
+ if (!stack.dependencies || Object.keys(stack.dependencies).length === 0) {
754
+ const depsRecord = {};
755
+ for (const dep of reqDeps)
756
+ depsRecord[dep] = '*';
757
+ stack.dependencies = depsRecord;
758
+ stack.packageManager = stack.packageManager || 'pip';
759
+ }
760
+ }
761
+ catch { }
762
+ }
763
+ // === FRAMEWORKS ===
764
+ const pyFrameworks = [];
765
+ // Web frameworks
766
+ if (allPyDeps.has('django'))
767
+ pyFrameworks.push('Django');
768
+ if (allPyDeps.has('flask'))
769
+ pyFrameworks.push('Flask');
770
+ if (allPyDeps.has('fastapi'))
771
+ pyFrameworks.push('FastAPI');
772
+ if (allPyDeps.has('starlette'))
773
+ pyFrameworks.push('Starlette');
774
+ if (allPyDeps.has('tornado'))
775
+ pyFrameworks.push('Tornado');
776
+ if (allPyDeps.has('pyramid'))
777
+ pyFrameworks.push('Pyramid');
778
+ if (allPyDeps.has('sanic'))
779
+ pyFrameworks.push('Sanic');
780
+ if (allPyDeps.has('falcon'))
781
+ pyFrameworks.push('Falcon');
782
+ if (allPyDeps.has('litestar'))
783
+ pyFrameworks.push('Litestar');
784
+ // CLI frameworks
785
+ if (allPyDeps.has('typer'))
786
+ pyFrameworks.push('Typer');
787
+ if (allPyDeps.has('click'))
788
+ pyFrameworks.push('Click');
789
+ // ML/AI frameworks
790
+ if (allPyDeps.has('langchain'))
791
+ pyFrameworks.push('LangChain');
792
+ if (allPyDeps.has('transformers'))
793
+ pyFrameworks.push('Hugging Face Transformers');
794
+ if (allPyDeps.has('torch') || allPyDeps.has('pytorch'))
795
+ pyFrameworks.push('PyTorch');
796
+ if (allPyDeps.has('tensorflow'))
797
+ pyFrameworks.push('TensorFlow');
798
+ if (allPyDeps.has('scikit-learn') || allPyDeps.has('sklearn'))
799
+ pyFrameworks.push('scikit-learn');
800
+ // Data
801
+ if (allPyDeps.has('pandas'))
802
+ pyFrameworks.push('pandas');
803
+ if (allPyDeps.has('numpy'))
804
+ pyFrameworks.push('NumPy');
805
+ if (pyFrameworks.length > 0) {
806
+ stack.frameworks = pyFrameworks;
807
+ stack.framework = pyFrameworks[0];
808
+ }
809
+ // === TESTING ===
810
+ const pyTesting = [];
811
+ if (allPyDeps.has('pytest'))
812
+ pyTesting.push('pytest');
813
+ if (allPyDeps.has('pytest-cov'))
814
+ pyTesting.push('pytest-cov');
815
+ if (allPyDeps.has('hypothesis'))
816
+ pyTesting.push('Hypothesis');
817
+ if (allPyDeps.has('tox'))
818
+ pyTesting.push('tox');
819
+ if (allPyDeps.has('nox'))
820
+ pyTesting.push('nox');
821
+ if (allPyDeps.has('coverage'))
822
+ pyTesting.push('coverage');
823
+ if (pyTesting.length > 0) {
824
+ stack.testingFrameworks = pyTesting;
825
+ }
826
+ // === BUILD TOOLS ===
827
+ const pyBuildTools = [];
828
+ if (allPyDeps.has('setuptools'))
829
+ pyBuildTools.push('setuptools');
830
+ if (allPyDeps.has('wheel'))
831
+ pyBuildTools.push('wheel');
832
+ if (allPyDeps.has('cython'))
833
+ pyBuildTools.push('Cython');
834
+ if (allPyDeps.has('maturin'))
835
+ pyBuildTools.push('Maturin');
836
+ if (pyBuildTools.length > 0) {
837
+ stack.buildTools = pyBuildTools;
838
+ }
839
+ // === ORM/DATABASE ===
840
+ if (allPyDeps.has('sqlalchemy'))
841
+ stack.orm = 'SQLAlchemy';
842
+ else if (allPyDeps.has('tortoise-orm'))
843
+ stack.orm = 'Tortoise ORM';
844
+ else if (allPyDeps.has('peewee'))
845
+ stack.orm = 'Peewee';
846
+ else if (allPyDeps.has('mongoengine'))
847
+ stack.orm = 'MongoEngine';
848
+ else if (allPyDeps.has('django'))
849
+ stack.orm = 'Django ORM';
850
+ const pyDatabases = [];
851
+ if (allPyDeps.has('psycopg2') || allPyDeps.has('psycopg2-binary') || allPyDeps.has('psycopg') || allPyDeps.has('asyncpg'))
852
+ pyDatabases.push('PostgreSQL');
853
+ if (allPyDeps.has('pymongo') || allPyDeps.has('motor'))
854
+ pyDatabases.push('MongoDB');
855
+ if (allPyDeps.has('redis') || allPyDeps.has('aioredis'))
856
+ pyDatabases.push('Redis');
857
+ if (allPyDeps.has('pymysql') || allPyDeps.has('mysqlclient'))
858
+ pyDatabases.push('MySQL');
859
+ if (allPyDeps.has('aiosqlite'))
860
+ pyDatabases.push('SQLite');
861
+ if (allPyDeps.has('supabase'))
862
+ pyDatabases.push('Supabase');
863
+ if (pyDatabases.length > 0) {
864
+ stack.database = pyDatabases.join(', ');
865
+ }
866
+ // === DEPLOYMENT ===
867
+ if (!stack.deployment) {
868
+ if (await fileExists(join(rootDir, 'Dockerfile')))
869
+ stack.deployment = 'Docker';
870
+ else if (await fileExists(join(rootDir, 'Procfile')))
871
+ stack.deployment = 'Heroku';
872
+ else if (await fileExists(join(rootDir, 'fly.toml')))
873
+ stack.deployment = 'Fly.io';
874
+ else if (await fileExists(join(rootDir, 'render.yaml')))
875
+ stack.deployment = 'Render';
876
+ }
877
+ // === CI/CD ===
878
+ if (!stack.cicd || stack.cicd.length === 0) {
879
+ const cicd = [];
880
+ if (await fileExists(join(rootDir, '.github/workflows')))
881
+ cicd.push('GitHub Actions');
882
+ if (await fileExists(join(rootDir, '.gitlab-ci.yml')))
883
+ cicd.push('GitLab CI');
884
+ if (await fileExists(join(rootDir, '.circleci')))
885
+ cicd.push('CircleCI');
886
+ if (await fileExists(join(rootDir, 'tox.ini')))
887
+ cicd.push('tox');
888
+ stack.cicd = cicd;
524
889
  }
525
890
  }
526
891
  // Check for Rust project
@@ -608,12 +973,52 @@ export async function inferArchitecture(rootDir) {
608
973
  const hasPackages = relativeDirs.some(d => d === 'packages');
609
974
  const hasApps = relativeDirs.some(d => d === 'apps');
610
975
  const hasServices = relativeDirs.some(d => d === 'services');
976
+ // Check for Python CLI entry points from pyproject.toml
977
+ let hasPythonScripts = false;
978
+ let hasPythonWebFramework = false;
979
+ const pyprojectArchPath = join(rootDir, 'pyproject.toml');
980
+ if (await fileExists(pyprojectArchPath)) {
981
+ try {
982
+ const pyContent = await readFile(pyprojectArchPath, 'utf-8');
983
+ const pyproject = parsePyprojectToml(pyContent);
984
+ // CLI entry points from [project.scripts]
985
+ const scripts = pyproject?.project?.scripts;
986
+ if (scripts && typeof scripts === 'object' && Object.keys(scripts).length > 0) {
987
+ hasPythonScripts = true;
988
+ for (const [cmdName, entryPoint] of Object.entries(scripts)) {
989
+ if (typeof entryPoint === 'string') {
990
+ architecture.entryPoints = architecture.entryPoints || [];
991
+ architecture.entryPoints.push({
992
+ file: entryPoint,
993
+ purpose: `CLI command: ${cmdName}`
994
+ });
995
+ }
996
+ }
997
+ }
998
+ // Check for web framework deps
999
+ const deps = pyproject?.project?.dependencies || [];
1000
+ const depNames = deps.map(extractPyDepName);
1001
+ hasPythonWebFramework = depNames.some(d => ['django', 'flask', 'fastapi', 'starlette', 'sanic', 'tornado', 'falcon', 'litestar'].includes(d));
1002
+ }
1003
+ catch { }
1004
+ }
1005
+ // Python-specific entry points
1006
+ if (await fileExists(join(rootDir, 'manage.py'))) {
1007
+ architecture.entryPoints = architecture.entryPoints || [];
1008
+ architecture.entryPoints.push({ file: 'manage.py', purpose: 'Django management' });
1009
+ }
611
1010
  if (hasPackages || hasApps) {
612
1011
  architecture.type = 'monorepo';
613
1012
  }
614
1013
  else if (hasServices) {
615
1014
  architecture.type = 'microservices';
616
1015
  }
1016
+ else if (hasPythonScripts && !hasPythonWebFramework) {
1017
+ architecture.type = 'cli';
1018
+ }
1019
+ else if (hasPythonWebFramework) {
1020
+ architecture.type = 'backend';
1021
+ }
617
1022
  else if (hasApp && hasSrc) {
618
1023
  architecture.type = 'fullstack';
619
1024
  }
@@ -700,6 +1105,12 @@ export async function inferArchitecture(rootDir) {
700
1105
  patterns.push('Feature-based organization');
701
1106
  if (relativeDirs.some(d => d.includes('modules')))
702
1107
  patterns.push('Module pattern');
1108
+ if (relativeDirs.some(d => d.includes('agents')))
1109
+ patterns.push('Agent-based architecture');
1110
+ if (relativeDirs.some(d => d.includes('missions') || d.includes('pipelines')))
1111
+ patterns.push('Pipeline pattern');
1112
+ if (relativeDirs.some(d => d.includes('models') && !d.includes('node_modules')))
1113
+ patterns.push('Data models');
703
1114
  architecture.patterns = patterns;
704
1115
  // Detect conventions
705
1116
  const conventions = [];
@@ -718,9 +1129,33 @@ export async function inferArchitecture(rootDir) {
718
1129
  if (await fileExists(join(rootDir, '.husky'))) {
719
1130
  conventions.push('Git hooks (Husky)');
720
1131
  }
1132
+ // Python conventions
1133
+ if (await fileExists(pyprojectArchPath)) {
1134
+ try {
1135
+ const pyContent = await readFile(pyprojectArchPath, 'utf-8');
1136
+ const pyproject = parsePyprojectToml(pyContent);
1137
+ if (pyproject?.tool?.ruff)
1138
+ conventions.push('Ruff code linting');
1139
+ if (pyproject?.tool?.black)
1140
+ conventions.push('Black code formatting');
1141
+ if (pyproject?.tool?.mypy)
1142
+ conventions.push('mypy type checking');
1143
+ if (pyproject?.tool?.isort)
1144
+ conventions.push('isort import sorting');
1145
+ if (pyproject?.tool?.pytest)
1146
+ conventions.push('pytest configuration');
1147
+ }
1148
+ catch { }
1149
+ }
1150
+ if (await fileExists(join(rootDir, '.flake8'))) {
1151
+ conventions.push('flake8 code linting');
1152
+ }
1153
+ if (await fileExists(join(rootDir, 'mypy.ini')) || await fileExists(join(rootDir, '.mypy.ini'))) {
1154
+ conventions.push('mypy type checking');
1155
+ }
721
1156
  architecture.conventions = conventions;
722
- // Detect entry points
723
- const entryPoints = [];
1157
+ // Detect entry points (preserve any already detected, e.g. from pyproject.toml)
1158
+ const entryPoints = architecture.entryPoints || [];
724
1159
  if (await fileExists(join(rootDir, 'src/index.ts')))
725
1160
  entryPoints.push({ file: 'src/index.ts', purpose: 'Main entry point' });
726
1161
  else if (await fileExists(join(rootDir, 'src/index.tsx')))
@@ -847,6 +1282,73 @@ export async function inferConstraints(rootDir) {
847
1282
  formatter: 'Prettier'
848
1283
  };
849
1284
  }
1285
+ // Python code style tools
1286
+ const pyprojectConstraintsPath = join(rootDir, 'pyproject.toml');
1287
+ if (await fileExists(pyprojectConstraintsPath)) {
1288
+ try {
1289
+ const pyContent = await readFile(pyprojectConstraintsPath, 'utf-8');
1290
+ const pyproject = parsePyprojectToml(pyContent);
1291
+ // Linter
1292
+ if (pyproject?.tool?.ruff) {
1293
+ constraints.codeStyle = {
1294
+ ...constraints.codeStyle,
1295
+ linter: 'Ruff'
1296
+ };
1297
+ const targetVersion = pyproject.tool.ruff['target-version'];
1298
+ if (targetVersion && typeof targetVersion === 'string') {
1299
+ // py311 → 3.11, py39 → 3.9
1300
+ const nums = targetVersion.replace('py', '');
1301
+ const major = nums[0];
1302
+ const minor = nums.slice(1);
1303
+ if (major && minor) {
1304
+ constraints.mustUse?.push(`Python ${major}.${minor}+`);
1305
+ }
1306
+ }
1307
+ const lineLength = pyproject.tool.ruff['line-length'];
1308
+ if (lineLength) {
1309
+ constraints.preferences?.push({
1310
+ category: 'Code Style',
1311
+ preference: `Line length: ${lineLength}`,
1312
+ rationale: 'Ruff configuration'
1313
+ });
1314
+ }
1315
+ }
1316
+ if (pyproject?.tool?.black) {
1317
+ constraints.codeStyle = {
1318
+ ...constraints.codeStyle,
1319
+ formatter: 'Black'
1320
+ };
1321
+ }
1322
+ if (pyproject?.tool?.mypy) {
1323
+ constraints.mustUse?.push('mypy for type checking');
1324
+ }
1325
+ // Python version constraint (only if not already added from Ruff target-version)
1326
+ const requiresPython = pyproject?.project?.['requires-python'];
1327
+ if (requiresPython && !constraints.mustUse?.some(m => m.startsWith('Python '))) {
1328
+ constraints.mustUse?.push(`Python ${requiresPython}`);
1329
+ }
1330
+ // Testing from pyproject
1331
+ const deps = pyproject?.project?.dependencies || [];
1332
+ const optDeps = pyproject?.project?.['optional-dependencies'] || {};
1333
+ const allDepNames = new Set([
1334
+ ...deps.map(extractPyDepName),
1335
+ ...Object.values(optDeps).flat().filter((d) => typeof d === 'string').map(extractPyDepName)
1336
+ ]);
1337
+ if (allDepNames.has('pytest') || pyproject?.tool?.pytest) {
1338
+ constraints.testing = {
1339
+ required: true,
1340
+ strategy: 'pytest'
1341
+ };
1342
+ }
1343
+ }
1344
+ catch { }
1345
+ }
1346
+ if (await fileExists(join(rootDir, '.flake8'))) {
1347
+ constraints.codeStyle = {
1348
+ ...constraints.codeStyle,
1349
+ linter: constraints.codeStyle?.linter || 'flake8'
1350
+ };
1351
+ }
850
1352
  // Check for TypeScript
851
1353
  if (await fileExists(join(rootDir, 'tsconfig.json'))) {
852
1354
  constraints.mustUse?.push('TypeScript for type safety');