diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 4e300359..60ffa68c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -117,7 +117,7 @@ PER_QUERY = {'target', 'textuse', 'importuse', 'inside_target', 'nonsource', 'qu # it obtained itself, so an added proxied annotation does not apply") — the differential test caught that immediately. # `ref` is not here either: the alongside layer reads it to tell a sibling that touches the same field from one that does # not, for EVERY target kind. What is left is the layer only a field or type target can reach. -_REF_LAYER = {'qualifier', 'typeref', 'typeref_file', 'sigtype', 'persist_field', 'type_alias', 'jsx_props', 'jsx_tag', 'discriminant', 'keyed_literal', 'literal', 'dec_literal', 'field', 'accessor', 'faccess', 'gen_table', +_REF_LAYER = {'qualifier', 'typeref', 'typeref_file', 'sigtype', 'persist_field', 'type_alias', 'jsx_props', 'jsx_tag', 'discriminant', 'keyed_literal', 'literal', 'dec_literal', 'field', 'field_holder', 'accessor', 'faccess', 'gen_table', 'base_name', 'field_type', 'reexport', 'reexport_from', 'switch_over'} _CONFIG = {'config', 'config_key_known', 'config_site'} # `reexport` is NOT out of a method's reach: rules 396 and 398 both start at target(q,"method",m,_) — the barrel @@ -1175,10 +1175,13 @@ class Impact: mod_of = {} for i, sy in g.sym.items(): if sy['kind'] == 'module' and sy.get('file'): mod_of.setdefault(sy['file'], i) - modules_used = set() + modules_used = set(); holders = []; key_names = set() for rid, f in self.fields.items(): # the field's own registry hash when the index has it, so a decoration ON the field joins (#750) fid = f['id'] or f"f:{rid}"; t = owner_tid(f['owner'] or '', f.get('file')) + # a key of a module-level object (`TOPICS.CREATED`) is owned by an OBJECT, not a type: its module owns it, and + # the object's name is what a read of it is written behind (`TOPICS.CREATED`, `nested.depth`) + if not t and f['owner']: holders.append((fid, f['owner'].rsplit('.', 1)[-1])); key_names.add(f['name']) if not t: t = mod_of.get(f.get('file') or '') if not t: continue if t in mod_of.values(): modules_used.add(t) @@ -1200,7 +1203,7 @@ class Impact: (fid, 'init_' + f['name'], 'write'), (fid, 'get_' + cap, 'read'), (fid, 'set_' + cap, 'write'), (fid, 'init_' + cap, 'write')] if f['name'].startswith('_') and len(f['name']) > 1: acc.append((fid, f['name'].lstrip('_'), 'read')) - W('member', members); W('owner', owners); W('field', fields); W('accessor', acc) + W('member', members); W('owner', owners); W('field', fields); W('accessor', acc); W('field_holder', sorted(set(holders))) # the accesses the ENGINE resolved, joined on the same id the `field` fact uses: symbols.id for a # field IS the bundle's fields.id. #1071 — the relation shipped populated and no rule read it, so a # resolved access was answered as a name match, with its read/write direction discarded. @@ -1328,13 +1331,15 @@ class Impact: for r in g.q("""SELECT DISTINCT s.caller_id, x.c1 t, s.file_path, s.start_line FROM ext_ctor_implicit_type x JOIN call_sites s ON s.id = x.c0""")] if g.has('ext_ctor_implicit_type') else []) refs = []; quals = [] + # an object's key names: TypeScript records the member of `TOPICS.CREATED` as a bare name, and the qualifier + # written in front of it is what tells that object's key from another's if g.has('refs'): for r in g.q("SELECT name, file, line, kind, entity_kind FROM refs WHERE line > 0"): c = self.at(r['file'], r['line']) if not c: continue rk = 'qualified' if r['kind'] in QUALIFIED_KINDS else 'bare'; ek = 'CLASS_LITERAL' if r['kind'] == 'CLASS_LITERAL' else (r['entity_kind'] or '') refs.append((c, r['name'], rk, ek, r['file'], r['line'])) - if rk == 'qualified': + if rk == 'qualified' or r['name'] in key_names: L = self.code(r['file']); text = L[r['line'] - 1] if r['line'] <= len(L) else '' for qn in re.findall(rf'([A-Za-z_$][\w$]*)\s*\.\s*{re.escape(r["name"])}\b', text): quals.append((r['file'], r['line'], r['name'], qn)) W('ref', refs); W('qualifier', sorted(set(quals))) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 830ba9a5..10a7bfec 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -20,9 +20,10 @@ 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 = '6' # 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 = '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; # 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); the query + # v6: TypeScript class-property arrows named after their field; + # v7: data keys of module-level const objects declared, Object.freeze seen through); 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 '.') @@ -114,7 +115,8 @@ A = { # `obj.x = function …`, `{ all: (p) => … }`: named for the property, as JavaScript's are (#1585). The rows are # read through `ts_member_rows`, which gives them JavaScript's expression shape memberNames=dict(file='all-typescript-expressions.csv', id='jsExpressionUniqueHash', method='introducesDeclarationLinkHash', - vars='all-typescript-variables.csv', shape='typescript'), + vars='all-typescript-variables.csv', shape='typescript', + consts=lambda r: r.get('scopeKind') == 'MODULE_SCOPE' and r.get('isConst') == 'true'), skipped='skipped-typescript-files.csv'), 'python': dict( modules=dict(file='all-python-modules.csv', id='pyModuleUniqueHash', filePath='filePath'), @@ -150,7 +152,9 @@ A = { # `exports.getUser = function …`, `obj.x = () => …`, `{ all: page => … }`: the function is named for the property it # is the value of, the way the language's own name inference does it, and is owned by the object's variable memberNames=dict(file='all-javascript-expressions.csv', id='jsExpressionUniqueHash', method='introducesDeclarationLinkHash', - vars='all-javascript-variables.csv'), + vars='all-javascript-variables.csv', + # the module-level consts whose object literal's data keys are declarations (`TOPICS.CREATED`) + consts=lambda r: not r.get('ownerMethodLinkHash') and r.get('bindingRegime', '').startswith('CONST') and not r.get('importLinkHash')), # `static [Symbol.hasInstance](x) {…}`: the parser leaves the name empty and links the key expression; the member is # named `[Symbol.hasInstance]`, as written, instead of displaying as `Tagged.` with nothing to ask for computedNames=dict(file='all-javascript-methods.csv', id='jsMethodUniqueHash', key='computedNameExpressionLinkHash', @@ -322,8 +326,12 @@ def ts_member_rows(file): p, role, pos = up(r) o = dict(jsExpressionUniqueHash=i, parentExpressionLinkHash=p['tsExpressionUniqueHash'] if p else '', edgeRole=TS_ROLES.get(role, role), childIndex=pos, operatorString=r.get('operatorString', ''), - expressionKind={'ARROW_FUNCTION': 'FUNCTION_EXPRESSION', 'ASSIGNMENT_EXPRESSION': 'ASSIGNMENT'}.get(kind, kind), - introducesDeclarationLinkHash=r.get('anonymousDeclarationHash', '')) + expressionKind={'ARROW_FUNCTION': 'FUNCTION_EXPRESSION', 'ASSIGNMENT_EXPRESSION': 'ASSIGNMENT', 'CALL_EXPRESSION': 'CALL'}.get(kind, kind), + introducesDeclarationLinkHash=r.get('anonymousDeclarationHash', ''), + ownerModuleLinkHash=r.get('tsModuleLinkHash', ''), startLine=r.get('startLine', '')) + if kind == 'CALL_EXPRESSION': # the callee as written, the way JavaScript's `text` starts: `Object.freeze(` + callee = next((k for k in kids.get(i, ()) if k.get('edgeRole') == 'METHOD_NAME'), None) + o['text'] = (dotted(callee) or '') + '(' if callee else '' if role == 'OBJECT_PROPERTY_KEY' and (kind == 'IDENTIFIER_REFERENCE' or (kind == 'LITERAL' and r.get('literalType') in ('STRING', 'NUMBER'))): o['name'] = r.get('literalValue', '') @@ -334,19 +342,35 @@ def ts_member_rows(file): out[i] = o return out.values(), {i: inner(i)['tsExpressionUniqueHash'] for i, r in src.items() if r.get('kind') in TS_WRAPPERS and inner(i)} bound_owner = {}; cls_named = {} +data_keys = [] # (key, owner key chain, the const's variable row, key row): see below if A.get('memberNames'): mn = A['memberNames']; ex = {}; kids = collections.defaultdict(list) ts_shape = mn.get('shape') == 'typescript' mrows, unwrap = ts_member_rows(mn['file']) if ts_shape else (rows(mn['file']), {}) for r in mrows: ex[r[mn['id']]] = r; kids[r.get('parentExpressionLinkHash', '')].append(r) - lit_var = {unwrap.get(r['initializerExpressionLinkHash'], r['initializerExpressionLinkHash']): r['name'] - for r in rows(mn['vars']) if r.get('initializerExpressionLinkHash') and r.get('name')} DOTTED = re.compile(r'^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$') def key_of(parent, role, idx): for k in kids.get(parent, ()): if k.get('edgeRole') == role and (idx is None or k.get('childIndex') == idx): return k + # `const T = Object.freeze({…})` holds the literal it is handed, as `const T = {…}` does: the variable's initializer + # is the CALL, and the literal it names is the call's argument. Without this the literal had no variable, so its + # function-valued keys lost their owner and its data keys had nothing to be declared under + FREEZE = re.compile(r'^Object\s*\.\s*(freeze|seal|preventExtensions)\s*\(') + def held_literal(i): + r = ex.get(i) + if r is not None and r.get('expressionKind') == 'CALL' and FREEZE.match(r.get('text') or ''): + a = next((k for k in kids.get(i, ()) if k.get('edgeRole') == 'ARGUMENT'), None) + a = ex.get(unwrap.get(a[mn['id']], a[mn['id']])) if a else None + if a is not None and a.get('expressionKind') == 'OBJECT_LITERAL': return a[mn['id']] + return i + lit_var = {}; const_lit = {} # literal -> variable name; literal -> variable row, module-level consts only + for r in rows(mn['vars']): + if not (r.get('initializerExpressionLinkHash') and r.get('name')): continue + i = held_literal(unwrap.get(r['initializerExpressionLinkHash'], r['initializerExpressionLinkHash'])) + lit_var[i] = r['name'] + if mn.get('consts') and mn['consts'](r): const_lit[i] = r def literal_owner(lit, depth=0): if lit.get(mn['id']) in lit_var: return lit_var[lit[mn['id']]] p = ex.get(lit.get('parentExpressionLinkHash', '')) @@ -389,6 +413,28 @@ if A.get('memberNames'): if name: bound[m_] = name if owner: bound_owner[m_] = owner + # A DATA KEY OF A MODULE-LEVEL CONST OBJECT is a declaration: `export const TOPICS = Object.freeze({ CREATED: 'a.b' })` + # declares TOPICS.CREATED, and `bus.publish(TOPICS.CREATED)` reads it. Only a function-valued key was a declaration (a + # method, above), so `impact TOPICS.CREATED` answered "nothing named" and its readers were word matches. A nested + # literal's keys follow the key chain (`LIMITS.nested.depth`). Not declared: a function (already the method), a bare + # name or shorthand (`{ create }` is another declaration, not a value of its own), a computed key, and any literal a + # function body builds or a `let` holds -- those are values, not the module's named constants + def const_root(lit, depth=0): + if lit[mn['id']] in const_lit: return const_lit[lit[mn['id']]] + p = ex.get(lit.get('parentExpressionLinkHash', '')) + if depth > 8 or not p or lit.get('edgeRole') != 'PROPERTY_VALUE' or p.get('expressionKind') != 'OBJECT_LITERAL': return None + return const_root(p, depth + 1) + NOT_DATA = {'FUNCTION_EXPRESSION', 'CLASS_EXPRESSION', 'IDENTIFIER', 'IDENTIFIER_REFERENCE'} + for k in list(ex.values()) if const_lit else (): + if k.get('edgeRole') != 'PROPERTY_KEY' or k.get('isComputedName') == 'true' or not k.get('name'): continue + p = ex.get(k.get('parentExpressionLinkHash', '')) + if not p or p.get('expressionKind') != 'OBJECT_LITERAL': continue + v = key_of(p[mn['id']], 'PROPERTY_VALUE', k.get('childIndex')) + v = ex.get(unwrap.get(v[mn['id']], v[mn['id']])) if v else None + if v is None or v.get('expressionKind') in NOT_DATA: continue + root = const_root(p); owner = literal_owner(p) if root else None + if not owner: continue + data_keys.append((k['name'], owner, root, k)) # ── types: display names with nesting recovered by line containment ─────────────────────────────── types = {r['id']: dict(r) for r in c.execute("SELECT id, name, qualified_name, category, file_path, start_line, end_line, provenance FROM types")} byfile = {} @@ -570,6 +616,14 @@ for d in A['decls']: qn = ((ot['qualified_name'] + '.' + name) if ot and ot.get('qualified_name') else None) or r.get('qualifiedName') or \ (((r.get(d['owner'], '') + '.') if d.get('owner') else (fp + '#')) + name) sym.append((r.get(d['id']) if d.get('id') else None, name, (od + '.' if od else '') + name, d['kind'](r), qn, None, fp, ln, en, od, 1 if fp and TESTRE.search(fp) else 0, None, None)) +# the data keys of module-level const objects (collected with the member names): a field of the object, owned by the key +# chain it is written under, so `TOPICS.CREATED` and `LIMITS.nested.depth` are names `impact` takes +for name, od, v, k in data_keys: + fp = rel(modules.get(k.get('ownerModuleLinkHash', ''), '')) or rel(v.get('filePath', '')) + ln = int(k.get('startLine') or 0) + vq = v.get('qualifiedName') or v.get('potentialQualifiedName') + qn = f"{vq}{od[len(v['name']):]}.{name}" if vq and od.startswith(v['name']) else f"{fp}#{od}.{name}" + sym.append((None, name, f"{od}.{name}", 'field', qn, None, fp, ln, ln, od, 1 if fp and TESTRE.search(fp) else 0, None, None)) c.executemany("INSERT INTO symbols VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", sym) # ── references, literals, comments ─────────────────────────────────────────────────────────────── diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index f46bbbbb..8b24bdd5 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -82,6 +82,8 @@ .decl reexport(c:symbol, n:symbol, f:symbol, l:number) .input reexport .decl reexport_from(f:symbol, l:number, src:symbol) .input reexport_from .decl field(fl:symbol, t:symbol, n:symbol, f:symbol, l:number) .input field +// field_holder(fl, h): fl is a key of an OBJECT named h, not a member of a type (`TOPICS` of `TOPICS.CREATED`) +.decl field_holder(fl:symbol, h:symbol) .input field_holder .decl accessor(fl:symbol, n:symbol, rw:symbol) .input accessor .decl faccess(c:symbol, fl:symbol, acc:symbol, tier:symbol, f:symbol, l:number) .input faccess .decl gen_table(d:symbol, what:symbol) .input gen_table @@ -468,7 +470,10 @@ direct(q, c, "uses", why, "by name", f, l) :- valueref(q, c, f, l), registered(q // a FIELD: references by name, judged by where they are and how they are written .decl fref(q:symbol, c:symbol, rk:symbol, f:symbol, l:number) fref(q, c, rk, f, l) :- target(q, "field", fl, _), field(fl, _, n, ff, fll), ref(c, n, rk, ek, f, l), !local_kind(ek), !type_or_call_kind(ek), (f != ff ; l != fll), - (!fa_line(q, f, l) ; ek = "OBJECT_PROPERTY_KEY"). + (!fa_line(q, f, l) ; ek = "OBJECT_PROPERTY_KEY"), (ek != "OBJECT_PROPERTY_KEY" ; !key_decl_at(n, f, l)). +// the key that DECLARES another object's same-named key (`QUEUES = { CREATED: … }`) is that declaration, not a use of this one +.decl key_decl_at(n:symbol, f:symbol, l:number) +key_decl_at(n, f, l) :- field_holder(fl, _), field(fl, _, n, f, l). // an enum member is written like a type, so the parser labels the genuine reference TYPE: keep those, but only in a // file that can see the enum — its own directory, or a file that names the enum type somewhere .decl enum_member_target(q:symbol, fl:symbol) @@ -555,7 +560,7 @@ direct(q, c, role, why, "in scope", f, l) :- fref(q, c, rk, f, l), frole(rk, rol direct(q, c, "uses", "writes/reads it", "in scope", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), owner(c, s), !scope(t, s), qualifier(f, l, n, tn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - owner(c, s), !scope(t, s), !declares(s, n), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c), !const_routed(q, c, f, l). + owner(c, s), !scope(t, s), !declares(s, n), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !holds_key(qn, n), !field_holder(fl, _), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), owner(c, s), !scope(t, s), !declares(s, n), !qualifier(f, l, n, _), !fa_known(q, c), !const_routed(q, c, f, l). // THE SAME, IN A CALLABLE THAT HAS NO OWNER TYPE. Every rule above starts at `owner(c, s)`, which in Java and @@ -567,13 +572,25 @@ direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualifie direct(q, c, "uses", "writes/reads it", "in scope", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), !owner(c, _), !scope(t, c), qualifier(f, l, n, tn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - !owner(c, _), !scope(t, c), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c), !const_routed(q, c, f, l). + !owner(c, _), !scope(t, c), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !holds_key(qn, n), !field_holder(fl, _), !fa_known(q, c), !const_routed(q, c, f, l). +// A KEY OF A MODULE-LEVEL OBJECT, read behind the object's own name: `TOPICS.CREATED` reads TOPICS's key wherever it is +// written, as `Order.TAX` reads Order's constant. The object is one value, read by its own name, so behind any OTHER +// name (`QUEUES.CREATED`, `upstreams.workspaces`) it is another object's member, and the by-name rules above leave it +// out (field_holder); a field of a TYPE read behind an object that holds a key of that name is left out too (holds_key) +.decl holds_key(h:symbol, n:symbol) +holds_key(h, n) :- field_holder(fl, h), field(fl, _, n, _, _). +// TypeScript records the member of `TOPICS.CREATED` as a BARE name, so the qualifier decides for a bare reference too. +// A bare name with no such qualifier is never an object's key -- a key is only ever read behind its object -- so the +// bare rules below leave object keys out: `workspaces.close()` is a variable, not OFFSETS.workspaces +direct(q, c, "uses", "writes/reads it", "in scope", f, l) :- fref(q, c, _, f, l), target(q, "field", fl, _), field(fl, t, n, _, _), field_holder(fl, h), + !scope(t, c), qualifier(f, l, n, h), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), !owner(c, _), !scope(t, c), !qualifier(f, l, n, _), !fa_known(q, c), !const_routed(q, c, f, l). // bare elsewhere: only when the enclosing type has no member of that name itself direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), - owner(c, s), !scope(t, s), !declares(s, n), !const_routed(q, c, f, l). -direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), !owner(c, _), !scope(t, c), !const_routed(q, c, f, l), !shadowed_in(fl, n, f). + owner(c, s), !scope(t, s), !declares(s, n), !const_routed(q, c, f, l), !field_holder(fl, _). +direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), !owner(c, _), !scope(t, c), !const_routed(q, c, f, l), !shadowed_in(fl, n, f), + !field_holder(fl, _). // a bare name in a file that declares ANOTHER field of that name is that file's own: `const listAdmins = ['root']` in // one module and a wrapped handler of the same name in another are two declarations, and each answer carried the // other's readers. A function of that name declared in the file is its own the same way: `module.exports = { createUser }` diff --git a/tests/cases/javascript/const-object-data-keys/case.json b/tests/cases/javascript/const-object-data-keys/case.json new file mode 100644 index 00000000..d970ff56 --- /dev/null +++ b/tests/cases/javascript/const-object-data-keys/case.json @@ -0,0 +1,55 @@ +{ + "lang": "javascript", + "src": ".", + "checks": [ + { + "why": "a data-valued key of an exported const object literal, through Object.freeze, is a declaration: its readers are the functions that read TOPICS.CREATED", + "run": ["impact", "TOPICS.CREATED"], + "want": ["change: field TOPICS.CREATED [field]", "create service.js:4"], + "avoid": ["nothing named", "remove", "enqueue"] + }, + { + "why": "a same-named key of ANOTHER const object is another declaration: QUEUES.CREATED is read by enqueue only, and TOPICS.CREATED's reader is not its reader", + "run": ["impact", "QUEUES.CREATED"], + "want": ["change: field QUEUES.CREATED [field]", "enqueue service.js:12"], + "avoid": ["nothing named", "create service.js"] + }, + { + "why": "CONTROL: a key read behind another name (`settings.page`, `counts.page`) is another object's member, not LIMITS.page", + "run": ["impact", "LIMITS.page"], + "want": ["change: field LIMITS.page [field]", "pageSize service.js:16"], + "avoid": ["mirror", "local"] + }, + { + "why": "a plain const object literal, and a nested literal's key, are declared under their key chain", + "run": ["impact", "LIMITS.nested.depth"], + "want": ["change: field LIMITS.nested.depth [field]", "pageSize service.js:16"], + "avoid": ["nothing named"] + }, + { + "why": "a CommonJS const object's key is declared the same way, and a member read of it is its reader; a captured local of the same name (`api.close()`) is not", + "run": ["impact", "OFFSETS.api"], + "want": ["change: field OFFSETS.api [field]", "apiPort ports.cjs:12"], + "avoid": ["nothing named", "closeLater", ""] + }, + { + "why": "a function-valued key inside Object.freeze is owned by the variable, as it is in a bare literal", + "run": ["impact", "HANDLERS.run"], + "want": ["change: HANDLERS.run [method]", "dispatch service.js:29"], + "avoid": ["nothing named", "[field]"] + }, + { + "why": "CONTROL: a function-valued key stays the method it was, not a second field", + "run": ["impact", "helpers.shout"], + "want": ["change: helpers.shout [method]", "loud service.js:20"], + "avoid": ["[field]", "more than one kind"] + }, + { + "why": "CONTROL: an object literal inside a function body declares nothing: its key is not a module-level declaration", + "run": ["impact", "counts.page"], + "expect_error": true, + "want": ["the graph has no declaration for 'counts.page'"], + "avoid": ["change: field counts.page"] + } + ] +} diff --git a/tests/cases/javascript/const-object-data-keys/ports.cjs b/tests/cases/javascript/const-object-data-keys/ports.cjs new file mode 100644 index 00000000..bfcb10b4 --- /dev/null +++ b/tests/cases/javascript/const-object-data-keys/ports.cjs @@ -0,0 +1,17 @@ +'use strict'; +const OFFSETS = Object.freeze({ + web: 0, + api: 1, +}); + +function portFor(name) { + return 1000 + OFFSETS[name]; +} + +function apiPort() { + return 1000 + OFFSETS.api; +} + +exports.OFFSETS = OFFSETS; +exports.portFor = portFor; +exports.apiPort = apiPort; diff --git a/tests/cases/javascript/const-object-data-keys/service.js b/tests/cases/javascript/const-object-data-keys/service.js new file mode 100644 index 00000000..d24764b5 --- /dev/null +++ b/tests/cases/javascript/const-object-data-keys/service.js @@ -0,0 +1,39 @@ +import { TOPICS, LIMITS, QUEUES, HANDLERS, helpers } from './topics.js'; + +export function create(bus) { + bus.publish(TOPICS.CREATED, {}); +} + +export function remove(bus) { + bus.publish(TOPICS.DELETED, {}); +} + +export function enqueue(q) { + q.send(QUEUES.CREATED); +} + +export function pageSize() { + return LIMITS.page + LIMITS.nested.depth; +} + +export function loud(s) { + return helpers.shout(s); +} + +export function local() { + const counts = { page: 1 }; + return counts.page; +} + +export function dispatch(job) { + return HANDLERS.run(job); +} + +export function closeLater(open) { + const api = open(); + return () => api.close(); +} + +export function mirror(settings) { + return settings.page; +} diff --git a/tests/cases/javascript/const-object-data-keys/topics.js b/tests/cases/javascript/const-object-data-keys/topics.js new file mode 100644 index 00000000..1fd78023 --- /dev/null +++ b/tests/cases/javascript/const-object-data-keys/topics.js @@ -0,0 +1,21 @@ +export const TOPICS = Object.freeze({ + CREATED: 'doc.created', + DELETED: 'doc.deleted', +}); + +export const LIMITS = { + page: 50, + nested: { depth: 3 }, +}; + +export const QUEUES = Object.freeze({ + CREATED: 'queue.created', +}); + +export const helpers = { + shout: (s) => s.toUpperCase(), +}; + +export const HANDLERS = Object.freeze({ + run: (job) => job.id, +}); diff --git a/tests/cases/typescript/const-object-data-keys/case.json b/tests/cases/typescript/const-object-data-keys/case.json new file mode 100644 index 00000000..e8893670 --- /dev/null +++ b/tests/cases/typescript/const-object-data-keys/case.json @@ -0,0 +1,43 @@ +{ + "lang": "typescript", + "src": ".", + "checks": [ + { + "why": "a data-valued key of an exported const object, through Object.freeze, is a declaration read by the function that reads TOPICS.CREATED", + "run": ["impact", "TOPICS.CREATED"], + "want": ["change: field TOPICS.CREATED [field]", "create service.ts:6"], + "avoid": ["nothing named", "enqueue"] + }, + { + "why": "through `as const` too, and a same-named key of another object is another declaration", + "run": ["impact", "QUEUES.CREATED"], + "want": ["change: field QUEUES.CREATED [field]", "enqueue service.ts:10"], + "avoid": ["nothing named", "create service.ts"] + }, + { + "why": "a nested literal's key is declared under its key chain", + "run": ["impact", "LIMITS.nested.depth"], + "want": ["change: field LIMITS.nested.depth [field]", "nestedDepth service.ts:14"], + "avoid": ["nothing named"] + }, + { + "why": "a function-valued key inside Object.freeze is owned by the variable, as it is in a bare literal", + "run": ["impact", "HANDLERS.run"], + "want": ["change: HANDLERS.run [method]", "dispatch service.ts:18"], + "avoid": ["nothing named", "[field]"] + }, + { + "why": "CONTROL: a function-valued key stays the method it was, not a second field", + "run": ["impact", "helpers.shout"], + "want": ["change: helpers.shout [method]", "loud service.ts:22"], + "avoid": ["[field]", "more than one kind"] + }, + { + "why": "CONTROL: an object literal inside a function body declares nothing", + "run": ["impact", "counts.page"], + "expect_error": true, + "want": ["the graph has no declaration for 'counts.page'"], + "avoid": ["change: field counts.page"] + } + ] +} diff --git a/tests/cases/typescript/const-object-data-keys/service.ts b/tests/cases/typescript/const-object-data-keys/service.ts new file mode 100644 index 00000000..38525808 --- /dev/null +++ b/tests/cases/typescript/const-object-data-keys/service.ts @@ -0,0 +1,28 @@ +import { TOPICS, QUEUES, LIMITS, HANDLERS, helpers } from './topics'; + +interface Bus { publish(topic: string, body: object): void; } + +export function create(bus: Bus) { + bus.publish(TOPICS.CREATED, {}); +} + +export function enqueue(bus: Bus) { + bus.publish(QUEUES.CREATED, {}); +} + +export function nestedDepth(): number { + return LIMITS.nested.depth; +} + +export function dispatch(id: string) { + return HANDLERS.run(id); +} + +export function loud(s: string) { + return helpers.shout(s); +} + +export function local() { + const counts = { page: 1 }; + return counts.page; +} diff --git a/tests/cases/typescript/const-object-data-keys/topics.ts b/tests/cases/typescript/const-object-data-keys/topics.ts new file mode 100644 index 00000000..1ba99e1d --- /dev/null +++ b/tests/cases/typescript/const-object-data-keys/topics.ts @@ -0,0 +1,20 @@ +export const TOPICS = Object.freeze({ + CREATED: 'doc.created', + DELETED: 'doc.deleted', +}); + +export const QUEUES = { + CREATED: 'queue.created', +} as const; + +export const LIMITS = { + nested: { depth: 3 }, +}; + +export const HANDLERS = Object.freeze({ + run: (id: string) => id.length, +}); + +export const helpers = { + shout: (s: string) => s.toUpperCase(), +};