@runhalo/engine 0.4.0 → 0.5.0

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.
@@ -0,0 +1,653 @@
1
+ "use strict";
2
+ /**
3
+ * Halo AST Rule Engine
4
+ *
5
+ * Takes a parsed tree-sitter AST + rule ID and returns an AST-based verdict
6
+ * that supplements the regex scanner. AST analysis can suppress false positives
7
+ * (e.g., a Schema that already has TTL) or confirm true positives with higher
8
+ * confidence.
9
+ *
10
+ * Sprint 8: 10 rule analyzers for JS/TS.
11
+ * HARD SCOPE: Single-file only (via DataFlowTracer).
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.ASTRuleEngine = void 0;
15
+ const scope_analyzer_1 = require("./scope-analyzer");
16
+ const data_flow_tracer_1 = require("./data-flow-tracer");
17
+ // ---------------------------------------------------------------------------
18
+ // AST walker helper
19
+ // ---------------------------------------------------------------------------
20
+ function walk(node, visitor) {
21
+ if (!node)
22
+ return;
23
+ visitor(node);
24
+ for (let i = 0; i < node.childCount; i++) {
25
+ walk(node.child(i), visitor);
26
+ }
27
+ }
28
+ // ---------------------------------------------------------------------------
29
+ // ASTRuleEngine
30
+ // ---------------------------------------------------------------------------
31
+ class ASTRuleEngine {
32
+ constructor() {
33
+ this.scopeAnalyzer = new scope_analyzer_1.ScopeAnalyzer();
34
+ }
35
+ /**
36
+ * Analyze a regex-detected violation using AST context.
37
+ *
38
+ * @param ruleId - The COPPA/ethical rule ID
39
+ * @param content - Full file content
40
+ * @param violation - The violation from the regex scanner
41
+ * @param tree - Parsed tree-sitter AST
42
+ * @returns ASTResult with verdict, confidence, and reason
43
+ */
44
+ analyzeViolation(ruleId, content, violation, tree) {
45
+ // First check scope-level suppressions that apply to all rules
46
+ const scopeContext = this.scopeAnalyzer.analyzeFile(violation.codeSnippet, // We don't have filePath here, but snippet gives some hint
47
+ content, tree);
48
+ // Type definition files should suppress most violations
49
+ if (scopeContext.isTypeDefinition) {
50
+ return {
51
+ verdict: 'suppressed',
52
+ confidence: 0.95,
53
+ reason: 'File is primarily type definitions — no runtime behavior',
54
+ };
55
+ }
56
+ // Test files get suppressed for most rules (test code isn't deployed)
57
+ if (scopeContext.isTestFile) {
58
+ return {
59
+ verdict: 'suppressed',
60
+ confidence: 0.90,
61
+ reason: 'Violation is in a test file — not deployed to production',
62
+ };
63
+ }
64
+ // Route to rule-specific analyzer
65
+ const analyzer = this.getAnalyzer(ruleId);
66
+ if (!analyzer) {
67
+ return { verdict: 'regex_only', confidence: 0, reason: 'No AST analyzer for this rule' };
68
+ }
69
+ return analyzer(tree, content, violation, scopeContext);
70
+ }
71
+ /**
72
+ * Analyze a violation with a known file path (used from scanFileWithAST integration).
73
+ * This version passes the real file path for scope analysis.
74
+ */
75
+ analyzeViolationWithPath(ruleId, filePath, content, violation, tree) {
76
+ const scopeContext = this.scopeAnalyzer.analyzeFile(filePath, content, tree);
77
+ if (scopeContext.isTypeDefinition) {
78
+ return {
79
+ verdict: 'suppressed',
80
+ confidence: 0.95,
81
+ reason: 'File is primarily type definitions — no runtime behavior',
82
+ };
83
+ }
84
+ if (scopeContext.isTestFile) {
85
+ return {
86
+ verdict: 'suppressed',
87
+ confidence: 0.90,
88
+ reason: 'Violation is in a test file — not deployed to production',
89
+ };
90
+ }
91
+ const analyzer = this.getAnalyzer(ruleId);
92
+ if (!analyzer) {
93
+ return { verdict: 'regex_only', confidence: 0, reason: 'No AST analyzer for this rule' };
94
+ }
95
+ return analyzer(tree, content, violation, scopeContext);
96
+ }
97
+ // -------------------------------------------------------------------------
98
+ // Analyzer dispatch
99
+ // -------------------------------------------------------------------------
100
+ getAnalyzer(ruleId) {
101
+ const analyzers = {
102
+ 'coppa-tracking-003': this.analyzeTracking003.bind(this),
103
+ 'coppa-retention-005': this.analyzeRetention005.bind(this),
104
+ 'coppa-ext-017': this.analyzeExtLinks017.bind(this),
105
+ 'coppa-sec-015': this.analyzeXSS015.bind(this),
106
+ 'coppa-auth-001': this.analyzeAuth001.bind(this),
107
+ 'coppa-ui-008': this.analyzeUI008.bind(this),
108
+ 'coppa-ugc-014': this.analyzeUGC014.bind(this),
109
+ 'coppa-flow-009': this.analyzeFlow009.bind(this),
110
+ 'coppa-cookies-016': this.analyzeCookies016.bind(this),
111
+ 'ETHICAL-001': this.analyzeInfiniteScroll001.bind(this),
112
+ };
113
+ return analyzers[ruleId] ?? null;
114
+ }
115
+ // -------------------------------------------------------------------------
116
+ // Rule: coppa-tracking-003 — Ad Trackers
117
+ // Check CallExpression args for child_directed_treatment
118
+ // -------------------------------------------------------------------------
119
+ analyzeTracking003(tree, content, violation, _scope) {
120
+ const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
121
+ // Check if the call at the violation line includes child_directed_treatment
122
+ if (tracer.hasArgument(violation.line, 'child_directed_treatment')) {
123
+ return {
124
+ verdict: 'suppressed',
125
+ confidence: 0.92,
126
+ reason: 'child_directed_treatment flag detected in tracker initialization arguments',
127
+ };
128
+ }
129
+ // Also check for restrictDataProcessing
130
+ if (tracer.hasArgument(violation.line, 'restrictDataProcessing')) {
131
+ return {
132
+ verdict: 'suppressed',
133
+ confidence: 0.88,
134
+ reason: 'restrictDataProcessing flag detected in tracker initialization',
135
+ };
136
+ }
137
+ // Check nearby context (within 5 lines) for the flag being set separately
138
+ const nearbyCalls = tracer.findNearbyCallExpressions(violation.line, 5);
139
+ for (const call of nearbyCalls) {
140
+ if (call.name.includes('set') || call.name.includes('config')) {
141
+ if (tracer.hasArgument(call.line, 'child_directed_treatment') ||
142
+ tracer.hasArgument(call.line, 'restrictDataProcessing')) {
143
+ return {
144
+ verdict: 'suppressed',
145
+ confidence: 0.85,
146
+ reason: 'Child-directed flag configured near tracker initialization',
147
+ };
148
+ }
149
+ }
150
+ }
151
+ // Check if this is in a config file (likely setting it up correctly)
152
+ if (_scope.isConfigFile) {
153
+ return {
154
+ verdict: 'confirmed',
155
+ confidence: 0.70,
156
+ reason: 'Tracker in config file without child_directed_treatment — may need flag',
157
+ };
158
+ }
159
+ return {
160
+ verdict: 'confirmed',
161
+ confidence: 0.85,
162
+ reason: 'No child_directed_treatment or restrictDataProcessing flag found in tracker setup',
163
+ };
164
+ }
165
+ // -------------------------------------------------------------------------
166
+ // Rule: coppa-retention-005 — Missing Data Retention
167
+ // Find Schema constructors, trace for TTL/expires/deletedAt in scope
168
+ // -------------------------------------------------------------------------
169
+ analyzeRetention005(tree, content, violation, _scope) {
170
+ const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
171
+ const retentionFields = [
172
+ 'TTL', 'ttl', 'expires', 'expireAt', 'expireAfterSeconds',
173
+ 'deletedAt', 'deleted_at', 'expiresAt', 'expires_at',
174
+ 'retention', 'paranoid', 'expiration', 'retentionPolicy',
175
+ ];
176
+ // Check if there's a retention-related property in the same scope
177
+ if (tracer.hasPropertyInScope(violation.line, retentionFields)) {
178
+ return {
179
+ verdict: 'suppressed',
180
+ confidence: 0.90,
181
+ reason: 'Data retention field (TTL/expires/deletedAt) found in same scope as schema',
182
+ };
183
+ }
184
+ // Also check if there's a Mongoose index with expires
185
+ const scope = tracer.getEnclosingScope(violation.line);
186
+ if (scope) {
187
+ const scopeContent = content.split('\n').slice(scope.startLine - 1, scope.endLine).join('\n');
188
+ if (/\.index\s*\([^)]*expires/i.test(scopeContent) ||
189
+ /expireAfterSeconds/i.test(scopeContent)) {
190
+ return {
191
+ verdict: 'suppressed',
192
+ confidence: 0.90,
193
+ reason: 'Mongoose TTL index (expireAfterSeconds) found in schema scope',
194
+ };
195
+ }
196
+ }
197
+ // Check config files — schema definitions in config might be handled elsewhere
198
+ if (_scope.isConfigFile) {
199
+ return {
200
+ verdict: 'confirmed',
201
+ confidence: 0.50,
202
+ reason: 'Schema in config file — retention may be handled at application level',
203
+ };
204
+ }
205
+ return {
206
+ verdict: 'confirmed',
207
+ confidence: 0.80,
208
+ reason: 'No TTL, expires, or deletedAt field found in schema scope',
209
+ };
210
+ }
211
+ // -------------------------------------------------------------------------
212
+ // Rule: coppa-ext-017 — External Links
213
+ // Parse JSX <a> elements, check for rel="noopener" and interstitial warning
214
+ // -------------------------------------------------------------------------
215
+ analyzeExtLinks017(tree, content, violation, _scope) {
216
+ // Admin routes don't need external link warnings
217
+ if (_scope.isAdminRoute) {
218
+ return {
219
+ verdict: 'suppressed',
220
+ confidence: 0.88,
221
+ reason: 'External link in admin route — admin users don\'t need child-safety warnings',
222
+ };
223
+ }
224
+ const lines = content.split('\n');
225
+ const violationLine = lines[violation.line - 1] || '';
226
+ // Check if the link is wrapped in a SafeLink or ExternalLink component
227
+ // Look at surrounding lines for component wrappers
228
+ const contextStart = Math.max(0, violation.line - 4);
229
+ const contextEnd = Math.min(lines.length, violation.line + 3);
230
+ const context = lines.slice(contextStart, contextEnd).join('\n');
231
+ if (/SafeLink|ExternalLink|InterstitialLink|WarningLink/i.test(context)) {
232
+ return {
233
+ verdict: 'suppressed',
234
+ confidence: 0.90,
235
+ reason: 'External link wrapped in safety/warning component',
236
+ };
237
+ }
238
+ // Check if the link has rel="noopener noreferrer" (partial mitigation)
239
+ if (/rel\s*=\s*["'][^"']*noopener[^"']*["']/i.test(violationLine)) {
240
+ return {
241
+ verdict: 'confirmed',
242
+ confidence: 0.60,
243
+ reason: 'External link has noopener but no interstitial warning for child users',
244
+ };
245
+ }
246
+ return {
247
+ verdict: 'confirmed',
248
+ confidence: 0.80,
249
+ reason: 'External link with target="_blank" lacks child-facing exit warning',
250
+ };
251
+ }
252
+ // -------------------------------------------------------------------------
253
+ // Rule: coppa-sec-015 — XSS Risk (dangerouslySetInnerHTML / innerHTML)
254
+ // Trace data flow for sanitization (DOMPurify, sanitize-html, xss, etc.)
255
+ // -------------------------------------------------------------------------
256
+ analyzeXSS015(tree, content, violation, _scope) {
257
+ const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
258
+ // Check if the value passes through a sanitization function
259
+ const sanitizers = [
260
+ 'DOMPurify.sanitize', 'sanitize', 'xss', 'sanitizeHtml',
261
+ 'purify', 'clean', 'bleach', 'escape', 'escapeHtml',
262
+ 'he.encode', 'encode', 'striptags', 'htmlEncode',
263
+ ];
264
+ if (tracer.passesThrough(violation.line, sanitizers)) {
265
+ return {
266
+ verdict: 'suppressed',
267
+ confidence: 0.92,
268
+ reason: 'Value passes through sanitization function before HTML insertion',
269
+ };
270
+ }
271
+ // Check if the file imports a sanitizer
272
+ const importLines = content.split('\n').slice(0, 20).join('\n');
273
+ const hasSanitizerImport = /import.*(?:DOMPurify|sanitize-html|xss|isomorphic-dompurify|dompurify)/i.test(importLines);
274
+ if (hasSanitizerImport) {
275
+ // File imports a sanitizer — check if it's used in the same function scope
276
+ const scope = tracer.getEnclosingScope(violation.line);
277
+ if (scope) {
278
+ const scopeContent = content.split('\n').slice(scope.startLine - 1, scope.endLine).join('\n');
279
+ if (/sanitize|purify|clean|escape/i.test(scopeContent)) {
280
+ return {
281
+ verdict: 'suppressed',
282
+ confidence: 0.85,
283
+ reason: 'Sanitizer imported and used in same function scope',
284
+ };
285
+ }
286
+ }
287
+ // Sanitizer imported but might not be used at this violation point
288
+ return {
289
+ verdict: 'confirmed',
290
+ confidence: 0.55,
291
+ reason: 'Sanitizer library imported but not clearly applied to this HTML insertion',
292
+ };
293
+ }
294
+ // Check for static content (string literal)
295
+ const violationLine = content.split('\n')[violation.line - 1] || '';
296
+ if (/dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:\s*['"`]/.test(violationLine)) {
297
+ return {
298
+ verdict: 'suppressed',
299
+ confidence: 0.95,
300
+ reason: 'innerHTML set with static string literal — no XSS vector',
301
+ };
302
+ }
303
+ return {
304
+ verdict: 'confirmed',
305
+ confidence: 0.85,
306
+ reason: 'No sanitization detected before HTML content insertion',
307
+ };
308
+ }
309
+ // -------------------------------------------------------------------------
310
+ // Rule: coppa-auth-001 — Social Login Without Age Gate
311
+ // Check for age verification wrapper around signInWithPopup
312
+ // -------------------------------------------------------------------------
313
+ analyzeAuth001(tree, content, violation, _scope) {
314
+ const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
315
+ // Check the enclosing function scope for age-gating patterns
316
+ const scope = tracer.getEnclosingScope(violation.line);
317
+ if (scope) {
318
+ const scopeContent = content.split('\n').slice(scope.startLine - 1, scope.endLine).join('\n');
319
+ // Look for age check patterns
320
+ const agePatterns = [
321
+ /age\s*>=?\s*13/,
322
+ /age\s*>\s*12/,
323
+ /isMinor/i,
324
+ /isChild/i,
325
+ /ageGate/i,
326
+ /age_gate/i,
327
+ /verifyAge/i,
328
+ /checkAge/i,
329
+ /parentalConsent/i,
330
+ /parental_consent/i,
331
+ /isOver13/i,
332
+ /isAdult/i,
333
+ ];
334
+ if (agePatterns.some(p => p.test(scopeContent))) {
335
+ return {
336
+ verdict: 'suppressed',
337
+ confidence: 0.88,
338
+ reason: 'Age verification check found in same function scope as social login',
339
+ };
340
+ }
341
+ }
342
+ // Check if there's a parent context with age gate (look 10 lines up)
343
+ const contextStart = Math.max(0, violation.line - 11);
344
+ const contextContent = content.split('\n').slice(contextStart, violation.line).join('\n');
345
+ if (/age\s*>=?\s*13|isMinor|ageGate|verifyAge|parentalConsent/i.test(contextContent)) {
346
+ return {
347
+ verdict: 'suppressed',
348
+ confidence: 0.80,
349
+ reason: 'Age verification found in preceding context',
350
+ };
351
+ }
352
+ return {
353
+ verdict: 'confirmed',
354
+ confidence: 0.85,
355
+ reason: 'Social login without age gate verification in scope',
356
+ };
357
+ }
358
+ // -------------------------------------------------------------------------
359
+ // Rule: coppa-ui-008 — Missing Privacy Policy on Registration
360
+ // Walk JSX tree for privacy/terms child components
361
+ // -------------------------------------------------------------------------
362
+ analyzeUI008(tree, content, violation, _scope) {
363
+ const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
364
+ // Check the enclosing scope (likely a component) for privacy-related elements
365
+ const scope = tracer.getEnclosingScope(violation.line);
366
+ if (!scope) {
367
+ // If no enclosing scope, check the whole file
368
+ return this.checkPrivacyInContent(content, violation);
369
+ }
370
+ const scopeContent = content.split('\n').slice(scope.startLine - 1, scope.endLine).join('\n');
371
+ // Look for privacy policy references in the component
372
+ const privacyPatterns = [
373
+ /privacy/i,
374
+ /PrivacyPolicy/,
375
+ /privacy-policy/i,
376
+ /terms.*service/i,
377
+ /TermsOfService/,
378
+ /terms-of-service/i,
379
+ /href\s*=\s*["'][^"']*privacy/i,
380
+ /href\s*=\s*["'][^"']*terms/i,
381
+ /<PrivacyLink/,
382
+ /<TermsLink/,
383
+ /PrivacyCheckbox/,
384
+ /AcceptTerms/,
385
+ ];
386
+ if (privacyPatterns.some(p => p.test(scopeContent))) {
387
+ return {
388
+ verdict: 'suppressed',
389
+ confidence: 0.88,
390
+ reason: 'Privacy policy / terms of service link found in registration component',
391
+ };
392
+ }
393
+ return {
394
+ verdict: 'confirmed',
395
+ confidence: 0.75,
396
+ reason: 'Registration form lacks visible privacy policy link in component scope',
397
+ };
398
+ }
399
+ checkPrivacyInContent(content, violation) {
400
+ // Check a wider window (50 lines around violation)
401
+ const lines = content.split('\n');
402
+ const start = Math.max(0, violation.line - 25);
403
+ const end = Math.min(lines.length, violation.line + 25);
404
+ const window = lines.slice(start, end).join('\n');
405
+ if (/privacy|PrivacyPolicy|terms.*service|TermsOfService/i.test(window)) {
406
+ return {
407
+ verdict: 'suppressed',
408
+ confidence: 0.75,
409
+ reason: 'Privacy/terms reference found near registration form',
410
+ };
411
+ }
412
+ return {
413
+ verdict: 'confirmed',
414
+ confidence: 0.70,
415
+ reason: 'No privacy policy reference found near registration form',
416
+ };
417
+ }
418
+ // -------------------------------------------------------------------------
419
+ // Rule: coppa-ugc-014 — UGC Upload Without PII Filter
420
+ // Find submit functions, check for moderation/scrubbing calls in body
421
+ // -------------------------------------------------------------------------
422
+ analyzeUGC014(tree, content, violation, _scope) {
423
+ const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
424
+ // Check the enclosing function for moderation/filter calls
425
+ const scope = tracer.getEnclosingScope(violation.line);
426
+ if (scope) {
427
+ const scopeLines = content.split('\n').slice(scope.startLine - 1, scope.endLine);
428
+ // Strip single-line comments to avoid false matches (e.g., "// Todo: add filter")
429
+ const scopeContent = scopeLines
430
+ .map(line => line.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, ''))
431
+ .join('\n');
432
+ const moderationPatterns = [
433
+ /scrub|filter|sanitize|moderate|moderation/i,
434
+ /piiFilter|PIIFilter|pii_filter/,
435
+ /contentFilter|ContentFilter/,
436
+ /comprehend|rekognition|detect_pii/i,
437
+ /removePII|stripPII|redact/i,
438
+ /textModeration|contentModeration/i,
439
+ /profanityFilter|wordFilter/i,
440
+ ];
441
+ if (moderationPatterns.some(p => p.test(scopeContent))) {
442
+ return {
443
+ verdict: 'suppressed',
444
+ confidence: 0.88,
445
+ reason: 'PII scrubbing / content moderation found in submit handler',
446
+ };
447
+ }
448
+ }
449
+ // Check if the data passes through a filter before the submission point
450
+ const filterFunctions = [
451
+ 'scrubPII', 'filterPII', 'sanitize', 'moderate', 'contentFilter',
452
+ 'textFilter', 'piiFilter', 'redact', 'filterContent',
453
+ ];
454
+ if (tracer.passesThrough(violation.line, filterFunctions)) {
455
+ return {
456
+ verdict: 'suppressed',
457
+ confidence: 0.85,
458
+ reason: 'UGC content passes through PII filter before storage',
459
+ };
460
+ }
461
+ return {
462
+ verdict: 'confirmed',
463
+ confidence: 0.80,
464
+ reason: 'No PII scrubbing or content moderation detected in submit handler',
465
+ };
466
+ }
467
+ // -------------------------------------------------------------------------
468
+ // Rule: coppa-flow-009 — Child Contact Collection
469
+ // Distinguish InterfaceDeclaration vs VariableDeclaration
470
+ // -------------------------------------------------------------------------
471
+ analyzeFlow009(tree, content, violation, _scope) {
472
+ // Check line context — if we're in an interface declaration, it's a type
473
+ // definition, not actual data collection
474
+ const lineContext = this.scopeAnalyzer.analyzeLineContext(violation.line, tree);
475
+ if (lineContext.inInterfaceDecl) {
476
+ return {
477
+ verdict: 'suppressed',
478
+ confidence: 0.92,
479
+ reason: 'child_email is in a TypeScript interface declaration — defines shape, not collection',
480
+ };
481
+ }
482
+ // Check if the line is a type alias or similar type-level construct
483
+ const violationLine = content.split('\n')[violation.line - 1] || '';
484
+ if (/^\s*(export\s+)?(type|interface)\s/.test(violationLine)) {
485
+ return {
486
+ verdict: 'suppressed',
487
+ confidence: 0.90,
488
+ reason: 'child_email is in a type/interface definition — not runtime collection',
489
+ };
490
+ }
491
+ // Check if there's a parent_email variable in the same scope (any context)
492
+ const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
493
+ const parentEmailVars = [
494
+ 'parent_email', 'parentEmail', 'guardian_email', 'guardianEmail',
495
+ 'parent_contact', 'parentContact', 'guardian_contact', 'guardianContact',
496
+ ];
497
+ // Use AST-aware variable detection (Sprint 9 DataFlowTracer improvement)
498
+ if (tracer.hasVariableInScope(violation.line, parentEmailVars)) {
499
+ return {
500
+ verdict: 'suppressed',
501
+ confidence: 0.85,
502
+ reason: 'Parent/guardian email also collected in same scope — consent flow likely in place',
503
+ };
504
+ }
505
+ // Fallback: also check via regex on enclosing scope content
506
+ const scope = tracer.getEnclosingScope(violation.line);
507
+ if (scope) {
508
+ const scopeContent = content.split('\n').slice(scope.startLine - 1, scope.endLine).join('\n');
509
+ if (/parent_email|parentEmail|guardian_email|guardianEmail/i.test(scopeContent)) {
510
+ return {
511
+ verdict: 'suppressed',
512
+ confidence: 0.85,
513
+ reason: 'Parent/guardian email also collected in same scope — consent flow likely in place',
514
+ };
515
+ }
516
+ }
517
+ return {
518
+ verdict: 'confirmed',
519
+ confidence: 0.80,
520
+ reason: 'Child contact information collected without visible parent consent flow',
521
+ };
522
+ }
523
+ // -------------------------------------------------------------------------
524
+ // Rule: coppa-cookies-016 — Missing Cookie Notice
525
+ // Check setItem() key for PII vs preference strings
526
+ // -------------------------------------------------------------------------
527
+ analyzeCookies016(tree, content, violation, _scope) {
528
+ const violationLine = content.split('\n')[violation.line - 1] || '';
529
+ // Check if this is setting a preference cookie (not PII/tracking)
530
+ const preferencePatterns = [
531
+ /theme|darkMode|dark_mode|colorScheme|color_scheme/i,
532
+ /locale|language|lang|i18n/i,
533
+ /viewMode|view_mode|layout|sidebar/i,
534
+ /consent|cookieConsent|cookie_consent/i,
535
+ /preference|pref|setting/i,
536
+ ];
537
+ if (preferencePatterns.some(p => p.test(violationLine))) {
538
+ return {
539
+ verdict: 'suppressed',
540
+ confidence: 0.90,
541
+ reason: 'Cookie stores user preference (theme/locale/layout) — not PII or tracking',
542
+ };
543
+ }
544
+ // Check if there's a cookie consent check before this setItem
545
+ const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
546
+ const scope = tracer.getEnclosingScope(violation.line);
547
+ if (scope) {
548
+ const scopeContent = content.split('\n').slice(scope.startLine - 1, scope.endLine).join('\n');
549
+ if (/consent|hasConsent|cookieConsent|getCookieConsent/i.test(scopeContent)) {
550
+ return {
551
+ verdict: 'suppressed',
552
+ confidence: 0.85,
553
+ reason: 'Cookie consent check found in same scope as storage operation',
554
+ };
555
+ }
556
+ }
557
+ // If it's clearly tracking/PII storage, confirm
558
+ const trackingPatterns = [
559
+ /analytics|track|pixel|fbq|ga\(|gtag/i,
560
+ /userId|user_id|sessionId|session_id/i,
561
+ /email|phone|name|dob|birthdate/i,
562
+ ];
563
+ if (trackingPatterns.some(p => p.test(violationLine))) {
564
+ return {
565
+ verdict: 'confirmed',
566
+ confidence: 0.90,
567
+ reason: 'Storage operation involves tracking or PII data without consent check',
568
+ };
569
+ }
570
+ return {
571
+ verdict: 'confirmed',
572
+ confidence: 0.65,
573
+ reason: 'Cookie/storage operation may involve tracking data — consent check recommended',
574
+ };
575
+ }
576
+ // -------------------------------------------------------------------------
577
+ // Rule: ETHICAL-001 — Infinite Scroll / Endless Feed
578
+ // Check IntersectionObserver callback: loadMore vs lazyLoad
579
+ // -------------------------------------------------------------------------
580
+ analyzeInfiniteScroll001(tree, content, violation, _scope) {
581
+ const violationLine = content.split('\n')[violation.line - 1] || '';
582
+ // Check if this is a lazy-loading observer (not infinite content scroll)
583
+ const lazyLoadPatterns = [
584
+ /lazyLoad|lazy-load|lazy_load/i,
585
+ /lazyImage|lazy-image/i,
586
+ /ImageObserver/i,
587
+ /loading\s*=\s*['"]lazy['"]/i,
588
+ ];
589
+ if (lazyLoadPatterns.some(p => p.test(violationLine))) {
590
+ return {
591
+ verdict: 'suppressed',
592
+ confidence: 0.90,
593
+ reason: 'IntersectionObserver used for lazy loading (images), not infinite content scroll',
594
+ };
595
+ }
596
+ // Check surrounding context for lazy loading vs content loading
597
+ const lines = content.split('\n');
598
+ const contextStart = Math.max(0, violation.line - 6);
599
+ const contextEnd = Math.min(lines.length, violation.line + 6);
600
+ const context = lines.slice(contextStart, contextEnd).join('\n');
601
+ if (/lazyLoad|lazy|preload|prefetch|image|img|src/i.test(context) &&
602
+ !/loadMore|fetchMore|nextPage|pagination/i.test(context)) {
603
+ return {
604
+ verdict: 'suppressed',
605
+ confidence: 0.80,
606
+ reason: 'Context suggests lazy loading of resources, not infinite content feed',
607
+ };
608
+ }
609
+ // Check for pagination controls nearby (indicates good pattern)
610
+ if (/pagination|Pagination|page.*button|PageButton|loadMore.*button/i.test(context)) {
611
+ return {
612
+ verdict: 'suppressed',
613
+ confidence: 0.75,
614
+ reason: 'Pagination controls found near scroll handler — natural stopping points exist',
615
+ };
616
+ }
617
+ // Check if there's a max/limit on loaded content
618
+ const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
619
+ // Sprint 9: Use AST-aware variable detection for limit constants
620
+ const limitVars = [
621
+ 'maxItems', 'maxPages', 'MAX_PAGES', 'MAX_ITEMS',
622
+ 'pageLimit', 'limit', 'maxResults', 'MAX_RESULTS',
623
+ 'itemLimit', 'ITEM_LIMIT', 'PAGE_LIMIT',
624
+ ];
625
+ if (tracer.hasVariableInScope(violation.line, limitVars)) {
626
+ return {
627
+ verdict: 'suppressed',
628
+ confidence: 0.78,
629
+ reason: 'Content loading has max items/pages limit — not truly infinite',
630
+ };
631
+ }
632
+ // Fallback: regex check on enclosing scope content
633
+ const scope = tracer.getEnclosingScope(violation.line);
634
+ if (scope) {
635
+ const scopeContent = content.split('\n').slice(scope.startLine - 1, scope.endLine).join('\n');
636
+ if (/maxItems|maxPages|MAX_PAGES|limit|MAX_ITEMS|pageLimit/i.test(scopeContent)) {
637
+ return {
638
+ verdict: 'suppressed',
639
+ confidence: 0.78,
640
+ reason: 'Content loading has max items/pages limit — not truly infinite',
641
+ };
642
+ }
643
+ }
644
+ return {
645
+ verdict: 'confirmed',
646
+ confidence: 0.80,
647
+ reason: 'Infinite scroll pattern detected without natural stopping points',
648
+ };
649
+ }
650
+ }
651
+ exports.ASTRuleEngine = ASTRuleEngine;
652
+ exports.default = ASTRuleEngine;
653
+ //# sourceMappingURL=ast-engine.js.map