diff --git a/docs/languages/r-provider.md b/docs/languages/r-provider.md index 3eb81c940..758a2bd89 100644 --- a/docs/languages/r-provider.md +++ b/docs/languages/r-provider.md @@ -120,6 +120,7 @@ Not supported: - Caps, in `linear-regex.ts`: `MAX_TOTAL_WORK` (40,000,000 state visits per matcher), `MAX_SHARED_WORK` (100,000,000 matching visits per load), `MAX_PACKAGE_WORK` (30,000,000 per package), `MAX_SHARED_STATES` (500,000 NFA states per load) and `MAX_PACKAGE_STATES` (100,000 per package); a single pattern is also capped by `MAX_PATTERN_LENGTH` (16,384 characters) and `MAX_STATES` (16,384). - A pattern refused after its NFA was partly built is charged for the build (eight visits per state or build step), and once the budget is spent later patterns are refused without being parsed (dropped, with a reason naming the work budget). A NAMESPACE of thousands of oversized patterns therefore spends the same budget as one of hostile matches. - Groups nested deeper than 256 (`MAX_NESTING`) are refused with the reason "groups are nested too deeply". The limit sits well inside the call stack (a quarter of the default stack still compiles 256 levels), so the verdict is the same cold or warm and on every machine. +- Once the work budget is spent the matchers are no longer asked. A name an `exportPattern()` matched while the budget lasted keeps that answer (a second definition of the same name stays exported); a name not decided by then reads as not exported (fail closed), and the cut-short patterns are named in a warning. - A verdict answered from a matcher's memo (a name asked about again) costs one visit of the work budget, so many matchers asked about the same names over and over are bounded by the budget too. - A pattern that does not fit is dropped, exports nothing and is warned about: at most 50 patterns are named per package (`MAX_LISTED_DROPPED_PATTERNS`), followed by a summary line counting the rest. - The first pattern refused for lack of states closes that package's allowance, so later patterns in the package are refused without being parsed. diff --git a/gitnexus/src/core/ingestion/languages/r/package-config.ts b/gitnexus/src/core/ingestion/languages/r/package-config.ts index 2389a34f5..56c21c4be 100644 --- a/gitnexus/src/core/ingestion/languages/r/package-config.ts +++ b/gitnexus/src/core/ingestion/languages/r/package-config.ts @@ -108,23 +108,41 @@ export interface RNamespaceInfo { */ export const MAX_LISTED_DROPPED_PATTERNS = 50; +/** Most names remembered as matched per package, so a hostile name list cannot grow the set without bound. */ +const MAX_REMEMBERED_MATCHES = 100_000; + +/** Names a pattern list matched while its budget lasted (only for lists that draw on a budget). */ +const matchedWhileBudgetLasted = new WeakMap>(); + /** * True when one of the package's `exportPattern()` matchers matches `name`. Once the - * package's work budget (or the load's) is spent it stops asking the matchers at all and - * answers false, so the cost no longer grows as patterns x names; the patterns it did not - * get to run are then reported by {@link reportRExportPatternProblems}. + * package's work budget (or the load's) is spent it stops asking the matchers, so the cost no + * longer grows as patterns x names. A name it matched while the budget lasted keeps that + * answer (one set lookup, so a duplicate-named definition does not flip from exported to + * private); a name it had not decided answers false, and the patterns it did not get to run are + * then reported by {@link reportRExportPatternProblems}. */ export function rExportPatternMatches( info: Pick, name: string, ): boolean { const budget = info.exportPatternBudget; + let remembered = matchedWhileBudgetLasted.get(info.exportPatterns); for (const pattern of info.exportPatterns) { if (budget?.isSpent === true) { budget.cutShort = true; - return false; + return remembered?.has(name) === true; + } + if (pattern.test(name)) { + if (budget !== undefined) { + if (remembered === undefined) { + remembered = new Set(); + matchedWhileBudgetLasted.set(info.exportPatterns, remembered); + } + if (remembered.size < MAX_REMEMBERED_MATCHES) remembered.add(name); + } + return true; } - if (pattern.test(name)) return true; } return false; } diff --git a/gitnexus/test/unit/r-export-pattern-shared-budget.test.ts b/gitnexus/test/unit/r-export-pattern-shared-budget.test.ts index 1f0499fb7..e3fc12b3e 100644 --- a/gitnexus/test/unit/r-export-pattern-shared-budget.test.ts +++ b/gitnexus/test/unit/r-export-pattern-shared-budget.test.ts @@ -244,6 +244,28 @@ describe('rExportPatternMatches stops asking matchers once the budget is spent', expect(budget.spent).toBeLessThanOrEqual(1_000 + 4 * 16384); }); + it('keeps the answer for a name it matched before the budget was spent, and only that', () => { + const budget = new SharedWorkBudget(1_000, 10_000_000); + const matcher = compileRExportPattern('^foo', budget); + expect(matcher).not.toBeNull(); + const info = { + exportPatterns: [matcher as NonNullable], + exportPatternBudget: budget, + }; + // A definition of `foo_a` is exported while the budget lasts ... + expect(rExportPatternMatches(info, 'foo_a')).toBe(true); + expect(rExportPatternMatches(info, 'bar')).toBe(false); + budget.charge(2_000); + expect(budget.isSpent).toBe(true); + // ... and a second definition of the same name (a duplicate) keeps that answer afterwards. + expect(rExportPatternMatches(info, 'foo_a')).toBe(true); + expect(budget.cutShort).toBe(true); + // A name nobody decided still answers false: documented fail-closed. + expect(rExportPatternMatches(info, 'foo_b')).toBe(false); + // A name decided unexported stays unexported. + expect(rExportPatternMatches(info, 'bar')).toBe(false); + }); + it('is spent by a spent parent', () => { const load = new SharedWorkBudget(10); const pkg = load.child();