Files
reverse-engineering-anything/src/application/GuidanceSections.ts
T
Kaoru0822andClaude Opus 5.5 41e7333cb5 feat(guidance): distinguish focused matches from broad parent sections (#8305)
* feat(guidance): distinguish focused matches from broad parent sections

Guidance search treated a parent section, including every descendant, as one
match. Terms scattered across unrelated subsections made a 49K-character root
the only result for "switch provider annotations", and every ancestor of a
focused hit was listed alongside it.

The section parser now records each section's own text (heading and body
before its first subsection) and its parent. Search keeps the same admission
rule but classifies each match by match_scope: "section" when every term is
in the section's own text, "subsections" when the terms are only spread
across subsections. Ancestors whose terms all sit inside one subsection are
no longer listed, section matches rank first, and subsections matches carry
own_text_terms plus bounded greedy pointers to the subsections holding the
remaining terms. Every match reports content_length so callers can judge a
read before making it. Read still returns the complete section.

Own text is tokenized once per request instead of each expanded subtree.

Fixes #8290

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(guidance): require the scattered-match checks to run

Count the subsections matches the real-bundle test inspects and fail when
none were checked, and document that a parent whose own text holds every
term stays listed beside its matching subsection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:11:05 -07:00

154 lines
5.2 KiB
TypeScript

/** Parsed documentation facts; callers never reinterpret Markdown boundaries. */
export interface GuideSection {
readonly guide: string;
readonly heading: string;
/** The complete section, including every subsection. */
readonly content: string;
/** Heading and body before the first subsection, where terms co-occur. */
readonly ownContent: string;
readonly ordinal: number;
/** Enclosing section of the same guide; top-level headings have none. */
readonly parent: GuideSection | undefined;
}
// The shipped bundle has tens of headings. These ceilings bound object count
// and UTF-16 text retained by a complete index before allocating section text.
// Own text needs no ceiling of its own: each code unit has exactly one owner.
const MAX_SECTIONS = 4096;
// ATX has at most six enclosing levels over the admitted 4 MiB bundle.
const MAX_EXPANDED_CHARACTERS = 6 * 4_194_304;
const blank = (value: string | undefined): boolean =>
value === " " || value === "\t";
/** ATX closing hashes and ASCII whitespace, scanned once from each end. */
const headingTitle = (raw: string): string => {
let start = 0;
let end = raw.length;
while (start < end && blank(raw[start])) start += 1;
while (end > start && blank(raw[end - 1])) end -= 1;
let hashes = end;
while (hashes > start && raw[hashes - 1] === "#") hashes -= 1;
if (hashes < end && (hashes === start || blank(raw[hashes - 1]))) {
end = hashes;
while (end > start && blank(raw[end - 1])) end -= 1;
}
return raw.slice(start, end);
};
interface Heading {
readonly level: number;
readonly title: string;
readonly start: number;
readonly ordinal: number;
readonly parent: number | undefined;
end: number;
}
const atxHeading = (
line: string,
): Pick<Heading, "level" | "title"> | undefined => {
const match = /^ {0,3}(#{1,6})(?:[ \t]+(.*)|$)/u.exec(line);
if (match?.[1] === undefined) return undefined;
const raw = match[2] ?? "";
// Closing hashes need whitespace; literal C# and escaped hashes survive.
const title = headingTitle(raw);
return { level: match[1].length, title };
};
const readHeadings = (
content: string,
remaining: number,
): Heading[] | undefined => {
const headings: Heading[] = [];
const enclosing: Heading[] = [];
let fence: { marker: string; length: number } | undefined;
let start = 0;
while (start < content.length) {
const newline = content.indexOf("\n", start);
const end = newline < 0 ? content.length : newline;
const line = content.slice(start, end);
const fenceMatch = /^ {0,3}(`{3,}|~{3,})(.*)$/u.exec(line);
const marker = fenceMatch?.[1];
if (fence !== undefined) {
if (
marker?.[0] === fence.marker &&
marker.length >= fence.length &&
/^[ \t]*$/u.test(fenceMatch?.[2] ?? "")
)
fence = undefined;
} else if (
marker !== undefined &&
!(marker[0] === "`" && fenceMatch?.[2]?.includes("`"))
) {
fence = { marker: marker[0] ?? "", length: marker.length };
} else {
const parsed = atxHeading(line);
if (parsed !== undefined) {
if (headings.length >= remaining) return undefined;
while ((enclosing.at(-1)?.level ?? 0) >= parsed.level) {
const parent = enclosing.pop();
if (parent !== undefined) parent.end = start;
}
const heading = {
...parsed,
start,
end: content.length,
ordinal: headings.length,
parent: enclosing.at(-1)?.ordinal,
};
headings.push(heading);
enclosing.push(heading);
}
}
start = newline < 0 ? content.length : newline + 1;
}
return headings;
};
/**
* Bound the whole bundle before materializing an expanded section index.
* Sections are returned in document order, so subsections follow their parent.
*/
export const parseGuidanceSections = (
files: ReadonlyMap<string, string>,
): readonly GuideSection[] | undefined => {
const documents: { guide: string; content: string; headings: Heading[] }[] =
[];
let count = 0;
let expanded = 0;
for (const [guide, content] of files) {
const headings = readHeadings(content, MAX_SECTIONS - count);
if (headings === undefined) return undefined;
count += headings.length;
for (const heading of headings) {
expanded += heading.end - heading.start;
if (expanded > MAX_EXPANDED_CHARACTERS) return undefined;
}
documents.push({ guide, content, headings });
}
const sections: GuideSection[] = [];
for (const { guide, content, headings } of documents) {
const guideSections: GuideSection[] = [];
for (const [ordinal, heading] of headings.entries()) {
// The next heading of any level ends this section's own text: it is
// either the first subsection or the end of the whole section.
const ownEnd = headings[ordinal + 1]?.start ?? heading.end;
const section = {
guide,
ordinal,
heading: heading.title,
content: content.slice(heading.start, heading.end).trim(),
ownContent: content.slice(heading.start, ownEnd).trim(),
parent:
heading.parent === undefined
? undefined
: guideSections[heading.parent],
};
guideSections.push(section);
sections.push(section);
}
}
return sections;
};