@mjasnikovs/pi-task 0.40.5 → 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) {
@@ -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.5",
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",