@mjasnikovs/pi-task 0.40.7 → 0.40.10

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.
@@ -41,17 +41,33 @@ export interface ExcerptVerification {
41
41
  contentLength: number;
42
42
  /** The whitespace-normalised excerpt that was searched for. */
43
43
  normalisedExcerpt: string;
44
+ /**
45
+ * How many verbatim spans of the source the excerpt is made of — 1 when it
46
+ * verified, more when the child stitched it. Over every unverified excerpt in
47
+ * five live runs it ran 2 to 11, with nothing missing.
48
+ */
49
+ verbatimSpans: number;
50
+ /** Words in no span of the source at all. This, not `verified`, is what a
51
+ * fabrication looks like. */
52
+ absent: string[];
44
53
  }
45
54
  export declare function verifyExcerpt(excerpt: string, content: string): ExcerptVerification;
46
55
  /**
47
56
  * Format the child's parsed output with a header and optional excerpt block.
48
57
  *
49
- * The warning is prepended only on the excerpt path, and only for an explicit
50
- * `false`. With no excerpt the function returns before the warning is built, and
51
- * an `undefined` verdict — nothing was checked — prints no warning either. So the
52
- * warning means "checked and not found", never "not checked".
58
+ * The note is prepended only on the excerpt path, and only when something was
59
+ * actually checked. With no excerpt the function returns before it is built, and
60
+ * an undefined verification — nothing was checked — prints nothing either. So a
61
+ * note means "checked", never "not checked".
62
+ *
63
+ * Two different findings, because they mean different things to the worker that
64
+ * reads this. An excerpt the source does not contain a word of is a possible
65
+ * fabrication. An excerpt assembled from several real spans is a stitched quote,
66
+ * which is what the extraction prompt produces — and calling that a possible
67
+ * hallucination was wrong on 21 of 21 measured cases, on a fifth of every run's
68
+ * answers. See "Defect 18" in DOC_REGRESSINONS.md.
53
69
  */
54
70
  export declare function formatResultText(header: string, parsed: {
55
71
  answer: string;
56
72
  excerpt?: string;
57
- }, verified: boolean | undefined): string;
73
+ }, check: ExcerptVerification | undefined): string;
@@ -44,6 +44,40 @@ export function isExcerptInContent(excerpt, content) {
44
44
  return false;
45
45
  return normaliseWhitespace(content).includes(ne);
46
46
  }
47
+ /** The child writes these to mark its own join, so they are not source text and
48
+ * not evidence of invention either. */
49
+ const ELISION = /^(?:\.{3}|\u2026|\/\/\s*\.{3})$/;
50
+ /**
51
+ * Cover the excerpt with the longest runs the source actually contains, greedily.
52
+ *
53
+ * `verified` asks whether the excerpt is ONE such run. This asks what it is made
54
+ * of, which is the difference between a quote assembled from four real places and
55
+ * a quote with a word nobody wrote. The warning needs the second question; it had
56
+ * only ever asked the first.
57
+ */
58
+ function coverBySource(normalisedExcerpt, normalisedContent) {
59
+ const words = normalisedExcerpt.length === 0 ? [] : normalisedExcerpt.split(' ');
60
+ const absent = [];
61
+ let verbatimSpans = 0;
62
+ let i = 0;
63
+ while (i < words.length) {
64
+ let run = 0;
65
+ while (i + run < words.length
66
+ && normalisedContent.includes(words.slice(i, i + run + 1).join(' '))) {
67
+ run++;
68
+ }
69
+ if (run === 0) {
70
+ if (!ELISION.test(words[i]))
71
+ absent.push(words[i]);
72
+ i++;
73
+ }
74
+ else {
75
+ verbatimSpans++;
76
+ i += run;
77
+ }
78
+ }
79
+ return { verbatimSpans, absent };
80
+ }
47
81
  export function verifyExcerpt(excerpt, content) {
48
82
  const nc = normaliseWhitespace(content);
49
83
  const ne = normaliseWhitespace(excerpt);
@@ -52,24 +86,36 @@ export function verifyExcerpt(excerpt, content) {
52
86
  verified: isExcerptInContent(excerpt, content),
53
87
  contentSha256: createHash('sha256').update(nc).digest('hex'),
54
88
  contentLength: nc.length,
55
- normalisedExcerpt: ne
89
+ normalisedExcerpt: ne,
90
+ ...coverBySource(ne, nc)
56
91
  };
57
92
  }
58
93
  /**
59
94
  * Format the child's parsed output with a header and optional excerpt block.
60
95
  *
61
- * The warning is prepended only on the excerpt path, and only for an explicit
62
- * `false`. With no excerpt the function returns before the warning is built, and
63
- * an `undefined` verdict — nothing was checked — prints no warning either. So the
64
- * warning means "checked and not found", never "not checked".
96
+ * The note is prepended only on the excerpt path, and only when something was
97
+ * actually checked. With no excerpt the function returns before it is built, and
98
+ * an undefined verification — nothing was checked — prints nothing either. So a
99
+ * note means "checked", never "not checked".
100
+ *
101
+ * Two different findings, because they mean different things to the worker that
102
+ * reads this. An excerpt the source does not contain a word of is a possible
103
+ * fabrication. An excerpt assembled from several real spans is a stitched quote,
104
+ * which is what the extraction prompt produces — and calling that a possible
105
+ * hallucination was wrong on 21 of 21 measured cases, on a fifth of every run's
106
+ * answers. See "Defect 18" in DOC_REGRESSINONS.md.
65
107
  */
66
- export function formatResultText(header, parsed, verified) {
108
+ export function formatResultText(header, parsed, check) {
67
109
  if (!parsed.excerpt) {
68
110
  return header ? `${header}\n\n${parsed.answer}` : parsed.answer;
69
111
  }
70
112
  const quote = parsed.excerpt.replace(/\n/g, '\n> ');
71
- const warning = verified === false ?
72
- 'WARNING: cited excerpt not found verbatim in source content — the child pi may have paraphrased or hallucinated.\n\n'
73
- : '';
74
- return `${warning}${header}\n\n${parsed.answer}\n\nSource excerpt:\n> ${quote}`;
113
+ let note = '';
114
+ if (check && !check.verified) {
115
+ note =
116
+ check.absent.length > 0 ?
117
+ 'WARNING: cited excerpt not found verbatim in source content — the child pi may have paraphrased or hallucinated.\n\n'
118
+ : `NOTE: cited excerpt is stitched from ${check.verbatimSpans} separate spans of the source; every span is verbatim.\n\n`;
119
+ }
120
+ return `${note}${header}\n\n${parsed.answer}\n\nSource excerpt:\n> ${quote}`;
75
121
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Sibling of refuted-constraint.ts, for the other half of the same failure.
3
+ *
4
+ * That pass deletes a refine-invented DEPENDENCY the research says is not needed.
5
+ * This one deletes a refine-invented API or package the research says is
6
+ * DEPRECATED. Defect 14: refine writes an API example into CONSTRAINTS from
7
+ * memory before research runs, research then names that exact form as superseded,
8
+ * and compose prefers the constraint — so the run ships the deprecated call with
9
+ * the correct answer sitting one section above it in the same file.
10
+ *
11
+ * Everything about the sibling's discipline carries over unchanged: the
12
+ * correction is SUBTRACTIVE (an appended one loses to the text it contradicts),
13
+ * the match is lexical, and an owned-requirement line is never touched.
14
+ *
15
+ * Two things are deliberately NOT shared with the sibling, which is why this is a
16
+ * second pass and not a widening of the first:
17
+ *
18
+ * - it reads research APIS as well as CONTEXT, and two of the three recorded
19
+ * fires are in APIS;
20
+ * - its token class admits an UN-BACKTICKED API expression (`z.string().email()`),
21
+ * which `isPackageToken` refuses on purpose to keep the dependency channel
22
+ * narrow. Widening that channel to reach this one would loosen it for every
23
+ * dependency drop.
24
+ */
25
+ export type Deprecation = {
26
+ /** The deprecated token, as it must be matched inside the constraint. */
27
+ token: string;
28
+ /** Index into the refined prompt's lines. */
29
+ line: number;
30
+ /** The refine CONSTRAINTS line, verbatim, before the drop. */
31
+ constraint: string;
32
+ /** The research line that deprecates it, verbatim. */
33
+ research: string;
34
+ /** `apis-symbol` or `marker-adjacent` — which rule fired. */
35
+ rule: string;
36
+ };
37
+ /** The tokens one research line deprecates, or an empty list. */
38
+ export declare function deprecatedTokens(line: string): string[];
39
+ /**
40
+ * Find every (constraint line, research line, deprecated token) triple. `refined`
41
+ * is the refined prompt and `research` the research output — the exact strings
42
+ * compose is handed.
43
+ */
44
+ export declare function detectDeprecations(refined: string, research: string): Deprecation[];
45
+ /**
46
+ * Delete one token from a constraint line, backticked or bare, plus one adjacent
47
+ * list separator. Then collapse the wreckage the deletion left: an emptied
48
+ * `(e.g. )`, doubled spaces, a space stranded before punctuation.
49
+ *
50
+ * Every step removes characters and adds none, so the result stays a character
51
+ * subsequence of the input — the invariant the whole approach rests on.
52
+ *
53
+ * The example-parenthetical collapse requires a literal `e.g.`/`i.e.` rather than
54
+ * matching any emptied `()`. A bare `\(\s*\)` also matches the call parens inside
55
+ * `z.string()` elsewhere on the same line, which would silently corrupt a
56
+ * constraint this pass is not even firing on.
57
+ *
58
+ * Returns null when nothing but boilerplate is left, which the caller turns into
59
+ * a whole-line drop.
60
+ */
61
+ export declare function dropExpression(line: string, token: string): string | null;
62
+ export type DeprecationResult = {
63
+ refined: string;
64
+ trail: string[];
65
+ deprecations: Deprecation[];
66
+ };
67
+ /** Apply every detected deprecation to the refined prompt. Purely subtractive. */
68
+ export declare function applyDeprecations(refined: string, research: string): DeprecationResult;
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Sibling of refuted-constraint.ts, for the other half of the same failure.
3
+ *
4
+ * That pass deletes a refine-invented DEPENDENCY the research says is not needed.
5
+ * This one deletes a refine-invented API or package the research says is
6
+ * DEPRECATED. Defect 14: refine writes an API example into CONSTRAINTS from
7
+ * memory before research runs, research then names that exact form as superseded,
8
+ * and compose prefers the constraint — so the run ships the deprecated call with
9
+ * the correct answer sitting one section above it in the same file.
10
+ *
11
+ * Everything about the sibling's discipline carries over unchanged: the
12
+ * correction is SUBTRACTIVE (an appended one loses to the text it contradicts),
13
+ * the match is lexical, and an owned-requirement line is never touched.
14
+ *
15
+ * Two things are deliberately NOT shared with the sibling, which is why this is a
16
+ * second pass and not a widening of the first:
17
+ *
18
+ * - it reads research APIS as well as CONTEXT, and two of the three recorded
19
+ * fires are in APIS;
20
+ * - its token class admits an UN-BACKTICKED API expression (`z.string().email()`),
21
+ * which `isPackageToken` refuses on purpose to keep the dependency channel
22
+ * narrow. Widening that channel to reach this one would loosen it for every
23
+ * dependency drop.
24
+ */
25
+ /** Bare ALL-CAPS section header — same boundary convention as the sibling. */
26
+ const HEADER = /^[A-Z][A-Z -]*$/;
27
+ /**
28
+ * The closed deprecation set, exactly as the base-rate measurement left it.
29
+ *
30
+ * `\breplaced by\b` was in the first pattern set and the measurement REMOVED it:
31
+ * it fired on "`src/shared/index.ts` Empty file to be replaced by schema.ts
32
+ * exports", a claim about a FILE, and it was the only false positive in 14,171
33
+ * task files. Dropping it loses none of the three true fires.
34
+ */
35
+ const DEPRECATION = /@?\bdeprecated\b|\bsupersed(?:e|es|ed)\b|\bmerged\s+into\b|\bno\s+longer\s+(?:recommended|supported|maintained)\b/i;
36
+ /**
37
+ * Where the token's own clause ends. A deprecation marker past one of these is a
38
+ * claim about something else — on "z.email() … for adminEmail (z.string().email()
39
+ * … is @deprecated)" the opening paren is what stops the leading symbol from
40
+ * inheriting the verdict on the expression inside it.
41
+ *
42
+ * A character window was measured first and rejected: it is flat from 41 to 4,000
43
+ * over 12,568 task files, so the corpus cannot choose one and any value would be
44
+ * a guess. The clause boundary reproduces the same 3 fires with no constant.
45
+ */
46
+ const CLAUSE_END = /[;()\u2014\u2013]|\.\s/;
47
+ /** The owned-requirement stamp requirements.ts writes. Never refutable. */
48
+ const OWNED_MARKER = 'owned requirement from the source design';
49
+ const IDENT = '[A-Za-z_$][A-Za-z0-9_$]*';
50
+ const CALL = '\\([^()]*\\)';
51
+ /**
52
+ * A call expression: identifiers and calls chained with `.`, requiring at least
53
+ * one of each. `Network.Wai.Test` is a module path and does not qualify;
54
+ * `z.string().email()` does. This is the class the sibling refuses.
55
+ */
56
+ const EXPRESSION = new RegExp(`${IDENT}(?:\\.${IDENT}|${CALL})+`, 'g');
57
+ /**
58
+ * A package name, narrowed to the compound forms. A bare word (`aeson`, `text`)
59
+ * is excluded: on the leading-symbol rule below it would let any one-word noun in
60
+ * a deprecation sentence reach CONSTRAINTS, and no recorded fire needs it.
61
+ */
62
+ const PACKAGE = /^@?[a-z][a-z0-9.]*(?:[-/][a-z0-9.]+)+$/;
63
+ /** Section body between a bare ALL-CAPS header and the next one (or EOF). */
64
+ function capsSection(text, heading) {
65
+ const lines = text.split('\n');
66
+ const start = lines.findIndex(l => l.trim() === heading);
67
+ if (start === -1)
68
+ return null;
69
+ const rest = lines.slice(start + 1);
70
+ const end = rest.findIndex(l => HEADER.test(l.trim()) && l.trim().length > 1);
71
+ return (end === -1 ? rest : rest.slice(0, end)).join('\n');
72
+ }
73
+ /** One entry per bullet or per `symbol description` row; continuations joined. */
74
+ function entries(section) {
75
+ const out = [];
76
+ for (const raw of section.split('\n')) {
77
+ const line = raw.trimEnd();
78
+ if (!line.trim())
79
+ continue;
80
+ if (/^\s*[-*]\s+/.test(line) || !/^\s/.test(line))
81
+ out.push(line.trim());
82
+ else if (out.length > 0)
83
+ out[out.length - 1] += ` ${line.trim()}`;
84
+ else
85
+ out.push(line.trim());
86
+ }
87
+ return out;
88
+ }
89
+ function stripTicks(s) {
90
+ return s.replace(/`/g, '');
91
+ }
92
+ /**
93
+ * Every expression on the line whose deprecation marker sits in the same clause,
94
+ * ahead of it. Forward-only: "X is deprecated, use Y" must not indict Y, and Y is
95
+ * always on the far side of the marker.
96
+ */
97
+ function markerAdjacent(line) {
98
+ const flat = stripTicks(line);
99
+ const out = [];
100
+ for (const m of flat.matchAll(EXPRESSION)) {
101
+ const tok = m[0];
102
+ if (!tok.includes('.') || !tok.includes('('))
103
+ continue;
104
+ const rest = flat.slice(m.index + tok.length);
105
+ const stop = CLAUSE_END.exec(rest);
106
+ const tail = stop ? rest.slice(0, stop.index) : rest;
107
+ if (DEPRECATION.test(tail) && !out.includes(tok))
108
+ out.push(tok);
109
+ }
110
+ return out;
111
+ }
112
+ /**
113
+ * An APIS row is `symbol description`, so the row's leading symbol is what the
114
+ * row is ABOUT. Used only when no expression sits next to the marker: on
115
+ * "z.email() … (z.string().email() … is @deprecated)" the leading symbol is the
116
+ * replacement, not the casualty, and the adjacent expression is the truth.
117
+ */
118
+ function apisSymbol(line) {
119
+ const m = /^(\S+)\s\s+\S/.exec(stripTicks(line));
120
+ if (!m)
121
+ return null;
122
+ const tok = m[1];
123
+ if (PACKAGE.test(tok))
124
+ return tok;
125
+ if (tok.includes('.') && tok.includes('('))
126
+ return tok;
127
+ return null;
128
+ }
129
+ /** The tokens one research line deprecates, or an empty list. */
130
+ export function deprecatedTokens(line) {
131
+ if (!DEPRECATION.test(stripTicks(line)))
132
+ return [];
133
+ const adjacent = markerAdjacent(line);
134
+ if (adjacent.length > 0)
135
+ return adjacent;
136
+ const symbol = apisSymbol(line);
137
+ return symbol === null ? [] : [symbol];
138
+ }
139
+ /**
140
+ * Find every (constraint line, research line, deprecated token) triple. `refined`
141
+ * is the refined prompt and `research` the research output — the exact strings
142
+ * compose is handed.
143
+ */
144
+ export function detectDeprecations(refined, research) {
145
+ const constraintsBody = capsSection(refined, 'CONSTRAINTS');
146
+ if (constraintsBody === null)
147
+ return [];
148
+ const deprecated = new Map();
149
+ for (const heading of ['APIS', 'CONTEXT']) {
150
+ const body = capsSection(research, heading);
151
+ if (body === null)
152
+ continue;
153
+ for (const line of entries(body)) {
154
+ const adjacent = markerAdjacent(line);
155
+ const rule = adjacent.length > 0 ? 'marker-adjacent' : 'apis-symbol';
156
+ for (const tok of deprecatedTokens(line)) {
157
+ if (!deprecated.has(tok))
158
+ deprecated.set(tok, { research: line, rule });
159
+ }
160
+ }
161
+ }
162
+ if (deprecated.size === 0)
163
+ return [];
164
+ const lines = refined.split('\n');
165
+ const start = lines.findIndex(l => l.trim() === 'CONSTRAINTS');
166
+ const out = [];
167
+ for (let i = start + 1; i < lines.length; i++) {
168
+ const line = lines[i];
169
+ if (HEADER.test(line.trim()) && line.trim().length > 1)
170
+ break;
171
+ if (line.includes(OWNED_MARKER))
172
+ continue;
173
+ for (const [tok, src] of deprecated) {
174
+ if (!stripTicks(line).includes(tok))
175
+ continue;
176
+ out.push({
177
+ token: tok,
178
+ line: i,
179
+ constraint: line,
180
+ research: src.research,
181
+ rule: src.rule
182
+ });
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+ function escapeRe(s) {
188
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
189
+ }
190
+ /**
191
+ * Delete one token from a constraint line, backticked or bare, plus one adjacent
192
+ * list separator. Then collapse the wreckage the deletion left: an emptied
193
+ * `(e.g. )`, doubled spaces, a space stranded before punctuation.
194
+ *
195
+ * Every step removes characters and adds none, so the result stays a character
196
+ * subsequence of the input — the invariant the whole approach rests on.
197
+ *
198
+ * The example-parenthetical collapse requires a literal `e.g.`/`i.e.` rather than
199
+ * matching any emptied `()`. A bare `\(\s*\)` also matches the call parens inside
200
+ * `z.string()` elsewhere on the same line, which would silently corrupt a
201
+ * constraint this pass is not even firing on.
202
+ *
203
+ * Returns null when nothing but boilerplate is left, which the caller turns into
204
+ * a whole-line drop.
205
+ */
206
+ export function dropExpression(line, token) {
207
+ const tokRe = new RegExp('`?' + escapeRe(token) + '`?', 'g');
208
+ let next = line;
209
+ for (;;) {
210
+ const m = tokRe.exec(next);
211
+ if (!m)
212
+ break;
213
+ const start = m.index;
214
+ const end = start + m[0].length;
215
+ const before = next.slice(0, start);
216
+ const sepBefore = /(?:,\s*|\s+or\s+|\s+and\s+)$/.exec(before);
217
+ if (sepBefore) {
218
+ next = next.slice(0, start - sepBefore[0].length) + next.slice(end);
219
+ }
220
+ else {
221
+ const sepAfter = /^(?:\s*,\s*|\s+or\s+|\s+and\s+)/.exec(next.slice(end));
222
+ next = before + next.slice(end + (sepAfter ? sepAfter[0].length : 0));
223
+ }
224
+ tokRe.lastIndex = 0;
225
+ }
226
+ if (next === line)
227
+ return line;
228
+ // Leading whitespace is held out of the collapse: it is a nested bullet's
229
+ // markdown level, not a gap this deletion opened.
230
+ const indent = /^\s*/.exec(next)?.[0] ?? '';
231
+ next =
232
+ indent
233
+ + next
234
+ .slice(indent.length)
235
+ .replace(/\s*\((?:\s*e\.g\.,?|\s*i\.e\.,?)\s*\)/gi, '')
236
+ .replace(/ {2,}/g, ' ')
237
+ .replace(/ +([.,;:)])/g, '$1');
238
+ const carcass = next.replace(/^\s*[-*]\s*/, '').replace(/[\s,.;:()]/g, '');
239
+ return carcass.length === 0 ? null : next;
240
+ }
241
+ /** Apply every detected deprecation to the refined prompt. Purely subtractive. */
242
+ export function applyDeprecations(refined, research) {
243
+ const deprecations = detectDeprecations(refined, research);
244
+ if (deprecations.length === 0)
245
+ return { refined, trail: [], deprecations };
246
+ const lines = refined.split('\n');
247
+ const dropped = new Set();
248
+ const trail = [];
249
+ for (const d of deprecations) {
250
+ const next = dropExpression(lines[d.line], d.token);
251
+ const what = next === null ?
252
+ `dropped the whole CONSTRAINTS line for '${d.token}'`
253
+ : `dropped '${d.token}' from CONSTRAINTS`;
254
+ if (next === null)
255
+ dropped.add(d.line);
256
+ else
257
+ lines[d.line] = next;
258
+ trail.push(`constraint deprecated by research — ${what}`
259
+ + ` | constraint: "${d.constraint.trim()}" | research: "${d.research.trim()}"`);
260
+ }
261
+ return { refined: lines.filter((_l, i) => !dropped.has(i)).join('\n'), trail, deprecations };
262
+ }
@@ -174,10 +174,12 @@ export declare function phaseResearch(deps: PhaseDeps, refined: string): Promise
174
174
  export declare function phaseAutoAnswer(deps: PhaseDeps, refined: string, research: string, question: string): Promise<AutoAnswer>;
175
175
  export declare function phaseGrill(deps: PhaseDeps, ctx: ExtensionCommandContext, widgetState: WidgetState, refined: string, research: string): Promise<string>;
176
176
  /**
177
- * A refutation is a DELETION. Where the run's own research explicitly says a
178
- * dependency refine invented is not needed, drop that token from CONSTRAINTS
179
- * compose cannot forbid the design's own API "because the refined task
180
- * explicitly requires `argon2`" if the refined task no longer requires it.
177
+ * A correction is a DELETION. Where the run's own research explicitly says a
178
+ * dependency refine invented is not needed, or names the API refine wrote as
179
+ * deprecated, drop that token from CONSTRAINTS compose cannot forbid the
180
+ * design's own API "because the refined task explicitly requires `argon2`" if the
181
+ * refined task no longer requires it, and cannot ship `z.string().email()` under
182
+ * a constraint that no longer names it.
181
183
  *
182
184
  * Applied to the REFINED TASK ITSELF, not to compose's copy of it, and that is
183
185
  * load-bearing: critique receives the refined task as GROUND TRUTH and is told
@@ -185,7 +187,8 @@ export declare function phaseGrill(deps: PhaseDeps, ctx: ExtensionCommandContext
185
187
  * them", so a deletion visible only to compose is restored one phase later. Both
186
188
  * spec-producing phases have to see the same text.
187
189
  *
188
- * Purely subtractive and never touches an owned line (task/refuted-constraint.ts).
190
+ * Purely subtractive and never touches an owned line (task/refuted-constraint.ts,
191
+ * task/deprecated-constraint.ts).
189
192
  * Idempotent, so a resumed run re-deriving `refined` from the task file lands in
190
193
  * the same place. The task file's `## refined prompt` is deliberately left as
191
194
  * refine wrote it; the drop is recorded on the `## gates` trail with both source
@@ -193,7 +196,7 @@ export declare function phaseGrill(deps: PhaseDeps, ctx: ExtensionCommandContext
193
196
  */
194
197
  export declare function dropRefutedConstraints(deps: PhaseDeps, refined: string, research: string): Promise<string>;
195
198
  /**
196
- * COMPOSE's carry: the refutation drop, as a `PhaseConfig.carry`.
199
+ * COMPOSE's carry: both subtractive drops, as a `PhaseConfig.carry`.
197
200
  *
198
201
  * Same transform as `dropRefutedConstraints` over the same pure core, minus the
199
202
  * recording — the caller decides whether this application is the live one or a
@@ -22,6 +22,7 @@ import { resolve } from 'node:path';
22
22
  import { buildExternalContext, gatherExternalContext } from './external-context.js';
23
23
  import { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT, MAX_GRILL_QUESTIONS } from './prompts.js';
24
24
  import { appendGateRecord, readSection, removeTaskSection, setTaskSection, updateTaskFrontMatter } from './task-io.js';
25
+ import { applyDeprecations } from './deprecated-constraint.js';
25
26
  import { applyRefutations } from './refuted-constraint.js';
26
27
  import { spawnSync } from 'node:child_process';
27
28
  import {} from './task-types.js';
@@ -906,10 +907,12 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
906
907
  return transcript.forRecord();
907
908
  }
908
909
  /**
909
- * A refutation is a DELETION. Where the run's own research explicitly says a
910
- * dependency refine invented is not needed, drop that token from CONSTRAINTS
911
- * compose cannot forbid the design's own API "because the refined task
912
- * explicitly requires `argon2`" if the refined task no longer requires it.
910
+ * A correction is a DELETION. Where the run's own research explicitly says a
911
+ * dependency refine invented is not needed, or names the API refine wrote as
912
+ * deprecated, drop that token from CONSTRAINTS compose cannot forbid the
913
+ * design's own API "because the refined task explicitly requires `argon2`" if the
914
+ * refined task no longer requires it, and cannot ship `z.string().email()` under
915
+ * a constraint that no longer names it.
913
916
  *
914
917
  * Applied to the REFINED TASK ITSELF, not to compose's copy of it, and that is
915
918
  * load-bearing: critique receives the refined task as GROUND TRUTH and is told
@@ -917,21 +920,22 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
917
920
  * them", so a deletion visible only to compose is restored one phase later. Both
918
921
  * spec-producing phases have to see the same text.
919
922
  *
920
- * Purely subtractive and never touches an owned line (task/refuted-constraint.ts).
923
+ * Purely subtractive and never touches an owned line (task/refuted-constraint.ts,
924
+ * task/deprecated-constraint.ts).
921
925
  * Idempotent, so a resumed run re-deriving `refined` from the task file lands in
922
926
  * the same place. The task file's `## refined prompt` is deliberately left as
923
927
  * refine wrote it; the drop is recorded on the `## gates` trail with both source
924
928
  * lines quoted, so the decision stays auditable after the fact.
925
929
  */
926
930
  export async function dropRefutedConstraints(deps, refined, research) {
927
- const refuted = applyRefutations(refined, research);
928
- if (refuted.trail.length === 0)
931
+ const dropped = dropRefutedAndDeprecated(refined, research);
932
+ if (dropped.trail.length === 0)
929
933
  return refined;
930
- await recordPhaseTrail(deps, 'compose', refuted.trail);
931
- return refuted.refined;
934
+ await recordPhaseTrail(deps, 'compose', dropped.trail);
935
+ return dropped.refined;
932
936
  }
933
937
  /**
934
- * COMPOSE's carry: the refutation drop, as a `PhaseConfig.carry`.
938
+ * COMPOSE's carry: both subtractive drops, as a `PhaseConfig.carry`.
935
939
  *
936
940
  * Same transform as `dropRefutedConstraints` over the same pure core, minus the
937
941
  * recording — the caller decides whether this application is the live one or a
@@ -941,11 +945,21 @@ export async function dropRefutedConstraints(deps, refined, research) {
941
945
  export function composeCarry(_deps, pc) {
942
946
  // Not `async`, and that is the shape rather than an oversight: this carry
943
947
  // performs no I/O at all, which is what lets the resume path replay it.
944
- const refuted = applyRefutations(pc.refined, pc.research);
945
- if (refuted.trail.length === 0)
948
+ const dropped = dropRefutedAndDeprecated(pc.refined, pc.research);
949
+ if (dropped.trail.length === 0)
946
950
  return Promise.resolve([]);
947
- pc.refined = refuted.refined;
948
- return Promise.resolve(refuted.trail);
951
+ pc.refined = dropped.refined;
952
+ return Promise.resolve(dropped.trail);
953
+ }
954
+ /**
955
+ * The two subtractive passes, in one place so the live path and the resume replay
956
+ * cannot drift apart. Chained rather than run side by side: the second reads the
957
+ * first's output, so a line the first deleted outright is never scanned again.
958
+ */
959
+ function dropRefutedAndDeprecated(refined, research) {
960
+ const refuted = applyRefutations(refined, research);
961
+ const deprecated = applyDeprecations(refuted.refined, research);
962
+ return { refined: deprecated.refined, trail: [...refuted.trail, ...deprecated.trail] };
949
963
  }
950
964
  /** Write a carry's trail to the debug log and the task file's `## gates` section. */
951
965
  export async function recordPhaseTrail(deps, phaseName, trail) {
@@ -79,6 +79,8 @@ export function buildExtractionPrompt(opts) {
79
79
  + `4. If the answer is unclear, ambiguous, or absent from <${tag}-content>, write exactly:\n`
80
80
  + ` <answer>${abstentionSentence(opts.kind)}</answer> and put the closest related text in <excerpt>.\n`
81
81
  + ` Do not guess.\n`
82
+ + ` A question with several parts: answer the parts <${tag}-content> covers, and name\n`
83
+ + ` the parts it does not. Use rule 4's sentence alone only when it covers no part.\n`
82
84
  + `5. Be terse. One short paragraph in <answer> max.\n`
83
85
  + `\n`
84
86
  + `<${tag}>${opts.identity}</${tag}>\n`
@@ -17,6 +17,7 @@ import { type AutoInstallPin } from './docs-core.js';
17
17
  import { resolvePackage, type ResolvedPackage } from './docs-resolve.js';
18
18
  import { npmVersionLookup, type NpmVersionInfo } from './npm-version.js';
19
19
  import { type SpawnFn } from '../shared/child-process.js';
20
+ import type { ExportGap } from './export-gap.js';
20
21
  export type EcosystemId = 'npm' | 'cargo' | 'hackage';
21
22
  /**
22
23
  * Every filesystem, process and network reach a row is allowed. Rows read no
@@ -75,10 +76,22 @@ export interface EcosystemProfile {
75
76
  }>;
76
77
  /**
77
78
  * 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.
79
+ * package exports names it does not declare `hspec`/`hspec-core`,
80
+ * `axum`/`axum-core`; see DEFECT-12-STOPPING-RULE.md.
80
81
  */
81
82
  supplements?: (pkg: ResolvedPackage, cwd: string, io: EcosystemIo) => Promise<ResolvedPackage[]>;
83
+ /**
84
+ * What this package publishes and declares nowhere, in this ecosystem's own
85
+ * syntax. Read only when `supplements` returned something, and it decides
86
+ * which of a supplement's chunks are kept.
87
+ */
88
+ exportGap?: (root: string) => ExportGap;
89
+ /**
90
+ * Source of `exportGap` AND everything it delegates to, for the index
91
+ * fingerprint. `String(exportGap)` alone leaves a fix in a helper invisible,
92
+ * which is the failure `chunkerFingerprint` exists to close one level up.
93
+ */
94
+ exportGapFingerprint?: () => string;
82
95
  /** The registry's own newest version, for grounding an answer in the present. */
83
96
  latest: (name: string, io: EcosystemIo) => Promise<NpmVersionInfo | null>;
84
97
  /** True for a file that carries the package's public API surface. */
@@ -21,8 +21,8 @@ import { runAutoInstall, findDeclaredRange, extractParentPackage, resolveTypeSou
21
21
  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
- 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, supplementCandidates, findCabalTarball, cachedVersions, resolvedVersions, manifestPackages, isValidHackageName, isHaskellFile, haskellSurface, HACKAGE_DECL_SPLIT_RE, HACKAGE_SKIP_DIRS } from './eco-hackage.js';
24
+ import { resolveCrate, cratesLatest, crateTarballUrl, crateOf, isValidCrateName, isRustFile, lockedVersion, rustSurface, cargoProjectName, childDirs, lockedDeps, manifestCrates, cargoExportGap, cargoGapFingerprint, cargoSupplementCandidates, CARGO_DECL_SPLIT_RE } from './eco-cargo.js';
25
+ import { resolveHackage, hackageLatest, hackageVersion, hackageTarballUrl, hackageExtractDir, hackageProjectName, supplementCandidates, hackageExportGap, hackageGapFingerprint, 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?
@@ -267,6 +267,39 @@ const cargoProfile = {
267
267
  }
268
268
  return acquireCrate(info?.pkg ?? name, version, io);
269
269
  },
270
+ supplements: async (pkg, cwd, io) => {
271
+ const deps = manifestCrates(pkg.root);
272
+ if (!deps)
273
+ return [];
274
+ // The PROJECT's lock, never the crate's own root. `findLock` walks upward,
275
+ // and a crate unpacked under `~/.cargo/registry` sits below whatever lock
276
+ // happens to be above it — which resolved a version this project never
277
+ // pinned, making the index a function of the machine.
278
+ const candidates = cargoSupplementCandidates(pkg.name, deps, lockedDeps(cwd) ?? {});
279
+ const out = [];
280
+ for (const c of candidates) {
281
+ try {
282
+ out.push(resolveCrate(c.name, cwd, { cargoHome: io.cargoHome, modulesDir: io.modulesDir }));
283
+ continue;
284
+ }
285
+ catch {
286
+ // Not unpacked here. `acquire` reads crates.io for the published
287
+ // spelling, so `tokio-util` and `tokio_util` both land.
288
+ }
289
+ const got = await cargoProfile.acquire(c.name, c.version, io);
290
+ if (!got.success)
291
+ continue;
292
+ try {
293
+ out.push(resolveCrate(c.name, cwd, { cargoHome: io.cargoHome, modulesDir: io.modulesDir }));
294
+ }
295
+ catch {
296
+ // A supplement that will not resolve leaves the facade as it was.
297
+ }
298
+ }
299
+ return out;
300
+ },
301
+ exportGap: cargoExportGap,
302
+ exportGapFingerprint: cargoGapFingerprint,
270
303
  latest: (name, io) => cratesLatest(name, io.fetch, io.signal),
271
304
  isSurfaceFile: isRustFile,
272
305
  surface: content => rustSurface(content),
@@ -381,6 +414,8 @@ const hackageProfile = {
381
414
  }
382
415
  return out;
383
416
  },
417
+ exportGap: hackageExportGap,
418
+ exportGapFingerprint: hackageGapFingerprint,
384
419
  latest: (name, io) => hackageLatest(name, io.fetch, io.signal),
385
420
  isSurfaceFile: isHaskellFile,
386
421
  surface: haskellSurface,
@@ -4,7 +4,6 @@ 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';
8
7
  const ZERO_SEP = Buffer.from([0]);
9
8
  /**
10
9
  * The gate that decides whether a package needs re-indexing.
@@ -55,9 +54,14 @@ function computeContentHash(pkg, profile, supplements = []) {
55
54
  // — and nothing surfaced the duplicate drop in `ingestBody` at all.
56
55
  hash.update(Buffer.from(`${String(profile.surface)}\u0000${String(ingestBody)}`, 'utf8'));
57
56
  hash.update(ZERO_SEP);
58
- // Which packages were folded in, so gaining or losing one re-indexes.
57
+ // Which packages were folded in, so gaining or losing one re-indexes — and the
58
+ // rule that decides WHICH of their chunks are kept, by source. Hashing the set
59
+ // alone left a fix to `cargoExportGap` invisible, so every cached facade held
60
+ // the chunks the old rule chose.
59
61
  hash.update(Buffer.from(supplements.map(s => `${s.name}@${s.version}`).join('\u0000'), 'utf8'));
60
62
  hash.update(ZERO_SEP);
63
+ hash.update(Buffer.from(profile.exportGapFingerprint?.() ?? String(profile.exportGap ?? ''), 'utf8'));
64
+ hash.update(ZERO_SEP);
61
65
  if (pkg.entry && fs.existsSync(pkg.entry)) {
62
66
  try {
63
67
  hash.update(Buffer.from(profile.surface(fs.readFileSync(pkg.entry, 'utf8')), 'utf8'));
@@ -216,10 +220,9 @@ function ingestBody(cache, pkg, profile, contentHash, supplements = []) {
216
220
  // A facade package indexes to a table of contents: `hspec` is 14 chunks of
217
221
  // export lists and every signature is in `hspec-core`. Fill only the holes —
218
222
  // 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
+ const found = supplements.length > 0 ? (profile.exportGap?.(pkg.root) ?? null) : null;
224
+ const gap = found !== null && !found.empty ? found : null;
225
+ for (const sup of gap === null ? [] : supplements) {
223
226
  for (const abs of collectFiles(sup, profile).surface) {
224
227
  let raw;
225
228
  try {
@@ -228,15 +231,13 @@ function ingestBody(cache, pkg, profile, contentHash, supplements = []) {
228
231
  catch {
229
232
  continue;
230
233
  }
231
- const module = /^module\s+([\w.']+)/m.exec(raw)?.[1];
232
- const whole = module !== undefined && gap.reexportedModules.has(module);
233
234
  // The path names the package the declaration really came from: the
234
235
  // chunk header is model-facing, and a signature attributed to the
235
236
  // wrong package is the bug this whole table exists for.
236
237
  const rel = `${sup.name}-${sup.version}/` + path.relative(sup.root, abs).replace(/\\/g, '/');
238
+ const whole = gap.wholesale(rel, raw);
237
239
  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
+ if (!whole && !gap.fillsHole(c.replace(/^\S.*\n/, '')))
240
241
  continue;
241
242
  if (seen.has(c))
242
243
  continue;
@@ -36,7 +36,7 @@ export async function docsLookup(input) {
36
36
  const excerptVerified = extraction.excerptVerified;
37
37
  return {
38
38
  kind: 'answer',
39
- body: formatResultText(input.corpus.header, extraction, excerptVerified),
39
+ body: formatResultText(input.corpus.header, extraction, extraction.excerptCheck),
40
40
  content,
41
41
  extraction,
42
42
  excerptVerified
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { type ResolvedPackage } from './docs-resolve.js';
14
14
  import type { NpmVersionInfo } from './npm-version.js';
15
+ import type { ExportGap } from './export-gap.js';
15
16
  export declare function isValidCrateName(name: string): boolean;
16
17
  /** `serde_json::Value` is a path into `serde_json`; the crate is what installs. */
17
18
  export declare function crateOf(name: string): string;
@@ -123,4 +124,37 @@ export declare function cargoProjectName(cwd: string): string | null;
123
124
  * Undefined when there is no readable manifest, which is not "declares nothing".
124
125
  */
125
126
  export declare function manifestCrates(cwd: string): Set<string> | undefined;
127
+ /**
128
+ * The names this crate publishes through a dependency and declares nowhere.
129
+ *
130
+ * The trigger is the hole, with no threshold — measured, and for the same reason
131
+ * as hackage: across twenty-two crates the unresolved fraction reads 100% on a
132
+ * crate with one re-export and 0% on a crate with none, so a ratio separates
133
+ * nothing. See "Defect 16" in DOC_REGRESSINONS.md for the sweep.
134
+ */
135
+ export declare function cargoExportGap(root: string): ExportGap;
136
+ /**
137
+ * Source of every function `cargoExportGap` delegates to.
138
+ *
139
+ * `String(cargoExportGap)` covers only the top level, and the two bugs already
140
+ * found in this pass — a rename split that truncated `Hasher`, a lock read from
141
+ * the wrong root — both lived in helpers. A fix to one of them has to move the
142
+ * index fingerprint or every cached facade keeps the chunks the old rule chose.
143
+ */
144
+ export declare function cargoGapFingerprint(): string;
145
+ /**
146
+ * Which declared dependencies may be opened to fill the gap.
147
+ *
148
+ * Cargo splits a facade from its implementation by name the way hackage does —
149
+ * `axum`/`axum-core`, `futures`/`futures-util`, `tracing`/`tracing-core` — and
150
+ * writes that name with either separator, so both spellings are one candidate.
151
+ *
152
+ * The bound's cost is stated rather than hidden: `hyper` re-exports twelve names
153
+ * from `http`, `bytes` and `http-body`, and axum's own `Bytes` comes from `bytes`.
154
+ * No prefix rule can see any of them.
155
+ */
156
+ export declare function cargoSupplementCandidates(pkgName: string, declaredDeps: ReadonlySet<string>, resolved: Readonly<Record<string, string>>): Array<{
157
+ name: string;
158
+ version: string;
159
+ }>;
126
160
  export {};
@@ -832,3 +832,223 @@ export function manifestCrates(cwd) {
832
832
  }
833
833
  return out;
834
834
  }
835
+ // ── the facade gap (DEFECT-12-STOPPING-RULE.md, cargo half) ─────────────────
836
+ /** A `pub use …;` statement, attributes and line breaks included. */
837
+ const PUB_USE_RE = /\bpub\s+use\s+([^;]+);/g;
838
+ /** Every item head that introduces a name, visibility ignored — a facade may
839
+ * re-export something its own private module declares. */
840
+ const RUST_DECL_RE = /\b(?:fn|struct|enum|union|trait|type|const|static|mod)\s+([A-Za-z_][A-Za-z0-9_]*)|macro_rules!\s*([A-Za-z_][A-Za-z0-9_]*)/g;
841
+ /** Path roots that name this crate, never a dependency. */
842
+ const OWN_PATH_ROOTS = new Set(['crate', 'self', 'super']);
843
+ /** Read `[dependencies]` only. Dev- and build-dependencies were measured and
844
+ * fetch `tokio-test`, `regex-test` and `tower-test` for zero extra names. */
845
+ function runtimeDeps(root) {
846
+ const text = safeRead(path.join(root, 'Cargo.toml'));
847
+ const out = new Set();
848
+ if (text === null)
849
+ return out;
850
+ let inDeps = false;
851
+ for (const raw of text.split('\n')) {
852
+ const line = raw.trim();
853
+ const header = /^\[([^\]]+)\]$/.exec(line);
854
+ if (header) {
855
+ const table = /^(?:target\.[^.]*\.)?dependencies(?:\.(.+))?$/.exec(header[1]);
856
+ inDeps = table !== null && table[1] === undefined;
857
+ if (table?.[1])
858
+ out.add(canonical(table[1]));
859
+ continue;
860
+ }
861
+ if (!inDeps)
862
+ continue;
863
+ const key = /^([A-Za-z0-9_-]+)\s*=/.exec(line);
864
+ if (key)
865
+ out.add(canonical(key[1]));
866
+ }
867
+ return out;
868
+ }
869
+ /** Every leaf name a use-path brings in, and every module it globs. */
870
+ function useTargets(body) {
871
+ // Whitespace is normalised, never removed: `Inner as Outer` collapsed to
872
+ // `InnerasOuter` is unrecoverable, and splitting a leaf on a bare "as" turns
873
+ // `Hasher` into `H`.
874
+ const flat = body
875
+ .replace(/#\[[^\]]*\]/g, '')
876
+ .replace(/\s+/g, ' ')
877
+ .trim();
878
+ const names = [];
879
+ const globs = [];
880
+ const expand = (prefix, rest) => {
881
+ const brace = rest.indexOf('{');
882
+ if (brace === -1) {
883
+ const full = prefix + rest;
884
+ if (full.endsWith('*'))
885
+ globs.push(full.replace(/::\*$/, ''));
886
+ else {
887
+ // The SOURCE name of a rename is the hole: the supplier declares
888
+ // `Inner`, whatever the facade calls it.
889
+ const leaf = full
890
+ .split('::')
891
+ .pop()
892
+ ?.split(/\s+as\s+/)[0]
893
+ .trim();
894
+ if (leaf)
895
+ names.push(leaf);
896
+ }
897
+ return;
898
+ }
899
+ const head = prefix + rest.slice(0, brace);
900
+ let depth = 0;
901
+ let start = brace + 1;
902
+ for (let i = brace; i < rest.length; i++) {
903
+ const c = rest[i];
904
+ if (c === '{')
905
+ depth++;
906
+ else if (c === '}') {
907
+ depth--;
908
+ if (depth === 0)
909
+ return expand(head, rest.slice(start, i));
910
+ }
911
+ else if (c === ',' && depth === 1) {
912
+ expand(head, rest.slice(start, i));
913
+ start = i + 1;
914
+ }
915
+ }
916
+ };
917
+ expand('', flat);
918
+ return { names, globs };
919
+ }
920
+ function rustSources(root) {
921
+ const out = [];
922
+ const walk = (dir) => {
923
+ let entries;
924
+ try {
925
+ entries = fs.readdirSync(dir, { withFileTypes: true });
926
+ }
927
+ catch {
928
+ return;
929
+ }
930
+ for (const e of entries) {
931
+ if (e.isDirectory()) {
932
+ if (!CARGO_SKIP_DIRS.includes(e.name))
933
+ walk(path.join(dir, e.name));
934
+ }
935
+ else if (isRustFile(e.name))
936
+ out.push(path.join(dir, e.name));
937
+ }
938
+ };
939
+ walk(root);
940
+ return out;
941
+ }
942
+ const CARGO_SKIP_DIRS = ['tests', 'benches', 'examples', 'target', '.git'];
943
+ /** `axum-core-0.5.6/src/response/mod.rs` -> `response`, the module path a
944
+ * `pub use axum_core::response::*` names. `lib.rs` and `mod.rs` are the module
945
+ * they sit in, not a module of their own. */
946
+ function moduleOfPath(relPath) {
947
+ const parts = relPath.replace(/\\/g, '/').split('/');
948
+ const src = parts.indexOf('src');
949
+ const tail = (src === -1 ? parts : parts.slice(src + 1)).join('/').replace(/\.rs$/, '');
950
+ return tail
951
+ .split('/')
952
+ .filter(seg => seg !== 'mod' && seg !== 'lib')
953
+ .join('::');
954
+ }
955
+ /**
956
+ * The names this crate publishes through a dependency and declares nowhere.
957
+ *
958
+ * The trigger is the hole, with no threshold — measured, and for the same reason
959
+ * as hackage: across twenty-two crates the unresolved fraction reads 100% on a
960
+ * crate with one re-export and 0% on a crate with none, so a ratio separates
961
+ * nothing. See "Defect 16" in DOC_REGRESSINONS.md for the sweep.
962
+ */
963
+ export function cargoExportGap(root) {
964
+ const deps = runtimeDeps(root);
965
+ const declared = new Set();
966
+ const reexported = new Set();
967
+ const globModules = new Set();
968
+ for (const file of rustSources(root)) {
969
+ const src = safeRead(file);
970
+ if (src === null)
971
+ continue;
972
+ for (const m of src.matchAll(RUST_DECL_RE))
973
+ declared.add((m[1] ?? m[2]));
974
+ for (const m of src.matchAll(PUB_USE_RE)) {
975
+ const rootSeg = m[1]
976
+ .replace(/#\[[^\]]*\]/g, '')
977
+ .trim()
978
+ .split(/::|\{/)[0]
979
+ .trim();
980
+ if (rootSeg === '' || OWN_PATH_ROOTS.has(rootSeg) || !deps.has(canonical(rootSeg)))
981
+ continue;
982
+ const { names, globs } = useTargets(m[1]);
983
+ for (const n of names)
984
+ if (/^[A-Za-z_]/.test(n))
985
+ reexported.add(n);
986
+ // Drop the leading crate segment: the supplier's own paths start below it.
987
+ for (const g of globs)
988
+ globModules.add(g.split('::').slice(1).join('::'));
989
+ }
990
+ }
991
+ const unresolved = new Set([...reexported].filter(n => !declared.has(n)));
992
+ return {
993
+ empty: unresolved.size === 0 && globModules.size === 0,
994
+ wholesale: relPath => globModules.has(moduleOfPath(relPath)),
995
+ fillsHole: chunk => {
996
+ for (const m of chunk.matchAll(RUST_DECL_RE)) {
997
+ if (unresolved.has((m[1] ?? m[2])))
998
+ return true;
999
+ }
1000
+ return false;
1001
+ }
1002
+ };
1003
+ }
1004
+ /**
1005
+ * Source of every function `cargoExportGap` delegates to.
1006
+ *
1007
+ * `String(cargoExportGap)` covers only the top level, and the two bugs already
1008
+ * found in this pass — a rename split that truncated `Hasher`, a lock read from
1009
+ * the wrong root — both lived in helpers. A fix to one of them has to move the
1010
+ * index fingerprint or every cached facade keeps the chunks the old rule chose.
1011
+ */
1012
+ export function cargoGapFingerprint() {
1013
+ return [cargoExportGap, runtimeDeps, useTargets, rustSources, moduleOfPath]
1014
+ .map(String)
1015
+ .concat([
1016
+ PUB_USE_RE.source,
1017
+ RUST_DECL_RE.source,
1018
+ CARGO_SKIP_DIRS.join(','),
1019
+ [...OWN_PATH_ROOTS].join(',')
1020
+ ])
1021
+ .join('\u0000');
1022
+ }
1023
+ /**
1024
+ * Which declared dependencies may be opened to fill the gap.
1025
+ *
1026
+ * Cargo splits a facade from its implementation by name the way hackage does —
1027
+ * `axum`/`axum-core`, `futures`/`futures-util`, `tracing`/`tracing-core` — and
1028
+ * writes that name with either separator, so both spellings are one candidate.
1029
+ *
1030
+ * The bound's cost is stated rather than hidden: `hyper` re-exports twelve names
1031
+ * from `http`, `bytes` and `http-body`, and axum's own `Bytes` comes from `bytes`.
1032
+ * No prefix rule can see any of them.
1033
+ */
1034
+ export function cargoSupplementCandidates(pkgName, declaredDeps, resolved) {
1035
+ const out = [];
1036
+ const seen = new Set();
1037
+ for (const dep of declaredDeps) {
1038
+ if (canonical(dep) === canonical(pkgName))
1039
+ continue;
1040
+ if (!canonical(dep).startsWith(`${canonical(pkgName)}_`))
1041
+ continue;
1042
+ const version = resolved[dep];
1043
+ if (!version || seen.has(canonical(dep)))
1044
+ continue;
1045
+ seen.add(canonical(dep));
1046
+ out.push({ name: dep, version });
1047
+ }
1048
+ // Code-unit order, not `localeCompare`: the sort decides the order supplement
1049
+ // chunks enter the index, and a locale-aware compare puts `-` and `_` in
1050
+ // different places under a different LANG.
1051
+ return out.sort((a, b) => a.name < b.name ? -1
1052
+ : a.name > b.name ? 1
1053
+ : 0);
1054
+ }
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { type ResolvedPackage } from './docs-resolve.js';
19
19
  import type { NpmVersionInfo } from './npm-version.js';
20
+ import type { ExportGap } from './export-gap.js';
20
21
  export declare function isValidHackageName(name: string): boolean;
21
22
  /** True for a dotted Haskell MODULE name, which is never a package name. */
22
23
  export declare function looksLikeModuleName(name: string): boolean;
@@ -100,29 +101,12 @@ export declare function hackageProjectName(cwd: string): string | null;
100
101
  * Undefined when there is no readable `.cabal` file.
101
102
  */
102
103
  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
104
  /** Every name the extracted surface declares: signatures, heads, constructors, fields. */
124
105
  export declare function declaredInSurface(surface: string): Set<string>;
125
- export declare function hackageExportGap(root: string): HackageExportGap;
106
+ /** Source of every function {@link hackageExportGap} delegates to — see
107
+ * `cargoGapFingerprint` for why the top level alone is not enough. */
108
+ export declare function hackageGapFingerprint(): string;
109
+ export declare function hackageExportGap(root: string): ExportGap;
126
110
  /**
127
111
  * Which declared dependencies may be opened to fill the gap.
128
112
  *
@@ -586,6 +586,21 @@ export function manifestPackages(cwd) {
586
586
  }
587
587
  return out;
588
588
  }
589
+ // ── re-export resolution ────────────────────────────────────────────────────
590
+ /**
591
+ * What a package exports but does not declare.
592
+ *
593
+ * `hspec` indexes to 14 chunks of export lists: `it`, `describe` and `shouldBe`
594
+ * are in the corpus as bare names with no signature attached, because every
595
+ * signature is in `hspec-core`. The whole index is a table of contents.
596
+ *
597
+ * Both shapes are here because either alone misses half of it. A name-level
598
+ * re-export puts the name in the export list; a `module X` re-export puts
599
+ * nothing there at all, which is why `shouldBe` is invisible to the first.
600
+ *
601
+ * See DEFECT-12-STOPPING-RULE.md for why this triggers on the hole itself
602
+ * rather than on a fraction of the export list.
603
+ */
589
604
  const EXPORT_NAME_RE = /^[A-Za-z_][\w']*$/;
590
605
  /** `module X` inside an export list is a re-export; `Prelude` is base, and base is not fetched. */
591
606
  const REEXPORT_RE = /\bmodule\s+([\w.']+)/g;
@@ -655,6 +670,14 @@ function haskellSources(root) {
655
670
  walk(root);
656
671
  return out;
657
672
  }
673
+ /** Source of every function {@link hackageExportGap} delegates to — see
674
+ * `cargoGapFingerprint` for why the top level alone is not enough. */
675
+ export function hackageGapFingerprint() {
676
+ return [hackageExportGap, haskellSources, exportListText, declaredInSurface, haskellSurface]
677
+ .map(String)
678
+ .concat([REEXPORT_RE.source, EXPORT_NAME_RE.source])
679
+ .join('\u0000');
680
+ }
658
681
  export function hackageExportGap(root) {
659
682
  const declared = new Set();
660
683
  const exported = new Set();
@@ -687,9 +710,14 @@ export function hackageExportGap(root) {
687
710
  for (const m of ownModules)
688
711
  reexportedModules.delete(m);
689
712
  reexportedModules.delete('Prelude');
713
+ const unresolved = new Set([...exported].filter(n => !declared.has(n)));
690
714
  return {
691
- unresolved: new Set([...exported].filter(n => !declared.has(n))),
692
- reexportedModules
715
+ empty: unresolved.size === 0 && reexportedModules.size === 0,
716
+ wholesale: (_relPath, raw) => {
717
+ const module = /^module\s+([\w.']+)/m.exec(raw)?.[1];
718
+ return module !== undefined && reexportedModules.has(module);
719
+ },
720
+ fillsHole: chunk => [...declaredInSurface(chunk)].some(n => unresolved.has(n))
693
721
  };
694
722
  }
695
723
  /**
@@ -713,5 +741,9 @@ export function supplementCandidates(pkgName, declaredDeps, resolved) {
713
741
  if (version)
714
742
  out.push({ name: dep, version });
715
743
  }
716
- return out.sort((a, b) => a.name.localeCompare(b.name));
744
+ // Code-unit order, not `localeCompare`: this sort decides index order and a
745
+ // locale-aware compare is a machine-dependent index.
746
+ return out.sort((a, b) => a.name < b.name ? -1
747
+ : a.name > b.name ? 1
748
+ : 0);
717
749
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * What a facade package publishes and does not declare.
3
+ *
4
+ * `hspec` indexes to a table of contents and every signature is in `hspec-core`;
5
+ * `axum` re-exports `IntoResponse` and the trait lives in `axum-core`. Both are
6
+ * the same failure — a query retrieves the package's own chunks and not one of
7
+ * them defines the thing asked about — and DEFECT-12-STOPPING-RULE.md fixes the
8
+ * boundary for following the re-export.
9
+ *
10
+ * The boundary is shared; the parsing is not. Haskell states the gap in an export
11
+ * list, Rust in `pub use`, so each ecosystem answers the same three questions in
12
+ * its own syntax and the indexer never learns either language.
13
+ */
14
+ export type ExportGap = {
15
+ /** No hole at all, so no supplement is opened. */
16
+ empty: boolean;
17
+ /**
18
+ * Does this whole supplement file belong in the index? True for the target of
19
+ * a wholesale re-export — Haskell's `module X`, Rust's `pub use x::y::*`.
20
+ * Given both the path and the source because Haskell names its module in the
21
+ * source and Rust names it by file location.
22
+ */
23
+ wholesale: (relPath: string, rawSource: string) => boolean;
24
+ /** Does this chunk declare a name the facade publishes and lacks? */
25
+ fillsHole: (chunkBody: string) => boolean;
26
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -56,7 +56,7 @@ export function registerPiWorkerFetch(pi, internals = {}) {
56
56
  }));
57
57
  }
58
58
  const body = formatResultText('', // a fetched page answer carries no package header
59
- { answer: result.answer, excerpt: result.excerpt }, result.excerptVerified) || '(no output)';
59
+ { answer: result.answer, excerpt: result.excerpt }, result.excerptCheck) || '(no output)';
60
60
  // The coverage miss is the one outcome that carries an instruction. It goes
61
61
  // in the TEXT, not only in details: details are for the harness, and the
62
62
  // worker acts on what it reads.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.40.7",
3
+ "version": "0.40.10",
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",