@mjasnikovs/pi-task 0.14.6 → 0.14.7

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,7 @@ import { docsFocused } from '../workers/docs-core.js';
6
6
  import { fetchFocused } from '../workers/fetch-core.js';
7
7
  import { formatNpmVersionSection } from '../workers/npm-version.js';
8
8
  import { runWorker } from '../workers/pi-worker-core.js';
9
+ import { findPhantomImports, formatApiCorrections } from '../workers/phantom-imports.js';
9
10
  import { search as defaultSearch } from '../workers/search-core.js';
10
11
  import { extractEnrichTargets } from './enrichment.js';
11
12
  import { getFileInventory } from './file-inventory.js';
@@ -663,6 +664,17 @@ export const PHASES = [
663
664
  const tVerify = Date.now();
664
665
  const out = await phaseVerifyTooling(d, rawResearch);
665
666
  d.recordSubStep?.('verify-tooling', Date.now() - tVerify);
667
+ // Deterministically verify every runtime builtin specifier the refined
668
+ // task names (`bun:sql`, `node:…`) against the installed types. A doc can
669
+ // confidently name a module that does not exist; left unchecked it rides
670
+ // through every phase and the implementer fabricates a `declare module`
671
+ // shim to compile it. Append the corrections so compose folds them into
672
+ // CONSTRAINTS. No LLM cost and silent when nothing is wrong.
673
+ const corrections = formatApiCorrections(findPhantomImports(p.refined, d.cwd));
674
+ if (corrections) {
675
+ d.logDebug?.(`phantom imports flagged:\n${corrections}`);
676
+ return `${out}\n\n${corrections}`;
677
+ }
666
678
  return out;
667
679
  }
668
680
  },
@@ -309,6 +309,8 @@ VERIFY must exercise the surface area the task actually touches. Draw VERIFY com
309
309
 
310
310
  If TOOLING is empty for a category the change clearly touches, still include the best-effort standard command for that ecosystem (e.g. \`npx tsc --noEmit\` for a TS repo with no script) and note that the receiving agent may need to install it.
311
311
 
312
+ If the research contains an "API CORRECTIONS" section, it is AUTHORITATIVE — each line was verified against the installed types. For every correction: use the import it prescribes, never the specifier it marks non-existent, and add a CONSTRAINT recording it verbatim (e.g. Use \`import { sql } from "bun"\`; \`bun:sql\` is not a module — do not import it or declare a module for it). This overrides any conflicting identifier carried up from the refined task or the spec doc.
313
+
312
314
  Refined task:
313
315
  ${refined}
314
316
 
@@ -0,0 +1,35 @@
1
+ export interface PhantomImport {
2
+ /** The bad specifier exactly as written, e.g. `bun:sql`. */
3
+ spec: string;
4
+ /** Real `<runtime>:` modules the types DO declare (evidence for the correction). */
5
+ realModules: string[];
6
+ /** Corrective guidance to inject into the spec. */
7
+ suggestion: string;
8
+ }
9
+ /** Every distinct runtime-namespace specifier mentioned in the text, in first-seen order. */
10
+ export declare function extractRuntimeSpecifiers(text: string): string[];
11
+ export interface RuntimeImportVerdict {
12
+ spec: string;
13
+ real: boolean;
14
+ /** When phantom: a base-module symbol matching the submodule leaf, if the types
15
+ * declare one (so the correction can name the real import). */
16
+ baseSymbol: string | null;
17
+ /** Real `<runtime>:` modules declared in the type text. */
18
+ realModules: string[];
19
+ }
20
+ /**
21
+ * Pure verdict over a runtime's concatenated type text — no I/O, so the rule is
22
+ * unit-testable with synthetic declarations.
23
+ */
24
+ export declare function classifyRuntimeImport(spec: string, runtime: string, sub: string, typeText: string): RuntimeImportVerdict;
25
+ /** Default loader: resolve the runtime's installed types and read their .d.ts. */
26
+ export declare function loadRuntimeTypeText(runtime: string, cwd: string): string | null;
27
+ /**
28
+ * Scan `text` for runtime-namespace specifiers and return the ones the installed
29
+ * types do not declare. `loadText` is injectable for tests; in production it reads
30
+ * the runtime's type package. A runtime whose types can't be loaded is skipped
31
+ * (we never flag what we can't verify), so this is silent when types are absent.
32
+ */
33
+ export declare function findPhantomImports(text: string, cwd: string, loadText?: (runtime: string, cwd: string) => string | null): PhantomImport[];
34
+ /** Render flagged phantoms as an authoritative research section, or '' if none. */
35
+ export declare function formatApiCorrections(phantoms: PhantomImport[]): string;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Detect phantom runtime imports — `bun:sql`, `node:nope`, etc. — referenced in a
3
+ * task/spec but NOT actually declared by the runtime's installed types.
4
+ *
5
+ * A real `<runtime>:<sub>` builtin appears as `declare module "<runtime>:<sub>"`
6
+ * in the runtime's type files (bun-types declares bun:sqlite/bun:test/bun:ffi/…;
7
+ * @types/node declares node:fs/…). A specifier with no such declaration is a
8
+ * hallucination — the symbol, if it exists at all, lives on the base module. This
9
+ * is the exact `bun:sql` failure class: a design doc invented `bun:sql`, every
10
+ * phase echoed it, and the implementer fabricated a `declare module "bun:sql"`
11
+ * shim to make it compile. Verifying the specifier against the installed types
12
+ * catches it deterministically, with no false positives and no LLM call.
13
+ */
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import { resolvePackage, splitRuntimeNamespace, detectTypesRedirect, typesPackageName, hasTypeFiles, isDtsFile, ResolveError } from './docs-resolve.js';
17
+ // Runtime builtin specifiers as they appear in prose/code: `bun:sql`,
18
+ // `node:fs/promises`. Bounded to the runtimes splitRuntimeNamespace accepts.
19
+ const SPEC_RE = /\b(?:bun|node|deno):[a-z0-9][a-z0-9/_-]*/gi;
20
+ const DECLARE_MODULE_RE = /declare module "([^"]+)"/g;
21
+ /** Every distinct runtime-namespace specifier mentioned in the text, in first-seen order. */
22
+ export function extractRuntimeSpecifiers(text) {
23
+ const seen = new Set();
24
+ for (const m of text.matchAll(SPEC_RE)) {
25
+ // Normalise: drop a trailing punctuation the word boundary may include is
26
+ // already excluded by the class; lowercase the runtime only (subpaths are
27
+ // case-sensitive). splitRuntimeNamespace lowercases the runtime itself.
28
+ if (!seen.has(m[0]))
29
+ seen.add(m[0]);
30
+ }
31
+ return [...seen];
32
+ }
33
+ /**
34
+ * Pure verdict over a runtime's concatenated type text — no I/O, so the rule is
35
+ * unit-testable with synthetic declarations.
36
+ */
37
+ export function classifyRuntimeImport(spec, runtime, sub, typeText) {
38
+ const declared = new Set();
39
+ for (const m of typeText.matchAll(DECLARE_MODULE_RE))
40
+ declared.add(m[1]);
41
+ const real = declared.has(spec);
42
+ const realModules = [...declared].filter(d => d.startsWith(`${runtime}:`)).sort();
43
+ let baseSymbol = null;
44
+ if (!real) {
45
+ const leaf = sub.split('/').pop() ?? sub;
46
+ // Does the base runtime module declare a symbol named like the submodule
47
+ // leaf (e.g. `bun:sql` → `const sql` / `class SQL` in `declare module "bun"`)?
48
+ // Case-insensitive so `sql` matches the `SQL` class too.
49
+ const re = new RegExp(`\\b(?:const|class|function|let|var|namespace|interface|type)\\s+(${escapeRe(leaf)})\\b`, 'i');
50
+ const m = re.exec(typeText);
51
+ baseSymbol = m ? m[1] : null;
52
+ }
53
+ return { spec, real, baseSymbol, realModules };
54
+ }
55
+ function escapeRe(s) {
56
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
57
+ }
58
+ function suggestionFor(v, runtime) {
59
+ if (v.baseSymbol) {
60
+ return (`\`${v.spec}\` is NOT a module — the symbol is exported from the base runtime `
61
+ + `module: use \`import { ${v.baseSymbol} } from "${runtime}"\` (never import from `
62
+ + `\`${v.spec}\` and never declare a module for it).`);
63
+ }
64
+ const list = v.realModules.length > 0 ?
65
+ ` The real \`${runtime}:\` modules are: ${v.realModules.join(', ')}.`
66
+ : '';
67
+ return (`\`${v.spec}\` is NOT a real module.${list} Import the needed symbol from `
68
+ + `"${runtime}" or verify the correct specifier with pi-worker-docs; do not declare a module for it.`);
69
+ }
70
+ /** Sync resolution of a runtime to the package that actually holds its type
71
+ * declarations (bun -> @types/bun -> bun-types), bounded to a few hops. No
72
+ * auto-install: a runtime whose types aren't installed simply can't be verified
73
+ * (returns null), so we never flag what we can't prove. */
74
+ function resolveRuntimeTypesRoot(runtime, cwd) {
75
+ let cur;
76
+ try {
77
+ cur = resolvePackage(runtime, cwd);
78
+ }
79
+ catch (err) {
80
+ if (err instanceof ResolveError)
81
+ return null;
82
+ throw err;
83
+ }
84
+ const visited = new Set([cur.name, runtime]);
85
+ for (let hop = 0; hop < 3; hop++) {
86
+ let next = detectTypesRedirect(cur);
87
+ if (next && visited.has(next))
88
+ next = null;
89
+ if (!next && !hasTypeFiles(cur.root)) {
90
+ const types = typesPackageName(cur.name);
91
+ if (types && !visited.has(types))
92
+ next = types;
93
+ }
94
+ if (!next)
95
+ break;
96
+ visited.add(next);
97
+ try {
98
+ cur = resolvePackage(next, cwd);
99
+ }
100
+ catch {
101
+ break;
102
+ }
103
+ }
104
+ return hasTypeFiles(cur.root) ? cur.root : null;
105
+ }
106
+ const MAX_TYPE_BYTES = 4_000_000;
107
+ function readRuntimeTypeText(root) {
108
+ const parts = [];
109
+ let total = 0;
110
+ const stack = [root];
111
+ while (stack.length) {
112
+ const dir = stack.pop();
113
+ let entries;
114
+ try {
115
+ entries = fs.readdirSync(dir, { withFileTypes: true });
116
+ }
117
+ catch {
118
+ continue;
119
+ }
120
+ for (const entry of entries) {
121
+ if (entry.name === 'node_modules')
122
+ continue;
123
+ const full = path.join(dir, entry.name);
124
+ if (entry.isDirectory()) {
125
+ stack.push(full);
126
+ }
127
+ else if (entry.isFile() && isDtsFile(entry.name)) {
128
+ try {
129
+ const raw = fs.readFileSync(full, 'utf8');
130
+ total += raw.length;
131
+ parts.push(raw);
132
+ if (total >= MAX_TYPE_BYTES)
133
+ return parts.join('\n');
134
+ }
135
+ catch {
136
+ // unreadable file — skip
137
+ }
138
+ }
139
+ }
140
+ }
141
+ return parts.join('\n');
142
+ }
143
+ /** Default loader: resolve the runtime's installed types and read their .d.ts. */
144
+ export function loadRuntimeTypeText(runtime, cwd) {
145
+ const root = resolveRuntimeTypesRoot(runtime, cwd);
146
+ if (!root)
147
+ return null;
148
+ const text = readRuntimeTypeText(root);
149
+ return text.length > 0 ? text : null;
150
+ }
151
+ /**
152
+ * Scan `text` for runtime-namespace specifiers and return the ones the installed
153
+ * types do not declare. `loadText` is injectable for tests; in production it reads
154
+ * the runtime's type package. A runtime whose types can't be loaded is skipped
155
+ * (we never flag what we can't verify), so this is silent when types are absent.
156
+ */
157
+ export function findPhantomImports(text, cwd, loadText = loadRuntimeTypeText) {
158
+ const out = [];
159
+ const typeTextByRuntime = new Map();
160
+ for (const spec of extractRuntimeSpecifiers(text)) {
161
+ const ns = splitRuntimeNamespace(spec);
162
+ if (!ns)
163
+ continue;
164
+ if (!typeTextByRuntime.has(ns.runtime)) {
165
+ typeTextByRuntime.set(ns.runtime, loadText(ns.runtime, cwd));
166
+ }
167
+ const typeText = typeTextByRuntime.get(ns.runtime);
168
+ if (!typeText)
169
+ continue;
170
+ const verdict = classifyRuntimeImport(spec, ns.runtime, ns.sub, typeText);
171
+ if (verdict.real)
172
+ continue;
173
+ out.push({
174
+ spec,
175
+ realModules: verdict.realModules,
176
+ suggestion: suggestionFor(verdict, ns.runtime)
177
+ });
178
+ }
179
+ return out;
180
+ }
181
+ /** Render flagged phantoms as an authoritative research section, or '' if none. */
182
+ export function formatApiCorrections(phantoms) {
183
+ if (phantoms.length === 0)
184
+ return '';
185
+ const lines = phantoms.map(p => ` - ${p.suggestion}`);
186
+ return `API CORRECTIONS\n${lines.join('\n')}`;
187
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.14.6",
3
+ "version": "0.14.7",
4
4
  "description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",