pi-python-helper 0.1.1 → 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.
@@ -19,8 +19,23 @@ export function uvLock(cwd: string): CommandPreview {
19
19
  return { executable: 'uv', args: ['lock'], cwd, risk: 'mutating' };
20
20
  }
21
21
 
22
- export function uvSyncFrozen(cwd: string): CommandPreview {
23
- return { executable: 'uv', args: ['sync', '--frozen', '--all-groups'], cwd, risk: 'mutating' };
22
+ export interface UvSyncOptions {
23
+ /**
24
+ * `all` requests every extra declared in `[project.optional-dependencies]`.
25
+ *
26
+ * `uv sync --all-groups` covers `[dependency-groups]` only, so a project that
27
+ * declares pytest/ruff/pyright as an extra has them **removed** by a plain
28
+ * sync. Installing more than needed is recoverable; deleting the project's
29
+ * own dev tooling mid-run is not, so extras are requested by default.
30
+ */
31
+ extras?: 'all' | 'none';
32
+ }
33
+
34
+ export function uvSyncFrozen(cwd: string, options: UvSyncOptions = {}): CommandPreview {
35
+ const extras = options.extras ?? 'all';
36
+ const args = ['sync', '--frozen', '--all-groups'];
37
+ if (extras === 'all') args.push('--all-extras');
38
+ return { executable: 'uv', args, cwd, risk: 'mutating' };
24
39
  }
25
40
 
26
41
  export function uvRun(cwd: string, args: string[]): CommandPreview {
@@ -1,6 +1,7 @@
1
1
  import type { Suggestion } from '../core/result.ts';
2
2
 
3
3
  export type FailureKind =
4
+ | 'tool_not_installed'
4
5
  | 'module_not_found'
5
6
  | 'environment_not_synced'
6
7
  | 'import_error'
@@ -33,6 +34,8 @@ export interface FailureDiagnosis {
33
34
  kind: FailureKind;
34
35
  summary: string;
35
36
  missingModule?: string;
37
+ /** Executable the command tried to start but could not find. */
38
+ missingTool?: string;
36
39
  importTarget?: { name: string; module: string };
37
40
  exceptionType?: string;
38
41
  frames: TracebackFrame[];
@@ -124,6 +127,46 @@ export function diagnoseFailure(output: string): FailureDiagnosis {
124
127
  };
125
128
  }
126
129
 
130
+ // An environment failure is not a code failure. It appears as a spawn error
131
+ // rather than a traceback, so it must be classified before the traceback
132
+ // patterns or the whole run reads as an unclassifiable crash.
133
+ const spawnFailure = lastMatch(output, /Failed to spawn:?\s*`?([A-Za-z0-9._+-]+)`?/);
134
+ const commandNotFound = lastMatch(
135
+ output,
136
+ /(?:^|\n)\s*(?:sh: |bash: )?([A-Za-z0-9._+-]+): (?:command not found|No such file or directory)/,
137
+ );
138
+ const missingExecutable = spawnFailure ?? commandNotFound;
139
+ const toolNotInstalled = (tool: string, matchedText: string): FailureDiagnosis => {
140
+ const noEntry = /No such file or directory/.test(output) || /command not found/.test(output);
141
+ return {
142
+ kind: 'tool_not_installed',
143
+ summary: noEntry
144
+ ? `The command could not start because "${tool}" is not installed in the environment that ran it.`
145
+ : `The command could not start: ${matchedText}`,
146
+ missingTool: tool,
147
+ exceptionType: 'environment',
148
+ frames,
149
+ firstUserFrame: userFrame,
150
+ evidence: [{ message: matchedText }],
151
+ suggestions: [
152
+ {
153
+ message: `Install the project environment so "${tool}" is available, then rerun through uv run.`,
154
+ confidence: 'high',
155
+ command: 'uv sync --frozen --all-groups --all-extras',
156
+ },
157
+ {
158
+ message:
159
+ 'A plain uv sync removes extras declared in [project.optional-dependencies], which is the usual reason a declared tool disappears mid-run.',
160
+ confidence: 'high',
161
+ },
162
+ {
163
+ message: `Run the tool from the project environment with uv run --frozen ${tool}.`,
164
+ confidence: 'high',
165
+ },
166
+ ],
167
+ };
168
+ };
169
+
127
170
  const missing = lastMatch(output, /ModuleNotFoundError: No module named '([^']+)'/);
128
171
  const cannotImport = lastMatch(
129
172
  output,
@@ -152,6 +195,11 @@ export function diagnoseFailure(output: string): FailureDiagnosis {
152
195
  if (match?.index !== undefined) candidates.push({ kind, index: match.index });
153
196
  };
154
197
  consider('module_not_found', /ModuleNotFoundError: No module named '([^']+)'/);
198
+ consider('tool_not_installed', /Failed to spawn:?\s*`?([A-Za-z0-9._+-]+)`?/);
199
+ consider(
200
+ 'tool_not_installed',
201
+ /(?:^|\n)\s*(?:sh: |bash: )?([A-Za-z0-9._+-]+): (?:command not found|No such file or directory)/,
202
+ );
155
203
  consider('import_error', /ImportError: cannot import name '([^']+)' from '([^']+)'/);
156
204
  consider('syntax_error', /SyntaxError: (.+)/);
157
205
  consider('fixture_error', /(?:fixture '[^']+' not found|ERROR at setup of|error in .* fixture)/);
@@ -164,6 +212,14 @@ export function diagnoseFailure(output: string): FailureDiagnosis {
164
212
  candidates.sort((left, right) => left.index - right.index);
165
213
  const kind: FailureKind = candidates[0]?.kind ?? 'unknown';
166
214
 
215
+ // Dispatch on the positional winner. `tool_not_installed` can be the earliest
216
+ // cause even when a traceback follows it, so it is not checked before the
217
+ // positional comparison.
218
+ if (kind === 'tool_not_installed') {
219
+ const match = missingExecutable;
220
+ if (match) return toolNotInstalled(match[1], match[0].trim());
221
+ }
222
+
167
223
  if (kind === 'module_not_found' && missing) {
168
224
  const module = missing[1].split('.')[0];
169
225
  return {
@@ -0,0 +1,49 @@
1
+ import { uvRun } from './commands.ts';
2
+ import type { CommandPreview } from '../core/result.ts';
3
+
4
+ /**
5
+ * Quality commands a validation bundle can run without any project-specific
6
+ * configuration. Every entry is invoked as an argument array through
7
+ * `uv run --frozen`, so the tool comes from the project environment.
8
+ */
9
+ export interface QualityRunner {
10
+ /** Normalized distribution name that must be declared for this to run. */
11
+ distribution: string;
12
+ name: string;
13
+ args: string[];
14
+ /**
15
+ * mypy needs a `[tool.mypy]` table: run bare it reports every untyped
16
+ * third-party call, which is pre-existing noise rather than a regression.
17
+ */
18
+ requiresToolSection?: boolean;
19
+ }
20
+
21
+ export const QUALITY_RUNNERS: QualityRunner[] = [
22
+ { distribution: 'ruff', name: 'ruff', args: ['ruff', 'check', '.'] },
23
+ { distribution: 'pyright', name: 'pyright', args: ['pyright'] },
24
+ { distribution: 'mypy', name: 'mypy', args: ['mypy', '.'], requiresToolSection: true },
25
+ ];
26
+
27
+ /**
28
+ * Pick the quality gates the project actually declares.
29
+ *
30
+ * Gating on the *declaration* rather than on a config file matches what CI
31
+ * usually runs: a project can run `ruff check .` with no `[tool.ruff]` table at
32
+ * all, and its absence from the gate would hide a real regression.
33
+ */
34
+ export function selectQualityRunners(input: {
35
+ declared: Iterable<string>;
36
+ toolConfiguration?: Record<string, boolean>;
37
+ }): QualityRunner[] {
38
+ const declared = new Set(input.declared);
39
+ const configuration = input.toolConfiguration ?? {};
40
+ return QUALITY_RUNNERS.filter((runner) => {
41
+ if (!declared.has(runner.distribution)) return false;
42
+ if (runner.requiresToolSection && configuration[runner.distribution] !== true) return false;
43
+ return true;
44
+ });
45
+ }
46
+
47
+ export function qualityCommands(cwd: string, runners: QualityRunner[]): CommandPreview[] {
48
+ return runners.map((runner) => uvRun(cwd, runner.args));
49
+ }
@@ -1,5 +1,12 @@
1
1
  import { basename, dirname, join } from 'node:path';
2
- import { isPythonFile, isTestFile, parentDir, pathTokens, toPosix } from '../project/paths.ts';
2
+ import {
3
+ isPythonFile,
4
+ isRunnableTestFile,
5
+ isTestFile,
6
+ parentDir,
7
+ pathTokens,
8
+ toPosix,
9
+ } from '../project/paths.ts';
3
10
 
4
11
  export interface TestSelection {
5
12
  path: string;
@@ -11,13 +18,40 @@ export interface SelectionResult {
11
18
  selected: TestSelection[];
12
19
  /** True when no changed file could be mapped and every test file is returned. */
13
20
  fellBackToAll: boolean;
21
+ /**
22
+ * False when the selection covers every considered test file, so the
23
+ * candidate list was not narrowed at all. Reported so a caller does not read
24
+ * "30 of 30 selected" as a focused run.
25
+ */
26
+ narrowed: boolean;
14
27
  changedSourceFiles: string[];
15
28
  changedTestFiles: string[];
16
29
  consideredTestFiles: string[];
30
+ /** Files under a test directory that pytest does not collect tests from. */
31
+ supportFiles: string[];
32
+ /**
33
+ * True when at least one candidate was matched by an actual import of a
34
+ * changed module, which is the strongest available signal. False means the
35
+ * selection rests on naming conventions alone.
36
+ */
37
+ importEvidenceUsed: boolean;
38
+ }
39
+
40
+ /**
41
+ * Dotted module names imported by each test file, keyed by test path.
42
+ * Supplied by the scanner because a test named `test_db_session.py` gives no
43
+ * naming hint that it covers `db/database.py`; its imports do.
44
+ */
45
+ export type TestImportMap = Record<string, string[]>;
46
+
47
+ export interface SelectionOptions {
48
+ testImports?: TestImportMap;
17
49
  }
18
50
 
19
51
  const SCORE = {
20
52
  changedTestItself: 100,
53
+ /** Importing the changed module is stronger than any name coincidence. */
54
+ importsChangedModule: 90,
21
55
  sameStemSameDir: 80,
22
56
  sameStem: 60,
23
57
  sameDirectory: 40,
@@ -46,34 +80,132 @@ function firstImportableSegment(path: string): string {
46
80
  return (segments[start] ?? '').replace(/\.py$/i, '').toLowerCase();
47
81
  }
48
82
 
83
+ /**
84
+ * Dotted module names a source path could be imported as.
85
+ *
86
+ * `src/pkg/db/database.py` is imported as `pkg.db.database`, and an
87
+ * `__init__.py` is the package itself, so both forms are produced with and
88
+ * without the `src/` prefix.
89
+ */
90
+ export function modulePathsFromFile(path: string): string[] {
91
+ const posix = toPosix(path);
92
+ if (!isPythonFile(posix)) return [];
93
+ const segments = posix.split('/').filter((segment) => segment.length > 0);
94
+ const srcIndex = segments.lastIndexOf('src');
95
+ const trimmed = (srcIndex === -1 ? segments : segments.slice(srcIndex + 1)).map((segment) =>
96
+ segment.replace(/\.py$/i, ''),
97
+ );
98
+ if (trimmed.length === 0) return [];
99
+ if (trimmed.at(-1) === '__init__') trimmed.pop();
100
+ if (trimmed.length === 0) return [];
101
+ const dotted = trimmed.join('.');
102
+ const withoutRoot = trimmed.slice(1).join('.');
103
+ const candidates = [dotted];
104
+ if (srcIndex !== -1) candidates.push(segments.slice(srcIndex).join('.').replace(/\.py$/i, ''));
105
+ if (withoutRoot.length > 0) candidates.push(withoutRoot);
106
+ return [...new Set(candidates)];
107
+ }
108
+
109
+ /** True when a test imports the module, or a parent package of it. */
110
+ function importsModule(imported: string[], modules: string[]): string | undefined {
111
+ let best: string | undefined;
112
+ for (const candidate of modules) {
113
+ for (const entry of imported) {
114
+ const matches =
115
+ entry === candidate ||
116
+ entry.startsWith(`${candidate}.`) ||
117
+ candidate.startsWith(`${entry}.`);
118
+ if (!matches) continue;
119
+ // Report the most specific import: naming `pkg` when the file actually
120
+ // imports `pkg.routes.admin` overstates how broadly the test is coupled.
121
+ if (!best || entry.length > best.length) best = entry;
122
+ }
123
+ }
124
+ return best;
125
+ }
126
+
127
+ /**
128
+ * Values that appear in at least this share of the candidates carry no
129
+ * information about *which* candidate to run: in a project whose tests all live
130
+ * inside the package under test, the package name matches every file.
131
+ */
132
+ const UBIQUITOUS_SHARE = 0.5;
133
+
134
+ function ubiquitousValues(documents: string[][]): Set<string> {
135
+ const counts = new Map<string, number>();
136
+ for (const values of documents) {
137
+ for (const value of new Set(values)) {
138
+ if (value.length === 0) continue;
139
+ counts.set(value, (counts.get(value) ?? 0) + 1);
140
+ }
141
+ }
142
+ const threshold = Math.max(2, documents.length * UBIQUITOUS_SHARE);
143
+ const ubiquitous = new Set<string>();
144
+ for (const [value, count] of counts) {
145
+ if (count >= threshold) ubiquitous.add(value);
146
+ }
147
+ return ubiquitous;
148
+ }
149
+
49
150
  /**
50
151
  * Rank test files against changed paths using pytest conventions first and
51
152
  * token overlap second. The convention signals are strong enough in Python that
52
153
  * a name match should always outrank a fuzzy token match.
154
+ *
155
+ * Signals shared by every candidate are discarded rather than scored. Without
156
+ * that step a package-rooted test tree (`<package>/tests/`) matches its own
157
+ * package on every file and the selection degenerates into the full suite.
53
158
  */
54
- export function selectTests(changedPaths: string[], testFiles: string[]): SelectionResult {
159
+ export function selectTests(
160
+ changedPaths: string[],
161
+ testFiles: string[],
162
+ options: SelectionOptions = {},
163
+ ): SelectionResult {
55
164
  const changed = changedPaths.map(toPosix).filter(isPythonFile);
56
165
  const changedSourceFiles = changed.filter((path) => !isTestFile(path));
57
166
  const changedTestFiles = changed.filter(isTestFile);
58
167
  const considered = [...new Set(testFiles.map(toPosix).filter(isPythonFile))].sort();
168
+ const supportFiles = considered.filter((path) => !isRunnableTestFile(path));
169
+ const testImports = options.testImports ?? {};
59
170
 
60
171
  if (!changed.length) {
61
172
  return {
62
173
  selected: [],
63
174
  fellBackToAll: false,
175
+ narrowed: false,
64
176
  changedSourceFiles,
65
177
  changedTestFiles,
66
178
  consideredTestFiles: considered,
179
+ supportFiles,
180
+ importEvidenceUsed: false,
67
181
  };
68
182
  }
69
183
 
70
184
  const sourceTokens = new Set(changedSourceFiles.flatMap(pathTokens));
71
185
  const sourceModules = new Set(changedSourceFiles.map(firstImportableSegment));
72
- const sourceDirs = new Set(changedSourceFiles.map(parentDir));
73
186
  const sourceStems = new Set(changedSourceFiles.map(normalizedStem));
187
+ const sourceModulePaths = changedSourceFiles.map((path) => modulePathsFromFile(path));
188
+
189
+ const ubiquitousTokens = ubiquitousValues(considered.map((path) => pathTokens(path)));
190
+ const ubiquitousModules = ubiquitousValues(
191
+ considered.map((path) => [firstImportableSegment(path)]),
192
+ );
193
+ // A source directory that contains every test file (the package root) cannot
194
+ // distinguish candidates, so it does not score.
195
+ const sourceDirs = new Set(
196
+ [...new Set(changedSourceFiles.map(parentDir))].filter(
197
+ (directory) =>
198
+ directory === '' || !considered.every((path) => path.startsWith(`${directory}/`)),
199
+ ),
200
+ );
74
201
 
202
+ let importEvidenceUsed = false;
75
203
  const selections: TestSelection[] = [];
76
204
  for (const testFile of considered) {
205
+ // Test infrastructure is not a target, but it can still affect the run, so it
206
+ // is reported separately instead of being scored as a test file.
207
+ if (!isRunnableTestFile(testFile)) continue;
208
+
77
209
  const reasons: string[] = [];
78
210
  let score = 0;
79
211
 
@@ -81,6 +213,19 @@ export function selectTests(changedPaths: string[], testFiles: string[]): Select
81
213
  score += SCORE.changedTestItself;
82
214
  reasons.push('the test file itself changed');
83
215
  }
216
+
217
+ const imported = testImports[testFile];
218
+ if (imported && imported.length > 0) {
219
+ const matched = sourceModulePaths
220
+ .map((modules) => importsModule(imported, modules))
221
+ .find((value) => value !== undefined);
222
+ if (matched) {
223
+ score += SCORE.importsChangedModule;
224
+ importEvidenceUsed = true;
225
+ reasons.push(`imports the changed module "${matched}"`);
226
+ }
227
+ }
228
+
84
229
  const testStem = normalizedStem(testFile);
85
230
  const testDir = parentDir(testFile);
86
231
  if (sourceStems.has(testStem)) {
@@ -99,13 +244,15 @@ export function selectTests(changedPaths: string[], testFiles: string[]): Select
99
244
  }
100
245
 
101
246
  const module = firstImportableSegment(testFile);
102
- if (module && sourceModules.has(module)) {
247
+ if (module && sourceModules.has(module) && !ubiquitousModules.has(module)) {
103
248
  score += SCORE.sharedModule;
104
249
  reasons.push(`covers module "${module}"`);
105
250
  }
106
251
 
107
252
  const tokens = pathTokens(testFile);
108
- const shared = tokens.filter((token) => sourceTokens.has(token));
253
+ const shared = tokens.filter(
254
+ (token) => sourceTokens.has(token) && !ubiquitousTokens.has(token),
255
+ );
109
256
  if (shared.length) {
110
257
  score += SCORE.sharedToken * Math.min(shared.length, 2);
111
258
  reasons.push(`shares token(s): ${shared.slice(0, 4).join(', ')}`);
@@ -125,14 +272,34 @@ export function selectTests(changedPaths: string[], testFiles: string[]): Select
125
272
  }
126
273
  }
127
274
 
275
+ const runnableConsidered = considered.filter(isRunnableTestFile);
276
+ const selectedRunnable = selections.filter((entry) => isRunnableTestFile(entry.path));
128
277
  const fellBackToAll = selections.length === 0 && considered.length > 0;
278
+ if (fellBackToAll) {
279
+ return {
280
+ selected: considered.map((path) => ({
281
+ path,
282
+ score: 0,
283
+ reason: 'no match; running the full suite',
284
+ })),
285
+ fellBackToAll: true,
286
+ narrowed: false,
287
+ changedSourceFiles,
288
+ changedTestFiles,
289
+ consideredTestFiles: considered,
290
+ supportFiles,
291
+ importEvidenceUsed,
292
+ };
293
+ }
294
+
129
295
  return {
130
- selected: fellBackToAll
131
- ? considered.map((path) => ({ path, score: 0, reason: 'no match; running the full suite' }))
132
- : selections,
133
- fellBackToAll,
296
+ selected: selections,
297
+ fellBackToAll: false,
298
+ narrowed: selectedRunnable.length < runnableConsidered.length,
134
299
  changedSourceFiles,
135
300
  changedTestFiles,
136
301
  consideredTestFiles: considered,
302
+ supportFiles,
303
+ importEvidenceUsed,
137
304
  };
138
305
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * uv prints a per-package inventory when `uv sync` changes the environment:
3
+ *
4
+ * ```text
5
+ * Uninstalled 10 packages in 305ms
6
+ * - coverage==7.15.4
7
+ * - pytest==9.0.3
8
+ * + ruff==0.15.5
9
+ * ```
10
+ *
11
+ * Removals matter because a sync that silently drops the project's own dev
12
+ * tooling leaves every later step unable to run, so the inventory is parsed
13
+ * rather than discarded.
14
+ */
15
+ export interface SyncInventory {
16
+ installed: string[];
17
+ uninstalled: string[];
18
+ /** One of `uv`'s summary lines, e.g. `Audited 78 packages in 3ms`. */
19
+ summaryLines: string[];
20
+ }
21
+
22
+ const COUNT_LINE = /^(Installed|Uninstalled|Prepared|Audited|Resolved)\b/;
23
+ const PACKAGE_LINE = /^\s*([+-])\s*([A-Za-z0-9._-]+)(?:==(\S+))?\s*$/;
24
+
25
+ function stripVersion(name: string): string {
26
+ return name.trim();
27
+ }
28
+
29
+ /**
30
+ * Read the installed/uninstalled inventory out of uv's stdout. Anything that
31
+ * does not match the documented layout is ignored rather than guessed at, so a
32
+ * future uv output change degrades to "no inventory" instead of a wrong one.
33
+ */
34
+ export function parseSyncOutput(stdout: string, stderr = ''): SyncInventory {
35
+ const installed: string[] = [];
36
+ const uninstalled: string[] = [];
37
+ const summaryLines: string[] = [];
38
+ let section: 'installed' | 'uninstalled' | null = null;
39
+
40
+ for (const rawLine of `${stdout}\n${stderr}`.split(/\r?\n/)) {
41
+ const line = rawLine.trimEnd();
42
+ if (COUNT_LINE.test(line.trim())) {
43
+ summaryLines.push(line.trim());
44
+ if (/^Uninstalled\b/.test(line.trim())) section = 'uninstalled';
45
+ else if (/^Installed\b/.test(line.trim())) section = 'installed';
46
+ else section = null;
47
+ continue;
48
+ }
49
+ const match = line.match(PACKAGE_LINE);
50
+ if (!match || !section) continue;
51
+ const name = stripVersion(match[2]);
52
+ if (match[1] === '-' && section === 'uninstalled') uninstalled.push(name);
53
+ else if (match[1] === '+' && section === 'installed') installed.push(name);
54
+ }
55
+
56
+ return { installed, uninstalled, summaryLines };
57
+ }
58
+
59
+ /**
60
+ * A sync that removes distributions is worth reporting even when it exits zero:
61
+ * on its own it is not a failure, but it explains every later "command not
62
+ * found" failure in the same run.
63
+ */
64
+ export function describeRemovals(inventory: SyncInventory): string | undefined {
65
+ if (inventory.uninstalled.length === 0) return undefined;
66
+ const shown = inventory.uninstalled.slice(0, 12).join(', ');
67
+ const rest =
68
+ inventory.uninstalled.length > 12 ? `, … (+${inventory.uninstalled.length - 12})` : '';
69
+ return `uv sync removed ${inventory.uninstalled.length} distribution(s) from .venv: ${shown}${rest}.`;
70
+ }
@@ -63,7 +63,16 @@ export interface UndeclaredImport {
63
63
  files: string[];
64
64
  fileCount: number;
65
65
  providers: string[];
66
- suggestedDistribution: string;
66
+ /**
67
+ * The distribution to declare, when the analysing interpreter could determine
68
+ * it. Absent otherwise: `uv add <import name>` would then install a different
69
+ * package, or nothing at all, because import names and distribution names
70
+ * frequently disagree (`wconfig` ships inside `wpyconf`, `yaml` inside
71
+ * `PyYAML`).
72
+ */
73
+ suggestedDistribution?: string;
74
+ /** True when the suggestion comes from installed metadata rather than a guess. */
75
+ providerKnown: boolean;
67
76
  typeCheckingOnly: boolean;
68
77
  reason: string;
69
78
  }
@@ -95,6 +104,12 @@ export interface DependencyPlan {
95
104
  requiresPythonMismatch: { manifest: string; lock: string } | null;
96
105
  };
97
106
  providerMappingReliable: boolean;
107
+ /**
108
+ * Third-party imports the analysing interpreter could not map to an installed
109
+ * distribution. A high count means the interpreter is not the project's own,
110
+ * so "undeclared" may really be "import name differs from distribution name".
111
+ */
112
+ unmappedImports: number;
98
113
  unparsable: { path: string; error: string }[];
99
114
  warnings: Diagnostic[];
100
115
  notes: Diagnostic[];
@@ -147,13 +162,19 @@ export function planDependencies(
147
162
  const runtimeRelevant = !entry.typeCheckingOnly && runtimeFiles.length > 0;
148
163
 
149
164
  if (matched.length === 0) {
165
+ // Prefer the distribution that actually owns the module. The static alias
166
+ // table is a fallback for checkouts where nothing is installed.
167
+ const suggested =
168
+ entry.providers[0] ??
169
+ IMPORT_ALIASES[entry.import]?.[0] ??
170
+ IMPORT_ALIASES[normalizeName(entry.import)]?.[0];
150
171
  undeclared.push({
151
172
  import: entry.import,
152
173
  files: entry.files,
153
174
  fileCount: entry.fileCount,
154
175
  providers: entry.providers,
155
- suggestedDistribution:
156
- entry.providers[0] ?? IMPORT_ALIASES[entry.import]?.[0] ?? entry.import,
176
+ suggestedDistribution: suggested,
177
+ providerKnown: suggested !== undefined,
157
178
  typeCheckingOnly: entry.typeCheckingOnly,
158
179
  reason: entry.typeCheckingOnly
159
180
  ? 'imported only under TYPE_CHECKING and declared in neither [project] tables nor uv.lock'
@@ -199,11 +220,21 @@ export function planDependencies(
199
220
  entry.files[0],
200
221
  ),
201
222
  );
202
- suggestions.push({
203
- message: `Declare ${entry.suggestedDistribution} with uv add${entry.typeCheckingOnly ? ' --dev' : ''} ${entry.suggestedDistribution}.`,
204
- confidence: entry.providers.length ? 'high' : 'medium',
205
- command: `uv add${entry.typeCheckingOnly ? ' --dev' : ''} ${entry.suggestedDistribution}`,
206
- });
223
+ const distribution = entry.suggestedDistribution;
224
+ if (distribution) {
225
+ const flag = entry.typeCheckingOnly ? ' --dev' : '';
226
+ suggestions.push({
227
+ message: `Declare ${distribution} with uv add${flag} ${distribution}.`,
228
+ confidence: entry.providers.length ? 'high' : 'medium',
229
+ command: `uv add${flag} ${distribution}`,
230
+ });
231
+ } else {
232
+ // Fabricating a command here would install the wrong package or fail.
233
+ suggestions.push({
234
+ message: `Look up the distribution that provides "${entry.import}" and declare that name: import names and distribution names frequently disagree, and the analysing interpreter could not map this one.`,
235
+ confidence: 'low',
236
+ });
237
+ }
207
238
  }
208
239
 
209
240
  for (const entry of misplaced) {
@@ -252,6 +283,11 @@ export function planDependencies(
252
283
  });
253
284
  }
254
285
 
286
+ const thirdPartyCount = imports?.thirdParty.length ?? 0;
287
+ const unmappedImports = (imports?.thirdParty ?? []).filter(
288
+ (entry) => entry.providers.length === 0,
289
+ ).length;
290
+
255
291
  if (imports?.providersUnavailable) {
256
292
  notes.push({
257
293
  code: 'PROVIDER_MAPPING_HEURISTIC',
@@ -259,6 +295,12 @@ export function planDependencies(
259
295
  'No installed distributions were visible to the analysing interpreter, so import-to-distribution mapping relied on a static alias table.',
260
296
  severity: 'info',
261
297
  });
298
+ } else if (unmappedImports > 0) {
299
+ notes.push({
300
+ code: 'UNMAPPED_IMPORTS',
301
+ message: `${unmappedImports} of ${thirdPartyCount} third-party import(s) could not be mapped to an installed distribution, so their distribution names are unknown rather than merely undeclared.`,
302
+ severity: 'info',
303
+ });
262
304
  }
263
305
  if (imports && !imports.stdlibAvailable) {
264
306
  notes.push({
@@ -288,7 +330,14 @@ export function planDependencies(
288
330
  misplaced,
289
331
  unused,
290
332
  drift,
291
- providerMappingReliable: !(imports?.providersUnavailable ?? true),
333
+ // A partial mapping is disclosed through `unmappedImports`; the claim here is
334
+ // only that the interpreter could see an installed environment at all. When
335
+ // it could see one yet owned none of the project's imports, it is not the
336
+ // project's interpreter and nothing it reports should be trusted.
337
+ providerMappingReliable:
338
+ !(imports?.providersUnavailable ?? true) &&
339
+ !(thirdPartyCount > 0 && unmappedImports === thirdPartyCount),
340
+ unmappedImports,
292
341
  unparsable: imports?.unparsable ?? [],
293
342
  warnings,
294
343
  notes,