pi-python-helper 0.1.0 → 0.1.1

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 CHANGED
@@ -7,6 +7,15 @@ does not guarantee a stable public tool schema.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.1] - 2026-09-21
11
+
12
+ ### Changed
13
+
14
+ - Refactored `src/dependencies/plan.ts` by extracting static import aliases and console-only distribution tables into `src/dependencies/aliases.ts`.
15
+ - Refactored `src/build/failure.ts` by extracting Python and pytest traceback frame parsing and library frame detection into `src/build/traceback.ts`.
16
+ - Decomposed monolithic `inspectProject` in `src/project/inspect.ts` into focused diagnostic collectors for manifest, lockfile, and environment rules.
17
+ - Moved `py_tdd_checkpoint` and `py_completion_evidence` tools from `extensions/tools/dependencies.ts` to `extensions/tools/validation.ts` to align tool registration with module responsibilities.
18
+
10
19
  ## [0.1.0] - 2026-09-21
11
20
 
12
21
  ### Added
@@ -1,9 +1,6 @@
1
1
  import { Type } from 'typebox';
2
2
  import { failure, result } from '../../src/core/result.ts';
3
- import { runCommand } from '../../src/core/runner.ts';
4
3
  import { planDependencies } from '../../src/dependencies/plan.ts';
5
- import { buildCompletionEvidence } from '../../src/validation/evidence.ts';
6
- import { checkTdd } from '../../src/validation/tdd.ts';
7
4
  import { messageOf, resolveProjectRoot, text, type Pi } from '../shared.ts';
8
5
  import { runScanProject } from '../../src/project/scanner.ts';
9
6
 
@@ -83,121 +80,4 @@ export function registerDependencyTools(pi: Pi): void {
83
80
  }
84
81
  },
85
82
  });
86
-
87
- pi.registerTool({
88
- name: 'py_tdd_checkpoint',
89
- label: 'Python TDD Checkpoint',
90
- description:
91
- 'Check whether production Python changes have related test changes before implementation is considered complete. Read-only.',
92
- promptSnippet: 'Check the Python TDD checkpoint for changed files',
93
- promptGuidelines: [
94
- 'Use py_tdd_checkpoint before reporting Python implementation work as complete.',
95
- ],
96
- parameters: Type.Object({
97
- changedPaths: Type.Optional(Type.Array(Type.String(), { maxItems: 500 })),
98
- testChangedPaths: Type.Optional(Type.Array(Type.String(), { maxItems: 500 })),
99
- }),
100
- async execute(_id, params, signal, _update, ctx) {
101
- const started = Date.now();
102
- let changedPaths = params.changedPaths ?? [];
103
- let source = 'argument';
104
- if (!changedPaths.length) {
105
- const diff = await runCommand('git', ['diff', '--name-only', 'HEAD'], {
106
- cwd: ctx.cwd,
107
- signal,
108
- timeoutMs: 10000,
109
- maxBytes: 100_000,
110
- });
111
- changedPaths = diff.stdout
112
- .split(/\r?\n/)
113
- .map((line) => line.trim())
114
- .filter(Boolean);
115
- source = 'git diff';
116
- }
117
- const checkpoint = checkTdd(changedPaths, params.testChangedPaths ?? changedPaths);
118
- return text(
119
- result(ctx.cwd, started, {
120
- ok: checkpoint.ok,
121
- summary: checkpoint.ok
122
- ? `TDD checkpoint passed across ${changedPaths.length} changed path(s) from ${source}.`
123
- : 'TDD checkpoint found production changes without a related test change.',
124
- data: { ...checkpoint, changedPaths, source },
125
- evidence: checkpoint.reasons.map((message) => ({ kind: 'tdd_blocker', message })),
126
- warnings: checkpoint.reasons.map((message) => ({
127
- code: 'TDD_CHECKPOINT',
128
- message,
129
- severity: 'warning' as const,
130
- })),
131
- errors: [],
132
- suggestions: checkpoint.ok
133
- ? []
134
- : [
135
- {
136
- message:
137
- 'Add the smallest focused test for the changed behaviour, or explain why the change needs no test.',
138
- confidence: 'high' as const,
139
- },
140
- ],
141
- }),
142
- );
143
- },
144
- });
145
-
146
- pi.registerTool({
147
- name: 'py_completion_evidence',
148
- label: 'Python Completion Evidence',
149
- description:
150
- 'Build a conservative completion report from environment sync and test execution results. Read-only.',
151
- promptSnippet: 'Create evidence for a Python completion report',
152
- promptGuidelines: [
153
- 'Use py_completion_evidence before claiming Python work is complete; a partial run is not evidence.',
154
- ],
155
- parameters: Type.Object({
156
- syncExecuted: Type.Boolean({
157
- description: 'Whether uv lock --check / uv sync actually ran.',
158
- }),
159
- syncOk: Type.Boolean(),
160
- testExecuted: Type.Boolean({ description: 'Whether pytest actually ran.' }),
161
- testOk: Type.Boolean(),
162
- stale: Type.Boolean({ description: 'Whether stale artifacts were detected.' }),
163
- changedPaths: Type.Array(Type.String(), { maxItems: 500 }),
164
- }),
165
- async execute(_id, params, _signal, _update, ctx) {
166
- const started = Date.now();
167
- const evidence = buildCompletionEvidence(params);
168
- return text(
169
- result(ctx.cwd, started, {
170
- ok: evidence.ok,
171
- summary: evidence.ok
172
- ? 'Completion evidence is sufficient for the supplied checks.'
173
- : 'Completion evidence is incomplete or contains failing checks.',
174
- data: evidence,
175
- evidence: evidence.blockers.map((message) => ({ kind: 'completion_blocker', message })),
176
- warnings: evidence.blockers.map((message) => ({
177
- code: 'INCOMPLETE_EVIDENCE',
178
- message,
179
- severity: 'warning' as const,
180
- })),
181
- errors: evidence.ok
182
- ? []
183
- : [
184
- {
185
- code: 'COMPLETION_NOT_PROVEN',
186
- message: 'The supplied evidence does not prove completion.',
187
- severity: 'error' as const,
188
- },
189
- ],
190
- suggestions: evidence.ok
191
- ? []
192
- : [
193
- {
194
- message:
195
- 'Run py_validation_bundle and address every blocker before reporting completion.',
196
- confidence: 'high' as const,
197
- },
198
- ],
199
- }),
200
- );
201
- },
202
- });
203
83
  }
@@ -11,6 +11,8 @@ import {
11
11
  } from '../../src/project/conformance.ts';
12
12
  import { readInstalledDistributions } from '../../src/project/installed.ts';
13
13
  import { summarizeValidation, type ValidationStep } from '../../src/validation/bundle.ts';
14
+ import { buildCompletionEvidence } from '../../src/validation/evidence.ts';
15
+ import { checkTdd } from '../../src/validation/tdd.ts';
14
16
  import { join } from 'node:path';
15
17
  import { isFile } from '../../src/project/root.ts';
16
18
  import { runScanProject } from '../../src/project/scanner.ts';
@@ -341,4 +343,121 @@ export function registerValidationTools(pi: Pi): void {
341
343
  }
342
344
  },
343
345
  });
346
+
347
+ pi.registerTool({
348
+ name: 'py_tdd_checkpoint',
349
+ label: 'Python TDD Checkpoint',
350
+ description:
351
+ 'Check whether production Python changes have related test changes before implementation is considered complete. Read-only.',
352
+ promptSnippet: 'Check the Python TDD checkpoint for changed files',
353
+ promptGuidelines: [
354
+ 'Use py_tdd_checkpoint before reporting Python implementation work as complete.',
355
+ ],
356
+ parameters: Type.Object({
357
+ changedPaths: Type.Optional(Type.Array(Type.String(), { maxItems: 500 })),
358
+ testChangedPaths: Type.Optional(Type.Array(Type.String(), { maxItems: 500 })),
359
+ }),
360
+ async execute(_id, params, signal, _update, ctx) {
361
+ const started = Date.now();
362
+ let changedPaths = params.changedPaths ?? [];
363
+ let source = 'argument';
364
+ if (!changedPaths.length) {
365
+ const diff = await runCommand('git', ['diff', '--name-only', 'HEAD'], {
366
+ cwd: ctx.cwd,
367
+ signal,
368
+ timeoutMs: 10000,
369
+ maxBytes: 100_000,
370
+ });
371
+ changedPaths = diff.stdout
372
+ .split(/\r?\n/)
373
+ .map((line) => line.trim())
374
+ .filter(Boolean);
375
+ source = 'git diff';
376
+ }
377
+ const checkpoint = checkTdd(changedPaths, params.testChangedPaths ?? changedPaths);
378
+ return text(
379
+ result(ctx.cwd, started, {
380
+ ok: checkpoint.ok,
381
+ summary: checkpoint.ok
382
+ ? `TDD checkpoint passed across ${changedPaths.length} changed path(s) from ${source}.`
383
+ : 'TDD checkpoint found production changes without a related test change.',
384
+ data: { ...checkpoint, changedPaths, source },
385
+ evidence: checkpoint.reasons.map((message) => ({ kind: 'tdd_blocker', message })),
386
+ warnings: checkpoint.reasons.map((message) => ({
387
+ code: 'TDD_CHECKPOINT',
388
+ message,
389
+ severity: 'warning' as const,
390
+ })),
391
+ errors: [],
392
+ suggestions: checkpoint.ok
393
+ ? []
394
+ : [
395
+ {
396
+ message:
397
+ 'Add the smallest focused test for the changed behaviour, or explain why the change needs no test.',
398
+ confidence: 'high' as const,
399
+ },
400
+ ],
401
+ }),
402
+ );
403
+ },
404
+ });
405
+
406
+ pi.registerTool({
407
+ name: 'py_completion_evidence',
408
+ label: 'Python Completion Evidence',
409
+ description:
410
+ 'Build a conservative completion report from environment sync and test execution results. Read-only.',
411
+ promptSnippet: 'Create evidence for a Python completion report',
412
+ promptGuidelines: [
413
+ 'Use py_completion_evidence before claiming Python work is complete; a partial run is not evidence.',
414
+ ],
415
+ parameters: Type.Object({
416
+ syncExecuted: Type.Boolean({
417
+ description: 'Whether uv lock --check / uv sync actually ran.',
418
+ }),
419
+ syncOk: Type.Boolean(),
420
+ testExecuted: Type.Boolean({ description: 'Whether pytest actually ran.' }),
421
+ testOk: Type.Boolean(),
422
+ stale: Type.Boolean({ description: 'Whether stale artifacts were detected.' }),
423
+ changedPaths: Type.Array(Type.String(), { maxItems: 500 }),
424
+ }),
425
+ async execute(_id, params, _signal, _update, ctx) {
426
+ const started = Date.now();
427
+ const evidence = buildCompletionEvidence(params);
428
+ return text(
429
+ result(ctx.cwd, started, {
430
+ ok: evidence.ok,
431
+ summary: evidence.ok
432
+ ? 'Completion evidence is sufficient for the supplied checks.'
433
+ : 'Completion evidence is incomplete or contains failing checks.',
434
+ data: evidence,
435
+ evidence: evidence.blockers.map((message) => ({ kind: 'completion_blocker', message })),
436
+ warnings: evidence.blockers.map((message) => ({
437
+ code: 'INCOMPLETE_EVIDENCE',
438
+ message,
439
+ severity: 'warning' as const,
440
+ })),
441
+ errors: evidence.ok
442
+ ? []
443
+ : [
444
+ {
445
+ code: 'COMPLETION_NOT_PROVEN',
446
+ message: 'The supplied evidence does not prove completion.',
447
+ severity: 'error' as const,
448
+ },
449
+ ],
450
+ suggestions: evidence.ok
451
+ ? []
452
+ : [
453
+ {
454
+ message:
455
+ 'Run py_validation_bundle and address every blocker before reporting completion.',
456
+ confidence: 'high' as const,
457
+ },
458
+ ],
459
+ }),
460
+ );
461
+ },
462
+ });
344
463
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-python-helper",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Python (uv) development tools for the pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -15,13 +15,13 @@ export type FailureKind =
15
15
  | 'timeout'
16
16
  | 'unknown';
17
17
 
18
- export interface TracebackFrame {
19
- path: string;
20
- line: number;
21
- func: string;
22
- /** True for site-packages, the standard library, and pytest internals. */
23
- library: boolean;
24
- }
18
+ import {
19
+ extractTracebackFrames,
20
+ firstUserFrame,
21
+ isLibraryFrame,
22
+ type TracebackFrame,
23
+ } from './traceback.ts';
24
+ export { extractTracebackFrames, firstUserFrame, isLibraryFrame, type TracebackFrame };
25
25
 
26
26
  export interface FailureEvidence {
27
27
  file?: string;
@@ -42,54 +42,6 @@ export interface FailureDiagnosis {
42
42
  suggestions: Suggestion[];
43
43
  }
44
44
 
45
- const FRAME_RE = /^\s*File "([^"]+)", line (\d+), in (.+?)\s*$/;
46
- /**
47
- * pytest `--tb=short` replaces the `File "..."` form with `path:line: in func`,
48
- * so both notations must be recognised or short tracebacks yield no frame at all.
49
- */
50
- const PYTEST_FRAME_RE = /^\s*([^\s:]+\.py):(\d+): in (.+?)\s*$/;
51
-
52
- /**
53
- * A frame belongs to library code when it sits in an installed distribution or
54
- * in the interpreter's own library tree. Pointing the agent at those frames is
55
- * how it ends up editing site-packages instead of the project.
56
- */
57
- export function isLibraryFrame(path: string): boolean {
58
- const normalized = path.replace(/\\/g, '/');
59
- return (
60
- /\/site-packages\//.test(normalized) ||
61
- /\/dist-packages\//.test(normalized) ||
62
- /\/lib\/python3\.\d+\//.test(normalized) ||
63
- /\/python3\.\d+\//.test(normalized) ||
64
- /<frozen /.test(normalized) ||
65
- /\/_pytest\//.test(normalized) ||
66
- /\/pluggy\//.test(normalized)
67
- );
68
- }
69
-
70
- export function extractTracebackFrames(output: string): TracebackFrame[] {
71
- const frames: TracebackFrame[] = [];
72
- for (const line of output.split(/\r?\n/)) {
73
- const match = line.match(FRAME_RE) ?? line.match(PYTEST_FRAME_RE);
74
- if (!match) continue;
75
- const path = match[1];
76
- frames.push({
77
- path,
78
- line: Number.parseInt(match[2], 10),
79
- func: match[3].trim(),
80
- library: isLibraryFrame(path),
81
- });
82
- }
83
- return frames;
84
- }
85
-
86
- function firstUserFrame(frames: TracebackFrame[]): TracebackFrame | undefined {
87
- for (let index = frames.length - 1; index >= 0; index -= 1) {
88
- if (!frames[index].library) return frames[index];
89
- }
90
- return undefined;
91
- }
92
-
93
45
  function lastMatch(output: string, pattern: RegExp): RegExpMatchArray | undefined {
94
46
  const matches = [...output.matchAll(new RegExp(pattern.source, `${pattern.flags}g`))];
95
47
  return matches.at(-1);
@@ -0,0 +1,55 @@
1
+ export interface TracebackFrame {
2
+ path: string;
3
+ line: number;
4
+ func: string;
5
+ /** True for site-packages, the standard library, and pytest internals. */
6
+ library: boolean;
7
+ }
8
+
9
+ const FRAME_RE = /^\s*File "([^"]+)", line (\d+), in (.+?)\s*$/;
10
+ /**
11
+ * pytest `--tb=short` replaces the `File "..."` form with `path:line: in func`,
12
+ * so both notations must be recognised or short tracebacks yield no frame at all.
13
+ */
14
+ const PYTEST_FRAME_RE = /^\s*([^\s:]+\.py):(\d+): in (.+?)\s*$/;
15
+
16
+ /**
17
+ * A frame belongs to library code when it sits in an installed distribution or
18
+ * in the interpreter's own library tree. Pointing the agent at those frames is
19
+ * how it ends up editing site-packages instead of the project.
20
+ */
21
+ export function isLibraryFrame(path: string): boolean {
22
+ const normalized = path.replace(/\\/g, '/');
23
+ return (
24
+ /\/site-packages\//.test(normalized) ||
25
+ /\/dist-packages\//.test(normalized) ||
26
+ /\/lib\/python3\.\d+\//.test(normalized) ||
27
+ /\/python3\.\d+\//.test(normalized) ||
28
+ /<frozen /.test(normalized) ||
29
+ /\/_pytest\//.test(normalized) ||
30
+ /\/pluggy\//.test(normalized)
31
+ );
32
+ }
33
+
34
+ export function extractTracebackFrames(output: string): TracebackFrame[] {
35
+ const frames: TracebackFrame[] = [];
36
+ for (const line of output.split(/\r?\n/)) {
37
+ const match = line.match(FRAME_RE) ?? line.match(PYTEST_FRAME_RE);
38
+ if (!match) continue;
39
+ const path = match[1];
40
+ frames.push({
41
+ path,
42
+ line: Number.parseInt(match[2], 10),
43
+ func: match[3].trim(),
44
+ library: isLibraryFrame(path),
45
+ });
46
+ }
47
+ return frames;
48
+ }
49
+
50
+ export function firstUserFrame(frames: TracebackFrame[]): TracebackFrame | undefined {
51
+ for (let index = frames.length - 1; index >= 0; index -= 1) {
52
+ if (!frames[index].library) return frames[index];
53
+ }
54
+ return undefined;
55
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Import name and distribution name frequently disagree. The scanner supplies
3
+ * the authoritative mapping when the analysing interpreter has the package
4
+ * installed; this table covers the cases where it does not, so an uninstalled
5
+ * checkout is still analysed correctly.
6
+ */
7
+ export const IMPORT_ALIASES: Record<string, string[]> = {
8
+ PIL: ['pillow'],
9
+ yaml: ['pyyaml'],
10
+ dateutil: ['python-dateutil'],
11
+ bs4: ['beautifulsoup4'],
12
+ cv2: ['opencv-python', 'opencv-python-headless'],
13
+ sklearn: ['scikit-learn'],
14
+ skimage: ['scikit-image'],
15
+ dotenv: ['python-dotenv'],
16
+ attr: ['attrs'],
17
+ attrs: ['attrs'],
18
+ jwt: ['pyjwt'],
19
+ jose: ['python-jose'],
20
+ serial: ['pyserial'],
21
+ OpenSSL: ['pyopenssl'],
22
+ Crypto: ['pycryptodome'],
23
+ pkg_resources: ['setuptools'],
24
+ MySQLdb: ['mysqlclient'],
25
+ googleapiclient: ['google-api-python-client'],
26
+ github: ['PyGithub'],
27
+ pytest_cov: ['pytest-cov'],
28
+ _pytest: ['pytest'],
29
+ pytest_asyncio: ['pytest-asyncio'],
30
+ psycopg: ['psycopg', 'psycopg-binary'],
31
+ psycopg2: ['psycopg2', 'psycopg2-binary'],
32
+ prometheus_client: ['prometheus-client'],
33
+ grpc: ['grpcio'],
34
+ kafka: ['kafka-python'],
35
+ docker: ['docker'],
36
+ numpy: ['numpy'],
37
+ pandas: ['pandas'],
38
+ ruamel: ['ruamel.yaml'],
39
+ setuptools: ['setuptools'],
40
+ mako: ['Mako'],
41
+ pytz: ['pytz'],
42
+ tzlocal: ['tzlocal'],
43
+ win32com: ['pywin32'],
44
+ lxml: ['lxml'],
45
+ matplotlib: ['matplotlib'],
46
+ seaborn: ['seaborn'],
47
+ sqlalchemy: ['sqlalchemy', 'SQLAlchemy'],
48
+ pydantic: ['pydantic'],
49
+ fastapi: ['fastapi'],
50
+ starlette: ['starlette'],
51
+ uvicorn: ['uvicorn'],
52
+ celery: ['celery'],
53
+ redis: ['redis'],
54
+ boto3: ['boto3'],
55
+ botocore: ['botocore'],
56
+ httpx: ['httpx'],
57
+ aiohttp: ['aiohttp'],
58
+ werkzeug: ['werkzeug'],
59
+ flask: ['flask'],
60
+ django: ['django'],
61
+ jinja2: ['jinja2'],
62
+ typer: ['typer'],
63
+ click: ['click'],
64
+ rich: ['rich'],
65
+ tqdm: ['tqdm'],
66
+ };
67
+
68
+ /** Distributions that are normally invoked as a console script, not imported. */
69
+ export const CONSOLE_ONLY: Set<string> = new Set([
70
+ 'ruff',
71
+ 'mypy',
72
+ 'pyright',
73
+ 'ty',
74
+ 'pytest',
75
+ 'pytest-cov',
76
+ 'coverage',
77
+ 'pre-commit',
78
+ 'tox',
79
+ 'nox',
80
+ 'hatch',
81
+ 'hatchling',
82
+ 'build',
83
+ 'twine',
84
+ 'black',
85
+ 'isort',
86
+ 'flake8',
87
+ 'pylint',
88
+ 'sphinx',
89
+ 'mkdocs',
90
+ 'uvicorn',
91
+ 'gunicorn',
92
+ 'alembic',
93
+ 'celery',
94
+ 'honcho',
95
+ 'maturin',
96
+ 'setuptools-scm',
97
+ 'pip-audit',
98
+ 'bandit',
99
+ 'commitizen',
100
+ 'towncrier',
101
+ ]);
@@ -3,109 +3,8 @@ import { warn } from '../core/result.ts';
3
3
  import { isTestFile } from '../project/paths.ts';
4
4
  import type { DeclaredDependency, ScanPayload } from '../project/scanner.ts';
5
5
 
6
- /**
7
- * Import name and distribution name frequently disagree. The scanner supplies
8
- * the authoritative mapping when the analysing interpreter has the package
9
- * installed; this table covers the cases where it does not, so an uninstalled
10
- * checkout is still analysed correctly.
11
- */
12
- export const IMPORT_ALIASES: Record<string, string[]> = {
13
- PIL: ['pillow'],
14
- yaml: ['pyyaml'],
15
- dateutil: ['python-dateutil'],
16
- bs4: ['beautifulsoup4'],
17
- cv2: ['opencv-python', 'opencv-python-headless'],
18
- sklearn: ['scikit-learn'],
19
- skimage: ['scikit-image'],
20
- dotenv: ['python-dotenv'],
21
- attr: ['attrs'],
22
- attrs: ['attrs'],
23
- jwt: ['pyjwt'],
24
- jose: ['python-jose'],
25
- serial: ['pyserial'],
26
- OpenSSL: ['pyopenssl'],
27
- Crypto: ['pycryptodome'],
28
- pkg_resources: ['setuptools'],
29
- MySQLdb: ['mysqlclient'],
30
- googleapiclient: ['google-api-python-client'],
31
- github: ['PyGithub'],
32
- pytest_cov: ['pytest-cov'],
33
- _pytest: ['pytest'],
34
- pytest_asyncio: ['pytest-asyncio'],
35
- psycopg: ['psycopg', 'psycopg-binary'],
36
- psycopg2: ['psycopg2', 'psycopg2-binary'],
37
- prometheus_client: ['prometheus-client'],
38
- grpc: ['grpcio'],
39
- kafka: ['kafka-python'],
40
- docker: ['docker'],
41
- numpy: ['numpy'],
42
- pandas: ['pandas'],
43
- ruamel: ['ruamel.yaml'],
44
- setuptools: ['setuptools'],
45
- mako: ['Mako'],
46
- pytz: ['pytz'],
47
- tzlocal: ['tzlocal'],
48
- win32com: ['pywin32'],
49
- lxml: ['lxml'],
50
- matplotlib: ['matplotlib'],
51
- seaborn: ['seaborn'],
52
- sqlalchemy: ['sqlalchemy', 'SQLAlchemy'],
53
- pydantic: ['pydantic'],
54
- fastapi: ['fastapi'],
55
- starlette: ['starlette'],
56
- uvicorn: ['uvicorn'],
57
- celery: ['celery'],
58
- redis: ['redis'],
59
- boto3: ['boto3'],
60
- botocore: ['botocore'],
61
- httpx: ['httpx'],
62
- aiohttp: ['aiohttp'],
63
- werkzeug: ['werkzeug'],
64
- flask: ['flask'],
65
- django: ['django'],
66
- jinja2: ['jinja2'],
67
- typer: ['typer'],
68
- click: ['click'],
69
- rich: ['rich'],
70
- tqdm: ['tqdm'],
71
- };
72
-
73
- /** Distributions that are normally invoked as a console script, not imported. */
74
- const CONSOLE_ONLY = new Set(
75
- [
76
- 'ruff',
77
- 'mypy',
78
- 'pyright',
79
- 'ty',
80
- 'pytest',
81
- 'pytest-cov',
82
- 'coverage',
83
- 'pre-commit',
84
- 'tox',
85
- 'nox',
86
- 'hatch',
87
- 'hatchling',
88
- 'build',
89
- 'twine',
90
- 'black',
91
- 'isort',
92
- 'flake8',
93
- 'pylint',
94
- 'sphinx',
95
- 'mkdocs',
96
- 'uvicorn',
97
- 'gunicorn',
98
- 'alembic',
99
- 'celery',
100
- 'honcho',
101
- 'maturin',
102
- 'setuptools-scm',
103
- 'pip-audit',
104
- 'bandit',
105
- 'commitizen',
106
- 'towncrier',
107
- ].map((name) => name),
108
- );
6
+ import { CONSOLE_ONLY, IMPORT_ALIASES } from './aliases.ts';
7
+ export { CONSOLE_ONLY, IMPORT_ALIASES };
109
8
 
110
9
  const NORMALIZE_RE = /[-_.]+/g;
111
10
 
@@ -5,7 +5,7 @@ import {
5
5
  type ConformanceReport,
6
6
  } from './conformance.ts';
7
7
  import type { InstalledEnvironment } from './installed.ts';
8
- import type { ScanPayload } from './scanner.ts';
8
+ import type { LockComparison, LockSection, ManifestSection, ScanPayload } from './scanner.ts';
9
9
 
10
10
  export interface ProjectInspection {
11
11
  root: string;
@@ -48,60 +48,56 @@ export interface InspectInput {
48
48
  installed?: InstalledEnvironment;
49
49
  }
50
50
 
51
- /**
52
- * Turn a scanner payload into a project model plus diagnostics. Pure so the
53
- * whole diagnostic surface is unit-testable without touching a filesystem.
54
- */
55
- export function inspectProject(input: InspectInput): ProjectInspection {
56
- const { payload, venvDir, venvIgnored, hasTestsDirectory, installed } = input;
57
- const manifest = payload.manifest;
58
- const lock = payload.lock;
59
- const comparison = payload.lockComparison;
60
- const warnings: Diagnostic[] = [];
61
- const notes: Diagnostic[] = [];
62
- const suggestions: Suggestion[] = [];
51
+ interface DiagnosticCollector {
52
+ warnings: Diagnostic[];
53
+ notes: Diagnostic[];
54
+ suggestions: Suggestion[];
55
+ }
63
56
 
64
- const root = payload.root;
57
+ function collectManifestDiagnostics(
58
+ manifest: ManifestSection | null | undefined,
59
+ root: string,
60
+ collector: DiagnosticCollector,
61
+ ): void {
65
62
  if (manifest?.tomlError) {
66
- warnings.push(
63
+ collector.warnings.push(
67
64
  warn('TOML_PARSE_ERROR', manifest.tomlError, manifest.pyprojectPath ?? undefined),
68
65
  );
69
66
  }
70
67
  for (const message of manifest?.warnings ?? []) {
71
- warnings.push(warn('MANIFEST_WARNING', message, manifest?.pyprojectPath ?? undefined));
72
- }
73
- for (const message of lock?.warnings ?? []) {
74
- notes.push({ code: 'LOCKFILE_NOTE', message, severity: 'info' });
68
+ collector.warnings.push(
69
+ warn('MANIFEST_WARNING', message, manifest?.pyprojectPath ?? undefined),
70
+ );
75
71
  }
76
72
 
77
73
  if (!manifest?.pyprojectPath) {
78
- warnings.push(
74
+ collector.warnings.push(
79
75
  warn(
80
76
  'PYPROJECT_MISSING',
81
77
  'pyproject.toml was not found; dependencies, layout, and tool configuration cannot be verified.',
82
78
  ),
83
79
  );
84
- suggestions.push({
80
+ collector.suggestions.push({
85
81
  message: 'Run uv init to create a pyproject.toml, then uv add the runtime dependencies.',
86
82
  confidence: 'high',
87
83
  command: 'uv init',
88
84
  });
89
85
  } else if (!manifest.name) {
90
- warnings.push(
86
+ collector.warnings.push(
91
87
  warn(
92
88
  'PROJECT_NAME_MISSING',
93
89
  'pyproject.toml has no [project] name, so the installed distribution name is unknown.',
94
90
  manifest.pyprojectPath ?? undefined,
95
91
  ),
96
92
  );
97
- suggestions.push({
93
+ collector.suggestions.push({
98
94
  message: 'Add a [project] table with name and version to pyproject.toml.',
99
95
  confidence: 'high',
100
96
  });
101
97
  }
102
98
 
103
99
  if (manifest?.legacySetupPy || manifest?.legacySetupCfg) {
104
- warnings.push(
100
+ collector.warnings.push(
105
101
  warn(
106
102
  'LEGACY_PACKAGING',
107
103
  `The project still uses ${[
@@ -113,14 +109,14 @@ export function inspectProject(input: InspectInput): ProjectInspection {
113
109
  root,
114
110
  ),
115
111
  );
116
- suggestions.push({
112
+ collector.suggestions.push({
117
113
  message: 'Move dependency metadata from setup.py/setup.cfg into [project] in pyproject.toml.',
118
114
  confidence: 'medium',
119
115
  });
120
116
  }
121
117
 
122
118
  if (manifest?.requirementsFiles.length && manifest.pyprojectPath) {
123
- warnings.push(
119
+ collector.warnings.push(
124
120
  warn(
125
121
  'DUPLICATE_DEPENDENCY_SOURCE',
126
122
  `${manifest.requirementsFiles.join(', ')} also declares dependencies; uv resolves from pyproject.toml and uv.lock only.`,
@@ -129,14 +125,31 @@ export function inspectProject(input: InspectInput): ProjectInspection {
129
125
  );
130
126
  }
131
127
 
128
+ if (manifest?.legacySetupPy && manifest.buildBackend === null && !manifest.pyprojectPath) {
129
+ collector.suggestions.push({
130
+ message: 'uv manages dependencies from pyproject.toml; migrate before running uv sync.',
131
+ confidence: 'medium',
132
+ });
133
+ }
134
+ }
135
+
136
+ function collectLockDiagnostics(
137
+ lock: LockSection | null | undefined,
138
+ comparison: LockComparison | null | undefined,
139
+ collector: DiagnosticCollector,
140
+ ): void {
141
+ for (const message of lock?.warnings ?? []) {
142
+ collector.notes.push({ code: 'LOCKFILE_NOTE', message, severity: 'info' });
143
+ }
144
+
132
145
  if (!lock?.present) {
133
- notes.push({
146
+ collector.notes.push({
134
147
  code: 'LOCKFILE_MISSING',
135
148
  message:
136
149
  'uv.lock was not found, so dependency drift and exact resolved versions cannot be verified.',
137
150
  severity: 'info',
138
151
  });
139
- suggestions.push({
152
+ collector.suggestions.push({
140
153
  message: 'Run uv lock to record resolved versions in uv.lock.',
141
154
  confidence: 'high',
142
155
  command: 'uv lock',
@@ -144,14 +157,14 @@ export function inspectProject(input: InspectInput): ProjectInspection {
144
157
  }
145
158
 
146
159
  if (comparison?.requiresPythonMismatch) {
147
- warnings.push(
160
+ collector.warnings.push(
148
161
  warn(
149
162
  'REQUIRES_PYTHON_MISMATCH',
150
163
  `pyproject.toml requires-python is "${comparison.requiresPythonMismatch.manifest}" but uv.lock records "${comparison.requiresPythonMismatch.lock}".`,
151
164
  lock?.path ?? undefined,
152
165
  ),
153
166
  );
154
- suggestions.push({
167
+ collector.suggestions.push({
155
168
  message: 'Run uv lock so the lockfile reflects the current requires-python constraint.',
156
169
  confidence: 'high',
157
170
  command: 'uv lock',
@@ -159,7 +172,7 @@ export function inspectProject(input: InspectInput): ProjectInspection {
159
172
  }
160
173
 
161
174
  for (const name of comparison?.missingFromLock ?? []) {
162
- warnings.push(
175
+ collector.warnings.push(
163
176
  warn(
164
177
  'LOCKFILE_MISSING_DEPENDENCY',
165
178
  `"${name}" is declared in pyproject.toml but absent from uv.lock.`,
@@ -168,7 +181,7 @@ export function inspectProject(input: InspectInput): ProjectInspection {
168
181
  );
169
182
  }
170
183
  if (comparison?.missingFromLock.length) {
171
- suggestions.push({
184
+ collector.suggestions.push({
172
185
  message: 'Run uv lock to add the missing declarations to the lockfile.',
173
186
  confidence: 'high',
174
187
  command: 'uv lock',
@@ -176,7 +189,7 @@ export function inspectProject(input: InspectInput): ProjectInspection {
176
189
  }
177
190
 
178
191
  for (const entry of comparison?.unsatisfiedInLock ?? []) {
179
- warnings.push(
192
+ collector.warnings.push(
180
193
  warn(
181
194
  'LOCKFILE_UNSATISFIED_DEPENDENCY',
182
195
  `"${entry.name}" is locked at ${entry.locked} which does not satisfy "${entry.specifier}".`,
@@ -186,42 +199,51 @@ export function inspectProject(input: InspectInput): ProjectInspection {
186
199
  }
187
200
 
188
201
  if (lock?.present && comparison && !comparison.specifierCheckAvailable) {
189
- notes.push({
202
+ collector.notes.push({
190
203
  code: 'SPECIFIER_CHECK_UNAVAILABLE',
191
204
  message:
192
205
  'The packaging library was unavailable, so only declared-versus-locked names were compared, not version constraints.',
193
206
  severity: 'info',
194
207
  });
195
- suggestions.push({
208
+ collector.suggestions.push({
196
209
  message:
197
210
  'Install the packaging library in the analysing interpreter to compare declared version constraints against uv.lock.',
198
211
  confidence: 'medium',
199
212
  command: 'python3 -m pip install packaging',
200
213
  });
201
214
  }
215
+ }
202
216
 
217
+ function collectEnvironmentDiagnostics(
218
+ root: string,
219
+ manifest: ManifestSection | null | undefined,
220
+ venvDir: string | undefined,
221
+ venvIgnored: boolean | undefined,
222
+ hasTestsDirectory: boolean,
223
+ collector: DiagnosticCollector,
224
+ ): void {
203
225
  if (!venvDir) {
204
- notes.push({
226
+ collector.notes.push({
205
227
  code: 'VENV_MISSING',
206
228
  message: 'No .venv directory exists at the project root; run uv sync before running tests.',
207
229
  severity: 'info',
208
230
  });
209
231
  } else if (venvIgnored === false) {
210
- warnings.push(
232
+ collector.warnings.push(
211
233
  warn(
212
234
  'VENV_NOT_IGNORED',
213
235
  '.venv exists but is not listed in .gitignore.',
214
236
  `${root}/.gitignore`,
215
237
  ),
216
238
  );
217
- suggestions.push({
239
+ collector.suggestions.push({
218
240
  message: 'Add .venv/ to .gitignore so the environment is never committed.',
219
241
  confidence: 'high',
220
242
  });
221
243
  }
222
244
 
223
245
  if (!hasTestsDirectory) {
224
- notes.push({
246
+ collector.notes.push({
225
247
  code: 'TESTS_DIRECTORY_MISSING',
226
248
  message:
227
249
  'No tests/ directory was found; test selection and TDD gates cannot match changed sources.',
@@ -230,7 +252,7 @@ export function inspectProject(input: InspectInput): ProjectInspection {
230
252
  }
231
253
 
232
254
  if (manifest?.pyprojectPath && manifest.toolConfiguration && !manifest.toolConfiguration.pytest) {
233
- notes.push({
255
+ collector.notes.push({
234
256
  code: 'PYTEST_NOT_CONFIGURED',
235
257
  message: 'pyproject.toml has no [tool.pytest.ini_options] table.',
236
258
  severity: 'info',
@@ -238,7 +260,7 @@ export function inspectProject(input: InspectInput): ProjectInspection {
238
260
  }
239
261
 
240
262
  if (manifest?.layout === 'src' && manifest.modules.length === 0) {
241
- warnings.push(
263
+ collector.warnings.push(
242
264
  warn(
243
265
  'EMPTY_SRC_LAYOUT',
244
266
  'The src/ directory exists but contains no importable module directories or modules.',
@@ -247,20 +269,32 @@ export function inspectProject(input: InspectInput): ProjectInspection {
247
269
  );
248
270
  }
249
271
 
250
- if (manifest?.legacySetupPy && manifest.buildBackend === null && !manifest.pyprojectPath) {
251
- suggestions.push({
252
- message: 'uv manages dependencies from pyproject.toml; migrate before running uv sync.',
253
- confidence: 'medium',
254
- });
255
- }
256
-
257
272
  if (manifest?.uvWorkspaceMembers.length) {
258
- notes.push({
273
+ collector.notes.push({
259
274
  code: 'UV_WORKSPACE',
260
275
  message: `This is a uv workspace with ${manifest.uvWorkspaceMembers.length} member(s); scope build and test tools per member.`,
261
276
  severity: 'info',
262
277
  });
263
278
  }
279
+ }
280
+
281
+ /**
282
+ * Turn a scanner payload into a project model plus diagnostics. Pure so the
283
+ * whole diagnostic surface is unit-testable without touching a filesystem.
284
+ */
285
+ export function inspectProject(input: InspectInput): ProjectInspection {
286
+ const { payload, venvDir, venvIgnored, hasTestsDirectory, installed } = input;
287
+ const manifest = payload.manifest;
288
+ const lock = payload.lock;
289
+ const comparison = payload.lockComparison;
290
+ const root = payload.root;
291
+
292
+ const collector: DiagnosticCollector = { warnings: [], notes: [], suggestions: [] };
293
+ collectManifestDiagnostics(manifest, root, collector);
294
+ collectLockDiagnostics(lock, comparison, collector);
295
+ collectEnvironmentDiagnostics(root, manifest, venvDir, venvIgnored, hasTestsDirectory, collector);
296
+
297
+ const { warnings, notes, suggestions } = collector;
264
298
 
265
299
  const conformance =
266
300
  installed !== undefined