@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.
- 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/docs-retrieve.js +63 -6
- 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) {
|
|
@@ -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
|
|
132
|
-
//
|
|
133
|
-
//
|
|
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 (
|
|
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' ?
|
|
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",
|