@mjasnikovs/pi-task 0.40.4 → 0.40.6

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.
@@ -465,8 +465,18 @@ export async function docsRaw(input) {
465
465
  catch (err) {
466
466
  cacheError = err instanceof Error ? err.message : String(err);
467
467
  }
468
+ // A facade package exports names it does not declare, and its index is a
469
+ // table of contents until the packages that DO declare them are folded in.
470
+ // Best-effort: a supplement that will not resolve leaves the index as it was.
471
+ let supplements;
472
+ try {
473
+ supplements = (await profile.supplements?.(pkg, input.cwd, io)) ?? [];
474
+ }
475
+ catch {
476
+ supplements = [];
477
+ }
468
478
  const result = cache ?
469
- docsRawCached(cache, pkg, profile, input.query, ensureIndexed, retrieveChunks, autoInstalled)
479
+ docsRawCached(cache, pkg, profile, input.query, ensureIndexed, retrieveChunks, autoInstalled, supplements)
470
480
  : docsRawUncached(pkg, profile, cacheError ?? 'unknown cache error', autoInstalled);
471
481
  result.npmVersion = await npmVersionPromise;
472
482
  result.registryLabel = registryLabel;
@@ -474,11 +484,11 @@ export async function docsRaw(input) {
474
484
  result.autoInstallPin = autoInstallPin;
475
485
  return result;
476
486
  }
477
- function docsRawCached(cache, pkg, profile, query, ensureIndexed, retrieveChunks, autoInstalled) {
487
+ function docsRawCached(cache, pkg, profile, query, ensureIndexed, retrieveChunks, autoInstalled, supplements = []) {
478
488
  let indexResult;
479
489
  const t0 = Date.now();
480
490
  try {
481
- indexResult = ensureIndexed(cache, pkg, profile);
491
+ indexResult = ensureIndexed(cache, pkg, profile, supplements);
482
492
  }
483
493
  catch (err) {
484
494
  return {
@@ -73,6 +73,12 @@ export interface EcosystemProfile {
73
73
  installed: boolean;
74
74
  pin?: AutoInstallPin;
75
75
  }>;
76
+ /**
77
+ * Packages whose declarations belong in THIS package's index, because this
78
+ * package exports names it does not declare. Only hackage has facades of the
79
+ * `hspec`/`hspec-core` shape; see DEFECT-12-STOPPING-RULE.md.
80
+ */
81
+ supplements?: (pkg: ResolvedPackage, cwd: string, io: EcosystemIo) => Promise<ResolvedPackage[]>;
76
82
  /** The registry's own newest version, for grounding an answer in the present. */
77
83
  latest: (name: string, io: EcosystemIo) => Promise<NpmVersionInfo | null>;
78
84
  /** True for a file that carries the package's public API surface. */
@@ -22,7 +22,7 @@ import { resolvePackage, isDtsFile, isValidModuleName } from './docs-resolve.js'
22
22
  import { DECL_SPLIT_RE } from './docs-chunk.js';
23
23
  import { npmVersionLookup } from './npm-version.js';
24
24
  import { resolveCrate, cratesLatest, crateTarballUrl, crateOf, isValidCrateName, isRustFile, lockedVersion, rustSurface, cargoProjectName, childDirs, lockedDeps, manifestCrates, CARGO_DECL_SPLIT_RE } from './eco-cargo.js';
25
- import { resolveHackage, hackageLatest, hackageVersion, hackageTarballUrl, hackageExtractDir, hackageProjectName, findCabalTarball, cachedVersions, resolvedVersions, manifestPackages, isValidHackageName, isHaskellFile, haskellSurface, HACKAGE_DECL_SPLIT_RE, HACKAGE_SKIP_DIRS } from './eco-hackage.js';
25
+ import { resolveHackage, hackageLatest, hackageVersion, hackageTarballUrl, hackageExtractDir, hackageProjectName, supplementCandidates, findCabalTarball, cachedVersions, resolvedVersions, manifestPackages, isValidHackageName, isHaskellFile, haskellSurface, HACKAGE_DECL_SPLIT_RE, HACKAGE_SKIP_DIRS } from './eco-hackage.js';
26
26
  import { runChild } from '../shared/child-process.js';
27
27
  /**
28
28
  * Is any of `names` present at `cwd` or above it?
@@ -354,6 +354,33 @@ const hackageProfile = {
354
354
  };
355
355
  }
356
356
  },
357
+ supplements: async (pkg, cwd, io) => {
358
+ const deps = manifestPackages(pkg.root);
359
+ if (!deps)
360
+ return [];
361
+ const candidates = supplementCandidates(pkg.name, deps, resolvedVersions(cwd) ?? {});
362
+ const out = [];
363
+ for (const c of candidates) {
364
+ try {
365
+ out.push(resolveHackage(c.name, cwd, { modulesDir: io.modulesDir }));
366
+ continue;
367
+ }
368
+ catch {
369
+ // Not unpacked yet. `acquire` prefers the tarball cabal already
370
+ // downloaded as a dependency, so this is local work, not a fetch.
371
+ }
372
+ const got = await hackageProfile.acquire(c.name, c.version, io);
373
+ if (!got.success)
374
+ continue;
375
+ try {
376
+ out.push(resolveHackage(c.name, cwd, { modulesDir: io.modulesDir }));
377
+ }
378
+ catch {
379
+ // A supplement that will not resolve leaves the facade as it was.
380
+ }
381
+ }
382
+ return out;
383
+ },
357
384
  latest: (name, io) => hackageLatest(name, io.fetch, io.signal),
358
385
  isSurfaceFile: isHaskellFile,
359
386
  surface: haskellSurface,
@@ -35,4 +35,4 @@ export interface IndexResult {
35
35
  * have said so.
36
36
  */
37
37
  export declare function chunkerFingerprint(): string;
38
- export declare function ensureIndexed(cache: CacheHandle, pkg: ResolvedPackage, profile?: EcosystemProfile): IndexResult;
38
+ export declare function ensureIndexed(cache: CacheHandle, pkg: ResolvedPackage, profile?: EcosystemProfile, supplements?: readonly ResolvedPackage[]): IndexResult;
@@ -4,6 +4,7 @@ import * as path from 'node:path';
4
4
  import {} from './docs-resolve.js';
5
5
  import { chunkDeclarations, chunkReadme, splitAtMatches } from './docs-chunk.js';
6
6
  import { ECOSYSTEMS } from './docs-ecosystems.js';
7
+ import { hackageExportGap, declaredInSurface } from './eco-hackage.js';
7
8
  const ZERO_SEP = Buffer.from([0]);
8
9
  /**
9
10
  * The gate that decides whether a package needs re-indexing.
@@ -35,7 +36,7 @@ const ZERO_SEP = Buffer.from([0]);
35
36
  export function chunkerFingerprint() {
36
37
  return `${String(splitAtMatches)}\u0000${String(chunkDeclarations)}\u0000${String(chunkReadme)}`;
37
38
  }
38
- function computeContentHash(pkg, profile) {
39
+ function computeContentHash(pkg, profile, supplements = []) {
39
40
  const hash = createHash('sha256');
40
41
  hash.update(Buffer.from(`${pkg.name}@${pkg.version}`, 'utf8'));
41
42
  hash.update(ZERO_SEP);
@@ -48,6 +49,15 @@ function computeContentHash(pkg, profile) {
48
49
  hash.update(Buffer.from(`${String(profile.isSurfaceFile)}\u0000${String(dropParallelDeclarations)}`
49
50
  + `\u0000${String(dropDeadMajors)}`, 'utf8'));
50
51
  hash.update(ZERO_SEP);
52
+ // The extractor and the writer, by source. Surfacing only `pkg.entry` below
53
+ // leaves a package cached whenever a fix moves some OTHER module — the
54
+ // wrapped `instance` head is in aeson's `Types/FromJSON.hs`, never its entry
55
+ // — and nothing surfaced the duplicate drop in `ingestBody` at all.
56
+ hash.update(Buffer.from(`${String(profile.surface)}\u0000${String(ingestBody)}`, 'utf8'));
57
+ hash.update(ZERO_SEP);
58
+ // Which packages were folded in, so gaining or losing one re-indexes.
59
+ hash.update(Buffer.from(supplements.map(s => `${s.name}@${s.version}`).join('\u0000'), 'utf8'));
60
+ hash.update(ZERO_SEP);
51
61
  if (pkg.entry && fs.existsSync(pkg.entry)) {
52
62
  try {
53
63
  hash.update(Buffer.from(profile.surface(fs.readFileSync(pkg.entry, 'utf8')), 'utf8'));
@@ -157,7 +167,7 @@ function collectFiles(pkg, profile) {
157
167
  readme: pkg.readme
158
168
  };
159
169
  }
160
- function ingestBody(cache, pkg, profile, contentHash) {
170
+ function ingestBody(cache, pkg, profile, contentHash, supplements = []) {
161
171
  const ecosystem = profile.id;
162
172
  const inside = cache.db
163
173
  .prepare('SELECT content_hash FROM packages WHERE ecosystem = ? AND name = ? AND version = ?')
@@ -172,6 +182,12 @@ function ingestBody(cache, pkg, profile, contentHash) {
172
182
  let chunksWritten = 0;
173
183
  let filesIngested = 0;
174
184
  const insertChunk = cache.db.prepare('INSERT INTO chunks (ecosystem, name, version, file_path, kind, content) VALUES (?, ?, ?, ?, ?, ?)');
185
+ // The content carries its own `<comment> <path>` header, so two rows that
186
+ // match on it are the same declaration from the same file, written twice.
187
+ // aeson's `Data.Aeson.KeyMap` declares its whole API once per `#ifdef`
188
+ // branch and nothing here preprocesses CPP: 43 of aeson's 55 duplicate
189
+ // bodies. A second copy carries no second fact and still spends a slot.
190
+ const seen = new Set();
175
191
  for (const abs of files.surface) {
176
192
  // Normalise the separator before storing, so the same package indexes to
177
193
  // the same rows whatever built the path. The value is a MODEL-FACING
@@ -190,10 +206,46 @@ function ingestBody(cache, pkg, profile, contentHash) {
190
206
  continue;
191
207
  filesIngested++;
192
208
  for (const c of chunks) {
209
+ if (seen.has(c))
210
+ continue;
211
+ seen.add(c);
193
212
  insertChunk.run(ecosystem, pkg.name, pkg.version, rel, 'dts', c);
194
213
  chunksWritten++;
195
214
  }
196
215
  }
216
+ // A facade package indexes to a table of contents: `hspec` is 14 chunks of
217
+ // export lists and every signature is in `hspec-core`. Fill only the holes —
218
+ // see DEFECT-12-STOPPING-RULE.md for the boundary and why it stops here.
219
+ const gap = supplements.length > 0 ? hackageExportGap(pkg.root) : null;
220
+ for (const sup of gap && (gap.unresolved.size > 0 || gap.reexportedModules.size > 0) ?
221
+ supplements
222
+ : []) {
223
+ for (const abs of collectFiles(sup, profile).surface) {
224
+ let raw;
225
+ try {
226
+ raw = fs.readFileSync(abs, 'utf8');
227
+ }
228
+ catch {
229
+ continue;
230
+ }
231
+ const module = /^module\s+([\w.']+)/m.exec(raw)?.[1];
232
+ const whole = module !== undefined && gap.reexportedModules.has(module);
233
+ // The path names the package the declaration really came from: the
234
+ // chunk header is model-facing, and a signature attributed to the
235
+ // wrong package is the bug this whole table exists for.
236
+ const rel = `${sup.name}-${sup.version}/` + path.relative(sup.root, abs).replace(/\\/g, '/');
237
+ for (const c of chunkDeclarations(profile.surface(raw), rel, profile.declSplitRe, profile.commentPrefix)) {
238
+ const declares = declaredInSurface(c.replace(/^\S.*\n/, ''));
239
+ if (!whole && ![...declares].some(n => gap.unresolved.has(n)))
240
+ continue;
241
+ if (seen.has(c))
242
+ continue;
243
+ seen.add(c);
244
+ insertChunk.run(ecosystem, pkg.name, pkg.version, rel, 'dts', c);
245
+ chunksWritten++;
246
+ }
247
+ }
248
+ }
197
249
  if (files.readme) {
198
250
  const rel = path.relative(pkg.root, files.readme).replace(/\\/g, '/');
199
251
  const raw = fs.readFileSync(files.readme, 'utf8');
@@ -211,9 +263,9 @@ function ingestBody(cache, pkg, profile, contentHash) {
211
263
  .run(ecosystem, pkg.name, pkg.version, contentHash, Date.now());
212
264
  return { hitCache: false, filesIngested, chunksWritten };
213
265
  }
214
- export function ensureIndexed(cache, pkg, profile = ECOSYSTEMS[pkg.ecosystem]) {
266
+ export function ensureIndexed(cache, pkg, profile = ECOSYSTEMS[pkg.ecosystem], supplements = []) {
215
267
  const ecosystem = profile.id;
216
- const contentHash = computeContentHash(pkg, profile);
268
+ const contentHash = computeContentHash(pkg, profile, supplements);
217
269
  const existing = cache.db
218
270
  .prepare('SELECT content_hash FROM packages WHERE ecosystem = ? AND name = ? AND version = ?')
219
271
  .get(ecosystem, pkg.name, pkg.version);
@@ -223,7 +275,7 @@ export function ensureIndexed(cache, pkg, profile = ECOSYSTEMS[pkg.ecosystem]) {
223
275
  cache.db.exec('BEGIN IMMEDIATE');
224
276
  let result;
225
277
  try {
226
- result = ingestBody(cache, pkg, profile, contentHash);
278
+ result = ingestBody(cache, pkg, profile, contentHash, supplements);
227
279
  cache.db.exec('COMMIT');
228
280
  }
229
281
  catch (err) {
@@ -22,10 +22,28 @@ const MIN_TOKEN_LEN = 2;
22
22
  * aliased members spend the whole budget on hops.
23
23
  */
24
24
  const MAX_ALIAS_HOPS = 3;
25
+ /**
26
+ * How many smallest-first candidates the value hop reads before giving up.
27
+ *
28
+ * A whole-word check cannot be pushed into SQL, so it runs over the shortest few.
29
+ * Only a name whose every shorter occurrence is a substring of a longer identifier
30
+ * needs more than a handful, and that name is not the one the query asked about.
31
+ */
32
+ const VALUE_CHUNK_CANDIDATES = 8;
25
33
  /** Backstop for a caller that names no ecosystem; every real one passes its own. */
26
34
  const DEFAULT_TYPE_KEYWORDS = ['interface', 'type', 'class', 'enum'];
27
35
  /** A member declared as a bare capitalised type: `get: HandlerInterface<…>`. */
28
36
  const MEMBER_TYPE_RE = /^\s*(?:readonly\s+)?([A-Za-z_$][\w$]*)\??\s*:\s*([A-Z][A-Za-z0-9_]*)\s*[<;,)|&]/gm;
37
+ /**
38
+ * A token that is a symbol rather than English: capitalised, or carrying an
39
+ * underscore or an internal capital.
40
+ *
41
+ * `/^[A-Z]/` alone was the whole rule, and it reached none of the 17 declarations
42
+ * the 2026-09-06 run named and never retrieved — `safeParse`, `from_str`,
43
+ * `into_make_service`, `parseJSON`. Widening to every token instead would hop on
44
+ * `signature` and `return`, spending a slot on whichever prose chunk is shortest.
45
+ */
46
+ const IDENTIFIER_SHAPED = /^(?:[A-Z][A-Za-z0-9_]{2,}|[a-z][A-Za-z0-9]*(?:_[A-Za-z0-9_]+|[A-Z][A-Za-z0-9_]*)[A-Za-z0-9_]*)$/;
29
47
  const TYPE_DECL_RE = /\b(?:interface|type|class|data|newtype|struct|trait|enum)\s+([A-Z][A-Za-z0-9_]*)/g;
30
48
  /** The `<E extends Env, BasePath extends string>` a declaration introduces itself. */
31
49
  const TYPE_PARAMS_RE = /<([^<>]*)>/g;
@@ -128,17 +146,21 @@ function hopNames(text, tokens) {
128
146
  }
129
147
  const asked = new Set(tokens.map(t => t.toLowerCase()));
130
148
  const out = [];
131
- // A capitalised name the QUERY itself asks about. scotty's seven failures were
132
- // all of this shape: `type ActionM = ActionT IO` sits in one chunk of 312
133
- // while 67 chunks USE the name, and a chunk carrying BOTH query terms
149
+ // A name the QUERY itself asks about. scotty's seven failures were all of this
150
+ // shape: `type ActionM = ActionT IO` sits in one chunk of 312 while 67 chunks
151
+ // USE the name, and a chunk carrying BOTH query terms
134
152
  // (`get :: RoutePattern -> ActionM () -> ScottyM ()`) outranks the definition
135
153
  // every time. Reading the ranked output, all eight slots went to uses.
154
+ // Not capped. MAX_ALIAS_HOPS bounds hops DERIVED from a chunk, where one chunk
155
+ // full of aliased members could generate them without end; a query names the
156
+ // handful of symbols it names, and that is the bound. Capping these at 3 as well
157
+ // cost 4 of the 6 recoveries this hop exists for — measured on the 2026-09-06
158
+ // run's own 35 named declarations: 17 missed uncapped-baseline, 15 at a cap of
159
+ // 3, 11 at a cap of 8. The content budget is what stops it running long.
136
160
  for (const t of tokens) {
137
- if (!/^[A-Z][A-Za-z0-9_]{2,}$/.test(t) || declared.has(t) || out.includes(t))
161
+ if (!IDENTIFIER_SHAPED.test(t) || declared.has(t) || out.includes(t))
138
162
  continue;
139
163
  out.push(t);
140
- if (out.length >= MAX_ALIAS_HOPS)
141
- return out;
142
164
  }
143
165
  for (const m of text.matchAll(MEMBER_TYPE_RE)) {
144
166
  const [, member, typeName] = m;
@@ -166,10 +188,45 @@ function definitionChunk(cache, opts, name) {
166
188
  AND (${where})
167
189
  ORDER BY length(content) LIMIT 1`)
168
190
  .get(opts.ecosystem, opts.name, opts.version, ...keywords.map(k => `*${k} ${name}[ <={(=]*`));
191
+ if (row) {
192
+ return {
193
+ filePath: row.file_path,
194
+ kind: row.kind,
195
+ content: row.content,
196
+ rank: row.rank
197
+ };
198
+ }
199
+ return valueChunk(cache, opts, name);
200
+ }
201
+ /**
202
+ * The smallest chunk declaring `name` where `name` is a VALUE, not a type.
203
+ *
204
+ * The keyword GLOB above finds `type ActionM` and `struct Config`; nothing it can
205
+ * spell finds `pub fn from_str<'a, T>` or `decodeValue :: String -> …`, and those
206
+ * were 17 of the 35 declarations the 2026-09-06 run named and never retrieved.
207
+ *
208
+ * Smallest-first is the same reasoning as the type path, and it is what makes this
209
+ * safe without a per-language declaration grammar: the surface extractor emits one
210
+ * declaration per chunk, so the short chunk carrying the name IS its declaration and
211
+ * the long ones are the prose that merely mentions it.
212
+ */
213
+ function valueChunk(cache, opts, name) {
214
+ const rows = cache.db
215
+ .prepare(`SELECT file_path, kind, content, 0 AS rank FROM chunks
216
+ WHERE ecosystem = ?1 AND name = ?2 AND version = ?3 AND content LIKE ?4
217
+ ORDER BY length(content) LIMIT ?5`)
218
+ .all(opts.ecosystem, opts.name, opts.version, `%${name}%`, VALUE_CHUNK_CANDIDATES);
219
+ // LIKE has no word boundary, so `decodeFile` matches `decodeFileStrict` — the
220
+ // wrong declaration, and the exact confusion these runs keep producing.
221
+ const whole = new RegExp(`(?<![A-Za-z0-9_])${escapeRe(name)}(?![A-Za-z0-9_])`);
222
+ const row = rows.find(r => whole.test(r.content));
169
223
  if (!row)
170
224
  return null;
171
225
  return { filePath: row.file_path, kind: row.kind, content: row.content, rank: row.rank };
172
226
  }
227
+ function escapeRe(s) {
228
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
229
+ }
173
230
  export function retrieveChunks(cache, opts) {
174
231
  const limit = opts.limit ?? DEFAULT_LIMIT;
175
232
  const budget = opts.contentBudget ?? DEFAULT_BUDGET;
@@ -100,3 +100,42 @@ export declare function hackageProjectName(cwd: string): string | null;
100
100
  * Undefined when there is no readable `.cabal` file.
101
101
  */
102
102
  export declare function manifestPackages(cwd: string): Set<string> | undefined;
103
+ /**
104
+ * What a package exports but does not declare.
105
+ *
106
+ * `hspec` indexes to 14 chunks of export lists: `it`, `describe` and `shouldBe`
107
+ * are in the corpus as bare names with no signature attached, because every
108
+ * signature is in `hspec-core`. The whole index is a table of contents.
109
+ *
110
+ * Both shapes are here because either alone misses half of it. A name-level
111
+ * re-export puts the name in the export list; a `module X` re-export puts
112
+ * nothing there at all, which is why `shouldBe` is invisible to the first.
113
+ *
114
+ * See DEFECT-12-STOPPING-RULE.md for why this triggers on the hole itself
115
+ * rather than on a fraction of the export list.
116
+ */
117
+ export interface HackageExportGap {
118
+ /** Exported names with no declaration anywhere in the package. */
119
+ unresolved: Set<string>;
120
+ /** `module X` re-exports of modules this package does not own. */
121
+ reexportedModules: Set<string>;
122
+ }
123
+ /** Every name the extracted surface declares: signatures, heads, constructors, fields. */
124
+ export declare function declaredInSurface(surface: string): Set<string>;
125
+ export declare function hackageExportGap(root: string): HackageExportGap;
126
+ /**
127
+ * Which declared dependencies may be opened to fill the gap.
128
+ *
129
+ * Hackage splits a facade from its implementation by name — `hspec`/`hspec-core`,
130
+ * `hspec`/`hspec-expectations` — and that convention is the whole bound. Without
131
+ * it the rule has to fetch every `build-depends` entry to find out whether it
132
+ * declares anything: aeson names 38 of them and would resolve none, because its
133
+ * nine unresolved exports are CPP macros and internal punctuation helpers.
134
+ *
135
+ * The cost of the bound is stated rather than hidden: `scotty` re-exports
136
+ * sixteen names from `cookie`, which shares no prefix, so that hole stays open.
137
+ */
138
+ export declare function supplementCandidates(pkgName: string, declaredDeps: ReadonlySet<string>, resolved: Readonly<Record<string, string>>): Array<{
139
+ name: string;
140
+ version: string;
141
+ }>;
@@ -381,6 +381,40 @@ function haddockBlockAt(lines, start) {
381
381
  }
382
382
  return { text: text.filter(l => l.trim() !== '--'), next: lines.length };
383
383
  }
384
+ /**
385
+ * A head line that has not finished: an open bracket, an arrow, a pragma, or the
386
+ * bare keyword — aeson writes the constraint block on the lines underneath.
387
+ */
388
+ const INSTANCE_HEAD_CONTINUES_RE = /(?:=>|->|,|\(|\[|#-\})\s*$|^\s*instance\s*$/;
389
+ /**
390
+ * The head of an `instance`, which may wrap.
391
+ *
392
+ * `instance {-# OVERLAPPING #-}` and `instance ( Selector s` are both a first
393
+ * line that names nothing, and aeson ships twelve chunks that are exactly that —
394
+ * each one still costing a retrieval slot. The head runs to `where`, or to the
395
+ * first line that closes its brackets without ending mid-declaration.
396
+ */
397
+ function instanceHead(block) {
398
+ const head = [];
399
+ let depth = 0;
400
+ for (const line of block) {
401
+ const w = /\bwhere\b/.exec(line);
402
+ if (w) {
403
+ head.push(line.slice(0, w.index + 'where'.length).trimEnd());
404
+ return head;
405
+ }
406
+ head.push(line);
407
+ for (const c of line) {
408
+ if (c === '(' || c === '[')
409
+ depth++;
410
+ else if (c === ')' || c === ']')
411
+ depth--;
412
+ }
413
+ if (depth <= 0 && !INSTANCE_HEAD_CONTINUES_RE.test(line))
414
+ return head;
415
+ }
416
+ return head;
417
+ }
384
418
  export function haskellSurface(rawSrc) {
385
419
  // Haddock blocks survive; ordinary `{- … -}` commentary does not.
386
420
  const src = stripBlockComments(rawSrc);
@@ -478,7 +512,7 @@ export function haskellSurface(rawSrc) {
478
512
  else if (TYPE_HEAD_RE.test(line)) {
479
513
  const head = TYPE_HEAD_RE.exec(line)[1];
480
514
  // An instance's indented lines are definitions, not fields.
481
- out.push(...pending, ...(head === 'instance' ? [line] : block), '');
515
+ out.push(...pending, ...(head === 'instance' ? instanceHead(block) : block), '');
482
516
  }
483
517
  pending = [];
484
518
  i = j;
@@ -552,3 +586,132 @@ export function manifestPackages(cwd) {
552
586
  }
553
587
  return out;
554
588
  }
589
+ const EXPORT_NAME_RE = /^[A-Za-z_][\w']*$/;
590
+ /** `module X` inside an export list is a re-export; `Prelude` is base, and base is not fetched. */
591
+ const REEXPORT_RE = /\bmodule\s+([\w.']+)/g;
592
+ /** The text between `module M (` and its balancing `)`. */
593
+ function exportListText(src) {
594
+ const m = /^module\s+[\w.']+\s*/m.exec(src);
595
+ if (!m)
596
+ return '';
597
+ const open = src.indexOf('(', m.index);
598
+ if (open < 0)
599
+ return '';
600
+ let depth = 0;
601
+ for (let i = open; i < src.length; i++) {
602
+ if (src[i] === '(')
603
+ depth++;
604
+ else if (src[i] === ')' && --depth === 0)
605
+ return src.slice(open + 1, i);
606
+ }
607
+ return '';
608
+ }
609
+ const SURFACE_DECL_RE = /^([a-z_][\w']*)\s*::|^\(([^)]+)\)\s*::|^(?:data|newtype|type|class)\s+(?:family\s+)?(?:[^=>]*=>\s*)?([A-Z][\w']*)/;
610
+ /** Every name the extracted surface declares: signatures, heads, constructors, fields. */
611
+ export function declaredInSurface(surface) {
612
+ const out = new Set();
613
+ const lines = surface.split('\n');
614
+ for (let i = 0; i < lines.length; i++) {
615
+ const line = lines[i];
616
+ const m = SURFACE_DECL_RE.exec(line);
617
+ if (m) {
618
+ out.add((m[1] ?? m[2] ?? m[3]).trim());
619
+ continue;
620
+ }
621
+ if (BARE_NAME_RE.test(line) && (lines[i + 1]?.trim().startsWith('::') ?? false)) {
622
+ for (const n of line.split(','))
623
+ out.add(n.trim());
624
+ continue;
625
+ }
626
+ if (!/^\s/.test(line))
627
+ continue;
628
+ for (const c of line.matchAll(/(?:^|[=|])\s*([A-Z][\w']*)/g))
629
+ out.add(c[1]);
630
+ for (const f of line.matchAll(/(?:^|[,{])\s*([a-z_][\w']*)\s*::/g))
631
+ out.add(f[1]);
632
+ }
633
+ return out;
634
+ }
635
+ /** Read every `.hs` under `root`, skipping the directories no surface pass reads. */
636
+ function haskellSources(root) {
637
+ const out = [];
638
+ const walk = (dir) => {
639
+ let entries;
640
+ try {
641
+ entries = fs.readdirSync(dir, { withFileTypes: true });
642
+ }
643
+ catch {
644
+ return;
645
+ }
646
+ for (const e of entries) {
647
+ if (e.isDirectory()) {
648
+ if (!HACKAGE_SKIP_DIRS.includes(e.name))
649
+ walk(path.join(dir, e.name));
650
+ }
651
+ else if (isHaskellFile(e.name))
652
+ out.push(path.join(dir, e.name));
653
+ }
654
+ };
655
+ walk(root);
656
+ return out;
657
+ }
658
+ export function hackageExportGap(root) {
659
+ const declared = new Set();
660
+ const exported = new Set();
661
+ const ownModules = new Set();
662
+ const reexportedModules = new Set();
663
+ for (const file of haskellSources(root)) {
664
+ const src = safeRead(file);
665
+ if (src === null)
666
+ continue;
667
+ for (const n of declaredInSurface(haskellSurface(src)))
668
+ declared.add(n);
669
+ const own = /^module\s+([\w.']+)/m.exec(src);
670
+ if (own)
671
+ ownModules.add(own[1]);
672
+ const list = exportListText(src);
673
+ for (const m of list.matchAll(REEXPORT_RE))
674
+ reexportedModules.add(m[1]);
675
+ for (const raw of list.split('\n')) {
676
+ const line = raw.replace(/--.*$/, '').replace(REEXPORT_RE, '');
677
+ for (const token of line.split(/[,\s]+/)) {
678
+ const name = token
679
+ .replace(/\(\.\.\)$/, '')
680
+ .replace(/[(),]/g, '')
681
+ .trim();
682
+ if (EXPORT_NAME_RE.test(name))
683
+ exported.add(name);
684
+ }
685
+ }
686
+ }
687
+ for (const m of ownModules)
688
+ reexportedModules.delete(m);
689
+ reexportedModules.delete('Prelude');
690
+ return {
691
+ unresolved: new Set([...exported].filter(n => !declared.has(n))),
692
+ reexportedModules
693
+ };
694
+ }
695
+ /**
696
+ * Which declared dependencies may be opened to fill the gap.
697
+ *
698
+ * Hackage splits a facade from its implementation by name — `hspec`/`hspec-core`,
699
+ * `hspec`/`hspec-expectations` — and that convention is the whole bound. Without
700
+ * it the rule has to fetch every `build-depends` entry to find out whether it
701
+ * declares anything: aeson names 38 of them and would resolve none, because its
702
+ * nine unresolved exports are CPP macros and internal punctuation helpers.
703
+ *
704
+ * The cost of the bound is stated rather than hidden: `scotty` re-exports
705
+ * sixteen names from `cookie`, which shares no prefix, so that hole stays open.
706
+ */
707
+ export function supplementCandidates(pkgName, declaredDeps, resolved) {
708
+ const out = [];
709
+ for (const dep of declaredDeps) {
710
+ if (!dep.startsWith(`${pkgName}-`))
711
+ continue;
712
+ const version = resolved[dep];
713
+ if (version)
714
+ out.push({ name: dep, version });
715
+ }
716
+ return out.sort((a, b) => a.name.localeCompare(b.name));
717
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.40.4",
3
+ "version": "0.40.6",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",