@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.
- package/dist/workers/docs-core.js +13 -3
- package/dist/workers/docs-ecosystems.d.ts +6 -0
- package/dist/workers/docs-ecosystems.js +28 -1
- package/dist/workers/docs-index.d.ts +1 -1
- package/dist/workers/docs-index.js +57 -5
- package/dist/workers/eco-hackage.d.ts +39 -0
- package/dist/workers/eco-hackage.js +164 -1
- package/package.json +1 -1
|
@@ -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' ?
|
|
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.
|
|
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",
|