diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 2516f40b..ff11bb7b 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -14,6 +14,7 @@ Search with grep as usual; the graph answers what grep cannot. Use the MCP tools |---|---|---| | where is the code for this task? | your own search (grep), then bring the name here | — | | who calls X, what does changing it reach, which tests? | `impact(name)` | `axiomcode impact ` | +| what is the value of constant X, and who reads it? | `impact(name)` | `axiomcode impact ` | | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | | which tests do my edits need, and how do I run them? | `tests()` | `axiomcode tests` | @@ -44,7 +45,8 @@ from an empty answer. With a name: who calls it, what depends on it further out, and the tests that exercise it. Example: `impact(name="PriceService.total")`. With no name: the first line is `your edits:` (each declaration you changed and -how), then the same answer for all of them. +how), then the same answer for all of them. A constant answers with its value — `change: const MAX_ITEMS = 5` — +so a limit, a default or a threshold is read off the first line rather than from the file. ## path diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index db6b6a19..6eb56111 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -63,7 +63,7 @@ def registrations(q, site_file=None): sf = site_file or (lambda x: x) lits = {} if _has(q, 'literals'): - for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + for v, f, l in _string_literals(q): lits.setdefault((f, l), []).append(v) # the declarations a name identifies uniquely: only those can be named as the registered declaration, because # a site names a VALUE by identifier and two callables of one name would each claim the other's registration @@ -107,7 +107,7 @@ def route_site_lines(q, site_file=None): return {} lits = {} if _has(q, 'literals'): - for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + for v, f, l in _string_literals(q): lits.setdefault((f, l), []).append(v) return _route_links(q, site_file or (lambda x: x), lits, set())[2] @@ -628,6 +628,20 @@ def _has(q, t): return bool(q("SELECT 1 FROM sqlite_master WHERE name=?", t)) +def _span_string_literals(q, f, a, b): + """the STRING literals inside one file span, kind-filtered the same way _string_literals is""" + try: return q("SELECT value, file, line FROM literals WHERE file = ? AND line BETWEEN ? AND ? AND kind = 'string'", f, a, b) + except Exception: return q("SELECT value, file, line FROM literals WHERE file = ? AND line BETWEEN ? AND ?", f, a, b) + + +def _string_literals(q): + """the literals rows that are STRINGS, as (value, file, line). A v8 index also carries numbers and + booleans, which are never registration keys and would join everything (`"1"` matches every retry + count); a pre-v8 or degraded index has no kind column, and there every row is a string.""" + try: return q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL AND kind = 'string'") + except Exception: return q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL") + + # ── the DECORATION path: the key is written at the `@`, and the owner is recorded ──────────────────────────── # `registrations()` above skips DECORATOR_CALL sites deliberately, because a decoration is not a call that hands a # value over. It is the other half of the same idea and it carries BETTER evidence: the index records which @@ -814,7 +828,7 @@ def literal_verbs(q, at, site_file=None): short = re.sub(r'Async$', '', (n or '').split('.')[-1].split('<')[0]).upper() if short in _VERBS: calls[sf(f) if f else ''].append((a, b or a, short)) out = set() - for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + for v, f, l in _string_literals(q): if not (isinstance(v, str) and v.startswith('/')): continue f2 = sf(f) if f else '' hold = [(b - a, -a, verb) for a, b, verb in calls.get(f2, ()) if a <= l <= b] @@ -857,7 +871,7 @@ def value_route_registrations(q, site_file=None): names_at.setdefault((f, l), set()).update(routed[(f, n)]) import re out = [] - for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + for v, f, l in _string_literals(q): if not (isinstance(v, str) and 0 < len(v) < 160): continue # A RESOURCE IS NOT A ROUTE. Measured on the JVM parser: the pair fired on @@ -1118,7 +1132,7 @@ def written(text, token): if not _KEY_POS.match(text, mm.end()): return True return False if _has(q, 'literals'): - for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + for v, f, l in _string_literals(q): if v not in keys or (f, l) in cpos: continue L = read(f) text = L[l - 1] if L and l <= len(L) else None @@ -1187,7 +1201,7 @@ def key_writes(q, table_keys=None): the table's own key position or the constant's declaration).""" if table_keys is None: table_keys = {r[4] for r in table_registrations(q)} - rows = [(v, f, l) for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL") + rows = [(v, f, l) for v, f, l in _string_literals(q) if v not in table_keys] if _has(q, 'literals') else [] return rows + table_key_writes(q, table_keys) @@ -1313,6 +1327,6 @@ def _sends_request(q, m): ph = ','.join('?' * len(REQUEST_CALLS)) if q(f"SELECT 1 FROM call_sites WHERE caller_id = ? AND callee_name IN ({ph}) LIMIT 1", m, *sorted(REQUEST_CALLS)): return True if _has(q, 'literals'): - for (v,) in q("SELECT value FROM literals WHERE file = ? AND line BETWEEN ? AND ?", f, a, b): + for v, _f, _l in _span_string_literals(q, f, a, b): if isinstance(v, str) and re.fullmatch(r'/[\w\-./{}:%]*', v): return True return False diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 9bd4561d..d75c967a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -411,7 +411,10 @@ def route_seeds(g, text): for r in g.q("SELECT owner_id, text, file, line FROM decorations WHERE text LIKE '%/%' OR text LIKE '%\"%'"): for v in re.findall(r'"([^"]*)"', r['text'] or ''): lits.append((v, r['file'], r['line'], r['owner_id'])) if asked and g.has('literals'): - for r in g.q("SELECT value, file, line FROM literals WHERE value LIKE '%/%' OR length(value) < 40"): + # strings only: a route is never a number, and the v8 index carries numbers and booleans too + try: lit_rows = g.q("SELECT value, file, line FROM literals WHERE (value LIKE '%/%' OR length(value) < 40) AND kind = 'string'") + except Exception: lit_rows = g.q("SELECT value, file, line FROM literals WHERE value LIKE '%/%' OR length(value) < 40") + for r in lit_rows: lits.append(((r['value'] or '').strip('"'), r['file'], r['line'], None)) by_file = collections.defaultdict(set) for v, f, _l, _o in lits: by_file[f].add(tuple(_route_parts(v))) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 2e030faa..7a8dc195 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -464,7 +464,7 @@ class Impact: if d and d[0] == 'field' and kind in (None, 'field'): row = self.fields.get(d[1]['rowid']) or d[1] self.WHY['field'] = {'step': 'file and line: a field declared there', 'won': f"the line declares {row['display']} and no callable of its own", 'ids': [], 'rows': [(row['display'], f"{row['file']}:{row['line']}")]} - return [('field', f"{row['kind']} {row['display']} (at {s})", [row])] + return [('field', f"{row['kind']} {row['display']}{self.const_value(row)} (at {s})", [row])] if d and d[0] == 'type' and kind in (None, 'type'): self.WHY['type'] = {'step': 'file and line: a type declared there', 'won': f"the line is the header of type {d[1]['display']}", 'ids': [d[1]['id']], 'rows': None} return [('type', f"{d[1]['kind']} {d[1]['display']} (at {s})", [d[1]['id']])] @@ -532,7 +532,7 @@ class Impact: if hit: return [('method', verb + (f" ({len(hit)} declarations)" if len(hit) > 1 else ''), [x['id'] for x in hit])] # …and a private FIELD (`Vault.#secret`, `#secret`) the same way: rewritten, it asked for `Vault..secret` rows = self.field_rows(verb) if kind in (None, 'field') and re.search(r'(^|\.)#[A-Za-z_$]', verb) else [] - if rows: return [('field', f"{rows[0]['kind']} {rows[0]['display']}" + (f" (+{len(rows)-1} declarations of that name)" if len(rows) > 1 else ''), rows)] + if rows: return [('field', f"{rows[0]['kind']} {rows[0]['display']}{self.const_value(rows[0])}" + (f" (+{len(rows)-1} declarations of that name)" if len(rows) > 1 else ''), rows)] base = re.sub(r'\(.*\)$', '', s).replace('#', '.').strip('.') if not (self.field_rows(base) or self.types(base, soft=True) or self.methods(base, soft=True)): base = base.replace('$', '.') # Outer$Inner — unless $ is part of the name ($Gson$Types) m0 = re.match(r'^(.+)\.$', base) @@ -552,7 +552,7 @@ class Impact: if kind in (None, 'field'): rows = self.field_rows(base) if rows: self.WHY['field'] = self.why_field(base, rows) - if rows: out.append(('field', f"{rows[0]['kind']} {rows[0]['display']}" + (f" (+{len(rows)-1} declarations of that name)" if len(rows) > 1 else ''), rows)) + if rows: out.append(('field', f"{rows[0]['kind']} {rows[0]['display']}{self.const_value(rows[0])}" + (f" (+{len(rows)-1} declarations of that name)" if len(rows) > 1 else ''), rows)) if kind in (None, 'type'): tids = self.types(base, soft=True) if tids: self.WHY['type'] = self._why_t.get(base) @@ -692,6 +692,16 @@ class Impact: f['file'], r['line'], f['line'], f['line']): continue return True return False + def const_value(self, row): + """`` = 5`` — the scalar the v8 index linked to this declaration's line (literals.name), so the + answer to `impact MAX_ITEMS` states the value a spec would print, not only who reads it. Empty + on a pre-v8 index, a non-scalar initializer, or any line the linker refused as ambiguous.""" + try: + for v, k in self.g.q("SELECT value, kind FROM literals WHERE name = ? AND file = ? AND line = ? LIMIT 1", + row['display'], row['file'], row['line']): + return f" = '{v}'" if k == 'string' else f" = {v}" + except Exception: pass + return '' def field_rows(self, s): # `file.js:12` — the field or const DECLARED on that line. Split on `.` it named a field `js:12`, so a const was # the one declaration a file:line could not target, and its bare name answered for every const so named @@ -3077,8 +3087,12 @@ def main(argv): if tests_only: sys.stdout = io.StringIO() for k, lab, pay in targets: if k in ('method', 'field', 'type'): - nm = lab.split()[-1].split('(')[0].split('.')[-1] - n = len(g.q("SELECT 1 FROM literals WHERE value = ?", nm)) if g.has('literals') else 0 + # a const's label carries its value (`const MAX_ITEMS = 5`): the name is the word before `=` + nm = lab.split(' = ')[0].split()[-1].split('(')[0].split('.')[-1] + if g.has('literals'): + try: n = len(g.q("SELECT 1 FROM literals WHERE value = ? AND kind = 'string'", nm)) + except Exception: n = len(g.q("SELECT 1 FROM literals WHERE value = ?", nm)) + else: n = 0 d = len(g.q("SELECT 1 FROM decorations WHERE text LIKE ?", f'%"{nm}"%')) if g.has('decorations') else 0 if n + d > 1: print(f" the name {nm} is also written as a string in {n + d} place(s) ({STRING_BINDS.get(target_ext(g, pay), STRING_BINDS[''])}): ask for it quoted, `impact '\"{nm}\"'`, to get those") break diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 2f08ca83..c3905612 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -7,7 +7,9 @@ Adds to /.axiomcode/out/graph.sqlite, idempotently: function | method | constructor | class | interface | enum | enum_member | type | namespace | const | variable | field | module refs every place an identifier or member is USED (the question grep answers) - literals string literals; comments comments and docstrings + literals string, number and boolean literals, with kind ('string'|'number'|'bool') and, when exactly + one scalar sits on a declaration's line, the NAME of the const/field it initializes — so + "what is MAX_ITEMS" is answerable; comments comments and docstrings nesting (type, outer type) recovered by line containment — Java's qualified_name drops the outer skipped files the parser skipped (so `find` can say "exists, not indexed" instead of nothing) index_meta what was indexed, from where, and whether the IR was available @@ -20,10 +22,12 @@ come from methods/types alone, refs/literals/comments are empty, and index_meta """ import csv, os, re, sqlite3, sys, time, glob, collections, functools csv.field_size_limit(10**9) -INDEX_VERSION = '7' # bump when the tables' CONTENT changes shape (v2: JavaScript arrows named after their variable; v3: paths table, sites view, no variable row for a bound function; +INDEX_VERSION = '8' # bump when the tables' CONTENT changes shape (v2: JavaScript arrows named after their variable; v3: paths table, sites view, no variable row for a bound function; # v5: JavaScript fields owned by their class, computed-key members named by their key, anonymous class expressions named by their binding; # v6: TypeScript class-property arrows named after their field; - # v7: data keys of module-level const objects declared, Object.freeze seen through); the query + # v7: data keys of module-level const objects declared, Object.freeze seen through; + # v8: literals carry numbers and booleans with a kind column, and the name of the constant + # a scalar initializes — a spec's numbers were the one thing the graph could not answer); the query # frontend re-indexes an older graph when the IR is still there REPO = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith('-') else os.environ.get('AXIOMCODE_REPO') or '.') @@ -80,7 +84,7 @@ A = { modules=None, expr=dict(file='all-expressions.csv', kind='kind', name='literalValue', line='startLine', fileVia=('types', 'typeRegistryLinkHash'), refKinds={'IDENTIFIER_REFERENCE', 'FIELD_ACCESS', 'CLASS_LITERAL'}, entityKind='referencedEntityKind', - litKinds={'LITERAL'}, litType=('literalType', 'STRING'), litValue='literalValue'), + litKinds={'LITERAL'}, litType=('literalType', {'STRING': 'string', 'INTEGER': 'number', 'LONG': 'number', 'FLOAT': 'number', 'DOUBLE': 'number', 'BOOLEAN': 'bool'}), litValue='literalValue', litLinkRoles={'ROOT'}), comments=dict(file='all-comments.csv', text='commentText', kind='commentKind', line='startLine', filePath='filePath'), typeRefs=dict(file='all-type-references.csv', name='typeName', context='context', ownerKind='referenceOwnerKind', line='startLine', fileVia=('types', 'typeRegistryLinkHash')), # @Annotation(args) on a method or type: the arguments live in a second file keyed by the annotation hash @@ -98,7 +102,7 @@ A = { # the key of an object literal (`{ eventId: … }`) is stored with the entity kind OBJECT_PROPERTY_KEY, not # UNKNOWN: it is never a property ACCESS, so an access the engine bound on the same line is not this name keyRole=dict(role='edgeRole', value='OBJECT_PROPERTY_KEY'), - litKinds={'LITERAL'}, litType=('literalType', 'STRING'), litValue='literalValue'), + litKinds={'LITERAL'}, litType=('literalType', {'STRING': 'string', 'NUMBER': 'number', 'BIGINT': 'number', 'BOOLEAN': 'bool'}), litValue='literalValue', litLinkRoles={'ROOT'}), comments=dict(file='all-typescript-comments.csv', text='commentText', kind='commentKind', line='startLine', filePath='filePath'), typeRefs=dict(file='all-typescript-type-references.csv', name='typeName', context='context', ownerKind='referenceOwnerKind', line='startLine', fileVia=('modules', 'tsModuleLinkHash')), # the variable's own hash: the engine's field_access names a module variable by it when an identifier the binder @@ -125,7 +129,7 @@ A = { modules=dict(file='all-python-modules.csv', id='pyModuleUniqueHash', filePath='filePath'), expr=dict(file='all-python-expressions.csv', kind='kind', name=('NAME_REFERENCE:literalValue', 'ATTRIBUTE_ACCESS:dottedPath'), line='startLine', fileVia=('modules', 'pyModuleLinkHash'), refKinds={'NAME_REFERENCE', 'ATTRIBUTE_ACCESS'}, entityKind='referencedEntityKind', - litKinds={'LITERAL'}, litType=('literalType', 'STRING'), litValue='literalValue'), + litKinds={'LITERAL'}, litType=('literalType', {'STRING': 'string', 'INTEGER': 'number', 'FLOAT': 'number', 'BOOLEAN': 'bool'}), litValue='literalValue', litLinkRoles={'ASSIGNMENT_VALUE'}), comments=dict(file='all-python-comments.csv', text='text', kind='kind', line='startLine', filePath='filePath'), typeRefs=dict(file='all-python-type-references.csv', name='typeName', context='context', ownerKind='referenceOwnerKind', line='startLine', fileVia=('modules', 'pyModuleLinkHash')), decorations=dict(file='all-python-decorators.csv', name='decoratorName', owner='ownerHash', line='startLine', fileVia=('modules', 'pyModuleLinkHash'), text='fullText'), @@ -137,7 +141,7 @@ A = { modules=dict(file='all-javascript-modules.csv', id='jsModuleUniqueHash', filePath='filePath'), expr=dict(file='all-javascript-expressions.csv', kind='expressionKind', name='name', line='startLine', fileVia=('modules', 'ownerModuleLinkHash'), refKinds={'IDENTIFIER', 'PROPERTY_ACCESS'}, entityKind='referenceKind', - litKinds={'LITERAL'}, litType=('literalKind', 'STRING'), litValue='text'), + litKinds={'LITERAL'}, litType=('literalKind', {'STRING': 'string', 'NUMBER': 'number', 'BIGINT': 'number', 'BOOLEAN': 'bool'}), litValue='text', litLinkRoles={'OPERAND', 'ASSIGNMENT_VALUE'}, litLinkBlockKinds={'UNARY'}), comments=dict(file='all-javascript-comments.csv', text='text', kind='commentKind', line='startLine', fileVia=('modules', 'ownerModuleLinkHash')), # a `function f` or `class C` is also a binding (FUNCTION_DECLARATION_HOISTED, CLASS_TDZ), and `const { C } = # require('./m')` is an import in all but syntax (it carries an importLinkHash): none of them is a variable, and @@ -175,7 +179,7 @@ A = { # name elsewhere is a by-name reader whatever its qualifier says, so `_r.Limit` was listed under AuditOptions.Limit memberName=dict(role='edgeRole', value='MEMBER_NAME', parent='parentExpressionHash', id='csExpressionUniqueHash'), # the literal TYPE column is literalKind here, not literalType as in java/typescript/python - litKinds={'LITERAL'}, litType=('literalKind', 'STRING'), litValue='literalValue'), + litKinds={'LITERAL'}, litType=('literalKind', {'STRING': 'string', 'INTEGER': 'number', 'REAL': 'number', 'BOOLEAN': 'bool'}), litValue='literalValue', litLinkRoles={'ROOT'}), comments=dict(file='all-csharp-comments.csv', text='commentText', kind='commentKind', line='startLine', fileVia=('modules', 'csModuleLinkHash')), # type references carry no module link of their own. The owner is a type only for a base-list reference; for # `M()`, `new T()`, a parameter, a return or a field it is the expression, method, parameter or field, so @@ -247,7 +251,7 @@ DROP TABLE IF EXISTS nesting; DROP TABLE IF EXISTS type_refs; DROP TABLE IF EXIS DROP VIEW IF EXISTS callers; DROP VIEW IF EXISTS callees; DROP VIEW IF EXISTS source; DROP VIEW IF EXISTS sites; CREATE TABLE symbols(id TEXT, name TEXT, display TEXT, kind TEXT, qualified_name TEXT, signature TEXT, file TEXT, line INT, end_line INT, owner TEXT, is_test INT, method_id TEXT, type_id TEXT); CREATE TABLE refs(name TEXT, file TEXT, line INT, kind TEXT, entity_kind TEXT); -CREATE TABLE literals(value TEXT, file TEXT, line INT); +CREATE TABLE literals(value TEXT, file TEXT, line INT, kind TEXT, name TEXT); CREATE TABLE comments(text TEXT, file TEXT, line INT, kind TEXT); CREATE TABLE nesting(type_id TEXT, outer_type_id TEXT); CREATE TABLE type_refs(name TEXT, file TEXT, line INT, context TEXT, owner_kind TEXT); @@ -658,15 +662,40 @@ for r in rows(e['file']): if mname and ek in ('', 'UNKNOWN'): ek = child_ek.get(r.get(mname['id'], ''), ek) or ek if kr and ek in ('', 'UNKNOWN') and r.get(kr['role']) == kr['value']: ek = kr['value'] refs.append((n, file_of(r, e), int(r.get(e['line']) or 0), k, ek)) - elif k in e['litKinds'] and r.get(e['litType'][0]) == e['litType'][1]: + elif k in e['litKinds'] and r.get(e['litType'][0]) in e['litType'][1]: + kind = e['litType'][1][r.get(e['litType'][0])] # 'string' | 'number' | 'bool' across every language v = (r.get(e['litValue']) or '') - # C# keeps the source token, and the IR writer RFC4180-quotes a field holding a double quote, so `"Fee"` arrived - # as `"""Fee"""` and was stored `""Fee""`: no key, route or member name written in a C# string ever matched - if LANG == 'csharp' and len(v) > 1 and v[0] == '"' == v[-1] and '""' in v: v = v[1:-1].replace('""', '"') - if LANG == 'csharp' and len(v) > 2 and v[0] in '@$' and v[-1] == '"': v = v.lstrip('@$') # @"verbatim", $"interpolated" - if len(v) >= 2 and v[0] in '\'"`' and v[-1] == v[0]: v = v[1:-1] # JavaScript keeps the quotes - if v: lits.append((v[:200], file_of(r, e), int(r.get(e['line']) or 0))) -c.executemany("INSERT INTO refs VALUES (?,?,?,?,?)", refs); c.executemany("INSERT INTO literals VALUES (?,?,?)", lits) + if kind == 'string': + # C# keeps the source token, and the IR writer RFC4180-quotes a field holding a double quote, so `"Fee"` arrived + # as `"""Fee"""` and was stored `""Fee""`: no key, route or member name written in a C# string ever matched + if LANG == 'csharp' and len(v) > 1 and v[0] == '"' == v[-1] and '""' in v: v = v[1:-1].replace('""', '"') + if LANG == 'csharp' and len(v) > 2 and v[0] in '@$' and v[-1] == '"': v = v.lstrip('@$') # @"verbatim", $"interpolated" + if len(v) >= 2 and v[0] in '\'"`' and v[-1] == v[0]: v = v[1:-1] # JavaScript keeps the quotes + if v: lits.append((v[:200], file_of(r, e), int(r.get(e['line']) or 0), kind, r.get('edgeRole', ''))) +# a scalar's NAME: `MAX_ITEMS = 5` — the one literal on a const/variable/field declaration's line IS its value, +# so the row gets the symbol's display and "what is MAX_ITEMS" has an answer with a source location. Strictly: +# one literal, one such symbol, no call on the line (`RETRY = max(3, env())` names no value), the literal in the +# language's VALUE position — litLinkRoles, which keeps a subscript's key (`d["timeout"]`, INDEX_ARGUMENT) and a +# call's argument out — and, where an initializer's sign hides in a parent node (JavaScript's UNARY around `-5`), +# no such node on the line either (litLinkBlockKinds). +# Dict passes, not correlated subqueries: the UPDATE form of this join ran minutes against seconds on a mid-sized +# repository, because at this point the literals indexes do not exist yet. +lit_count = collections.Counter((f, l) for _v, f, l, _k, _r in lits) +decl_at = {} # (file, line) -> the ONE declared name there, or None once a second appears +for d, f, l in c.execute("SELECT display, file, line FROM symbols WHERE kind IN ('const', 'variable', 'field', 'enum_member') AND line > 0"): + decl_at[(f, l)] = None if (f, l) in decl_at else d +call_at = {(rel(f), l) for f, l in c.execute("SELECT DISTINCT file_path, start_line FROM call_sites WHERE file_path IS NOT NULL")} +link_roles = e.get('litLinkRoles') or set() +block_kinds = e.get('litLinkBlockKinds') or set() +blocked_at = set() +if block_kinds: + for r in rows(e['file']): + if r.get(e['kind'], '') in block_kinds: blocked_at.add((file_of(r, e), int(r.get(e['line']) or 0))) +lits = [(v, f, l, k, + (decl_at.get((f, l)) if role in link_roles and l > 0 and lit_count[(f, l)] == 1 + and (f, l) not in call_at and (f, l) not in blocked_at else None)) + for v, f, l, k, role in lits] +c.executemany("INSERT INTO refs VALUES (?,?,?,?,?)", refs); c.executemany("INSERT INTO literals VALUES (?,?,?,?,?)", lits) cm = A['comments'] c.executemany("INSERT INTO comments VALUES (?,?,?,?)", ((r.get(cm['text'], '')[:400], file_of(r, cm), int(r.get(cm['line']) or 0), r.get(cm['kind'], '')) for r in rows(cm['file']) if r.get(cm['text']))) # where a TYPE is used in a declaration or expression — field type, parameter, return, generic argument, `new` — diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index db0458a3..ecf87c75 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -1990,7 +1990,10 @@ def direct_for_string(q, vals, at, rel): """ rows = [] if _has(q, 'literals'): - for v, f, l in q("SELECT value, file, line FROM literals WHERE value GLOB '[A-Za-z_]*' AND length(value) < 64"): + # strings only: the v8 index also carries numbers and booleans, and `True` is identifier-shaped + try: lit_rows = q("SELECT value, file, line FROM literals WHERE value GLOB '[A-Za-z_]*' AND length(value) < 64 AND kind = 'string'") + except Exception: lit_rows = q("SELECT value, file, line FROM literals WHERE value GLOB '[A-Za-z_]*' AND length(value) < 64") + for v, f, l in lit_rows: if v not in vals or not re.fullmatch(r'[A-Za-z_]\w*', v): continue c = at(f, l) if c: rows.append((c, 'uses', 'names it in a string literal', 'text', rel(f) if f else '', l or 0)) @@ -2473,8 +2476,9 @@ def discriminants(q): def keyed_literals(q, code, at, values): """`keyed_literal(c, k, v, f, l)`: an object literal inside c writes the property `k: 'v'` — the discriminant of a - type it builds without naming it. Read from the literals table, which holds string EXPRESSIONS only (a literal - type `kind: 'X'` in an interface is not there), then confirmed on the line: `node.kind === 'X'` compares and + type it builds without naming it. Read from the literals table — only rows whose value is one of the known + discriminant strings can match, so the numbers a v8 index adds never join (a literal + type `kind: 'X'` in an interface is still not there), then confirmed on the line: `node.kind === 'X'` compares and builds nothing, so it is not a row.""" rows = [] if not values or not _has(q, 'literals'): return rows diff --git a/tests/cases/python/constant-value/case.json b/tests/cases/python/constant-value/case.json new file mode 100644 index 00000000..be534a24 --- /dev/null +++ b/tests/cases/python/constant-value/case.json @@ -0,0 +1,16 @@ +{"lang": "python", "src": "src", + "checks": [ + {"why": "a numeric module constant answers with its value: the number a spec would print", + "run": ["impact", "MAX_ITEMS"], + "want": ["const MAX_ITEMS = 5"]}, + {"why": "a string constant answers with its value, quoted as a string", + "run": ["impact", "TIER_NAME"], + "want": ["const TIER_NAME = 'gold'"]}, + {"why": "a boolean constant answers with its value", + "run": ["impact", "ENABLED"], + "want": ["const ENABLED = True"]}, + {"why": "control: a constant initialized through a CALL names no value — the 3 on that line is an argument, not what RETRY_LIMIT holds", + "run": ["impact", "RETRY_LIMIT"], + "want": ["const RETRY_LIMIT"], + "avoid": ["RETRY_LIMIT = 3", "RETRY_LIMIT = "]} + ]} diff --git a/tests/cases/python/constant-value/src/limits.py b/tests/cases/python/constant-value/src/limits.py new file mode 100644 index 00000000..14202567 --- /dev/null +++ b/tests/cases/python/constant-value/src/limits.py @@ -0,0 +1,13 @@ +MAX_ITEMS = 5 +TIER_NAME = 'gold' +ENABLED = True + + +def pick(n): + return min(n, MAX_ITEMS) + + +RETRY_LIMIT = pick(3) + +WINDOW = ( + 60) diff --git a/tests/cases/typescript/constant-value/case.json b/tests/cases/typescript/constant-value/case.json new file mode 100644 index 00000000..3441f810 --- /dev/null +++ b/tests/cases/typescript/constant-value/case.json @@ -0,0 +1,16 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a numeric module constant answers with its value: the number a spec would print", + "run": ["impact", "MAX_ITEMS"], + "want": ["const MAX_ITEMS = 5"]}, + {"why": "a string constant answers with its value, quoted as a string", + "run": ["impact", "TIER_NAME"], + "want": ["const TIER_NAME = 'gold'"]}, + {"why": "a boolean constant answers with its value", + "run": ["impact", "ENABLED"], + "want": ["const ENABLED = true"]}, + {"why": "control: a constant initialized through a CALL names no value — the 3 on that line is an argument, not what RETRY_LIMIT holds", + "run": ["impact", "RETRY_LIMIT"], + "want": ["const RETRY_LIMIT"], + "avoid": ["RETRY_LIMIT = 3", "RETRY_LIMIT = "]} + ]} diff --git a/tests/cases/typescript/constant-value/src/limits.ts b/tests/cases/typescript/constant-value/src/limits.ts new file mode 100644 index 00000000..9abed5da --- /dev/null +++ b/tests/cases/typescript/constant-value/src/limits.ts @@ -0,0 +1,9 @@ +export const MAX_ITEMS = 5; +export const TIER_NAME = 'gold'; +export const ENABLED = true; + +export function pick(n: number): number { + return Math.min(n, MAX_ITEMS); +} + +export const RETRY_LIMIT = pick(3);