Skip to content

feat: implement FIXED suggestion regression detection (PR #1999 missing feature)#2608

Open
absarasw wants to merge 12 commits into
mainfrom
fix/sites-44646-fixed-regression-detection
Open

feat: implement FIXED suggestion regression detection (PR #1999 missing feature)#2608
absarasw wants to merge 12 commits into
mainfrom
fix/sites-44646-fixed-regression-detection

Conversation

@absarasw

@absarasw absarasw commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Problem

PR #1999 promised to implement two key behaviors but only delivered one:

Delivered: Auto-detect publish state for Fix entities
Missing: Regression detection - create NEW suggestions when FIXED+fully-resolved issues reappear

What happened:
The regression detection logic was present in intermediate commits of PR #1730/#1999:

  • 39bcfe66 - had fullyPublishedFixedKeys logic
  • 790687c6 - refined the implementation

But when PR #1999 was squash-merged (b24e8fe7), this logic was dropped. The final merge just has a simple existingSuggestionKeys check that blocks all existing keys equally, regardless of Fix entity status.

Current behavior (broken):
When a FIXED+PUBLISHED issue reappears in audit results:


Solution

This PR restores the missing regression detection logic with improvements:

1. Skip Updating FIXED Suggestions

FIXED suggestions are never updated when they reappear in audit results:

if (existing.getStatus() === STATUSES.FIXED) {
  log.debug(`Skipping FIXED suggestion - FIXED suggestions are never updated`);
  return; // Don't add to toUpdate array
}

2. Detect Fully Resolved FIXED Suggestions

Before creating new suggestions, query Fix entities to identify FIXED suggestions where ALL fix entities have reached terminal status:

For non-author-only opportunities (e.g., broken-backlinks):

  • Check: ALL fix entities have status='PUBLISHED'
  • Rationale: Verified on production, the fix has shipped

For author-only opportunities (e.g., security-permissions):

  • Check: ALL fix entities have status='DEPLOYED'
  • Rationale: Author-only changes can't publish to production, DEPLOYED is terminal

3. Allow Regression Creation

When filtering new suggestions:

if (!existingSuggestionKeys.has(key)) {
  return true; // No existing - create
}

if (fullyResolvedFixedKeys.has(key)) {
  log.warn(`Regression detected: FIXED+${statusType} suggestion found in audit`);
  return true; // Regression - create NEW suggestion
}

return false; // Block otherwise

Behavior Matrix

Scenario Before This PR After This PR
New issue appears ✅ Create NEW suggestion ✅ Create NEW suggestion
Existing NEW/PENDING updated ✅ Update if changed ✅ Update if changed
FIXED (unpublished) reappears ⚠️ No update, no new suggestion ✅ Skip update, no new suggestion
FIXED (all fixes PUBLISHED) reappears ❌ No update, no new suggestion (silent regression) Create NEW suggestion

Scope

Applies to: syncSuggestionsWithPublishDetection only
Does not apply to: Base syncSuggestions (unchanged)

This matches the original intent - regression detection is a publish-detection feature, not a base sync behavior.


Testing Strategy

Unit Tests (TODO - will add after implementation review)

  1. FIXED skip test: Verify FIXED suggestions are not updated
  2. Regression detection (non-author-only): FIXED+PUBLISHED → NEW suggestion created
  3. Regression detection (author-only): FIXED+DEPLOYED → NEW suggestion created
  4. No regression when not fully resolved: FIXED with mixed DEPLOYED/PUBLISHED → no new suggestion

Integration Testing

  • Broken-backlinks audit (non-author-only path)
  • Security-permissions audit (author-only path)

Performance Considerations

Fix entity queries: This adds N async database calls (one per FIXED suggestion).

Mitigation strategies considered:

  • ✅ Only queries FIXED suggestions (typically small subset)
  • ✅ Uses Promise.all for parallel execution
  • ✅ Only runs in syncSuggestionsWithPublishDetection (not base syncSuggestions)
  • 🔄 Future: Batch query for fix entities (if N grows large)

Related PRs

References


Merge Order

Can be merged in any order with PR #2593. They are independent changes:

Recommended order: Merge #2593 first (simpler change), then this PR.

…ng feature)

Implements the missing feature from PR #1999 that was lost during squash merge:

**Problem:**
PR #1999 claimed to 'create a New Suggestion if same issue re-appears and
previous Suggestions were Deployed+Published' but this logic was present in
intermediate commits (39bcfe6, 790687c) but dropped in the final squash
merge (b24e8fe).

**Solution:**
1. Skip updating FIXED suggestions entirely (don't change their data/status)
2. Detect regressions by querying Fix entities for FIXED suggestions
3. Create NEW suggestions when FIXED+fully-resolved issues reappear

**Regression Detection Logic:**
- Non-author-only opportunities: Check ALL fix entities are PUBLISHED
- Author-only opportunities: Check ALL fix entities are DEPLOYED
  (author-only can't publish to production, DEPLOYED is terminal)

**Scope:**
Only applies to syncSuggestionsWithPublishDetection (not base syncSuggestions).
Works for broken-backlinks and future author-only opportunities using the
publish detection flow.

Related:
- Original intent: PR #1730 and PR #1999
- Upstream issue: SITES-44646 (spurious updatedAt bumps)
- Companion PR: #2593 (deepEqual optimization)
@absarasw absarasw requested a review from MysticatBot June 5, 2026 12:05
The function expects context, existingSuggestions, newDataKeys, and buildKey,
not disappearedSuggestions and log directly. The function internally filters
to find disappeared suggestions.

Fixes 23 failing backlinks tests that were getting:
TypeError: Cannot read properties of undefined (reading 'dataAccess')
@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

This PR will trigger a minor release when merged.

@MysticatBot MysticatBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @absarasw,

Strengths

  • src/utils/data-access.js:894 - The FIXED skip logic with early return is clean and well-placed. Prevents unnecessary data merging for resolved suggestions.
  • src/utils/data-access.js:910 - The deepEqual optimization to avoid writing unchanged suggestions is a genuine improvement preventing spurious updatedAt bumps.
  • src/utils/data-access.js:940-950 - Regression detection design is sound: requiring both non-empty fix entities AND all-terminal-status before declaring a regression prevents false positives. A FIXED suggestion with no fix entities (data inconsistency) will not trigger false regressions.
  • The decision to inline the update logic rather than adding more callback parameters to syncSuggestions keeps each variant self-contained and avoids over-parameterizing the base function.
  • No new security surface - the change operates entirely within trusted boundaries (SQS -> Lambda -> DB) with fail-closed error handling in the regression detection path.

Issues

Critical (Must Fix)

1. handleOutdatedSuggestions called with wrong parameters - will break at runtime
src/utils/data-access.js:1051

The PR passes:
```javascript
await handleOutdatedSuggestions({
disappearedSuggestions,
statusToSetForOutdated,
scrapedUrlsSet,
log,
});
```

But handleOutdatedSuggestions expects { context, existingSuggestions, newDataKeys, buildKey, statusToSetForOutdated, scrapedUrlsSet }. It uses context.dataAccess internally to get Suggestion and context.log for logging, and computes disappeared suggestions by filtering existingSuggestions against newDataKeys using buildKey.

The function will receive undefined for context, causing a throw on context.dataAccess. No suggestions will ever be marked OUTDATED in syncSuggestionsWithPublishDetection.

Fix: Pass the correct parameters:
```javascript
await handleOutdatedSuggestions({
context,
existingSuggestions,
newDataKeys,
buildKey,
statusToSetForOutdated,
scrapedUrlsSet,
});
```

2. Default merge/status functions diverge from base syncSuggestions
src/utils/data-access.js:900

```javascript
const mergedData = mergeDataFunction
? mergeDataFunction(existingData, newDataItem)
: { ...existingData, ...newDataItem };
```

In base syncSuggestions, mergeDataFunction defaults to defaultMergeDataFunction at the function signature level. Similarly, mergeStatusFunction defaults to defaultMergeStatusFunction which handles REJECTED->keep, OUTDATED->regression, and ERROR->NEW transitions. The inlined version defaults to returning null (no status change), silently dropping these transitions.

If any caller of syncSuggestionsWithPublishDetection relies on these default transitions, behavior silently diverges from what the base function would do.

Fix: Apply the same defaults:
```javascript
const effectiveMergeData = mergeDataFunction ?? defaultMergeDataFunction;
const effectiveMergeStatus = mergeStatusFunction ?? defaultMergeStatusFunction;
```

Important (Should Fix)

3. Missing tests - 100% coverage requirement violated
src/utils/data-access.js:875-1057

The repo's CLAUDE.md states: "Coverage enforcement is strict: 100% lines, branches, and statements across all src/**/*.js. Every code path must be tested." The PR adds ~167 lines of production code with 12+ branch paths and zero test files. CI is failing. The PR body acknowledges this ("Unit Tests (TODO - will add after implementation review)").

Fix: Add unit tests covering at minimum:

  • FIXED suggestion skip logic
  • Regression detection (both author-only/DEPLOYED and non-author-only/PUBLISHED paths)
  • Partial resolution (not all fixes at terminal status)
  • Fix entity query failures
  • deepEqual change detection
  • New suggestion status determination (PENDING_VALIDATION vs NEW)
  • Partial and total addSuggestions failures

4. Silent error swallowing in fix entity queries
src/utils/data-access.js:948

The catch block logs at debug level. If getFixEntitiesBySuggestionId fails systematically (DB connectivity, missing table), ALL FIXED suggestions silently skip regression detection. The function completes successfully with fullyResolvedFixedKeys empty, meaning regressions are silently missed - which directly undermines the PR's stated goal.

Fix: Count failures and escalate when all queries fail:
```javascript
let failCount = 0;
// ...inside catch:
failCount++;
// ...after Promise.all:
if (failCount > 0 && failCount === fixedSuggestions.length) {
log.warn(`All ${failCount} fix entity checks failed - regression detection incomplete`);
}
```

5. Unbounded concurrent database calls in Step 4
src/utils/data-access.js:931

Promise.all(fixedSuggestions.map(...)) fires one getFixEntitiesBySuggestionId call per FIXED suggestion with no concurrency limit. FIXED suggestions accumulate monotonically over a site's lifetime. A site audited for 2+ years could have dozens to hundreds of FIXED suggestions on a single opportunity, creating unbounded fan-out against the database.

Fix: Use a bounded concurrency approach (batch processing in chunks of 10, or a concurrency-limiting utility). Alternatively, if the data-access layer supports it, add a batch query that fetches fix entities for multiple suggestion IDs in one round-trip.

6. Code duplication creates divergence surface
src/utils/data-access.js:875-1057

Steps 3, 5, and 6 now carry their own copy of update logic, new-suggestion creation, error handling, and outdated-suggestion handling. When base syncSuggestions evolves (new validation, bug fixes), the publish-detection variant will silently drift unless both are patched in lockstep.

Fix: Consider a strategy-pattern extension: syncSuggestions could accept optional shouldSkipUpdate(existing) and allowCreationOverride(key, existingSuggestionKeys) predicates. This keeps the behavioral extension isolated while the mechanical plumbing stays in one place.

Recommendations

  • The isAuthorOnly boolean for terminal-status branching (DEPLOYED vs PUBLISHED) may grow as the platform adds opportunity types with different lifecycle models. Consider whether this should become a status-resolution function supplied by each audit type rather than accumulating more boolean parameters.
  • Structure tests around the 6 steps with describe blocks per step. Test the interaction between Step 3 (skipping FIXED in update) and Step 4 (using those same FIXED suggestions for regression detection).

Assessment

Ready to merge? No
Reasoning: The handleOutdatedSuggestions call passes incorrect parameters and will throw at runtime, breaking outdated-suggestion detection entirely. The merge/status function defaults diverge from the base, risking silent behavioral differences. CI is failing due to missing test coverage (a documented 100% requirement). These must be fixed before merge.

Note: CI checks are currently failing - this should be resolved before merge.

Next Steps

  1. Fix the handleOutdatedSuggestions call to pass the correct parameters (Critical).
  2. Apply proper defaults for merge/status functions to match base syncSuggestions behavior (Critical).
  3. Add unit tests for all branch paths to satisfy the 100% coverage requirement and fix CI.
  4. Add failure counting for the fix entity query catch block so systematic failures are visible.

Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 2m 22s | Cost: $3.16 | Commit: 92476a4703cf280696727f65dde7c30b89817776
If this code review was useful, please react with 👍. Otherwise, react with 👎.

absarasw added 6 commits June 5, 2026 18:05
Adds comprehensive test coverage for the new regression detection logic:

1. FIXED suggestions not updated when they reappear
2. Regression detection for non-author-only (ALL fixes PUBLISHED)
3. Regression detection for author-only (ALL fixes DEPLOYED)
4. No regression when Fix entities not fully resolved (mixed statuses)
5. Error handling when suggestion creation fails (all errors)
6. Partial success handling (some created, some failed)
7. Error logging when more than 5 failures

Covers lines 1000, 1025-1043 to reach 100% coverage threshold.
Adds tests for edge cases in the regression detection Fix entities query:

1. FIXED suggestion with no ID (getId() returns null)
   - Covers lines 948-949 (early return guard)
   - Should not crash, just skip checking Fix entities

2. getFixEntitiesBySuggestionId throws an error
   - Covers lines 965-966 (catch block)
   - Logs debug message and continues gracefully

This should achieve 100% coverage on all lines and branches.
Adds final 2 tests to achieve 100% coverage:

1. 'should update suggestion when status changes'
   - Covers lines 914-915 (setStatus when newStatus !== null)
   - Data same but status different → should update

2. 'should skip update when neither data nor status changed'
   - Covers lines 919-920 (else block, no changes detected)
   - Data same AND status same → should skip

With this commit, all lines, branches, and statements should be 100% covered.
Breaks long line at 3627 into multiple lines to comply with
eslint max-len rule (100 char limit).
Adds 2 tests to cover uncovered branches:

1. 'should use default merge when mergeDataFunction is not provided'
   - Covers line 901 false branch (mergeDataFunction falsy)
   - Uses default spread merge: { ...existingData, ...newDataItem }

2. 'should handle error with no error message gracefully'
   - Covers line 1038 false branch (errorItems[0].error falsy)
   - Falls back to 'Unknown error' message

This should achieve 100% branch coverage.
Adds test 'should use custom merge when mergeDataFunction is provided'
to cover line 901 (mergeDataFunction true branch in ternary).

This completes 100% branch coverage by testing both:
- TRUE branch: mergeDataFunction provided → uses custom merge
- FALSE branch: mergeDataFunction not provided → uses default spread

The custom merge test uses a stub that sums counts, verifying
the custom function is actually called.
@codecov

codecov Bot commented Jun 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Addresses PR review Critical #2:
#2608 (review)

Changes:
1. Replace inline fallbacks with defaultMergeDataFunction and
   defaultMergeStatusFunction to match base syncSuggestions behavior
2. Preserves status transitions: REJECTED→keep, OUTDATED→regression,
   ERROR→NEW that were previously dropped by 'null' fallback

Tests added:
- OUTDATED suggestions transition to PENDING_VALIDATION or NEW
- ERROR suggestions transition to NEW
- REJECTED suggestions keep REJECTED status (no setStatus call)

This ensures syncSuggestionsWithPublishDetection behaves identically
to base syncSuggestions when merge/status functions are not provided.
@absarasw absarasw requested a review from MysticatBot June 10, 2026 06:09

@MysticatBot MysticatBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @absarasw,

Verdict: Request changes - two correctness/resilience issues need fixing before this safely detects regressions in production.
Changes: implements FIXED-suggestion regression detection by inlining the sync loop with a FIXED-skip, querying fix entities to identify fully-resolved suggestions, and allowing new suggestion creation for regressions (2 files).

Must fix before merge

  1. [Important] isTBYB not passed to mergeStatusFunction - silent behavioral divergence from base syncSuggestions - src/utils/data-access.js:908 (details inline)
  2. [Important] Silent error swallowing in fix entity queries - regression detection silently skipped with no operator signal - src/utils/data-access.js:965 (details inline)
  3. [Important] Unbounded concurrent database calls in Step 4 - no concurrency limit despite existing limitConcurrencyAllSettled pattern - src/utils/data-access.js:945 (details inline)
Non-blocking (4): minor issues and suggestions
  • nit: effectiveMergeData and effectiveMergeStatus are loop-invariant but recomputed inside forEach - hoist above the loop - src/utils/data-access.js:902
  • nit: suggestions.createdItems?.length <= 0 is misleading - if createdItems is undefined, optional chaining yields undefined <= 0 which is false, skipping the throw even when zero items were created - use !suggestions.createdItems?.length instead - src/utils/data-access.js:1038
  • suggestion: partition matchedSuggestions into fixed/non-fixed upfront rather than filtering FIXED in the forEach (Step 3) and re-filtering the same array in Step 4 - src/utils/data-access.js:880
  • suggestion: extract Step 4 (regression-eligibility check) into a named function to isolate the concurrency fix and improve testability - src/utils/data-access.js:931

Previously flagged, now resolved

  • handleOutdatedSuggestions called with correct parameters (commit d4bcc0f)
  • Default merge/status functions now use defaultMergeDataFunction/defaultMergeStatusFunction via nullish coalescing (commit 9850794)
  • Test coverage meets 100% requirement (Codecov confirmed)

Note: CI checks are currently pending - resolve before merge.


Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 4m 48s | Cost: $4.63 | Commit: 56687cf13b441f80914a2a5af3864556ade04083
If this code review was useful, please react with 👍. Otherwise, react with 👎.

Comment thread src/utils/data-access.js Outdated
const mergedData = effectiveMergeData(existingData, newDataItem);
warnOnInvalidSuggestionData(mergedData, opportunityType, log);

const newStatus = effectiveMergeStatus(existing, newDataItem, context);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): mergeStatusFunction receives bare context instead of { ...context, isTBYB }.

In base syncSuggestions (line 507), isTBYB is computed via checkIsTBYBSite(context) and passed as { ...context, isTBYB } to mergeStatusFunction. The inlined version here passes bare context, so defaultMergeStatusFunction sees context.isTBYB as undefined (falsy). For PLG/freemium sites, this means OUTDATED suggestions that reappear will get PENDING_VALIDATION instead of NEW, diverging from base behavior.

checkIsTBYBSite is already called at line 848 (Step 1). Store its result and pass { ...context, isTBYB } here:

const newStatus = effectiveMergeStatus(existing, newDataItem, { ...context, isTBYB });

Also use it for defaultNewSuggestionStatus computation in Step 5 (line 982).

Comment thread src/utils/data-access.js Outdated
fullyResolvedFixedKeys.add(key);
log.debug(`FIXED suggestion ${suggestionId} is fully resolved (${requiredFixStatus}): ${key}`);
}
} catch (e) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): The catch block logs at debug level. If getFixEntitiesBySuggestionId fails (DB throttling, timeout, permissions), the suggestion is silently excluded from fullyResolvedFixedKeys - regression detection simply does not fire for that suggestion, with no operator-visible signal.

This is the core feature of this PR. A degraded database that causes all queries to fail means zero regressions are detected and no one knows.

Fix: log at warn to match the pattern used in publishDeployedFixEntities (line 789) and reconcileDisappearedSuggestions (line 675) for the same class of failure:

log.warn(`Failed to check fix entities for suggestion ${suggestion.getId()}, regression check skipped: ${e.message}`);

Comment thread src/utils/data-access.js Outdated
const requiredFixStatus = isAuthorOnly ? 'DEPLOYED' : 'PUBLISHED';
log.debug(`[syncSuggestionsWithPublishDetection] Checking ${fixedSuggestions.length} FIXED suggestions for regression (required status: ${requiredFixStatus})`);

await Promise.all(fixedSuggestions.map(async (suggestion) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): Promise.all(fixedSuggestions.map(...)) with no concurrency limit. This file already uses limitConcurrencyAllSettled with MAX_CONCURRENT_CHECKS = 5 for the same fan-out pattern in reconcileDisappearedSuggestions (line 634) and publishDeployedFixEntities (lines 765, 772). A site accumulating FIXED suggestions over time creates unbounded burst load.

Fix: use the existing utility:

const fixEntityTasks = fixedSuggestions.map((suggestion) => async () => {
  // ... existing logic
});
await limitConcurrencyAllSettled(fixEntityTasks, MAX_CONCURRENT_CHECKS);

absarasw added 2 commits June 10, 2026 15:04
Addresses MysticatBot review 4465104490 - 3 must-fix issues:

1. Pass isTBYB to mergeStatusFunction (line 908)
   - Now: effectiveMergeStatus(existing, newDataItem, { ...context, isTBYB })
   - Matches base syncSuggestions behavior, prevents silent divergence

2. Add concurrency limit to fix entity queries (line 945)
   - Was: unbounded Promise.all()
   - Now: limitConcurrencyAllSettled() with MAX_CONCURRENT_CHECKS (5)
   - Prevents DB overload on sites with many FIXED suggestions

3. Escalate when ALL fix entity queries fail (line 965)
   - Added failedQueries counter
   - Warns when all queries fail (systematic DB/connectivity issue)
   - Prevents silent regression detection skip

All 3 fixes maintain existing behavior while adding resilience.
- Move isTBYB extraction before first use (line 908 no-use-before-define)
- Remove unused results variable from limitConcurrencyAllSettled call
- No behavior changes, pure lint compliance
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants