@kernlang/review-python 0.2.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.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Python Concept Mapper — tree-sitter based.
3
+ *
4
+ * Maps Python syntax → universal KERN concepts.
5
+ * Phase 1: error_raise, error_handle, effect
6
+ */
7
+ import type { ConceptMap } from '@kernlang/core';
8
+ export declare function extractPythonConcepts(source: string, filePath: string): ConceptMap;
package/dist/mapper.js ADDED
@@ -0,0 +1,522 @@
1
+ /**
2
+ * Python Concept Mapper — tree-sitter based.
3
+ *
4
+ * Maps Python syntax → universal KERN concepts.
5
+ * Phase 1: error_raise, error_handle, effect
6
+ */
7
+ import Parser from 'tree-sitter';
8
+ import Python from 'tree-sitter-python';
9
+ import { conceptId, conceptSpan } from '@kernlang/core';
10
+ const EXTRACTOR_VERSION = '1.0.0';
11
+ // ── Network call patterns ────────────────────────────────────────────────
12
+ const NETWORK_MODULES = new Set(['requests', 'httpx', 'aiohttp', 'urllib']);
13
+ const NETWORK_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'request', 'fetch']);
14
+ const DB_MODULES = new Set(['psycopg2', 'asyncpg', 'pymongo', 'sqlalchemy', 'django']);
15
+ const DB_METHODS = new Set(['execute', 'executemany', 'fetchone', 'fetchall', 'fetchmany', 'query', 'find', 'find_one', 'insert_one', 'insert_many', 'update_one', 'delete_one']);
16
+ const FS_FUNCTIONS = new Set(['open', 'read', 'write', 'readlines', 'writelines']);
17
+ const STDLIB_MODULES = new Set([
18
+ 'os', 'sys', 'json', 're', 'math', 'datetime', 'time', 'logging', 'argparse',
19
+ 'collections', 'itertools', 'functools', 'pathlib', 'shutil', 'subprocess',
20
+ 'threading', 'multiprocessing', 'abc', 'typing', 'io', 'pickle', 'random',
21
+ 'hashlib', 'hmac', 'base64', 'csv', 'sqlite3', 'zlib', 'gzip', 'tarfile', 'zipfile',
22
+ 'enum', 'struct', 'tempfile', 'unittest', 'urllib', 'uuid', 'xml',
23
+ ]);
24
+ // ── Parser setup ─────────────────────────────────────────────────────────
25
+ let parser = null;
26
+ function getParser() {
27
+ if (!parser) {
28
+ parser = new Parser();
29
+ parser.setLanguage(Python);
30
+ }
31
+ return parser;
32
+ }
33
+ // ── Main Extractor ───────────────────────────────────────────────────────
34
+ export function extractPythonConcepts(source, filePath) {
35
+ const tree = getParser().parse(source);
36
+ const nodes = [];
37
+ const edges = [];
38
+ extractErrorRaise(tree.rootNode, source, filePath, nodes);
39
+ extractErrorHandle(tree.rootNode, source, filePath, nodes);
40
+ extractEffects(tree.rootNode, source, filePath, nodes);
41
+ extractEntrypoints(tree.rootNode, source, filePath, nodes);
42
+ extractGuards(tree.rootNode, source, filePath, nodes);
43
+ extractStateMutation(tree.rootNode, source, filePath, nodes);
44
+ extractDependencyEdges(tree.rootNode, source, filePath, edges);
45
+ return {
46
+ filePath,
47
+ language: 'py',
48
+ nodes,
49
+ edges,
50
+ extractorVersion: EXTRACTOR_VERSION,
51
+ };
52
+ }
53
+ // ── error_raise ──────────────────────────────────────────────────────────
54
+ function extractErrorRaise(root, source, filePath, nodes) {
55
+ // raise statements
56
+ walkNodes(root, 'raise_statement', (node) => {
57
+ const errorType = extractRaiseType(node);
58
+ nodes.push({
59
+ id: conceptId(filePath, 'error_raise', node.startIndex),
60
+ kind: 'error_raise',
61
+ primarySpan: nodeSpan(filePath, node),
62
+ evidence: nodeText(source, node, 100),
63
+ confidence: 1.0,
64
+ language: 'py',
65
+ containerId: getContainerId(node, filePath),
66
+ payload: {
67
+ kind: 'error_raise',
68
+ subtype: 'throw', // Python raise ≡ throw
69
+ errorType,
70
+ },
71
+ });
72
+ });
73
+ }
74
+ // ── error_handle ─────────────────────────────────────────────────────────
75
+ function extractErrorHandle(root, source, filePath, nodes) {
76
+ // except clauses
77
+ walkNodes(root, 'except_clause', (node) => {
78
+ const block = node.children.find(c => c.type === 'block');
79
+ const disposition = classifyPythonDisposition(block, source);
80
+ const errorVar = extractExceptVar(node);
81
+ nodes.push({
82
+ id: conceptId(filePath, 'error_handle', node.startIndex),
83
+ kind: 'error_handle',
84
+ primarySpan: nodeSpan(filePath, node),
85
+ evidence: nodeText(source, node, 150),
86
+ confidence: disposition.confidence,
87
+ language: 'py',
88
+ containerId: getContainerId(node, filePath),
89
+ payload: {
90
+ kind: 'error_handle',
91
+ disposition: disposition.type,
92
+ errorVariable: errorVar,
93
+ },
94
+ });
95
+ });
96
+ }
97
+ function classifyPythonDisposition(block, source) {
98
+ if (!block)
99
+ return { type: 'ignored', confidence: 1.0 };
100
+ const children = block.namedChildren;
101
+ // except: pass → ignored
102
+ if (children.length === 1 && children[0].type === 'pass_statement') {
103
+ return { type: 'ignored', confidence: 1.0 };
104
+ }
105
+ // except: ... (ellipsis) → ignored
106
+ if (children.length === 1 && children[0].type === 'expression_statement') {
107
+ const text = source.substring(children[0].startIndex, children[0].endIndex).trim();
108
+ if (text === '...')
109
+ return { type: 'ignored', confidence: 1.0 };
110
+ }
111
+ // Empty block
112
+ if (children.length === 0) {
113
+ return { type: 'ignored', confidence: 1.0 };
114
+ }
115
+ const bodyText = source.substring(block.startIndex, block.endIndex);
116
+ // raise → rethrown or wrapped
117
+ if (bodyText.includes('raise')) {
118
+ // bare `raise` → rethrown
119
+ if (/\braise\s*$|\braise\s*\n/m.test(bodyText)) {
120
+ return { type: 'rethrown', confidence: 0.95 };
121
+ }
122
+ return { type: 'wrapped', confidence: 0.9 };
123
+ }
124
+ // return → returned
125
+ if (bodyText.includes('return')) {
126
+ return { type: 'returned', confidence: 0.85 };
127
+ }
128
+ // logging
129
+ if (/\b(logging|logger|log|print)\b/.test(bodyText)) {
130
+ if (children.length === 1)
131
+ return { type: 'logged', confidence: 0.9 };
132
+ return { type: 'logged', confidence: 0.7 };
133
+ }
134
+ return { type: 'wrapped', confidence: 0.5 };
135
+ }
136
+ // ── effect ───────────────────────────────────────────────────────────────
137
+ function extractEffects(root, source, filePath, nodes) {
138
+ walkNodes(root, 'call', (node) => {
139
+ const funcNode = node.childForFieldName('function');
140
+ if (!funcNode)
141
+ return;
142
+ const funcText = source.substring(funcNode.startIndex, funcNode.endIndex);
143
+ // Network: requests.get(), httpx.post(), etc.
144
+ if (funcNode.type === 'attribute') {
145
+ const obj = funcNode.childForFieldName('object');
146
+ const attr = funcNode.childForFieldName('attribute');
147
+ if (obj && attr) {
148
+ const objName = source.substring(obj.startIndex, obj.endIndex);
149
+ const methodName = source.substring(attr.startIndex, attr.endIndex);
150
+ if (NETWORK_MODULES.has(objName) && NETWORK_METHODS.has(methodName)) {
151
+ nodes.push({
152
+ id: conceptId(filePath, 'effect', node.startIndex),
153
+ kind: 'effect',
154
+ primarySpan: nodeSpan(filePath, node),
155
+ evidence: nodeText(source, node, 120),
156
+ confidence: 0.95,
157
+ language: 'py',
158
+ containerId: getContainerId(node, filePath),
159
+ payload: { kind: 'effect', subtype: 'network', async: isInAsyncDef(node) },
160
+ });
161
+ return;
162
+ }
163
+ // DB: cursor.execute(), db.query(), etc.
164
+ if (DB_METHODS.has(methodName) && (DB_MODULES.has(objName) || /cursor|conn|db|session|collection/i.test(objName))) {
165
+ nodes.push({
166
+ id: conceptId(filePath, 'effect', node.startIndex),
167
+ kind: 'effect',
168
+ primarySpan: nodeSpan(filePath, node),
169
+ evidence: nodeText(source, node, 120),
170
+ confidence: 0.85,
171
+ language: 'py',
172
+ containerId: getContainerId(node, filePath),
173
+ payload: { kind: 'effect', subtype: 'db', async: isInAsyncDef(node) },
174
+ });
175
+ return;
176
+ }
177
+ }
178
+ }
179
+ // FS: open()
180
+ if (funcText === 'open') {
181
+ nodes.push({
182
+ id: conceptId(filePath, 'effect', node.startIndex),
183
+ kind: 'effect',
184
+ primarySpan: nodeSpan(filePath, node),
185
+ evidence: nodeText(source, node, 120),
186
+ confidence: 0.9,
187
+ language: 'py',
188
+ containerId: getContainerId(node, filePath),
189
+ payload: { kind: 'effect', subtype: 'fs', async: false },
190
+ });
191
+ }
192
+ // fetch() in async context (aiohttp pattern)
193
+ if (funcText === 'fetch' || funcText === 'aiohttp.request') {
194
+ nodes.push({
195
+ id: conceptId(filePath, 'effect', node.startIndex),
196
+ kind: 'effect',
197
+ primarySpan: nodeSpan(filePath, node),
198
+ evidence: nodeText(source, node, 120),
199
+ confidence: 0.8,
200
+ language: 'py',
201
+ containerId: getContainerId(node, filePath),
202
+ payload: { kind: 'effect', subtype: 'network', async: true },
203
+ });
204
+ }
205
+ });
206
+ }
207
+ // ── entrypoint ──────────────────────────────────────────────────────────
208
+ function extractEntrypoints(root, source, filePath, nodes) {
209
+ // 1. Route decorators: @app.route, @app.get, @router.post, etc.
210
+ // tree-sitter Python wraps decorated functions in 'decorated_definition'
211
+ walkNodes(root, 'decorated_definition', (node) => {
212
+ const fnDef = node.children.find(c => c.type === 'function_definition');
213
+ if (!fnDef)
214
+ return;
215
+ for (const child of node.children) {
216
+ if (child.type !== 'decorator')
217
+ continue;
218
+ const decText = source.substring(child.startIndex, child.endIndex);
219
+ const routeMatch = decText.match(/@(app|router|bp)\.(route|get|post|put|delete|patch)\s*\(/);
220
+ if (routeMatch) {
221
+ const method = routeMatch[2].toUpperCase();
222
+ const nameNode = fnDef.childForFieldName('name');
223
+ // Try to extract path from decorator args
224
+ const pathMatch = decText.match(/['"]([^'"]+)['"]/);
225
+ nodes.push({
226
+ id: conceptId(filePath, 'entrypoint', child.startIndex),
227
+ kind: 'entrypoint',
228
+ primarySpan: nodeSpan(filePath, child),
229
+ evidence: nodeText(source, child, 100),
230
+ confidence: 1.0,
231
+ language: 'py',
232
+ containerId: getContainerId(node, filePath),
233
+ payload: {
234
+ kind: 'entrypoint',
235
+ subtype: 'route',
236
+ name: nameNode ? nameNode.text : (pathMatch?.[1] || 'anonymous'),
237
+ httpMethod: method === 'ROUTE' ? undefined : method,
238
+ },
239
+ });
240
+ }
241
+ }
242
+ });
243
+ // 2. if __name__ == '__main__':
244
+ walkNodes(root, 'if_statement', (node) => {
245
+ const condition = node.childForFieldName('condition');
246
+ if (condition && condition.text.includes('__name__') && condition.text.includes('__main__')) {
247
+ nodes.push({
248
+ id: conceptId(filePath, 'entrypoint', node.startIndex),
249
+ kind: 'entrypoint',
250
+ primarySpan: nodeSpan(filePath, node),
251
+ evidence: nodeText(source, node, 100),
252
+ confidence: 1.0,
253
+ language: 'py',
254
+ payload: {
255
+ kind: 'entrypoint',
256
+ subtype: 'main',
257
+ name: 'main',
258
+ },
259
+ });
260
+ }
261
+ });
262
+ }
263
+ // ── guard ───────────────────────────────────────────────────────────────
264
+ function extractGuards(root, source, filePath, nodes) {
265
+ // 1. Auth decorators (tree-sitter: decorated_definition → decorator + function_definition)
266
+ walkNodes(root, 'decorated_definition', (node) => {
267
+ for (const child of node.children) {
268
+ if (child.type !== 'decorator')
269
+ continue;
270
+ const decText = source.substring(child.startIndex, child.endIndex);
271
+ if (/@(login_required|requires_auth|permission_required|auth_required|authenticated)/.test(decText)) {
272
+ nodes.push({
273
+ id: conceptId(filePath, 'guard', child.startIndex),
274
+ kind: 'guard',
275
+ primarySpan: nodeSpan(filePath, child),
276
+ evidence: nodeText(source, child, 100),
277
+ confidence: 1.0,
278
+ language: 'py',
279
+ containerId: getContainerId(node, filePath),
280
+ payload: {
281
+ kind: 'guard',
282
+ subtype: 'auth',
283
+ name: decText.replace('@', '').split('(')[0].trim(),
284
+ },
285
+ });
286
+ }
287
+ }
288
+ });
289
+ // 2. Pydantic validation: BaseModel.model_validate()
290
+ walkNodes(root, 'call', (node) => {
291
+ const func = node.childForFieldName('function');
292
+ if (func && func.text.includes('model_validate')) {
293
+ nodes.push({
294
+ id: conceptId(filePath, 'guard', node.startIndex),
295
+ kind: 'guard',
296
+ primarySpan: nodeSpan(filePath, node),
297
+ evidence: nodeText(source, node, 100),
298
+ confidence: 0.9,
299
+ language: 'py',
300
+ containerId: getContainerId(node, filePath),
301
+ payload: { kind: 'guard', subtype: 'validation', name: 'pydantic' },
302
+ });
303
+ }
304
+ });
305
+ // 3. Early return/raise after auth check: if not request.user: raise/return
306
+ walkNodes(root, 'if_statement', (node) => {
307
+ const cond = node.childForFieldName('condition');
308
+ if (cond && /\b(user|auth|request\.user)\b/.test(cond.text)) {
309
+ const block = node.namedChildren.find(c => c.type === 'block');
310
+ if (block) {
311
+ const firstStmt = block.namedChildren[0];
312
+ if (firstStmt && (firstStmt.type === 'return_statement' || firstStmt.type === 'raise_statement')) {
313
+ nodes.push({
314
+ id: conceptId(filePath, 'guard', node.startIndex),
315
+ kind: 'guard',
316
+ primarySpan: nodeSpan(filePath, node),
317
+ evidence: nodeText(source, node, 100),
318
+ confidence: 0.8,
319
+ language: 'py',
320
+ containerId: getContainerId(node, filePath),
321
+ payload: { kind: 'guard', subtype: 'auth' },
322
+ });
323
+ }
324
+ }
325
+ }
326
+ });
327
+ }
328
+ // ── state_mutation ───────────────────────────────────────────────────────
329
+ function extractStateMutation(root, source, filePath, nodes) {
330
+ // Track global keyword usage
331
+ const globalVarsInFile = new Set();
332
+ walkNodes(root, 'global_statement', (node) => {
333
+ for (const child of node.namedChildren) {
334
+ if (child.type === 'identifier')
335
+ globalVarsInFile.add(child.text);
336
+ }
337
+ });
338
+ walkNodes(root, 'assignment', (node) => {
339
+ const left = node.childForFieldName('left');
340
+ if (!left)
341
+ return;
342
+ // self.x = ... → scope 'module' (as requested)
343
+ if (left.type === 'attribute') {
344
+ const obj = left.childForFieldName('object');
345
+ if (obj && obj.text === 'self') {
346
+ nodes.push({
347
+ id: conceptId(filePath, 'state_mutation', node.startIndex),
348
+ kind: 'state_mutation',
349
+ primarySpan: nodeSpan(filePath, node),
350
+ evidence: nodeText(source, node, 100),
351
+ confidence: 0.9,
352
+ language: 'py',
353
+ containerId: getContainerId(node, filePath),
354
+ payload: { kind: 'state_mutation', target: left.text, scope: 'module' },
355
+ });
356
+ return;
357
+ }
358
+ }
359
+ // Global or Module level assignment
360
+ if (left.type === 'identifier') {
361
+ const name = left.text;
362
+ const containerId = getContainerId(node, filePath);
363
+ if (globalVarsInFile.has(name)) {
364
+ nodes.push({
365
+ id: conceptId(filePath, 'state_mutation', node.startIndex),
366
+ kind: 'state_mutation',
367
+ primarySpan: nodeSpan(filePath, node),
368
+ evidence: nodeText(source, node, 100),
369
+ confidence: 1.0,
370
+ language: 'py',
371
+ containerId,
372
+ payload: { kind: 'state_mutation', target: name, scope: 'global' },
373
+ });
374
+ }
375
+ else if (!containerId) {
376
+ // Module level (top level)
377
+ nodes.push({
378
+ id: conceptId(filePath, 'state_mutation', node.startIndex),
379
+ kind: 'state_mutation',
380
+ primarySpan: nodeSpan(filePath, node),
381
+ evidence: nodeText(source, node, 100),
382
+ confidence: 0.8,
383
+ language: 'py',
384
+ payload: { kind: 'state_mutation', target: name, scope: 'module' },
385
+ });
386
+ }
387
+ }
388
+ });
389
+ }
390
+ // ── dependency ──────────────────────────────────────────────────────────
391
+ function extractDependencyEdges(root, source, filePath, edges) {
392
+ const addDependency = (node, specifier) => {
393
+ let subtype = 'external';
394
+ if (specifier.startsWith('.')) {
395
+ subtype = 'internal';
396
+ }
397
+ else {
398
+ const rootModule = specifier.split('.')[0];
399
+ if (STDLIB_MODULES.has(rootModule)) {
400
+ subtype = 'stdlib';
401
+ }
402
+ }
403
+ edges.push({
404
+ id: `${filePath}#dep@${node.startIndex}`,
405
+ kind: 'dependency',
406
+ sourceId: filePath,
407
+ targetId: specifier,
408
+ primarySpan: nodeSpan(filePath, node),
409
+ evidence: nodeText(source, node, 100),
410
+ confidence: 1.0,
411
+ language: 'py',
412
+ payload: { kind: 'dependency', subtype, specifier },
413
+ });
414
+ };
415
+ walkNodes(root, 'import_statement', (node) => {
416
+ // import x, y as z
417
+ for (const child of node.namedChildren) {
418
+ if (child.type === 'dotted_name') {
419
+ addDependency(node, child.text);
420
+ }
421
+ else if (child.type === 'aliased_import') {
422
+ const name = child.childForFieldName('name');
423
+ if (name)
424
+ addDependency(node, name.text);
425
+ }
426
+ }
427
+ });
428
+ walkNodes(root, 'import_from_statement', (node) => {
429
+ // from x import y
430
+ const moduleNode = node.childForFieldName('module_name');
431
+ const relativeMatch = node.text.match(/^from\s+(\.+)/);
432
+ let specifier = moduleNode ? moduleNode.text : '';
433
+ if (relativeMatch) {
434
+ specifier = relativeMatch[1] + specifier;
435
+ }
436
+ if (specifier) {
437
+ addDependency(node, specifier);
438
+ }
439
+ });
440
+ }
441
+ // ── Tree-sitter Helpers ──────────────────────────────────────────────────
442
+ function walkNodes(root, type, callback) {
443
+ const cursor = root.walk();
444
+ let reachedRoot = false;
445
+ while (true) {
446
+ if (cursor.nodeType === type) {
447
+ callback(cursor.currentNode);
448
+ }
449
+ if (cursor.gotoFirstChild())
450
+ continue;
451
+ if (cursor.gotoNextSibling())
452
+ continue;
453
+ while (true) {
454
+ if (!cursor.gotoParent()) {
455
+ reachedRoot = true;
456
+ break;
457
+ }
458
+ if (cursor.gotoNextSibling())
459
+ break;
460
+ }
461
+ if (reachedRoot)
462
+ break;
463
+ }
464
+ }
465
+ function nodeSpan(filePath, node) {
466
+ return conceptSpan(filePath, node.startPosition.row + 1, node.startPosition.column + 1, node.endPosition.row + 1, node.endPosition.column + 1);
467
+ }
468
+ function nodeText(source, node, maxLen) {
469
+ return source.substring(node.startIndex, Math.min(node.endIndex, node.startIndex + maxLen));
470
+ }
471
+ function getContainerId(node, filePath) {
472
+ let parent = node.parent;
473
+ while (parent) {
474
+ if (parent.type === 'function_definition' || parent.type === 'class_definition') {
475
+ const nameNode = parent.childForFieldName('name');
476
+ const name = nameNode ? nameNode.text : 'anonymous';
477
+ return `${filePath}#fn:${name}@${parent.startIndex}`;
478
+ }
479
+ parent = parent.parent;
480
+ }
481
+ return undefined;
482
+ }
483
+ function extractRaiseType(node) {
484
+ // raise ValueError("...") → "ValueError"
485
+ const callNode = node.namedChildren.find(c => c.type === 'call');
486
+ if (callNode) {
487
+ const func = callNode.childForFieldName('function');
488
+ if (func)
489
+ return func.text;
490
+ }
491
+ // raise ValueError → just identifier
492
+ const ident = node.namedChildren.find(c => c.type === 'identifier');
493
+ if (ident)
494
+ return ident.text;
495
+ return undefined;
496
+ }
497
+ function extractExceptVar(node) {
498
+ // except Exception as e → "e"
499
+ for (const child of node.children) {
500
+ if (child.type === 'as_pattern') {
501
+ const alias = child.childForFieldName('alias');
502
+ if (alias)
503
+ return alias.text;
504
+ }
505
+ // Also try direct identifier after 'as'
506
+ if (child.type === 'identifier' && child.previousSibling?.text === 'as') {
507
+ return child.text;
508
+ }
509
+ }
510
+ return undefined;
511
+ }
512
+ function isInAsyncDef(node) {
513
+ let parent = node.parent;
514
+ while (parent) {
515
+ if (parent.type === 'function_definition') {
516
+ // Check for 'async' keyword before 'def'
517
+ return parent.children.some(c => c.type === 'async');
518
+ }
519
+ parent = parent.parent;
520
+ }
521
+ return false;
522
+ }
package/jest.config.js ADDED
@@ -0,0 +1,10 @@
1
+ export default {
2
+ testMatch: ['**/tests/**/*.test.ts'],
3
+ extensionsToTreatAsEsm: ['.ts'],
4
+ transform: {
5
+ '^.+\\.ts$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.json' }],
6
+ },
7
+ moduleNameMapper: {
8
+ '^(\\.{1,2}/.*)\\.js$': '$1',
9
+ },
10
+ };
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@kernlang/review-python",
3
+ "version": "0.2.0",
4
+ "description": "Python concept mapper for kern review — tree-sitter based",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "dependencies": {
9
+ "tree-sitter": "^0.22.4",
10
+ "tree-sitter-python": "^0.23.6",
11
+ "@kernlang/core": "3.0.0",
12
+ "@kernlang/review": "3.0.0"
13
+ },
14
+ "devDependencies": {
15
+ "ts-morph": "^24.0.0",
16
+ "typescript": "^5.7.0"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ }
23
+ },
24
+ "scripts": {
25
+ "build": "tsc -b",
26
+ "test": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --forceExit --config jest.config.js"
27
+ }
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @kernlang/review-python — Python concept mapper using tree-sitter.
3
+ *
4
+ * Translates Python syntax into universal KERN concepts.
5
+ * Same ConceptMap output as the TypeScript mapper.
6
+ */
7
+
8
+ export { extractPythonConcepts } from './mapper.js';