@suss/adapter-python 0.4.0 → 0.5.1

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.
package/README.md CHANGED
@@ -186,8 +186,18 @@ call that starts the chain is the one resolved, and the method the chain ends
186
186
  with tells a read from a write. A method a base and a subclass both declare
187
187
  gives two, and nothing is claimed.
188
188
 
189
- `fields` and `selector` come back empty, so an effect says which model and
190
- which operation and not which columns. Raw SQL handed to `execute` is not read
189
+ A query built from a function the library exports has no project method in
190
+ between for its return to be read off, so a pack lists those by name and a call
191
+ site importing one matches on that:
192
+
193
+ ```ts
194
+ queryFunctions: ["select", "insert", "update", "delete"]
195
+ ```
196
+
197
+ `fields` comes from the columns a query names, `User.id` in
198
+ `select(User.id, User.email)`. `selector` comes from the keywords the chain
199
+ picks rows by, `id` in `filter_by(id=user_id)`, gathered from every call in
200
+ the chain rather than only the last. Raw SQL handed to `execute` is not read
191
201
  at all.
192
202
 
193
203
  ## Where a mount is written
package/dist/index.cjs CHANGED
@@ -994,12 +994,26 @@ function argsOf(call) {
994
994
  function calleeText(call) {
995
995
  return field(call, "function")?.text ?? call.text;
996
996
  }
997
+ function withoutChainLinks(calls) {
998
+ const isLink = /* @__PURE__ */ new Set();
999
+ for (const call of calls) {
1000
+ const callee = field(call, "function");
1001
+ if (callee === null || callee.type !== "attribute") {
1002
+ continue;
1003
+ }
1004
+ const object2 = field(callee, "object");
1005
+ if (object2 !== null && object2.type === "call") {
1006
+ isLink.add(object2.id);
1007
+ }
1008
+ }
1009
+ return calls.filter((call) => !isLink.has(call.id));
1010
+ }
997
1011
  function invocationEffects(definitionNode) {
998
1012
  const body = field(definitionNode, "body");
999
1013
  if (body === null) {
1000
1014
  return [];
1001
1015
  }
1002
- const calls = bodyCalls(body);
1016
+ const calls = withoutChainLinks(bodyCalls(body));
1003
1017
  if (calls.length === 0) {
1004
1018
  return [];
1005
1019
  }
@@ -16233,21 +16247,22 @@ var RESOLUTION_RULES = [
16233
16247
  ]
16234
16248
  ),
16235
16249
  // Which calls a function, found by the name the call is written as
16236
- // rather than by resolving every callee in the project. Asking what
16237
- // reaches a parameter means looking for call sites, and looking for
16238
- // them the other way round costs a whole-project resolution.
16250
+ // rather than by resolving every callee in the project. A caller knows
16251
+ // the function and wants its call sites, so both of these start from
16252
+ // the function. Starting from `call` instead asks what every call in
16253
+ // the project imports, which was 72% of everything derived.
16239
16254
  (0, import_datalog.rule)(
16240
16255
  "callsFunction",
16241
16256
  [(0, import_datalog.variable)("r"), (0, import_datalog.variable)("f")],
16242
- [(0, import_datalog.lit)("call", (0, import_datalog.variable)("r"), (0, import_datalog.variable)("c")), (0, import_datalog.lit)("binds", (0, import_datalog.variable)("c"), (0, import_datalog.variable)("f"))]
16257
+ [(0, import_datalog.lit)("binds", (0, import_datalog.variable)("c"), (0, import_datalog.variable)("f")), (0, import_datalog.lit)("call", (0, import_datalog.variable)("r"), (0, import_datalog.variable)("c"))]
16243
16258
  ),
16244
16259
  (0, import_datalog.rule)(
16245
16260
  "callsFunction",
16246
16261
  [(0, import_datalog.variable)("r"), (0, import_datalog.variable)("f")],
16247
16262
  [
16248
- (0, import_datalog.lit)("call", (0, import_datalog.variable)("r"), (0, import_datalog.variable)("c")),
16263
+ (0, import_datalog.lit)("moduleExport", (0, import_datalog.variable)("m"), (0, import_datalog.variable)("n"), (0, import_datalog.variable)("f")),
16249
16264
  (0, import_datalog.lit)("imports", (0, import_datalog.variable)("c"), (0, import_datalog.variable)("m"), (0, import_datalog.variable)("n")),
16250
- (0, import_datalog.lit)("moduleExport", (0, import_datalog.variable)("m"), (0, import_datalog.variable)("n"), (0, import_datalog.variable)("f"))
16265
+ (0, import_datalog.lit)("call", (0, import_datalog.variable)("r"), (0, import_datalog.variable)("c"))
16251
16266
  ]
16252
16267
  ),
16253
16268
  // A function whose return is itself a call arrives at the same object
@@ -16941,11 +16956,85 @@ function chainsIn(calls) {
16941
16956
  root: call,
16942
16957
  last,
16943
16958
  subject: subjectOf(call),
16944
- operation: methodName(last)
16959
+ operation: methodName(last),
16960
+ between: calls.filter(
16961
+ (other) => other !== call && other !== last && other.startIndex >= call.startIndex && other.endIndex <= last.endIndex
16962
+ )
16945
16963
  });
16946
16964
  }
16947
16965
  return chains;
16948
16966
  }
16967
+ function importedQueryFunction(options, chain) {
16968
+ const callee = field(chain.root, "function");
16969
+ if (callee === null || callee.type !== "identifier") {
16970
+ return void 0;
16971
+ }
16972
+ const from = options.facts.facts("pyImportedName").find((row) => String(row[0]) === `${options.filePath}#${callee.text}`);
16973
+ if (from === void 0) {
16974
+ return void 0;
16975
+ }
16976
+ return options.patterns.find(
16977
+ (pattern) => String(from[1]) === pattern.module && (pattern.queryFunctions ?? []).includes(String(from[2]))
16978
+ );
16979
+ }
16980
+ function fieldsOf(chain) {
16981
+ const args = field(chain.root, "arguments");
16982
+ const named = [];
16983
+ for (const argument of args === null ? [] : children3(args)) {
16984
+ if (argument.type === "keyword_argument") {
16985
+ const name = field(argument, "name");
16986
+ if (name !== null) {
16987
+ named.push(name.text);
16988
+ }
16989
+ continue;
16990
+ }
16991
+ if (argument.type === "attribute") {
16992
+ const property = field(argument, "attribute");
16993
+ if (property !== null) {
16994
+ named.push(property.text);
16995
+ }
16996
+ }
16997
+ }
16998
+ return named;
16999
+ }
17000
+ function selectorOf(call) {
17001
+ const args = field(call, "arguments");
17002
+ const picked = [];
17003
+ for (const argument of args === null ? [] : children3(args)) {
17004
+ if (argument.type === "keyword_argument") {
17005
+ const name = field(argument, "name");
17006
+ if (name !== null) {
17007
+ picked.push(name.text);
17008
+ }
17009
+ }
17010
+ }
17011
+ return picked;
17012
+ }
17013
+ function effectFor(pattern, chain, options) {
17014
+ const picked = [
17015
+ ...new Set([chain.last, ...chain.between].flatMap(selectorOf))
17016
+ ];
17017
+ return {
17018
+ type: "interaction",
17019
+ binding: storageRelationalBinding({
17020
+ recognition: "python-storage",
17021
+ storageSystem: pattern.storageSystem,
17022
+ scope: pattern.module,
17023
+ table: chain.subject
17024
+ }),
17025
+ callee: chain.last.text,
17026
+ interaction: {
17027
+ class: "storage-access",
17028
+ kind: pattern.writes.includes(chain.operation) ? "write" : "read",
17029
+ fields: fieldsOf(chain),
17030
+ operation: chain.operation,
17031
+ ...picked.length > 0 ? { selector: picked } : {}
17032
+ }
17033
+ };
17034
+ }
17035
+ function children3(node) {
17036
+ return node.namedChildren.filter((child) => child !== null);
17037
+ }
16949
17038
  function fileOf(key) {
16950
17039
  const at = key.lastIndexOf(":");
16951
17040
  return at === -1 ? key : key.slice(0, at);
@@ -16960,26 +17049,90 @@ function returnTypeName(node) {
16960
17049
  }
16961
17050
  return annotation.type === "subscript" ? field(annotation, "value")?.text ?? null : annotation.text;
16962
17051
  }
17052
+ function callsIn(node, found = []) {
17053
+ for (const child of node.namedChildren) {
17054
+ if (child === null) {
17055
+ continue;
17056
+ }
17057
+ if (child.type === "call") {
17058
+ found.push(child);
17059
+ }
17060
+ callsIn(child, found);
17061
+ }
17062
+ return found;
17063
+ }
17064
+ function storageEffects(calls, options) {
17065
+ const seen = /* @__PURE__ */ new Set();
17066
+ const asked = new Set(
17067
+ options.facts.facts("wanted").map((row) => String(row[0]))
17068
+ );
17069
+ const gather = (inCalls, filePath) => {
17070
+ const found = [...directStorageEffects(inCalls, { ...options, filePath })];
17071
+ resolveCalls(
17072
+ options.facts,
17073
+ inCalls.filter((call) => options.leadsToStorage.has(methodName(call))).map((call) => field(call, "function")).filter((callee) => callee !== null).map((callee) => calleeKeyOf(filePath, callee)).filter((key) => {
17074
+ const fresh = !asked.has(key);
17075
+ asked.add(key);
17076
+ return fresh;
17077
+ })
17078
+ );
17079
+ for (const call of inCalls) {
17080
+ const callee = field(call, "function");
17081
+ if (callee === null || !options.leadsToStorage.has(methodName(call))) {
17082
+ continue;
17083
+ }
17084
+ const key = resolvedOnce(options, calleeKeyOf(filePath, callee));
17085
+ const body = key === void 0 ? void 0 : options.definitionAt(key);
17086
+ if (key === void 0 || body === void 0 || seen.has(key)) {
17087
+ continue;
17088
+ }
17089
+ seen.add(key);
17090
+ const name = field(body, "name")?.text;
17091
+ const origin = {
17092
+ file: fileOf(key),
17093
+ line: body.startPosition.row + 1,
17094
+ ...name === void 0 ? {} : { function: name }
17095
+ };
17096
+ found.push(
17097
+ ...gather(callsIn(body), fileOf(key)).map(
17098
+ (effect) => effect.type === "interaction" && effect.origin === void 0 ? { ...effect, origin } : effect
17099
+ )
17100
+ );
17101
+ }
17102
+ return found;
17103
+ };
17104
+ return gather(calls, options.filePath);
17105
+ }
17106
+ function calleeKeyOf(filePath, callee) {
17107
+ return callee.type === "identifier" ? `${filePath}#${callee.text}` : nodeId(filePath, callee);
17108
+ }
17109
+ function resolvedOnce(options, calleeKey) {
17110
+ const resolved = options.facts.facts("wantedResolves").filter((row) => String(row[0]) === calleeKey).map((row) => String(row[1]));
17111
+ return resolved.length === 1 ? resolved[0] : void 0;
17112
+ }
16963
17113
  function storageCallees(calls, filePath, couldMatch) {
16964
17114
  return chainsIn(calls).filter((chain) => couldMatch.has(methodName(chain.root))).map((chain) => field(chain.root, "function")).filter((callee) => callee !== null).map((callee) => nodeId(filePath, callee));
16965
17115
  }
16966
- function storageEffects(calls, options) {
17116
+ function directStorageEffects(calls, options) {
16967
17117
  if (options.patterns.length === 0) {
16968
17118
  return [];
16969
17119
  }
17120
+ const named = new Set(
17121
+ options.patterns.flatMap((pattern) => pattern.queryFunctions ?? [])
17122
+ );
16970
17123
  const chains = chainsIn(calls).filter(
16971
- (chain) => options.couldMatch.has(methodName(chain.root))
17124
+ (chain) => options.couldMatch.has(methodName(chain.root)) || named.has(methodName(chain.root))
16972
17125
  );
16973
17126
  if (chains.length === 0) {
16974
17127
  return [];
16975
17128
  }
16976
- const asked = new Set(
17129
+ const alreadyAsked = new Set(
16977
17130
  options.facts.facts("wanted").map((row) => String(row[0]))
16978
17131
  );
16979
17132
  resolveCalls(
16980
17133
  options.facts,
16981
17134
  storageCallees(calls, options.filePath, options.couldMatch).filter(
16982
- (key) => !asked.has(key)
17135
+ (key) => !alreadyAsked.has(key)
16983
17136
  )
16984
17137
  );
16985
17138
  const effects = [];
@@ -16988,6 +17141,11 @@ function storageEffects(calls, options) {
16988
17141
  if (callee === null) {
16989
17142
  continue;
16990
17143
  }
17144
+ const imported = importedQueryFunction(options, chain);
17145
+ if (imported !== void 0) {
17146
+ effects.push(effectFor(imported, chain, options));
17147
+ continue;
17148
+ }
16991
17149
  const resolved = options.facts.facts("wantedResolves").filter((row) => String(row[0]) === nodeId(options.filePath, callee)).map((row) => String(row[1]));
16992
17150
  const settled = resolved.length === 1 ? resolved[0] : void 0;
16993
17151
  if (settled === void 0) {
@@ -17005,22 +17163,7 @@ function storageEffects(calls, options) {
17005
17163
  if (pattern === void 0) {
17006
17164
  continue;
17007
17165
  }
17008
- effects.push({
17009
- type: "interaction",
17010
- binding: storageRelationalBinding({
17011
- recognition: "python-storage",
17012
- storageSystem: pattern.storageSystem,
17013
- scope: pattern.module,
17014
- table: chain.subject
17015
- }),
17016
- callee: chain.last.text,
17017
- interaction: {
17018
- class: "storage-access",
17019
- kind: pattern.writes.includes(chain.operation) ? "write" : "read",
17020
- fields: [],
17021
- operation: chain.operation
17022
- }
17023
- });
17166
+ effects.push(effectFor(pattern, chain, options));
17024
17167
  }
17025
17168
  return effects;
17026
17169
  }
@@ -17597,7 +17740,7 @@ function buildRouteUnit(options) {
17597
17740
  const branches = (perReturn ?? []).map(
17598
17741
  (branch) => storage.length === 0 ? branch : { ...branch, extraEffects: storage }
17599
17742
  );
17600
- if (perReturn === null && (responseShape.reading.kind !== "absent" || statusCode.reading.kind !== "absent" || storage.length > 0)) {
17743
+ if (perReturn === null && (responseShape.reading.kind !== "absent" || statusCode.reading.kind !== "absent" || storage.length > 0 || effects.length > 0)) {
17601
17744
  branches.push({
17602
17745
  conditions: [],
17603
17746
  terminal: {
@@ -17897,6 +18040,11 @@ function emitModuleImportFacts(db, filePath, module2, resolverOptions) {
17897
18040
  moduleText,
17898
18041
  resolution.status === "resolved" ? "resolved" : resolution.reason
17899
18042
  ]);
18043
+ db.add("pyImportedName", [
18044
+ `${filePath}#${localName}`,
18045
+ moduleText,
18046
+ binding.kind === "import" ? binding.localName : binding.importedName
18047
+ ]);
17900
18048
  if (resolution.status === "resolved") {
17901
18049
  db.add("pyImportResolved", [filePath, moduleText, resolution.file]);
17902
18050
  db.add("imports", [
@@ -18047,6 +18195,11 @@ function unmountedReason(index) {
18047
18195
  if (loops.length === 0) {
18048
18196
  return "is never mounted through a single variable binding in the files read";
18049
18197
  }
18198
+ const stopped = loops.find((loop) => loop.unmatched !== void 0);
18199
+ if (stopped?.unmatched !== void 0) {
18200
+ const { name, resolvedTo, matched, of } = stopped.unmatched;
18201
+ return `is not mounted by name in the files read, and a loop at ${stopped.location} mounts a list this reading resolved, where ${matched} of ${of} entries matched a router and ${name} did not (it resolved to ${resolvedTo}), so the whole list is declined rather than most of it mounted`;
18202
+ }
18050
18203
  return `is not mounted by name in the files read, and ${loopsClause(loops, "mount")} routers read out of a call this reading does not follow`;
18051
18204
  }
18052
18205
  function rivalRegistration(index, site) {
@@ -18474,25 +18627,32 @@ function mountCallOf(stmt, scope, scan) {
18474
18627
  function constructionsReturnedBy(target, index) {
18475
18628
  const facts = index.facts;
18476
18629
  if (facts === void 0) {
18477
- return [];
18630
+ return { kind: "unread" };
18478
18631
  }
18479
18632
  const callKey = nodeId(target.file, target.call);
18480
18633
  resolveCalls(facts, [callKey]);
18481
18634
  const returned = objectReturnedBy(facts, callKey);
18482
18635
  if (returned === null) {
18483
- return [];
18636
+ return { kind: "unread" };
18484
18637
  }
18485
18638
  const contained = containedValues(facts, returned);
18486
18639
  resolveCalls(facts, contained);
18487
18640
  const found = [];
18488
18641
  for (const value of contained) {
18489
- const construction = index.byValueKey.get(resolvedValueKey(facts, value));
18642
+ const resolved = resolvedValueKey(facts, value);
18643
+ const construction = index.byValueKey.get(resolved);
18490
18644
  if (construction === void 0) {
18491
- return [];
18645
+ return {
18646
+ kind: "unmatched",
18647
+ name: value,
18648
+ resolvedTo: resolved,
18649
+ matched: found.length,
18650
+ of: contained.length
18651
+ };
18492
18652
  }
18493
18653
  found.push(construction);
18494
18654
  }
18495
- return found;
18655
+ return { kind: "settled", found };
18496
18656
  }
18497
18657
  function resolvedValueKey(facts, value) {
18498
18658
  const row = facts.facts("wantedIsWrittenAs").find((entry) => String(entry[0]) === value);
@@ -18514,10 +18674,16 @@ function mountedConstructions(arg, position, scan) {
18514
18674
  return target.elements.flatMap(named);
18515
18675
  }
18516
18676
  if (target.kind === "call") {
18517
- const settled = constructionsReturnedBy(target, scan.index);
18518
- if (settled.length > 0) {
18519
- return settled;
18520
- }
18677
+ const returned = constructionsReturnedBy(target, scan.index);
18678
+ if (returned.kind === "settled" && returned.found.length > 0) {
18679
+ return returned.found;
18680
+ }
18681
+ scan.index.unenumerableLoops.set(target.location, {
18682
+ site: position.site,
18683
+ location: target.location,
18684
+ ...returned.kind === "unmatched" ? { unmatched: returned } : {}
18685
+ });
18686
+ return [];
18521
18687
  }
18522
18688
  scan.index.unenumerableLoops.set(target.location, {
18523
18689
  site: position.site,
@@ -18835,6 +19001,11 @@ async function extractPythonProject(options) {
18835
19001
  }
18836
19002
  }
18837
19003
  const couldMatch = methodsDeclaredNear(db, storagePatterns, definitions);
19004
+ const startsAQuery = /* @__PURE__ */ new Set([
19005
+ ...couldMatch,
19006
+ ...storagePatterns.flatMap((pattern) => pattern.queryFunctions ?? [])
19007
+ ]);
19008
+ const leadsToStorage = functionsReachingStorage(definitions, startsAQuery);
18838
19009
  const routerIndex = buildRouterIndex(bound, options.packs, {
18839
19010
  roots: options.roots,
18840
19011
  ...mountsRouters ? { facts: db } : {}
@@ -18852,7 +19023,8 @@ async function extractPythonProject(options) {
18852
19023
  factsPath: file2,
18853
19024
  patterns: storagePatterns,
18854
19025
  definitionAt: (key) => definitions.get(key),
18855
- couldMatch
19026
+ couldMatch,
19027
+ leadsToStorage
18856
19028
  }
18857
19029
  } : {}
18858
19030
  });
@@ -18918,6 +19090,52 @@ function methodsDeclaredNear(db, patterns, definitions) {
18918
19090
  }
18919
19091
  return found;
18920
19092
  }
19093
+ function calledName(call) {
19094
+ const callee = field(call, "function");
19095
+ if (callee === null) {
19096
+ return "";
19097
+ }
19098
+ return callee.type === "attribute" ? field(callee, "attribute")?.text ?? "" : callee.text;
19099
+ }
19100
+ function callsUnder(node, found = []) {
19101
+ for (const child of node.namedChildren) {
19102
+ if (child === null) {
19103
+ continue;
19104
+ }
19105
+ if (child.type === "call") {
19106
+ found.push(child);
19107
+ }
19108
+ callsUnder(child, found);
19109
+ }
19110
+ return found;
19111
+ }
19112
+ function functionsReachingStorage(definitions, couldMatch) {
19113
+ const bodies = [];
19114
+ for (const node of definitions.values()) {
19115
+ const name = field(node, "name");
19116
+ if (name === null) {
19117
+ continue;
19118
+ }
19119
+ bodies.push({ name: name.text, calls: callsUnder(node).map(calledName) });
19120
+ }
19121
+ const reaching = /* @__PURE__ */ new Set();
19122
+ for (const body of bodies) {
19123
+ if (body.calls.some((called) => couldMatch.has(called))) {
19124
+ reaching.add(body.name);
19125
+ }
19126
+ }
19127
+ for (let grew = true; grew; ) {
19128
+ grew = false;
19129
+ for (const body of bodies) {
19130
+ if (reaching.has(body.name) || !body.calls.some((c) => reaching.has(c))) {
19131
+ continue;
19132
+ }
19133
+ reaching.add(body.name);
19134
+ grew = true;
19135
+ }
19136
+ }
19137
+ return reaching;
19138
+ }
18921
19139
  // Annotate the CommonJS export names for ESM import in node:
18922
19140
  0 && (module.exports = {
18923
19141
  annotationToShape,
package/dist/index.d.cts CHANGED
@@ -182,6 +182,12 @@ interface StoragePattern {
182
182
  queryTypes: string[];
183
183
  /** Chain-ending methods that change what is stored. Anything else reads. */
184
184
  writes: string[];
185
+ /**
186
+ * Functions the library exports that start a query on their own, for the
187
+ * case where a call site imports one rather than reaching it through a
188
+ * project class. `select(...)` in SQLAlchemy 2.0 is one.
189
+ */
190
+ queryFunctions?: string[];
185
191
  /** Which database the library is talking to, for the boundary binding. */
186
192
  storageSystem: "postgres" | "mysql" | "sqlite";
187
193
  }
@@ -336,16 +342,13 @@ type NoValuePrefix = "unstated" | "unreadable";
336
342
  */
337
343
  type PathRepeatedSlashes = "kept" | "merged";
338
344
 
339
- /**
340
- * What discovery uses for one file. `factsPath` is the path the facts were
341
- * keyed under, which is the absolute one, while a summary shows the short one.
342
- */
343
345
  interface StorageLookup {
344
346
  readonly facts: Database;
345
347
  readonly factsPath: string;
346
348
  readonly patterns: readonly StoragePattern[];
347
349
  readonly definitionAt: (key: string) => Node | undefined;
348
350
  readonly couldMatch: ReadonlySet<string>;
351
+ readonly leadsToStorage: ReadonlySet<string>;
349
352
  }
350
353
 
351
354
  /**
package/dist/index.d.ts CHANGED
@@ -182,6 +182,12 @@ interface StoragePattern {
182
182
  queryTypes: string[];
183
183
  /** Chain-ending methods that change what is stored. Anything else reads. */
184
184
  writes: string[];
185
+ /**
186
+ * Functions the library exports that start a query on their own, for the
187
+ * case where a call site imports one rather than reaching it through a
188
+ * project class. `select(...)` in SQLAlchemy 2.0 is one.
189
+ */
190
+ queryFunctions?: string[];
185
191
  /** Which database the library is talking to, for the boundary binding. */
186
192
  storageSystem: "postgres" | "mysql" | "sqlite";
187
193
  }
@@ -336,16 +342,13 @@ type NoValuePrefix = "unstated" | "unreadable";
336
342
  */
337
343
  type PathRepeatedSlashes = "kept" | "merged";
338
344
 
339
- /**
340
- * What discovery uses for one file. `factsPath` is the path the facts were
341
- * keyed under, which is the absolute one, while a summary shows the short one.
342
- */
343
345
  interface StorageLookup {
344
346
  readonly facts: Database;
345
347
  readonly factsPath: string;
346
348
  readonly patterns: readonly StoragePattern[];
347
349
  readonly definitionAt: (key: string) => Node | undefined;
348
350
  readonly couldMatch: ReadonlySet<string>;
351
+ readonly leadsToStorage: ReadonlySet<string>;
349
352
  }
350
353
 
351
354
  /**
package/dist/index.js CHANGED
@@ -950,12 +950,26 @@ function argsOf(call) {
950
950
  function calleeText(call) {
951
951
  return field(call, "function")?.text ?? call.text;
952
952
  }
953
+ function withoutChainLinks(calls) {
954
+ const isLink = /* @__PURE__ */ new Set();
955
+ for (const call of calls) {
956
+ const callee = field(call, "function");
957
+ if (callee === null || callee.type !== "attribute") {
958
+ continue;
959
+ }
960
+ const object2 = field(callee, "object");
961
+ if (object2 !== null && object2.type === "call") {
962
+ isLink.add(object2.id);
963
+ }
964
+ }
965
+ return calls.filter((call) => !isLink.has(call.id));
966
+ }
953
967
  function invocationEffects(definitionNode) {
954
968
  const body = field(definitionNode, "body");
955
969
  if (body === null) {
956
970
  return [];
957
971
  }
958
- const calls = bodyCalls(body);
972
+ const calls = withoutChainLinks(bodyCalls(body));
959
973
  if (calls.length === 0) {
960
974
  return [];
961
975
  }
@@ -16189,21 +16203,22 @@ var RESOLUTION_RULES = [
16189
16203
  ]
16190
16204
  ),
16191
16205
  // Which calls a function, found by the name the call is written as
16192
- // rather than by resolving every callee in the project. Asking what
16193
- // reaches a parameter means looking for call sites, and looking for
16194
- // them the other way round costs a whole-project resolution.
16206
+ // rather than by resolving every callee in the project. A caller knows
16207
+ // the function and wants its call sites, so both of these start from
16208
+ // the function. Starting from `call` instead asks what every call in
16209
+ // the project imports, which was 72% of everything derived.
16195
16210
  rule(
16196
16211
  "callsFunction",
16197
16212
  [v("r"), v("f")],
16198
- [lit("call", v("r"), v("c")), lit("binds", v("c"), v("f"))]
16213
+ [lit("binds", v("c"), v("f")), lit("call", v("r"), v("c"))]
16199
16214
  ),
16200
16215
  rule(
16201
16216
  "callsFunction",
16202
16217
  [v("r"), v("f")],
16203
16218
  [
16204
- lit("call", v("r"), v("c")),
16219
+ lit("moduleExport", v("m"), v("n"), v("f")),
16205
16220
  lit("imports", v("c"), v("m"), v("n")),
16206
- lit("moduleExport", v("m"), v("n"), v("f"))
16221
+ lit("call", v("r"), v("c"))
16207
16222
  ]
16208
16223
  ),
16209
16224
  // A function whose return is itself a call arrives at the same object
@@ -16897,11 +16912,85 @@ function chainsIn(calls) {
16897
16912
  root: call,
16898
16913
  last,
16899
16914
  subject: subjectOf(call),
16900
- operation: methodName(last)
16915
+ operation: methodName(last),
16916
+ between: calls.filter(
16917
+ (other) => other !== call && other !== last && other.startIndex >= call.startIndex && other.endIndex <= last.endIndex
16918
+ )
16901
16919
  });
16902
16920
  }
16903
16921
  return chains;
16904
16922
  }
16923
+ function importedQueryFunction(options, chain) {
16924
+ const callee = field(chain.root, "function");
16925
+ if (callee === null || callee.type !== "identifier") {
16926
+ return void 0;
16927
+ }
16928
+ const from = options.facts.facts("pyImportedName").find((row) => String(row[0]) === `${options.filePath}#${callee.text}`);
16929
+ if (from === void 0) {
16930
+ return void 0;
16931
+ }
16932
+ return options.patterns.find(
16933
+ (pattern) => String(from[1]) === pattern.module && (pattern.queryFunctions ?? []).includes(String(from[2]))
16934
+ );
16935
+ }
16936
+ function fieldsOf(chain) {
16937
+ const args = field(chain.root, "arguments");
16938
+ const named = [];
16939
+ for (const argument of args === null ? [] : children3(args)) {
16940
+ if (argument.type === "keyword_argument") {
16941
+ const name = field(argument, "name");
16942
+ if (name !== null) {
16943
+ named.push(name.text);
16944
+ }
16945
+ continue;
16946
+ }
16947
+ if (argument.type === "attribute") {
16948
+ const property = field(argument, "attribute");
16949
+ if (property !== null) {
16950
+ named.push(property.text);
16951
+ }
16952
+ }
16953
+ }
16954
+ return named;
16955
+ }
16956
+ function selectorOf(call) {
16957
+ const args = field(call, "arguments");
16958
+ const picked = [];
16959
+ for (const argument of args === null ? [] : children3(args)) {
16960
+ if (argument.type === "keyword_argument") {
16961
+ const name = field(argument, "name");
16962
+ if (name !== null) {
16963
+ picked.push(name.text);
16964
+ }
16965
+ }
16966
+ }
16967
+ return picked;
16968
+ }
16969
+ function effectFor(pattern, chain, options) {
16970
+ const picked = [
16971
+ ...new Set([chain.last, ...chain.between].flatMap(selectorOf))
16972
+ ];
16973
+ return {
16974
+ type: "interaction",
16975
+ binding: storageRelationalBinding({
16976
+ recognition: "python-storage",
16977
+ storageSystem: pattern.storageSystem,
16978
+ scope: pattern.module,
16979
+ table: chain.subject
16980
+ }),
16981
+ callee: chain.last.text,
16982
+ interaction: {
16983
+ class: "storage-access",
16984
+ kind: pattern.writes.includes(chain.operation) ? "write" : "read",
16985
+ fields: fieldsOf(chain),
16986
+ operation: chain.operation,
16987
+ ...picked.length > 0 ? { selector: picked } : {}
16988
+ }
16989
+ };
16990
+ }
16991
+ function children3(node) {
16992
+ return node.namedChildren.filter((child) => child !== null);
16993
+ }
16905
16994
  function fileOf(key) {
16906
16995
  const at = key.lastIndexOf(":");
16907
16996
  return at === -1 ? key : key.slice(0, at);
@@ -16916,26 +17005,90 @@ function returnTypeName(node) {
16916
17005
  }
16917
17006
  return annotation.type === "subscript" ? field(annotation, "value")?.text ?? null : annotation.text;
16918
17007
  }
17008
+ function callsIn(node, found = []) {
17009
+ for (const child of node.namedChildren) {
17010
+ if (child === null) {
17011
+ continue;
17012
+ }
17013
+ if (child.type === "call") {
17014
+ found.push(child);
17015
+ }
17016
+ callsIn(child, found);
17017
+ }
17018
+ return found;
17019
+ }
17020
+ function storageEffects(calls, options) {
17021
+ const seen = /* @__PURE__ */ new Set();
17022
+ const asked = new Set(
17023
+ options.facts.facts("wanted").map((row) => String(row[0]))
17024
+ );
17025
+ const gather = (inCalls, filePath) => {
17026
+ const found = [...directStorageEffects(inCalls, { ...options, filePath })];
17027
+ resolveCalls(
17028
+ options.facts,
17029
+ inCalls.filter((call) => options.leadsToStorage.has(methodName(call))).map((call) => field(call, "function")).filter((callee) => callee !== null).map((callee) => calleeKeyOf(filePath, callee)).filter((key) => {
17030
+ const fresh = !asked.has(key);
17031
+ asked.add(key);
17032
+ return fresh;
17033
+ })
17034
+ );
17035
+ for (const call of inCalls) {
17036
+ const callee = field(call, "function");
17037
+ if (callee === null || !options.leadsToStorage.has(methodName(call))) {
17038
+ continue;
17039
+ }
17040
+ const key = resolvedOnce(options, calleeKeyOf(filePath, callee));
17041
+ const body = key === void 0 ? void 0 : options.definitionAt(key);
17042
+ if (key === void 0 || body === void 0 || seen.has(key)) {
17043
+ continue;
17044
+ }
17045
+ seen.add(key);
17046
+ const name = field(body, "name")?.text;
17047
+ const origin = {
17048
+ file: fileOf(key),
17049
+ line: body.startPosition.row + 1,
17050
+ ...name === void 0 ? {} : { function: name }
17051
+ };
17052
+ found.push(
17053
+ ...gather(callsIn(body), fileOf(key)).map(
17054
+ (effect) => effect.type === "interaction" && effect.origin === void 0 ? { ...effect, origin } : effect
17055
+ )
17056
+ );
17057
+ }
17058
+ return found;
17059
+ };
17060
+ return gather(calls, options.filePath);
17061
+ }
17062
+ function calleeKeyOf(filePath, callee) {
17063
+ return callee.type === "identifier" ? `${filePath}#${callee.text}` : nodeId(filePath, callee);
17064
+ }
17065
+ function resolvedOnce(options, calleeKey) {
17066
+ const resolved = options.facts.facts("wantedResolves").filter((row) => String(row[0]) === calleeKey).map((row) => String(row[1]));
17067
+ return resolved.length === 1 ? resolved[0] : void 0;
17068
+ }
16919
17069
  function storageCallees(calls, filePath, couldMatch) {
16920
17070
  return chainsIn(calls).filter((chain) => couldMatch.has(methodName(chain.root))).map((chain) => field(chain.root, "function")).filter((callee) => callee !== null).map((callee) => nodeId(filePath, callee));
16921
17071
  }
16922
- function storageEffects(calls, options) {
17072
+ function directStorageEffects(calls, options) {
16923
17073
  if (options.patterns.length === 0) {
16924
17074
  return [];
16925
17075
  }
17076
+ const named = new Set(
17077
+ options.patterns.flatMap((pattern) => pattern.queryFunctions ?? [])
17078
+ );
16926
17079
  const chains = chainsIn(calls).filter(
16927
- (chain) => options.couldMatch.has(methodName(chain.root))
17080
+ (chain) => options.couldMatch.has(methodName(chain.root)) || named.has(methodName(chain.root))
16928
17081
  );
16929
17082
  if (chains.length === 0) {
16930
17083
  return [];
16931
17084
  }
16932
- const asked = new Set(
17085
+ const alreadyAsked = new Set(
16933
17086
  options.facts.facts("wanted").map((row) => String(row[0]))
16934
17087
  );
16935
17088
  resolveCalls(
16936
17089
  options.facts,
16937
17090
  storageCallees(calls, options.filePath, options.couldMatch).filter(
16938
- (key) => !asked.has(key)
17091
+ (key) => !alreadyAsked.has(key)
16939
17092
  )
16940
17093
  );
16941
17094
  const effects = [];
@@ -16944,6 +17097,11 @@ function storageEffects(calls, options) {
16944
17097
  if (callee === null) {
16945
17098
  continue;
16946
17099
  }
17100
+ const imported = importedQueryFunction(options, chain);
17101
+ if (imported !== void 0) {
17102
+ effects.push(effectFor(imported, chain, options));
17103
+ continue;
17104
+ }
16947
17105
  const resolved = options.facts.facts("wantedResolves").filter((row) => String(row[0]) === nodeId(options.filePath, callee)).map((row) => String(row[1]));
16948
17106
  const settled = resolved.length === 1 ? resolved[0] : void 0;
16949
17107
  if (settled === void 0) {
@@ -16961,22 +17119,7 @@ function storageEffects(calls, options) {
16961
17119
  if (pattern === void 0) {
16962
17120
  continue;
16963
17121
  }
16964
- effects.push({
16965
- type: "interaction",
16966
- binding: storageRelationalBinding({
16967
- recognition: "python-storage",
16968
- storageSystem: pattern.storageSystem,
16969
- scope: pattern.module,
16970
- table: chain.subject
16971
- }),
16972
- callee: chain.last.text,
16973
- interaction: {
16974
- class: "storage-access",
16975
- kind: pattern.writes.includes(chain.operation) ? "write" : "read",
16976
- fields: [],
16977
- operation: chain.operation
16978
- }
16979
- });
17122
+ effects.push(effectFor(pattern, chain, options));
16980
17123
  }
16981
17124
  return effects;
16982
17125
  }
@@ -17553,7 +17696,7 @@ function buildRouteUnit(options) {
17553
17696
  const branches = (perReturn ?? []).map(
17554
17697
  (branch) => storage.length === 0 ? branch : { ...branch, extraEffects: storage }
17555
17698
  );
17556
- if (perReturn === null && (responseShape.reading.kind !== "absent" || statusCode.reading.kind !== "absent" || storage.length > 0)) {
17699
+ if (perReturn === null && (responseShape.reading.kind !== "absent" || statusCode.reading.kind !== "absent" || storage.length > 0 || effects.length > 0)) {
17557
17700
  branches.push({
17558
17701
  conditions: [],
17559
17702
  terminal: {
@@ -17853,6 +17996,11 @@ function emitModuleImportFacts(db, filePath, module, resolverOptions) {
17853
17996
  moduleText,
17854
17997
  resolution.status === "resolved" ? "resolved" : resolution.reason
17855
17998
  ]);
17999
+ db.add("pyImportedName", [
18000
+ `${filePath}#${localName}`,
18001
+ moduleText,
18002
+ binding.kind === "import" ? binding.localName : binding.importedName
18003
+ ]);
17856
18004
  if (resolution.status === "resolved") {
17857
18005
  db.add("pyImportResolved", [filePath, moduleText, resolution.file]);
17858
18006
  db.add("imports", [
@@ -18003,6 +18151,11 @@ function unmountedReason(index) {
18003
18151
  if (loops.length === 0) {
18004
18152
  return "is never mounted through a single variable binding in the files read";
18005
18153
  }
18154
+ const stopped = loops.find((loop) => loop.unmatched !== void 0);
18155
+ if (stopped?.unmatched !== void 0) {
18156
+ const { name, resolvedTo, matched, of } = stopped.unmatched;
18157
+ return `is not mounted by name in the files read, and a loop at ${stopped.location} mounts a list this reading resolved, where ${matched} of ${of} entries matched a router and ${name} did not (it resolved to ${resolvedTo}), so the whole list is declined rather than most of it mounted`;
18158
+ }
18006
18159
  return `is not mounted by name in the files read, and ${loopsClause(loops, "mount")} routers read out of a call this reading does not follow`;
18007
18160
  }
18008
18161
  function rivalRegistration(index, site) {
@@ -18430,25 +18583,32 @@ function mountCallOf(stmt, scope, scan) {
18430
18583
  function constructionsReturnedBy(target, index) {
18431
18584
  const facts = index.facts;
18432
18585
  if (facts === void 0) {
18433
- return [];
18586
+ return { kind: "unread" };
18434
18587
  }
18435
18588
  const callKey = nodeId(target.file, target.call);
18436
18589
  resolveCalls(facts, [callKey]);
18437
18590
  const returned = objectReturnedBy(facts, callKey);
18438
18591
  if (returned === null) {
18439
- return [];
18592
+ return { kind: "unread" };
18440
18593
  }
18441
18594
  const contained = containedValues(facts, returned);
18442
18595
  resolveCalls(facts, contained);
18443
18596
  const found = [];
18444
18597
  for (const value of contained) {
18445
- const construction = index.byValueKey.get(resolvedValueKey(facts, value));
18598
+ const resolved = resolvedValueKey(facts, value);
18599
+ const construction = index.byValueKey.get(resolved);
18446
18600
  if (construction === void 0) {
18447
- return [];
18601
+ return {
18602
+ kind: "unmatched",
18603
+ name: value,
18604
+ resolvedTo: resolved,
18605
+ matched: found.length,
18606
+ of: contained.length
18607
+ };
18448
18608
  }
18449
18609
  found.push(construction);
18450
18610
  }
18451
- return found;
18611
+ return { kind: "settled", found };
18452
18612
  }
18453
18613
  function resolvedValueKey(facts, value) {
18454
18614
  const row = facts.facts("wantedIsWrittenAs").find((entry) => String(entry[0]) === value);
@@ -18470,10 +18630,16 @@ function mountedConstructions(arg, position, scan) {
18470
18630
  return target.elements.flatMap(named);
18471
18631
  }
18472
18632
  if (target.kind === "call") {
18473
- const settled = constructionsReturnedBy(target, scan.index);
18474
- if (settled.length > 0) {
18475
- return settled;
18476
- }
18633
+ const returned = constructionsReturnedBy(target, scan.index);
18634
+ if (returned.kind === "settled" && returned.found.length > 0) {
18635
+ return returned.found;
18636
+ }
18637
+ scan.index.unenumerableLoops.set(target.location, {
18638
+ site: position.site,
18639
+ location: target.location,
18640
+ ...returned.kind === "unmatched" ? { unmatched: returned } : {}
18641
+ });
18642
+ return [];
18477
18643
  }
18478
18644
  scan.index.unenumerableLoops.set(target.location, {
18479
18645
  site: position.site,
@@ -18791,6 +18957,11 @@ async function extractPythonProject(options) {
18791
18957
  }
18792
18958
  }
18793
18959
  const couldMatch = methodsDeclaredNear(db, storagePatterns, definitions);
18960
+ const startsAQuery = /* @__PURE__ */ new Set([
18961
+ ...couldMatch,
18962
+ ...storagePatterns.flatMap((pattern) => pattern.queryFunctions ?? [])
18963
+ ]);
18964
+ const leadsToStorage = functionsReachingStorage(definitions, startsAQuery);
18794
18965
  const routerIndex = buildRouterIndex(bound, options.packs, {
18795
18966
  roots: options.roots,
18796
18967
  ...mountsRouters ? { facts: db } : {}
@@ -18808,7 +18979,8 @@ async function extractPythonProject(options) {
18808
18979
  factsPath: file2,
18809
18980
  patterns: storagePatterns,
18810
18981
  definitionAt: (key) => definitions.get(key),
18811
- couldMatch
18982
+ couldMatch,
18983
+ leadsToStorage
18812
18984
  }
18813
18985
  } : {}
18814
18986
  });
@@ -18874,6 +19046,52 @@ function methodsDeclaredNear(db, patterns, definitions) {
18874
19046
  }
18875
19047
  return found;
18876
19048
  }
19049
+ function calledName(call) {
19050
+ const callee = field(call, "function");
19051
+ if (callee === null) {
19052
+ return "";
19053
+ }
19054
+ return callee.type === "attribute" ? field(callee, "attribute")?.text ?? "" : callee.text;
19055
+ }
19056
+ function callsUnder(node, found = []) {
19057
+ for (const child of node.namedChildren) {
19058
+ if (child === null) {
19059
+ continue;
19060
+ }
19061
+ if (child.type === "call") {
19062
+ found.push(child);
19063
+ }
19064
+ callsUnder(child, found);
19065
+ }
19066
+ return found;
19067
+ }
19068
+ function functionsReachingStorage(definitions, couldMatch) {
19069
+ const bodies = [];
19070
+ for (const node of definitions.values()) {
19071
+ const name = field(node, "name");
19072
+ if (name === null) {
19073
+ continue;
19074
+ }
19075
+ bodies.push({ name: name.text, calls: callsUnder(node).map(calledName) });
19076
+ }
19077
+ const reaching = /* @__PURE__ */ new Set();
19078
+ for (const body of bodies) {
19079
+ if (body.calls.some((called) => couldMatch.has(called))) {
19080
+ reaching.add(body.name);
19081
+ }
19082
+ }
19083
+ for (let grew = true; grew; ) {
19084
+ grew = false;
19085
+ for (const body of bodies) {
19086
+ if (reaching.has(body.name) || !body.calls.some((c) => reaching.has(c))) {
19087
+ continue;
19088
+ }
19089
+ reaching.add(body.name);
19090
+ grew = true;
19091
+ }
19092
+ }
19093
+ return reaching;
19094
+ }
18877
19095
  export {
18878
19096
  annotationToShape,
18879
19097
  bindModule,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suss/adapter-python",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "Python language adapter for suss — parses source with tree-sitter (WASM), resolves names with a lexical binder, and emits raw extractions for the suss pipeline.",
6
6
  "keywords": [
@@ -47,9 +47,9 @@
47
47
  "typecheck": "tsc --noEmit"
48
48
  },
49
49
  "dependencies": {
50
- "@suss/behavioral-ir": "0.4.0",
51
- "@suss/datalog": "0.4.0",
52
- "@suss/extractor": "0.4.0",
50
+ "@suss/behavioral-ir": "0.5.1",
51
+ "@suss/datalog": "0.5.1",
52
+ "@suss/extractor": "0.5.1",
53
53
  "web-tree-sitter": "^0.26.11"
54
54
  },
55
55
  "devDependencies": {