@mjasnikovs/pi-task 0.40.8 → 0.40.11

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) {
@@ -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,26 @@ 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 everything this row contributes to a chunk's CONTENT — `surface`,
91
+ * `exportGap`, and every function and regex they delegate to.
92
+ *
93
+ * Required, and not derived from `String(surface)`, because that has hidden a
94
+ * real fix three times: `surface` here is the wrapper
95
+ * `content => rustSurface(content)`, which shows none of `rustSurface`, and
96
+ * neither gap rule's helpers appear in its entry point either.
97
+ */
98
+ contentFingerprint: () => string;
82
99
  /** The registry's own newest version, for grounding an answer in the present. */
83
100
  latest: (name: string, io: EcosystemIo) => Promise<NpmVersionInfo | null>;
84
101
  /** True for a file that carries the package's public API surface. */
@@ -131,13 +148,6 @@ export interface NpmProfileHooks {
131
148
  resolvePackage?: typeof resolvePackage;
132
149
  npmVersionLookup?: typeof npmVersionLookup;
133
150
  }
134
- /**
135
- * The npm row, with the pieces a caller may have replaced left as parameters.
136
- *
137
- * `docsRaw` already takes `resolvePackage` and `npmVersionLookup` as injection
138
- * hooks, and those hooks must keep reaching the resolution they are injected
139
- * for. A per-call row carries them; {@link ECOSYSTEMS} holds the plain one.
140
- */
141
151
  export declare function npmProfile(hooks?: NpmProfileHooks): EcosystemProfile;
142
152
  /**
143
153
  * The package.json manifest, not the lockfile: a lockfile is rewritten by
@@ -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, cargoContentFingerprint, cargoSupplementCandidates, CARGO_DECL_SPLIT_RE } from './eco-cargo.js';
25
+ import { resolveHackage, hackageLatest, hackageVersion, hackageTarballUrl, hackageExtractDir, hackageProjectName, supplementCandidates, hackageExportGap, hackageContentFingerprint, 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?
@@ -101,6 +101,9 @@ function defaultCabalPackageDirs() {
101
101
  * hooks, and those hooks must keep reaching the resolution they are injected
102
102
  * for. A per-call row carries them; {@link ECOSYSTEMS} holds the plain one.
103
103
  */
104
+ /** A `.d.ts` is already declarations only, so npm's extractor is the identity.
105
+ * Named rather than inline so `contentFingerprint` can point at it. */
106
+ const npmSurface = (content) => content;
104
107
  export function npmProfile(hooks = {}) {
105
108
  const resolve = hooks.resolvePackage ?? resolvePackage;
106
109
  const versionLookup = hooks.npmVersionLookup ?? npmVersionLookup;
@@ -126,7 +129,10 @@ export function npmProfile(hooks = {}) {
126
129
  afterResolve: (pkg, requested, cwd, io) => resolveTypeSourceForDocs(pkg, requested, cwd, io.spawn, resolve, io.signal),
127
130
  latest: (name, io) => versionLookup(name, io.signal === undefined ? {} : { signal: io.signal }),
128
131
  isSurfaceFile: isDtsFile,
129
- surface: content => content,
132
+ surface: npmSurface,
133
+ // No gap rule, and a `.d.ts` IS the surface, so there is nothing below the
134
+ // identity for a fingerprint to miss.
135
+ contentFingerprint: () => String(npmSurface),
130
136
  declSplitRe: DECL_SPLIT_RE,
131
137
  typeKeywords: ['interface', 'type', 'class', 'enum'],
132
138
  commentPrefix: '//',
@@ -267,8 +273,44 @@ const cargoProfile = {
267
273
  }
268
274
  return acquireCrate(info?.pkg ?? name, version, io);
269
275
  },
276
+ supplements: async (pkg, cwd, io) => {
277
+ const deps = manifestCrates(pkg.root);
278
+ if (!deps)
279
+ return [];
280
+ // The PROJECT's lock, never the crate's own root. `findLock` walks upward,
281
+ // and a crate unpacked under `~/.cargo/registry` sits below whatever lock
282
+ // happens to be above it — which resolved a version this project never
283
+ // pinned, making the index a function of the machine.
284
+ const candidates = cargoSupplementCandidates(pkg.name, deps, lockedDeps(cwd) ?? {});
285
+ const out = [];
286
+ for (const c of candidates) {
287
+ try {
288
+ out.push(resolveCrate(c.name, cwd, { cargoHome: io.cargoHome, modulesDir: io.modulesDir }));
289
+ continue;
290
+ }
291
+ catch {
292
+ // Not unpacked here. `acquire` reads crates.io for the published
293
+ // spelling, so `tokio-util` and `tokio_util` both land.
294
+ }
295
+ const got = await cargoProfile.acquire(c.name, c.version, io);
296
+ if (!got.success)
297
+ continue;
298
+ try {
299
+ out.push(resolveCrate(c.name, cwd, { cargoHome: io.cargoHome, modulesDir: io.modulesDir }));
300
+ }
301
+ catch {
302
+ // A supplement that will not resolve leaves the facade as it was.
303
+ }
304
+ }
305
+ return out;
306
+ },
307
+ exportGap: cargoExportGap,
308
+ contentFingerprint: cargoContentFingerprint,
270
309
  latest: (name, io) => cratesLatest(name, io.fetch, io.signal),
271
310
  isSurfaceFile: isRustFile,
311
+ // Wrapped, not passed by reference: `rustSurface` takes two more optional
312
+ // arguments, and a bare reference would let a `.map(profile.surface)` pass an
313
+ // index as `insideTrait`. `contentFingerprint` is what covers its source.
272
314
  surface: content => rustSurface(content),
273
315
  declSplitRe: CARGO_DECL_SPLIT_RE,
274
316
  typeKeywords: ['struct', 'trait', 'enum', 'type', 'union'],
@@ -381,6 +423,8 @@ const hackageProfile = {
381
423
  }
382
424
  return out;
383
425
  },
426
+ exportGap: hackageExportGap,
427
+ contentFingerprint: hackageContentFingerprint,
384
428
  latest: (name, io) => hackageLatest(name, io.fetch, io.signal),
385
429
  isSurfaceFile: isHaskellFile,
386
430
  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.
@@ -53,11 +52,16 @@ function computeContentHash(pkg, profile, supplements = []) {
53
52
  // leaves a package cached whenever a fix moves some OTHER module — the
54
53
  // wrapped `instance` head is in aeson's `Types/FromJSON.hs`, never its entry
55
54
  // — and nothing surfaced the duplicate drop in `ingestBody` at all.
56
- hash.update(Buffer.from(`${String(profile.surface)}\u0000${String(ingestBody)}`, 'utf8'));
55
+ hash.update(Buffer.from(`${String(profile.surface)}\u0000${profile.contentFingerprint()}`
56
+ + `\u0000${String(ingestBody)}`, 'utf8'));
57
57
  hash.update(ZERO_SEP);
58
- // Which packages were folded in, so gaining or losing one re-indexes.
58
+ // Which packages were folded in, so gaining or losing one re-indexes — and the
59
+ // rule that decides WHICH of their chunks are kept, by source. Hashing the set
60
+ // alone left a fix to `cargoExportGap` invisible, so every cached facade held
61
+ // the chunks the old rule chose.
59
62
  hash.update(Buffer.from(supplements.map(s => `${s.name}@${s.version}`).join('\u0000'), 'utf8'));
60
63
  hash.update(ZERO_SEP);
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;
@@ -98,16 +99,6 @@ interface Item {
98
99
  * finds the end of the item it is in.
99
100
  */
100
101
  export declare function splitRustItems(src: string): Item[];
101
- /**
102
- * Reduce Rust source to its public API surface.
103
- *
104
- * Function bodies go — they are the bulk of the file and answer no question the
105
- * docs tool is asked. Everything a caller can name stays: the item head, its doc
106
- * comment, its attributes, and for a struct or enum its fields and variants.
107
- *
108
- * Inside a `trait` every member is public by definition, so the `pub` test is
109
- * suspended there; anywhere else a bare or `pub(crate)` item is dropped.
110
- */
111
102
  export declare function rustSurface(src: string, insideTrait?: boolean, topLevel?: boolean): string;
112
103
  export declare function isRustFile(name: string): boolean;
113
104
  /** The `[package] name` of a cargo project, for labelling its own source. */
@@ -123,4 +114,40 @@ export declare function cargoProjectName(cwd: string): string | null;
123
114
  * Undefined when there is no readable manifest, which is not "declares nothing".
124
115
  */
125
116
  export declare function manifestCrates(cwd: string): Set<string> | undefined;
117
+ /**
118
+ * The names this crate publishes through a dependency and declares nowhere.
119
+ *
120
+ * The trigger is the hole, with no threshold — measured, and for the same reason
121
+ * as hackage: across twenty-two crates the unresolved fraction reads 100% on a
122
+ * crate with one re-export and 0% on a crate with none, so a ratio separates
123
+ * nothing. See "Defect 16" in DOC_REGRESSINONS.md for the sweep.
124
+ */
125
+ export declare function cargoExportGap(root: string): ExportGap;
126
+ /**
127
+ * Source of everything this row contributes to a chunk's CONTENT — the surface
128
+ * extractor, the gap rule, and every function and regex they delegate to.
129
+ *
130
+ * `String(fn)` covers only a top level, and three separate bugs have now hidden
131
+ * one level below it: the chunker (which `chunkerFingerprint` closes), the gap
132
+ * rule's helpers, and `surface` itself, declared as the wrapper
133
+ * `content => rustSurface(content)` which shows none of `rustSurface`. A fix that
134
+ * does not move this leaves every cached crate holding the chunks the old rule
135
+ * chose.
136
+ */
137
+ export declare function cargoContentFingerprint(): string;
138
+ /**
139
+ * Which declared dependencies may be opened to fill the gap.
140
+ *
141
+ * Cargo splits a facade from its implementation by name the way hackage does —
142
+ * `axum`/`axum-core`, `futures`/`futures-util`, `tracing`/`tracing-core` — and
143
+ * writes that name with either separator, so both spellings are one candidate.
144
+ *
145
+ * The bound's cost is stated rather than hidden: `hyper` re-exports twelve names
146
+ * from `http`, `bytes` and `http-body`, and axum's own `Bytes` comes from `bytes`.
147
+ * No prefix rule can see any of them.
148
+ */
149
+ export declare function cargoSupplementCandidates(pkgName: string, declaredDeps: ReadonlySet<string>, resolved: Readonly<Record<string, string>>): Array<{
150
+ name: string;
151
+ version: string;
152
+ }>;
126
153
  export {};
@@ -631,6 +631,37 @@ const LIST_KINDS = new Set(['use']);
631
631
  * Inside a `trait` every member is public by definition, so the `pub` test is
632
632
  * suspended there; anywhere else a bare or `pub(crate)` item is dropped.
633
633
  */
634
+ /** A `#name` interpolation. Rust item syntax has none; a `quote!` template is
635
+ * nothing but. Comments come out first — a doctest hides lines with `# use …`. */
636
+ const INTERPOLATION = /#[A-Za-z_(]/;
637
+ function withoutComments(body) {
638
+ return body
639
+ .split('\n')
640
+ .map(l => (/^\s*(?:\/\/|\*)/.test(l) ? '' : l.replace(/\/\/.*$/, '')))
641
+ .join('\n');
642
+ }
643
+ /**
644
+ * The body of a `name! { … }` block that WRAPS items, or null.
645
+ *
646
+ * tokio declares `pub struct TcpListener` inside `cfg_net! { … }`, and every
647
+ * async crate does the same: 818 public items across twenty-two crates sit inside
648
+ * such a block, 441 of them tokio's. Treating the invocation as one opaque item
649
+ * dropped all of them, `TcpListener` — a ground-truth symbol of the live test —
650
+ * included.
651
+ *
652
+ * The guard is what the body IS, never which macro it is. A `quote!` body is a
653
+ * token template, and the same measurement found zero public items inside an
654
+ * interpolating body, so the two populations do not overlap.
655
+ */
656
+ function macroWrappedItems(item) {
657
+ if (item.body === null)
658
+ return null;
659
+ if (!/^[a-z_][a-z0-9_]*!$/.test(item.head.trim()))
660
+ return null;
661
+ if (INTERPOLATION.test(withoutComments(item.body)))
662
+ return null;
663
+ return item.body;
664
+ }
634
665
  export function rustSurface(src, insideTrait = false, topLevel = true) {
635
666
  const out = [];
636
667
  const items = splitRustItems(src);
@@ -645,8 +676,12 @@ export function rustSurface(src, insideTrait = false, topLevel = true) {
645
676
  out.push(moduleDoc);
646
677
  for (const item of items) {
647
678
  const headMatch = ITEM_HEAD_RE.exec(item.head);
648
- if (!headMatch)
679
+ if (!headMatch) {
680
+ const wrapped = macroWrappedItems(item);
681
+ if (wrapped !== null)
682
+ out.push(rustSurface(wrapped, insideTrait, false));
649
683
  continue;
684
+ }
650
685
  const kind = headMatch[1];
651
686
  // A `#[macro_export]` macro IS the crate's API — `anyhow::bail!`,
652
687
  // `serde_json::json!`. Dropping every macro answered "no such thing"
@@ -832,3 +867,242 @@ export function manifestCrates(cwd) {
832
867
  }
833
868
  return out;
834
869
  }
870
+ // ── the facade gap (DEFECT-12-STOPPING-RULE.md, cargo half) ─────────────────
871
+ /** A `pub use …;` statement, attributes and line breaks included. */
872
+ const PUB_USE_RE = /\bpub\s+use\s+([^;]+);/g;
873
+ /** Every item head that introduces a name, visibility ignored — a facade may
874
+ * re-export something its own private module declares. */
875
+ 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;
876
+ /** Path roots that name this crate, never a dependency. */
877
+ const OWN_PATH_ROOTS = new Set(['crate', 'self', 'super']);
878
+ /** Read `[dependencies]` only. Dev- and build-dependencies were measured and
879
+ * fetch `tokio-test`, `regex-test` and `tower-test` for zero extra names. */
880
+ function runtimeDeps(root) {
881
+ const text = safeRead(path.join(root, 'Cargo.toml'));
882
+ const out = new Set();
883
+ if (text === null)
884
+ return out;
885
+ let inDeps = false;
886
+ for (const raw of text.split('\n')) {
887
+ const line = raw.trim();
888
+ const header = /^\[([^\]]+)\]$/.exec(line);
889
+ if (header) {
890
+ const table = /^(?:target\.[^.]*\.)?dependencies(?:\.(.+))?$/.exec(header[1]);
891
+ inDeps = table !== null && table[1] === undefined;
892
+ if (table?.[1])
893
+ out.add(canonical(table[1]));
894
+ continue;
895
+ }
896
+ if (!inDeps)
897
+ continue;
898
+ const key = /^([A-Za-z0-9_-]+)\s*=/.exec(line);
899
+ if (key)
900
+ out.add(canonical(key[1]));
901
+ }
902
+ return out;
903
+ }
904
+ /** Every leaf name a use-path brings in, and every module it globs. */
905
+ function useTargets(body) {
906
+ // Whitespace is normalised, never removed: `Inner as Outer` collapsed to
907
+ // `InnerasOuter` is unrecoverable, and splitting a leaf on a bare "as" turns
908
+ // `Hasher` into `H`.
909
+ const flat = body
910
+ .replace(/#\[[^\]]*\]/g, '')
911
+ .replace(/\s+/g, ' ')
912
+ .trim();
913
+ const names = [];
914
+ const globs = [];
915
+ const expand = (prefix, rest) => {
916
+ const brace = rest.indexOf('{');
917
+ if (brace === -1) {
918
+ const full = prefix + rest;
919
+ if (full.endsWith('*'))
920
+ globs.push(full.replace(/::\*$/, ''));
921
+ else {
922
+ // The SOURCE name of a rename is the hole: the supplier declares
923
+ // `Inner`, whatever the facade calls it.
924
+ const leaf = full
925
+ .split('::')
926
+ .pop()
927
+ ?.split(/\s+as\s+/)[0]
928
+ .trim();
929
+ if (leaf)
930
+ names.push(leaf);
931
+ }
932
+ return;
933
+ }
934
+ const head = prefix + rest.slice(0, brace);
935
+ let depth = 0;
936
+ let start = brace + 1;
937
+ for (let i = brace; i < rest.length; i++) {
938
+ const c = rest[i];
939
+ if (c === '{')
940
+ depth++;
941
+ else if (c === '}') {
942
+ depth--;
943
+ if (depth === 0)
944
+ return expand(head, rest.slice(start, i));
945
+ }
946
+ else if (c === ',' && depth === 1) {
947
+ expand(head, rest.slice(start, i));
948
+ start = i + 1;
949
+ }
950
+ }
951
+ };
952
+ expand('', flat);
953
+ return { names, globs };
954
+ }
955
+ function rustSources(root) {
956
+ const out = [];
957
+ const walk = (dir) => {
958
+ let entries;
959
+ try {
960
+ entries = fs.readdirSync(dir, { withFileTypes: true });
961
+ }
962
+ catch {
963
+ return;
964
+ }
965
+ for (const e of entries) {
966
+ if (e.isDirectory()) {
967
+ if (!CARGO_SKIP_DIRS.includes(e.name))
968
+ walk(path.join(dir, e.name));
969
+ }
970
+ else if (isRustFile(e.name))
971
+ out.push(path.join(dir, e.name));
972
+ }
973
+ };
974
+ walk(root);
975
+ return out;
976
+ }
977
+ const CARGO_SKIP_DIRS = ['tests', 'benches', 'examples', 'target', '.git'];
978
+ /** `axum-core-0.5.6/src/response/mod.rs` -> `response`, the module path a
979
+ * `pub use axum_core::response::*` names. `lib.rs` and `mod.rs` are the module
980
+ * they sit in, not a module of their own. */
981
+ function moduleOfPath(relPath) {
982
+ const parts = relPath.replace(/\\/g, '/').split('/');
983
+ const src = parts.indexOf('src');
984
+ const tail = (src === -1 ? parts : parts.slice(src + 1)).join('/').replace(/\.rs$/, '');
985
+ return tail
986
+ .split('/')
987
+ .filter(seg => seg !== 'mod' && seg !== 'lib')
988
+ .join('::');
989
+ }
990
+ /**
991
+ * The names this crate publishes through a dependency and declares nowhere.
992
+ *
993
+ * The trigger is the hole, with no threshold — measured, and for the same reason
994
+ * as hackage: across twenty-two crates the unresolved fraction reads 100% on a
995
+ * crate with one re-export and 0% on a crate with none, so a ratio separates
996
+ * nothing. See "Defect 16" in DOC_REGRESSINONS.md for the sweep.
997
+ */
998
+ export function cargoExportGap(root) {
999
+ const deps = runtimeDeps(root);
1000
+ const declared = new Set();
1001
+ const reexported = new Set();
1002
+ const globModules = new Set();
1003
+ for (const file of rustSources(root)) {
1004
+ const src = safeRead(file);
1005
+ if (src === null)
1006
+ continue;
1007
+ for (const m of src.matchAll(RUST_DECL_RE))
1008
+ declared.add((m[1] ?? m[2]));
1009
+ for (const m of src.matchAll(PUB_USE_RE)) {
1010
+ const rootSeg = m[1]
1011
+ .replace(/#\[[^\]]*\]/g, '')
1012
+ .trim()
1013
+ .split(/::|\{/)[0]
1014
+ .trim();
1015
+ if (rootSeg === '' || OWN_PATH_ROOTS.has(rootSeg) || !deps.has(canonical(rootSeg)))
1016
+ continue;
1017
+ const { names, globs } = useTargets(m[1]);
1018
+ for (const n of names)
1019
+ if (/^[A-Za-z_]/.test(n))
1020
+ reexported.add(n);
1021
+ // Drop the leading crate segment: the supplier's own paths start below it.
1022
+ for (const g of globs)
1023
+ globModules.add(g.split('::').slice(1).join('::'));
1024
+ }
1025
+ }
1026
+ const unresolved = new Set([...reexported].filter(n => !declared.has(n)));
1027
+ return {
1028
+ empty: unresolved.size === 0 && globModules.size === 0,
1029
+ wholesale: relPath => globModules.has(moduleOfPath(relPath)),
1030
+ fillsHole: chunk => {
1031
+ for (const m of chunk.matchAll(RUST_DECL_RE)) {
1032
+ if (unresolved.has((m[1] ?? m[2])))
1033
+ return true;
1034
+ }
1035
+ return false;
1036
+ }
1037
+ };
1038
+ }
1039
+ /**
1040
+ * Source of everything this row contributes to a chunk's CONTENT — the surface
1041
+ * extractor, the gap rule, and every function and regex they delegate to.
1042
+ *
1043
+ * `String(fn)` covers only a top level, and three separate bugs have now hidden
1044
+ * one level below it: the chunker (which `chunkerFingerprint` closes), the gap
1045
+ * rule's helpers, and `surface` itself, declared as the wrapper
1046
+ * `content => rustSurface(content)` which shows none of `rustSurface`. A fix that
1047
+ * does not move this leaves every cached crate holding the chunks the old rule
1048
+ * chose.
1049
+ */
1050
+ export function cargoContentFingerprint() {
1051
+ return [
1052
+ rustSurface,
1053
+ splitRustItems,
1054
+ macroWrappedItems,
1055
+ withoutComments,
1056
+ keptPreamble,
1057
+ privateTypeNames,
1058
+ implTarget,
1059
+ fieldsOf,
1060
+ cargoExportGap,
1061
+ runtimeDeps,
1062
+ useTargets,
1063
+ rustSources,
1064
+ moduleOfPath
1065
+ ]
1066
+ .map(String)
1067
+ .concat([
1068
+ PUB_USE_RE.source,
1069
+ RUST_DECL_RE.source,
1070
+ ITEM_HEAD_RE.source,
1071
+ INTERPOLATION.source,
1072
+ CARGO_SKIP_DIRS.join(','),
1073
+ [...OWN_PATH_ROOTS].join(',')
1074
+ ])
1075
+ .join('\u0000');
1076
+ }
1077
+ /**
1078
+ * Which declared dependencies may be opened to fill the gap.
1079
+ *
1080
+ * Cargo splits a facade from its implementation by name the way hackage does —
1081
+ * `axum`/`axum-core`, `futures`/`futures-util`, `tracing`/`tracing-core` — and
1082
+ * writes that name with either separator, so both spellings are one candidate.
1083
+ *
1084
+ * The bound's cost is stated rather than hidden: `hyper` re-exports twelve names
1085
+ * from `http`, `bytes` and `http-body`, and axum's own `Bytes` comes from `bytes`.
1086
+ * No prefix rule can see any of them.
1087
+ */
1088
+ export function cargoSupplementCandidates(pkgName, declaredDeps, resolved) {
1089
+ const out = [];
1090
+ const seen = new Set();
1091
+ for (const dep of declaredDeps) {
1092
+ if (canonical(dep) === canonical(pkgName))
1093
+ continue;
1094
+ if (!canonical(dep).startsWith(`${canonical(pkgName)}_`))
1095
+ continue;
1096
+ const version = resolved[dep];
1097
+ if (!version || seen.has(canonical(dep)))
1098
+ continue;
1099
+ seen.add(canonical(dep));
1100
+ out.push({ name: dep, version });
1101
+ }
1102
+ // Code-unit order, not `localeCompare`: the sort decides the order supplement
1103
+ // chunks enter the index, and a locale-aware compare puts `-` and `_` in
1104
+ // different places under a different LANG.
1105
+ return out.sort((a, b) => a.name < b.name ? -1
1106
+ : a.name > b.name ? 1
1107
+ : 0);
1108
+ }
@@ -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 everything this row contributes to a chunk's content — see
107
+ * `cargoContentFingerprint` for why a top-level `String(fn)` is not enough. */
108
+ export declare function hackageContentFingerprint(): 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 everything this row contributes to a chunk's content — see
674
+ * `cargoContentFingerprint` for why a top-level `String(fn)` is not enough. */
675
+ export function hackageContentFingerprint() {
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.8",
3
+ "version": "0.40.11",
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",