summaryrefslogtreecommitdiff
path: root/scripts/newos4-feedback-autoreview.py
blob: 1bb6d25d9f7a916233cefa15080ce972234c0fa6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
#!/usr/bin/env python3
"""Autonomous first-pass reviewer for the WebX11 feedback queue.

Decides ONLY what it can prove, appends the decision to data/feedback/decisions.jsonl tagged
actor=auto-reviewer, and prints one line per action (silent when it did nothing — the cron
contract). The operator's later click always wins: the applier keeps the last row per item.

Modes in data/feedback/autonomy.json: off | safe (default) | aggressive.
"""
import argparse, glob, json, os, re, subprocess, sys
from datetime import datetime, timezone

ROOT = '/var/www/webx11.duckdns.org'
ROOTDIR = ROOT                       # overridable for fixture runs
REPORTS = f'{ROOT}/spaces/newos4/data/reports'
MODULES = f'{ROOT}/spaces/newos4/modules'
DECISIONS = f'{ROOT}/spaces/newos4/data/feedback/decisions.jsonl'
AUTONOMY = f'{ROOT}/spaces/newos4/data/feedback/autonomy.json'
SUMMARY = f'{ROOT}/spaces/newos4/data/feedback/autoreview-latest.json'
ERRORS = f'{ROOT}/spaces/newos4/data/debug/errors.json'   # symptom store (count/last_seen)
STALE_DAYS = 5                                            # quiet this long => stale artefact
CAPS = {'safe': 2, 'aggressive': 5}

NOISE = re.compile(r'fetch failed|failed to fetch|network ?error|load failed|user aborted'
                   r'|signal is aborted|PROBE|probe-only|boom from|\bone-off test\b|\btest only\b', re.I)
IDENT = re.compile(r'[A-Za-z_$][\w$]{4,}')


def load_json(path, default):
    try:
        with open(path, encoding='utf-8') as f:
            return json.load(f)
    except Exception:
        return default


def mode(cap_override=None):
    m = str((load_json(AUTONOMY, {}) or {}).get('mode') or 'safe').lower()
    if m not in CAPS:
        return 'off', 0
    return m, cap_override if cap_override is not None else CAPS[m]


def decisions():
    out = {}
    if not os.path.exists(DECISIONS):
        return out
    with open(DECISIONS, encoding='utf-8') as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                d = json.loads(line)
            except Exception:
                continue
            out[d.get('item_id', '')] = d
    return out


def approved_today():
    today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
    n = 0
    if not os.path.exists(DECISIONS):
        return 0
    with open(DECISIONS, encoding='utf-8') as f:
        for line in f:
            try:
                d = json.loads(line)
            except Exception:
                continue
            if (str(d.get('ts', '')).startswith(today) and d.get('actor') == 'auto-reviewer'
                    and d.get('decision') == 'approve'):
                n += 1
    return n


# Generic vocabulary: JS keywords, builtins and DOM handler names. A token in here identifies the
# LANGUAGE, not the defect, so it can never justify "still present in shipped code" (2026-11-14).
# Before this, `cmds.indexOf is not a function` produced present_tokens ["indexOf","function"] with
# hits {indexOf: 36, function: 74} and became a HIGH steering directive for a function that does not
# exist anywhere in the tree (plan 2026-11-13_062052 §2).
SKIP = {'error', 'failed', 'fetch', 'undefined', 'cannot', 'reading', 'properties', 'uncaught',
        'invalid', 'unexpected', 'token', 'https', 'http', 'com', 'org', 'null', 'true', 'false',
        # language / builtins
        'function', 'return', 'indexof', 'lastindexof', 'tolowercase', 'touppercase', 'tostring',
        'length', 'push', 'pop', 'slice', 'splice', 'split', 'join', 'replace', 'concat', 'shift',
        'map', 'filter', 'reduce', 'foreach', 'json', 'parse', 'stringify', 'object', 'array',
        'promise', 'async', 'await', 'then', 'catch', 'finally', 'typeof', 'constructor',
        'prototype', 'valueof', 'hasownproperty', 'assign', 'keys', 'entries', 'values',
        # host objects and event plumbing
        'window', 'document', 'console', 'navigator', 'localstorage', 'settimeout', 'setinterval',
        'addeventlistener', 'removeeventlistener', 'queryselector', 'onclick', 'onload', 'onerror',
        'eval', 'anonymous', 'innerhtml', 'textcontent', 'classlist', 'dispatchevent'}

# `at isCommand (<anonymous>:34:17)`, `at t.executeCommand (<anonymous>:66:12)`,
# `at HTMLButtonElement.onclick (…/main.html:954:454)` -> the function name is the only frame part
# that can identify code, and it was never parsed before.
STACK_FN = re.compile(r'at\s+(?:async\s+)?([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(')


def identifiers(msg):
    """Distinctive names in an error message: drop numbers/quoted parts, keep real tokens."""
    cleaned = re.sub(r'["\'].*?["\']', ' ', str(msg))
    cleaned = re.sub(r'0x[0-9a-f]+', ' ', cleaned, flags=re.I)
    return [t for t in IDENT.findall(cleaned) if t.lower() not in SKIP][:4]


def stack_functions(stack):
    """Function names from stack frames, most specific last segment, generics removed."""
    out = []
    for name in STACK_FN.findall(str(stack or '')):
        base = name.split('.')[-1]
        if not base or base.startswith('<') or base.lower() in SKIP:
            continue
        if base not in out:
            out.append(base)
    return out[:4]


ANON_FRAME = re.compile(r'at\s+[^\n]*\(<anonymous>(?::\d+:\d+)?\)')
REAL_FRAME = re.compile(r'at\s+[^\n]*\((?:https?:)?//?[^)\s]+\.(?:js|mjs|html)(?:[:?][^)\s]*)?\)')


def stack_generated(stack):
    """True when the stack describes runtime-generated code rather than a shipped file.

    `<anonymous>` frames come from `new Function(...)` module loading and the `js`/`js-run` eval
    path — a snippet someone pasted, not our source. A function name from such a frame says nothing
    about shipped code, which is exactly how `cmds.indexOf` became a HIGH directive (2026-11-12).
    Rule: anonymous frames present AND no frame pointing at a real .js/.html file.
    """
    frames = [ln.strip() for ln in str(stack or '').splitlines() if ln.strip().startswith('at ')]
    if not frames:
        return False
    # The ORIGIN is the top frame. The bottom `HTMLButtonElement.onclick (…/main.html)` frame proves
    # only that a click started it, so deciding on "does any frame name a real file" got this wrong.
    top = frames[0]
    return '<anonymous>' in top or not REAL_FRAME.search(top)


def distinctive(tokens, hits):
    """Tokens specific enough to justify 'still present': >=6 chars, in modules/, not generic."""
    return [t for t in tokens if len(t) >= 6 and hits.get(t) and t.lower() not in SKIP]


def code_hits(tokens):
    """Which tokens still appear in shipped modules (grep -rlF, no shell)."""
    hits = {}
    for t in tokens:
        try:
            r = subprocess.run(['grep', '-rlF', '--include=*.js', t, MODULES],
                               capture_output=True, text=True, timeout=20)
            files = [ln for ln in r.stdout.splitlines() if '.orig.js' not in ln]
        except Exception:
            files = []
        hits[t] = len(files)
    return hits


def token_history(tokens):
    """Commits that added or removed each identifier under modules/ (git log -S).

    This is the proof an "already fixed" verdict needs: the token is gone from HEAD *and* git
    shows it once existed, so a commit removed it rather than it never being there. Without this,
    a report about a string that never lived in the code reads as fixed (fixture RPT-G caught it).
    """
    out = {}
    for t in tokens:
        try:
            r = subprocess.run(['git', '-C', ROOTDIR, 'log', '--oneline', '-S', t, '--',
                                os.path.relpath(MODULES, ROOTDIR)],
                               capture_output=True, text=True, timeout=30)
            out[t] = [ln for ln in r.stdout.splitlines() if ln.strip()]
        except Exception:
            out[t] = []
    return out


def _age_days(ts):
    """Days since an ISO-8601 stamp; tolerant of Z, fractional seconds and bare datetimes."""
    s = str(ts or '').strip().replace('Z', '+00:00')
    if not s:
        return None
    try:
        dt = datetime.fromisoformat(s)
    except ValueError:
        return None
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return (datetime.now(timezone.utc) - dt).total_seconds() / 86400.0


def error_stats(msg):
    """count/last_seen for one exact message in the debug error store; None when unknown."""
    rows = load_json(ERRORS, [])
    if not isinstance(rows, list):
        return None
    key = str(msg or '').strip()
    for e in rows:
        if isinstance(e, dict) and str(e.get('message') or '').strip() == key:
            return {'count': int(e.get('count') or 0), 'last_seen': str(e.get('last_seen') or '')}
    return None


def fingerprints():
    """fingerprint -> id, for reports that already exist (duplicate detection)."""
    out = {}
    for p in glob.glob(f'{REPORTS}/RPT-*.json'):
        d = load_json(p, {})
        fp = d.get('fingerprint')
        if fp:
            out.setdefault(fp, []).append(d.get('id') or os.path.basename(p)[:-5])
    return out


def classify(rep, dec, fps, cap_left):
    """Return (decision, reason, evidence) or (None, why_skipped, evidence)."""
    rid = rep.get('id') or ''
    title = str(rep.get('title') or '')
    diag = rep.get('diagnostics') or {}
    errs = [e for e in (diag.get('errors') or []) if isinstance(e, dict)]
    msg = ' '.join(str(e.get('message') or '') for e in errs) or title
    stack = any(e.get('stack') for e in errs)
    auto = bool(diag.get('auto'))

    # R6 first: a human wrote it, a human answers it.
    if not auto:
        return None, 'user report — left for the operator', {}

    # R1 duplicate: same fingerprint already decided.
    fp = rep.get('fingerprint')
    twins = [x for x in fps.get(fp, []) if x != rid]
    if fp and twins and any(('report:' + t) in dec for t in twins):
        done = [t for t in twins if ('report:' + t) in dec]
        return 'resolve', f'duplicate of already-decided {done[0]}', {'fingerprint': fp, 'twin': done[0]}

    # R3 noise: test artefact / client-side network drop.
    noise_hit = NOISE.search(title) or NOISE.search(msg)
    if noise_hit:
        return 'reject', 'noise: test artefact or client-side network failure', {'match': noise_hit.group(0)}

    tokens = identifiers(msg)
    for fn in stack_functions(stack):          # the frame names are the most specific identifiers we get
        if fn not in tokens:
            tokens.append(fn)
    hits = code_hits(tokens) if tokens else {}
    present = [t for t in tokens if hits.get(t)]
    absent = [t for t in tokens if not hits.get(t)]

    # R2 already-fixed: every distinctive token gone AND git proves it was removed (not never there).
    if tokens and not present:
        hist = token_history(tokens)
        proven = {t: c for t, c in hist.items() if c}
        if proven:
            t0 = next(iter(proven))
            return 'resolve', f'already fixed: "{t0}" was removed from modules/ in {proven[t0][0].split()[0]}', {
                'absent_tokens': absent, 'removed_in': proven[t0][0], 'history_tokens': list(proven)}
        return None, 'identifiers gone but git shows they never existed — needs a human', {
            'absent_tokens': absent}

    # R4 still-present: real stack error whose identifiers still exist in shipped code.
    distinct = distinctive(present, hits)
    if stack and stack_generated(stack):
        return None, 'stack frames are <anonymous> (generated code — js-run / new Function), not shipped source — needs a human', {
            'present_tokens': present, 'generated_stack': True}
    if stack and distinct and cap_left > 0:
        return 'approve', 'still present in shipped code (stack + identifiers found)', {
            'present_tokens': present, 'distinctive_tokens': distinct,
            'hits': {t: hits[t] for t in present}}
    if stack and distinct and cap_left <= 0:
        return None, 'daily auto-approve cap reached — left for the operator', {'present_tokens': distinct}
    if stack and present and not distinct:
        # Generic-only match (indexOf/function/length...): a human must decide, because "the word
        # appears in 74 files" is not evidence the defect is in shipped code.
        return None, 'only generic identifiers in message — needs a human', {
            'present_tokens': present, 'hits': {t: hits[t] for t in present}}

    # R5 stale artefact: one occurrence that never came back, and no stack to act on.
    # Distinct from R2 (identifier removed) — here the emitter is still there but the symptom is not.
    if errs and not stack and all(int(e.get('count') or 0) <= 1 for e in errs):
        stats = [error_stats(str(e.get('message') or '')) for e in errs]
        stats = [s for s in stats if s and s.get('last_seen')]
        ages = [_age_days(s['last_seen']) for s in stats]
        if len(stats) == len(errs) and ages and all(a is not None for a in ages):
            quiet = max(a for a in ages if a is not None)
            if quiet >= STALE_DAYS and all(s['count'] <= 1 for s in stats):
                return 'resolve', (f'stale single-occurrence auto report: no stack, count 1, '
                                   f'quiet {quiet:.1f}d — reopen if it recurs'), {
                    'counts': [s['count'] for s in stats],
                    'last_seen': [s['last_seen'] for s in stats],
                    'quiet_days': round(quiet, 1),
                    'emitter_tokens': tokens}

    return None, 'not provable by the rules — left for the operator', {
        'tokens': tokens, 'present': present, 'stack': stack}


SELF_CASES = [
    ("identifiers drop generic words",
     identifiers('cmds.indexOf is not a function'), []),
    ("stack names are the specific identifiers",
     stack_functions("at isCommand (<anonymous>:34:17)\n"
                     "    at t.executeCommand (<anonymous>:66:12)\n"
                     "    at HTMLButtonElement.onclick (https://x/main.html:954:454)"),
     ['isCommand', 'executeCommand']),
    ("generics cannot justify 'still present'",
     distinctive(['indexOf', 'function'], {'indexOf': 36, 'function': 74}), []),
    ("a >=6-char shipped name still can",
     distinctive(['indexOf', 'executeCommand'], {'indexOf': 36, 'executeCommand': 12}),
     ['executeCommand']),
    ("anonymous-origin stack is generated code",
     stack_generated("TypeError: x\n    at isCommand (<anonymous>:34:17)\n"
                     "    at HTMLButtonElement.onclick (https://x/main.html:954:454)"), True),
    ("shipped-origin stack is not generated",
     stack_generated("TypeError: x\n    at Terminal.dispatchCommand (https://x/core-bundle.js:1:2)"),
     False),
]


def self_test():
    """Regression cover for the 2026-11-12 false positive (plan 2026-11-13_062052 §2).

    `--self-test` — run it after touching the identifier or stack parsers. A false "still present"
    turns a non-defect into a HIGH steering directive, which is worse than a missed report.
    """
    bad = 0
    for label, got, want in SELF_CASES:
        ok = got == want
        bad += 0 if ok else 1
        print(('PASS ' if ok else 'FAIL ') + label + ('' if ok else '  got=%r want=%r' % (got, want)))
    print('%d/%d self-tests pass' % (len(SELF_CASES) - bad, len(SELF_CASES)))
    return 0 if not bad else 1


def main():
    global REPORTS, MODULES, DECISIONS, ROOTDIR, ERRORS
    ap = argparse.ArgumentParser()
    ap.add_argument('--root', default=ROOT)
    ap.add_argument('--dry-run', action='store_true')
    ap.add_argument('--reports-dir', default=REPORTS)
    ap.add_argument('--decisions', default=DECISIONS)
    ap.add_argument('--modules', default=MODULES)
    ap.add_argument('--cap', type=int, default=None)
    ap.add_argument('--errors', default=ERRORS, help='debug error store (fixtures)')
    ap.add_argument('--include-plans', action='store_true', help='never used in production')
    ap.add_argument('--self-test', action='store_true', help='run identifier/stack regression checks and exit')
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    ROOTDIR = a.root
    REPORTS, MODULES, DECISIONS = a.reports_dir, a.modules, a.decisions
    ERRORS = a.errors

    m, cap = mode(a.cap)
    if m == 'off':
        return 0                      # silent: autonomy disabled
    cap_left = max(0, cap - approved_today())
    dec = decisions()
    fps = fingerprints()
    rows, skipped = [], []
    for p in sorted(glob.glob(f'{REPORTS}/RPT-*.json')):
        rep = load_json(p, {})
        rid = 'report:' + (rep.get('id') or os.path.basename(p)[:-5])
        if not rep or rid in dec:
            continue                  # already decided → operator or a previous run
        decision, why, ev = classify(rep, dec, fps, cap_left)
        if decision:
            rows.append({'ts': datetime.now(timezone.utc).isoformat(timespec='seconds'),
                         'item_id': rid, 'decision': decision, 'note': why,
                         'actor': 'auto-reviewer', 'evidence': ev})
            if decision == 'approve':
                cap_left -= 1
        else:
            skipped.append({'item_id': rid, 'why': why, 'evidence': ev})

    summary = {'generated_at': datetime.now(timezone.utc).isoformat(timespec='seconds'),
               'mode': m, 'cap_left': cap_left, 'decided': len(rows),
               'skipped': len(skipped), 'rows': rows, 'skips': skipped[:20]}
    if not a.dry_run:
        if rows:
            with open(DECISIONS, 'a', encoding='utf-8') as f:
                for r in rows:
                    f.write(json.dumps(r, ensure_ascii=False) + '\n')
        tmp = SUMMARY + '.tmp'
        os.makedirs(os.path.dirname(SUMMARY), exist_ok=True)
        with open(tmp, 'w', encoding='utf-8') as f:
            json.dump(summary, f, ensure_ascii=False, indent=1)
        os.replace(tmp, SUMMARY)
    for r in rows:
        print(f"{r['decision']:<8} {r['item_id'][:40]:<42} {r['note']}")
    if not rows:
        print(f'autoreview: nothing provable ({len(skipped)} left for the operator, mode={m})'
              if a.dry_run else '', end='')
    return 0


if __name__ == '__main__':
    sys.exit(main())