/** * Dynamic-boundary sites (change: disclose-dynamic-boundary-regions). * * The call graph already recovers *some* dynamic dispatch — event/route/callback synthesis, CHA * virtual dispatch. What it cannot follow at all is reflection (`getattr`, `send`, `Method.invoke`), * computed member dispatch (`obj[name]()`), `eval`/`new Function`, non-literal dynamic imports, * metaprogrammed definitions (`define_method`, `Proxy`), and DI-container resolution. Today those * constructs are *swallowed*: `getattr` sits in the Python ignore table, `reflect` resolves to a * bare `external::` edge, Ruby `send` has no handling at all. A file that dispatches only through * them produces a graph indistinguishable from a file with no calls — a confident-looking silence. * * This module records each such construct as a **dynamic-boundary site** so a conclusion can * disclose *unknown* instead of implying *absent*. It is the same move `parse-health.ts` makes for * failed parses and the epistemic lease makes for staleness, applied to the last large undisclosed * unknown in the graph. * * Four rules are load-bearing: * * 1. **Records, never resolves.** A site NEVER produces a node or an edge. The matcher only records * candidates; the one structurally provable family — a stable literal dispatch table — is bound * after Pass 7 by `literal-reflection.ts` (change: resolve-literal-reflective-dispatch), which * discharges exactly the candidates it binds. * 2. **The partition is by resolution OUTCOME, not argument form.** Every recognized construct is * a *candidate*; {@link finalizeDynamicBoundarySites} retracts only those the resolver actually * bound to an internal symbol. A static literal that resolves to nothing, or ambiguously, still * yields a site — with its refusal reason. A syntactic partition would leave those in a silent * hole, which is the exact failure this module exists to remove. * 3. **Grounded in syntax or a declared framework binding, never a bare callee name.** A method * merely *named* `get`/`resolve`/`make` is not a container resolution: the file must also import * a declared DI package. `.get(` alone matches hundreds of innocent call sites in any repo. * 4. **False-negative biased and fail-soft.** An unrecognized construct is simply not recorded, * exactly as today. A language with no declared spec contributes nothing and is reported as * *unsupported* by the capability registry — never as "contains no dynamic dispatch". * * Cost: no second parse — the walk runs over the tree the extractor already parsed, gated by a * substring pre-scan of the source. The gate is real but not free, and the honest figures are: * roughly 30% of this repository's TypeScript files trip it (the tokens `eval`, `require(`, * `import(` and `](` occur inside ordinary identifiers and CommonJS), and a triggered file costs * about 30% more extraction time than an untriggered one. An untriggered file pays only the * `indexOf` scans. Retained candidates are capped per file, so a generated dispatch table cannot * grow the payload that crosses the worker and fact-cache boundaries. * * Deterministic: integer positions over a deterministic walk, sorted output, no clock — so two * analyses of unchanged sources produce byte-identical artifacts. */ /** Bump when the persisted artifact shape changes incompatibly. */ export declare const DYNAMIC_BOUNDARY_SCHEMA_VERSION = 1; /** * The closed site vocabulary. Source-declared so it is queryable and testable; a matcher emitting * anything outside this set fails `dynamic-boundary.test.ts`. */ export declare const DYNAMIC_BOUNDARY_KINDS: readonly ["reflective-invoke", "computed-member", "code-eval", "dynamic-import", "metaprogrammed-definition", "container-resolution"]; export type DynamicBoundaryKind = (typeof DYNAMIC_BOUNDARY_KINDS)[number]; /** Human phrasing for one kind, shared by every surface that renders a site. */ export declare const DYNAMIC_BOUNDARY_KIND_LABEL: Record; /** * Why the resolver refused this construct. Decided AFTER resolution, never from the argument's * syntactic form — see {@link finalizeDynamicBoundarySites}. */ export declare const DYNAMIC_BOUNDARY_REFUSALS: readonly ["no-static-target", "unresolved-external", "resolvable-but-unbound", "ambiguous-target", "unresolved-in-file-scope", "over-cap", "synthesized-binding", "unattributed-caller"]; export type DynamicBoundaryRefusal = (typeof DYNAMIC_BOUNDARY_REFUSALS)[number]; /** Human phrasing for one refusal reason. */ export declare const DYNAMIC_BOUNDARY_REFUSAL_LABEL: Record; /** * Maximum characters of matched source retained as evidence. * * Evidence is untrusted repository text that is PERSISTED — into `dynamic-boundary.json`, through * the Pass-1 fact cache, and across the extraction-worker boundary. It is neutralized, redacted and * truncated at EXTRACTION time so none of those can ever hold a credential or a control sequence. * (No conclusion surface renders it today — a disclosed site carries only file, line and kind — but * the artifact is read by humans and committed by repositories, which is reason enough.) */ export declare const DYNAMIC_BOUNDARY_EVIDENCE_MAX = 120; /** * Maximum sites retained per file. A generated dispatch table could otherwise carry thousands; the * per-file count stays exact, only the site LIST is bounded, and truncation is disclosed. */ export declare const DYNAMIC_BOUNDARY_SITE_CAP = 50; /** * Declared density ceiling: recorded sites per thousand lines, asserted against realistically-sized * fixtures of ordinary code. A matcher that fires more often than this on ordinary code is matching * an idiom, not a boundary, and fails the suite rather than shipping. * * A ceiling, not a tuning knob: it is asserted in tests, never consulted at run time. The figure the * substrate's own repository produces is MEASURED by running `analyze` rather than asserted here — * a full analyze is far too slow for a unit test. At the time of writing it is 5 sites. */ export declare const DYNAMIC_BOUNDARY_DENSITY_CEILING_PER_KLOC = 12; /** One recorded site, as persisted. `filePath`/`language` live on the enclosing file record. */ export interface DynamicBoundarySite { /** 1-based line of the matched construct. */ line: number; kind: DynamicBoundaryKind; refusal: DynamicBoundaryRefusal; /** The enclosing function's node id. Absent when no indexed symbol contains the construct. */ symbolId?: string; /** * No indexed symbol contains this construct. Explicit, so an absent `symbolId` is never silently * ambiguous — but deliberately NOT called "module level", because the extractor cannot tell the * two apart and one of them would be a false statement. * * `findEnclosingFunction` maps an offset onto the nodes the language extractor emitted. A miss * means either the construct really is at module scope, OR it sits inside something that * extractor does not model — and the second case is common: OpenLore's Python extractor emits no * node for a dunder other than `__init__`, so every `getattr` inside `__eq__`, `__getstate__` or * `__init_subclass__` misses. Dogfooding found 23 such sites across two Python repositories, each * one asserting module scope from inside a function. * * Claiming module scope there would convert an UNKNOWN attribution into a confident false one, * inside the one feature whose premise is disclosing unknown rather than implying absent. So the * marker says only what is actually known: nothing in the index contains this. */ unattributed?: true; /** Redacted, terminal-neutralized, truncated source of the matched construct. */ evidence: string; /** `evidence` hit {@link DYNAMIC_BOUNDARY_EVIDENCE_MAX}. */ evidenceTruncated?: true; } /** Every site recorded in one file. Present only for a file with at least one site. */ export interface FileDynamicBoundary { filePath: string; language: string; /** Sorted by line, then kind. Bounded by {@link DYNAMIC_BOUNDARY_SITE_CAP}. */ sites: DynamicBoundarySite[]; /** Total matched in this file, when it exceeds the retained list. */ totalSites?: number; /** `sites` hit the cap — more exist than are listed. */ truncated?: true; /** * Constructs literal reflection bound to an edge, so they are NOT sites (change: * resolve-literal-reflective-dispatch). Persisted so a directly-resolved-only consumer — which * ignores the synthesized edge — can still disclose them. Bounded like `sites`. */ bound?: DynamicBoundarySite[]; } /** The persisted, rolled-up report (`dynamic-boundary.json`). Absent when nothing was recorded. */ export interface DynamicBoundaryReport { version: number; /** Sum of every file's recorded site count (exact, not the bounded list length). */ totalSites: number; /** Files carrying at least one site. */ totalFiles: number; /** * Per-kind rollup, sorted by the declared vocabulary order. * * Counted over the RETAINED site lists, not over `totalSites`: a file past the per-file cap has * sites whose kind nothing recorded, so these will not sum to `totalSites` for such a file. The * exact figure is the one to quote; this breakdown is a shape, not a total. */ byKind: Array<{ kind: DynamicBoundaryKind; count: number; }>; /** Per-language rollup, sorted by count desc then name. */ byLanguage: Array<{ language: string; files: number; sites: number; }>; /** Every per-file record, sorted by path — the source of truth the watcher splices. */ files: FileDynamicBoundary[]; } /** * A construct the matcher recognized, before resolution decided whether it is a site. Carries the * byte offset so the extractor can attribute it to its enclosing function, and the literal dispatch * target (when the selector is a static literal) so the partition can be decided against the graph. */ export interface DynamicBoundaryCandidate { kind: DynamicBoundaryKind; /** 1-based line. */ line: number; /** Byte offset of the matched construct, for `findEnclosingFunction`. */ startIndex: number; /** Already redacted, neutralized and truncated. */ evidence: string; evidenceTruncated?: true; /** The static literal the construct dispatches to, when it has one (`getattr(o, "run")`). */ literalTarget?: string; /** * Retained under the separate budget for constructs recorded only because literal reflection can * recover them (a literal-key dispatch into a stable table). Listed after every other site when a * file's site list is capped, so they never crowd out a real boundary * (change: resolve-literal-reflective-dispatch). */ recoverable?: true; /** * The construct indexes a module-level literal dispatch table declared once in this file: the * sorted, deduplicated names its entries bind (only the selected entry's when the key is a * literal). `names` is bounded by {@link DYNAMIC_BOUNDARY_SITE_CAP}; `size` stays exact * (change: resolve-literal-reflective-dispatch). */ table?: { names: string[]; size: number; /** * `[start, end)` byte span of each name's same-file module-level function declaration, parallel * to `names`. Absent with `nonLocal` when any entry is bound by something else (an import, a * variable), which a single file cannot resolve. */ decls?: Array<[number, number]>; nonLocal?: true; }; /** * The EXACT number of constructs matched in this file, present on the first candidate only and * only when the retained list was capped. Keeps a file's reported scale true after the matcher * bounds what it carries across the worker and cache boundaries. */ matchedTotal?: number; } /** * How one language's constructs are recognized. * * Every rule is keyed on a **callee name plus a syntactic position** (the callee of a call node), * or on a syntactic node shape (a computed-subscript callee). Never on a bare identifier anywhere * in the file. */ interface LanguageSpec { /** * Cheap source pre-scan. The tree is walked only when at least one of these substrings occurs in * the source, so a file with no dynamic construct pays no traversal. */ triggers: string[]; /** Node types that denote a call in this grammar (the `function`/`method` field is inspected). */ callTypes: string[]; /** Node types that denote `new X(...)`, if the grammar has one. */ newTypes?: string[]; /** Bare callee name → kind. `getattr(...)`, `eval(...)`, `send(...)`. */ calleeKinds: Record; /** * Dotted callee (`object.method`) → kind, matched on the FULL dotted text of the callee. Keyed on * a namespace the language reserves (`importlib.import_module`, `Reflect.get`), never a bare name. */ dottedKinds?: Record; /** * Member-call rules that need import evidence: the file must contain one of `requires` before the * `method` name counts. This is what keeps `.invoke(`/`.Call(` from firing on ordinary code. */ gatedMethods?: Array<{ methods: string[]; requires: string[]; kind: DynamicBoundaryKind; }>; /** `new X(...)` constructor name → kind. */ constructorKinds?: Record; /** Node types that, used as a call's callee, denote computed member dispatch (`obj[expr]()`). */ computedCalleeTypes?: string[]; /** * Node types that a computed callee's index may be for the dispatch to count as STATIC (and so * not a boundary): `obj["literal"]()` is a resolvable member access, `obj[name]()` is not. */ staticIndexTypes?: string[]; /** Declared DI packages; a `container-resolution` rule fires only when one is imported. */ diPackages?: string[]; /** Resolution APIs of those packages. Only consulted when a DI package is present. */ diMethods?: string[]; /** Node types whose text is a string literal, used to read a literal dispatch target. */ literalTypes: string[]; /** * How this language spells an import, so a gated or DI rule can require REAL import evidence. * * Load-bearing, not cosmetic: a bare substring scan reads a package name out of a comment, a * string table, or a framework-detection list, and then every `map.get(k)` in that file becomes a * "DI container resolution". This module's own matcher table names six DI packages, so a * substring gate flags the matcher itself. The requirement is a DECLARED BINDING; only an import * is one. */ importStyle: ImportStyle; /** * Node types that ARE an import in this grammar. When the tree carries at least one, import * evidence is read from those nodes alone rather than from the whole source — which is what stops * an import spelled inside a string literal (a test fixture, a code sample, a generator template) * from binding a package into the file that merely quotes it. A file with none falls back to the * anchored source scan, so a CommonJS `require` is still recognised. */ importNodeTypes?: string[]; /** * Which ARGUMENT carries the dispatch selector, per rule name — `getattr(o, "run")` is index 1, * `send(:run)` is index 0. A rule absent from this table has no static selector to read, which is * the right answer for `eval`/`exec`/`Proxy`: the argument is code or an object, not a name. */ selectorIndex?: Record; /** * Rules that fire ONLY when the declared argument is not a static string literal. `import(spec)` * and `require(name)` are dynamic boundaries; `import('./known')` is an ordinary statically * resolvable import and must not be recorded. Keyed by callee name → the argument to inspect. */ nonLiteralArg?: Record; /** * Rules that fire only when the call's RESULT IS INVOKED — `getattr(o, a)()` is a dispatch, * `getattr(o, a)` is an attribute read. Recording the read would caveat every conclusion in the * region on the strength of a dispatch that never happens; this module already excludes * `operator.attrgetter` for exactly that reason, and the same reasoning applies to the bare form. * * The declared cost is a false negative: `h = getattr(o, a)` followed later by `h()` is not * recorded. That is the module's stated bias, and it is the safer one — a false positive at a hub * propagates its caveat across every file the hub can name. */ invokeOnlyKinds?: Record; /** * Rules suppressed when the declared argument is a literal that cannot be a callable — `None`, * a number, a string. `setattr(self, "raw", None)` defines nothing dispatchable; it is * `self.raw = None` spelled reflectively. */ nonCallableValueArg?: Record; /** * Node types that, as the RECEIVER of a computed member call, mean the subscript is a type * expression rather than a dispatch table. See {@link isGenericSubscription}. */ genericSubscriptReceiverPattern?: RegExp; /** Require a lowercase letter in the receiver, so SCREAMING_CASE constants stay dispatch tables. */ genericSubscriptRequiresLowercase?: boolean; /** * Whether a {@link LanguageSpec.calleeKinds} name still counts when called on an arbitrary * receiver (`mailer.send(:deliver)`). * * True only where the language defines the name on its universal base object, so the reflective * meaning is the language's and not one object's: Ruby's `send`/`public_send`/`instance_eval` are * `Object` methods. Everywhere else a dotted receiver means the name belongs to that object — * `stream.eval(x)` is not JavaScript's `eval` — so the rule is restricted to a bare call or a * self-like receiver, and the bare-name matching the honesty contract forbids never happens. */ calleeKindsOnAnyReceiver?: boolean; /** * How a module-level literal dispatch table is declared, when this language's tables are read for * literal-reflection recovery (change: resolve-literal-reflective-dispatch). Recording only; the * resolver binds after Pass 7. */ dispatchTables?: 'js'; } /** * The declared per-language matchers. **This table is the language-support source of truth** — a * language absent from it has no `dynamicBoundary` capability, and the registry says so rather than * implying the language is clean. */ export declare const DYNAMIC_BOUNDARY_LANG_SPECS: Record; /** How a language spells the import that binds a package name into a file. */ type ImportStyle = 'js' | 'python' | 'jvm' | 'go' | 'php'; /** * Does `source` actually IMPORT `token`? Anchored to each language's import syntax, so a package * name mentioned in a comment, a string literal, or a framework-name check is not mistaken for a * binding. Conservative in the false-negative direction, like every other rule here: an import * spelled in a way this does not recognise simply disables the rule for that file. */ export declare function hasImportEvidence(source: string, token: string, style: ImportStyle): boolean; /** * True when this language has a declared matcher — the one hook the capability registry consults, * so the published matrix cannot claim coverage the table does not have. */ export declare function supportsDynamicBoundary(language: string): boolean; /** * True when this language's matcher records the structure literal-reflection recovery needs — a * module-level literal dispatch table (change: resolve-literal-reflective-dispatch). * Read from the same table, so the capability registry cannot claim a rule that does not exist. */ export declare function supportsLiteralReflection(language: string): boolean; /** * Minimal structural view of a tree-sitter node — kept dependency-light (no `tree-sitter` import) * so this module stays a leaf and can be unit-tested with plain objects, exactly like * {@link ../analyzer/parse-health.js ParseHealthNode}. Both bindings expose `childCount`/`child(i)`; * `children` is the plain-object fallback. */ export interface DynamicBoundaryNode { type: string; startIndex: number; endIndex: number; startPosition: { row: number; }; childCount?: number; child?(i: number): DynamicBoundaryNode | null; children?: DynamicBoundaryNode[]; childForFieldName?(name: string): DynamicBoundaryNode | null; } /** * Neutralize, redact, collapse and truncate one matched construct's source into storable evidence. * * **Order is load-bearing, and it is neutralization FIRST.** `sanitizeForTerminal` DELETES control * characters, so redacting first leaves a credential split by one — `AIza…\0…` — invisible to the * matcher, and then welds it back together whole. Every one of NUL, ESC, VT, DEL and C1 defeats a * redact-first order that way, and the result is a real key persisted verbatim into an artifact * repositories commonly commit. Neutralizing first removes the splitter before redaction looks. * Whitespace collapse cannot reassemble anything (it leaves one space), so it follows. * * Applied HERE — at extraction — so the fact is already safe before it crosses the worker boundary, * enters the fact cache, or is persisted. */ export declare function toEvidence(raw: string): { evidence: string; truncated: boolean; }; /** * Every substring whose presence could make some rule in this spec fire — the language's own * construct tokens plus the import evidence its gated and DI rules require. Derived so a rule can * never be added without also being reachable through the pre-scan. */ export declare function triggersFor(spec: { triggers: string[]; diPackages?: string[]; gatedMethods?: Array<{ requires: string[]; }>; }): string[]; /** * Walk one already-parsed tree and record every candidate the resolver cannot follow. * * Fail-soft by construction: an unrecognized construct is not recorded, an unsupported language * returns `[]`, and a source with none of the language's trigger tokens is never walked at all. * * The walk is ITERATIVE, for the reason `tallyParseHealth` documents: tree depth is not bounded by * anything the analyzer controls, and a `RangeError` raised inside a native node accessor becomes an * uncatchable abort rather than a JavaScript error. An explicit stack cannot overflow. */ export declare function matchDynamicBoundaries(language: string, root: DynamicBoundaryNode, source: string): DynamicBoundaryCandidate[]; /** * The synthesis rule a reflective-resolution edge carries. Declared here, next to the partition it * governs, so the recovering change (`resolve-literal-reflective-dispatch`, `literal-reflection.ts`) * and the disclosing one cannot drift apart on the name. */ export declare const REFLECTIVE_RESOLUTION_RULE = "literal-reflective"; export interface ResolutionProbe { /** * True when the resolver emitted a REFLECTIVE-RESOLUTION edge for this construct — the candidate * is retracted. * * Gated on the synthesis rule, not on a position key, because a resolved edge carries no byte * offset and no column: a caller+line+name key cannot tell two calls apart, so in * `x = getattr(o, "run"); run()` the ordinary `run()` edge would erase the `getattr` site, * leaving neither an edge NOR a site — a silence indistinguishable from "no dynamic dispatch * here", which is the exact outcome this module exists to prevent. Only an edge the reflective * resolver itself produced means "the resolver followed this". */ resolvedToEdge(candidate: { symbolId?: string; startIndex: number; literalTarget?: string; }): boolean; /** * How many internal symbols carry this name: 0 → `unresolved-external`, 1 → * `resolvable-but-unbound`, >1 → `ambiguous-target`. `null` means the count could not be taken at * all — a single-file derivation with no repository-wide symbol table — which yields * `unresolved-in-file-scope` rather than a repository-wide claim the probe never checked. */ countSymbolsNamed(name: string): number | null; /** * The resolver's OWN refusal for a construct it attempted and declined (`over-cap`, * `unresolved-in-file-scope`, `unattributed-caller`, …). Wins over the name count, which cannot see * a table's entries (change: resolve-literal-reflective-dispatch). */ refusalFor?(candidate: { startIndex: number; }): DynamicBoundaryRefusal | undefined; } /** A candidate with its enclosing-symbol attribution filled in by the extractor. */ export interface AttributedCandidate extends DynamicBoundaryCandidate { symbolId?: string; } /** * Finalize one file's candidates into persisted sites — the second half of the two-phase partition. * * A candidate is RETRACTED only when the resolver actually bound it to an internal symbol; every * other candidate becomes a site carrying the reason the resolver refused it. That is the whole * point of deciding after resolution rather than on argument form: a static literal naming an * external target resolves to nothing, and would otherwise produce neither an edge nor a site — * a silent hole that reads as "no dynamic dispatch here". * * Only a literal dispatch table is ever bound (change: resolve-literal-reflective-dispatch); every * other candidate reaches this function unbound and becomes a site. */ export declare function finalizeDynamicBoundarySites(candidates: AttributedCandidate[], probe: ResolutionProbe): DynamicBoundarySite[]; /** * The constructs the resolver BOUND, as records for the persisted `bound` list — never as sites. A * directly-resolved-only consumer folds them back in as `synthesized-binding` boundaries, because it * ignores the edge that discharged them (change: resolve-literal-reflective-dispatch). */ export declare function boundDynamicBoundarySites(candidates: AttributedCandidate[], probe: ResolutionProbe): DynamicBoundarySite[]; /** * Build one file's record from its finalized sites, or `undefined` when it has none. * * `matchedTotal` is the count the MATCHER saw before it bounded what it carried; without it a file * with 800 reflective calls would report `sites: 50` and no truncation at all, because everything * downstream only ever sees the 50 that survived. The bound is disclosed at whichever layer * actually applied it. */ export declare function buildFileDynamicBoundary(filePath: string, language: string, allSites: DynamicBoundarySite[], matchedTotal?: number, extras?: { deferred?: DynamicBoundarySite[]; bound?: DynamicBoundarySite[]; }): FileDynamicBoundary | undefined; /** Exact recorded-site count for one file — the total, not the retained list length. */ export declare function fileSiteCount(f: FileDynamicBoundary): number; /** * Roll per-file records up into the persisted report. Returns `undefined` when there are no records * — a clean repo persists no artifact, and every consumer reads "no artifact" as "no boundary", so * a clean repo pays nothing. */ export declare function buildDynamicBoundaryReport(records: FileDynamicBoundary[]): DynamicBoundaryReport | undefined; export {}; //# sourceMappingURL=dynamic-boundary.d.ts.map