@suss/adapter-python 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -16233,21 +16233,22 @@ var RESOLUTION_RULES = [
16233
16233
  ]
16234
16234
  ),
16235
16235
  // 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.
16236
+ // rather than by resolving every callee in the project. A caller knows
16237
+ // the function and wants its call sites, so both of these start from
16238
+ // the function. Starting from `call` instead asks what every call in
16239
+ // the project imports, which was 72% of everything derived.
16239
16240
  (0, import_datalog.rule)(
16240
16241
  "callsFunction",
16241
16242
  [(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"))]
16243
+ [(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
16244
  ),
16244
16245
  (0, import_datalog.rule)(
16245
16246
  "callsFunction",
16246
16247
  [(0, import_datalog.variable)("r"), (0, import_datalog.variable)("f")],
16247
16248
  [
16248
- (0, import_datalog.lit)("call", (0, import_datalog.variable)("r"), (0, import_datalog.variable)("c")),
16249
+ (0, import_datalog.lit)("moduleExport", (0, import_datalog.variable)("m"), (0, import_datalog.variable)("n"), (0, import_datalog.variable)("f")),
16249
16250
  (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"))
16251
+ (0, import_datalog.lit)("call", (0, import_datalog.variable)("r"), (0, import_datalog.variable)("c"))
16251
16252
  ]
16252
16253
  ),
16253
16254
  // A function whose return is itself a call arrives at the same object
@@ -16941,11 +16942,85 @@ function chainsIn(calls) {
16941
16942
  root: call,
16942
16943
  last,
16943
16944
  subject: subjectOf(call),
16944
- operation: methodName(last)
16945
+ operation: methodName(last),
16946
+ between: calls.filter(
16947
+ (other) => other !== call && other !== last && other.startIndex >= call.startIndex && other.endIndex <= last.endIndex
16948
+ )
16945
16949
  });
16946
16950
  }
16947
16951
  return chains;
16948
16952
  }
16953
+ function importedQueryFunction(options, chain) {
16954
+ const callee = field(chain.root, "function");
16955
+ if (callee === null || callee.type !== "identifier") {
16956
+ return void 0;
16957
+ }
16958
+ const from = options.facts.facts("pyImportedName").find((row) => String(row[0]) === `${options.filePath}#${callee.text}`);
16959
+ if (from === void 0) {
16960
+ return void 0;
16961
+ }
16962
+ return options.patterns.find(
16963
+ (pattern) => String(from[1]) === pattern.module && (pattern.queryFunctions ?? []).includes(String(from[2]))
16964
+ );
16965
+ }
16966
+ function fieldsOf(chain) {
16967
+ const args = field(chain.root, "arguments");
16968
+ const named = [];
16969
+ for (const argument of args === null ? [] : children3(args)) {
16970
+ if (argument.type === "keyword_argument") {
16971
+ const name = field(argument, "name");
16972
+ if (name !== null) {
16973
+ named.push(name.text);
16974
+ }
16975
+ continue;
16976
+ }
16977
+ if (argument.type === "attribute") {
16978
+ const property = field(argument, "attribute");
16979
+ if (property !== null) {
16980
+ named.push(property.text);
16981
+ }
16982
+ }
16983
+ }
16984
+ return named;
16985
+ }
16986
+ function selectorOf(call) {
16987
+ const args = field(call, "arguments");
16988
+ const picked = [];
16989
+ for (const argument of args === null ? [] : children3(args)) {
16990
+ if (argument.type === "keyword_argument") {
16991
+ const name = field(argument, "name");
16992
+ if (name !== null) {
16993
+ picked.push(name.text);
16994
+ }
16995
+ }
16996
+ }
16997
+ return picked;
16998
+ }
16999
+ function effectFor(pattern, chain, options) {
17000
+ const picked = [
17001
+ ...new Set([chain.last, ...chain.between].flatMap(selectorOf))
17002
+ ];
17003
+ return {
17004
+ type: "interaction",
17005
+ binding: storageRelationalBinding({
17006
+ recognition: "python-storage",
17007
+ storageSystem: pattern.storageSystem,
17008
+ scope: pattern.module,
17009
+ table: chain.subject
17010
+ }),
17011
+ callee: chain.last.text,
17012
+ interaction: {
17013
+ class: "storage-access",
17014
+ kind: pattern.writes.includes(chain.operation) ? "write" : "read",
17015
+ fields: fieldsOf(chain),
17016
+ operation: chain.operation,
17017
+ ...picked.length > 0 ? { selector: picked } : {}
17018
+ }
17019
+ };
17020
+ }
17021
+ function children3(node) {
17022
+ return node.namedChildren.filter((child) => child !== null);
17023
+ }
16949
17024
  function fileOf(key) {
16950
17025
  const at = key.lastIndexOf(":");
16951
17026
  return at === -1 ? key : key.slice(0, at);
@@ -16960,26 +17035,90 @@ function returnTypeName(node) {
16960
17035
  }
16961
17036
  return annotation.type === "subscript" ? field(annotation, "value")?.text ?? null : annotation.text;
16962
17037
  }
17038
+ function callsIn(node, found = []) {
17039
+ for (const child of node.namedChildren) {
17040
+ if (child === null) {
17041
+ continue;
17042
+ }
17043
+ if (child.type === "call") {
17044
+ found.push(child);
17045
+ }
17046
+ callsIn(child, found);
17047
+ }
17048
+ return found;
17049
+ }
17050
+ function storageEffects(calls, options) {
17051
+ const seen = /* @__PURE__ */ new Set();
17052
+ const asked = new Set(
17053
+ options.facts.facts("wanted").map((row) => String(row[0]))
17054
+ );
17055
+ const gather = (inCalls, filePath) => {
17056
+ const found = [...directStorageEffects(inCalls, { ...options, filePath })];
17057
+ resolveCalls(
17058
+ options.facts,
17059
+ inCalls.filter((call) => options.leadsToStorage.has(methodName(call))).map((call) => field(call, "function")).filter((callee) => callee !== null).map((callee) => calleeKeyOf(filePath, callee)).filter((key) => {
17060
+ const fresh = !asked.has(key);
17061
+ asked.add(key);
17062
+ return fresh;
17063
+ })
17064
+ );
17065
+ for (const call of inCalls) {
17066
+ const callee = field(call, "function");
17067
+ if (callee === null || !options.leadsToStorage.has(methodName(call))) {
17068
+ continue;
17069
+ }
17070
+ const key = resolvedOnce(options, calleeKeyOf(filePath, callee));
17071
+ const body = key === void 0 ? void 0 : options.definitionAt(key);
17072
+ if (key === void 0 || body === void 0 || seen.has(key)) {
17073
+ continue;
17074
+ }
17075
+ seen.add(key);
17076
+ const name = field(body, "name")?.text;
17077
+ const origin = {
17078
+ file: fileOf(key),
17079
+ line: body.startPosition.row + 1,
17080
+ ...name === void 0 ? {} : { function: name }
17081
+ };
17082
+ found.push(
17083
+ ...gather(callsIn(body), fileOf(key)).map(
17084
+ (effect) => effect.type === "interaction" && effect.origin === void 0 ? { ...effect, origin } : effect
17085
+ )
17086
+ );
17087
+ }
17088
+ return found;
17089
+ };
17090
+ return gather(calls, options.filePath);
17091
+ }
17092
+ function calleeKeyOf(filePath, callee) {
17093
+ return callee.type === "identifier" ? `${filePath}#${callee.text}` : nodeId(filePath, callee);
17094
+ }
17095
+ function resolvedOnce(options, calleeKey) {
17096
+ const resolved = options.facts.facts("wantedResolves").filter((row) => String(row[0]) === calleeKey).map((row) => String(row[1]));
17097
+ return resolved.length === 1 ? resolved[0] : void 0;
17098
+ }
16963
17099
  function storageCallees(calls, filePath, couldMatch) {
16964
17100
  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
17101
  }
16966
- function storageEffects(calls, options) {
17102
+ function directStorageEffects(calls, options) {
16967
17103
  if (options.patterns.length === 0) {
16968
17104
  return [];
16969
17105
  }
17106
+ const named = new Set(
17107
+ options.patterns.flatMap((pattern) => pattern.queryFunctions ?? [])
17108
+ );
16970
17109
  const chains = chainsIn(calls).filter(
16971
- (chain) => options.couldMatch.has(methodName(chain.root))
17110
+ (chain) => options.couldMatch.has(methodName(chain.root)) || named.has(methodName(chain.root))
16972
17111
  );
16973
17112
  if (chains.length === 0) {
16974
17113
  return [];
16975
17114
  }
16976
- const asked = new Set(
17115
+ const alreadyAsked = new Set(
16977
17116
  options.facts.facts("wanted").map((row) => String(row[0]))
16978
17117
  );
16979
17118
  resolveCalls(
16980
17119
  options.facts,
16981
17120
  storageCallees(calls, options.filePath, options.couldMatch).filter(
16982
- (key) => !asked.has(key)
17121
+ (key) => !alreadyAsked.has(key)
16983
17122
  )
16984
17123
  );
16985
17124
  const effects = [];
@@ -16988,6 +17127,11 @@ function storageEffects(calls, options) {
16988
17127
  if (callee === null) {
16989
17128
  continue;
16990
17129
  }
17130
+ const imported = importedQueryFunction(options, chain);
17131
+ if (imported !== void 0) {
17132
+ effects.push(effectFor(imported, chain, options));
17133
+ continue;
17134
+ }
16991
17135
  const resolved = options.facts.facts("wantedResolves").filter((row) => String(row[0]) === nodeId(options.filePath, callee)).map((row) => String(row[1]));
16992
17136
  const settled = resolved.length === 1 ? resolved[0] : void 0;
16993
17137
  if (settled === void 0) {
@@ -17005,22 +17149,7 @@ function storageEffects(calls, options) {
17005
17149
  if (pattern === void 0) {
17006
17150
  continue;
17007
17151
  }
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
- });
17152
+ effects.push(effectFor(pattern, chain, options));
17024
17153
  }
17025
17154
  return effects;
17026
17155
  }
@@ -17597,7 +17726,7 @@ function buildRouteUnit(options) {
17597
17726
  const branches = (perReturn ?? []).map(
17598
17727
  (branch) => storage.length === 0 ? branch : { ...branch, extraEffects: storage }
17599
17728
  );
17600
- if (perReturn === null && (responseShape.reading.kind !== "absent" || statusCode.reading.kind !== "absent" || storage.length > 0)) {
17729
+ if (perReturn === null && (responseShape.reading.kind !== "absent" || statusCode.reading.kind !== "absent" || storage.length > 0 || effects.length > 0)) {
17601
17730
  branches.push({
17602
17731
  conditions: [],
17603
17732
  terminal: {
@@ -17897,6 +18026,11 @@ function emitModuleImportFacts(db, filePath, module2, resolverOptions) {
17897
18026
  moduleText,
17898
18027
  resolution.status === "resolved" ? "resolved" : resolution.reason
17899
18028
  ]);
18029
+ db.add("pyImportedName", [
18030
+ `${filePath}#${localName}`,
18031
+ moduleText,
18032
+ binding.kind === "import" ? binding.localName : binding.importedName
18033
+ ]);
17900
18034
  if (resolution.status === "resolved") {
17901
18035
  db.add("pyImportResolved", [filePath, moduleText, resolution.file]);
17902
18036
  db.add("imports", [
@@ -18047,6 +18181,11 @@ function unmountedReason(index) {
18047
18181
  if (loops.length === 0) {
18048
18182
  return "is never mounted through a single variable binding in the files read";
18049
18183
  }
18184
+ const stopped = loops.find((loop) => loop.unmatched !== void 0);
18185
+ if (stopped?.unmatched !== void 0) {
18186
+ const { name, resolvedTo, matched, of } = stopped.unmatched;
18187
+ 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`;
18188
+ }
18050
18189
  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
18190
  }
18052
18191
  function rivalRegistration(index, site) {
@@ -18474,25 +18613,32 @@ function mountCallOf(stmt, scope, scan) {
18474
18613
  function constructionsReturnedBy(target, index) {
18475
18614
  const facts = index.facts;
18476
18615
  if (facts === void 0) {
18477
- return [];
18616
+ return { kind: "unread" };
18478
18617
  }
18479
18618
  const callKey = nodeId(target.file, target.call);
18480
18619
  resolveCalls(facts, [callKey]);
18481
18620
  const returned = objectReturnedBy(facts, callKey);
18482
18621
  if (returned === null) {
18483
- return [];
18622
+ return { kind: "unread" };
18484
18623
  }
18485
18624
  const contained = containedValues(facts, returned);
18486
18625
  resolveCalls(facts, contained);
18487
18626
  const found = [];
18488
18627
  for (const value of contained) {
18489
- const construction = index.byValueKey.get(resolvedValueKey(facts, value));
18628
+ const resolved = resolvedValueKey(facts, value);
18629
+ const construction = index.byValueKey.get(resolved);
18490
18630
  if (construction === void 0) {
18491
- return [];
18631
+ return {
18632
+ kind: "unmatched",
18633
+ name: value,
18634
+ resolvedTo: resolved,
18635
+ matched: found.length,
18636
+ of: contained.length
18637
+ };
18492
18638
  }
18493
18639
  found.push(construction);
18494
18640
  }
18495
- return found;
18641
+ return { kind: "settled", found };
18496
18642
  }
18497
18643
  function resolvedValueKey(facts, value) {
18498
18644
  const row = facts.facts("wantedIsWrittenAs").find((entry) => String(entry[0]) === value);
@@ -18514,10 +18660,16 @@ function mountedConstructions(arg, position, scan) {
18514
18660
  return target.elements.flatMap(named);
18515
18661
  }
18516
18662
  if (target.kind === "call") {
18517
- const settled = constructionsReturnedBy(target, scan.index);
18518
- if (settled.length > 0) {
18519
- return settled;
18520
- }
18663
+ const returned = constructionsReturnedBy(target, scan.index);
18664
+ if (returned.kind === "settled" && returned.found.length > 0) {
18665
+ return returned.found;
18666
+ }
18667
+ scan.index.unenumerableLoops.set(target.location, {
18668
+ site: position.site,
18669
+ location: target.location,
18670
+ ...returned.kind === "unmatched" ? { unmatched: returned } : {}
18671
+ });
18672
+ return [];
18521
18673
  }
18522
18674
  scan.index.unenumerableLoops.set(target.location, {
18523
18675
  site: position.site,
@@ -18835,6 +18987,11 @@ async function extractPythonProject(options) {
18835
18987
  }
18836
18988
  }
18837
18989
  const couldMatch = methodsDeclaredNear(db, storagePatterns, definitions);
18990
+ const startsAQuery = /* @__PURE__ */ new Set([
18991
+ ...couldMatch,
18992
+ ...storagePatterns.flatMap((pattern) => pattern.queryFunctions ?? [])
18993
+ ]);
18994
+ const leadsToStorage = functionsReachingStorage(definitions, startsAQuery);
18838
18995
  const routerIndex = buildRouterIndex(bound, options.packs, {
18839
18996
  roots: options.roots,
18840
18997
  ...mountsRouters ? { facts: db } : {}
@@ -18852,7 +19009,8 @@ async function extractPythonProject(options) {
18852
19009
  factsPath: file2,
18853
19010
  patterns: storagePatterns,
18854
19011
  definitionAt: (key) => definitions.get(key),
18855
- couldMatch
19012
+ couldMatch,
19013
+ leadsToStorage
18856
19014
  }
18857
19015
  } : {}
18858
19016
  });
@@ -18918,6 +19076,52 @@ function methodsDeclaredNear(db, patterns, definitions) {
18918
19076
  }
18919
19077
  return found;
18920
19078
  }
19079
+ function calledName(call) {
19080
+ const callee = field(call, "function");
19081
+ if (callee === null) {
19082
+ return "";
19083
+ }
19084
+ return callee.type === "attribute" ? field(callee, "attribute")?.text ?? "" : callee.text;
19085
+ }
19086
+ function callsUnder(node, found = []) {
19087
+ for (const child of node.namedChildren) {
19088
+ if (child === null) {
19089
+ continue;
19090
+ }
19091
+ if (child.type === "call") {
19092
+ found.push(child);
19093
+ }
19094
+ callsUnder(child, found);
19095
+ }
19096
+ return found;
19097
+ }
19098
+ function functionsReachingStorage(definitions, couldMatch) {
19099
+ const bodies = [];
19100
+ for (const node of definitions.values()) {
19101
+ const name = field(node, "name");
19102
+ if (name === null) {
19103
+ continue;
19104
+ }
19105
+ bodies.push({ name: name.text, calls: callsUnder(node).map(calledName) });
19106
+ }
19107
+ const reaching = /* @__PURE__ */ new Set();
19108
+ for (const body of bodies) {
19109
+ if (body.calls.some((called) => couldMatch.has(called))) {
19110
+ reaching.add(body.name);
19111
+ }
19112
+ }
19113
+ for (let grew = true; grew; ) {
19114
+ grew = false;
19115
+ for (const body of bodies) {
19116
+ if (reaching.has(body.name) || !body.calls.some((c) => reaching.has(c))) {
19117
+ continue;
19118
+ }
19119
+ reaching.add(body.name);
19120
+ grew = true;
19121
+ }
19122
+ }
19123
+ return reaching;
19124
+ }
18921
19125
  // Annotate the CommonJS export names for ESM import in node:
18922
19126
  0 && (module.exports = {
18923
19127
  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
@@ -16189,21 +16189,22 @@ var RESOLUTION_RULES = [
16189
16189
  ]
16190
16190
  ),
16191
16191
  // 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.
16192
+ // rather than by resolving every callee in the project. A caller knows
16193
+ // the function and wants its call sites, so both of these start from
16194
+ // the function. Starting from `call` instead asks what every call in
16195
+ // the project imports, which was 72% of everything derived.
16195
16196
  rule(
16196
16197
  "callsFunction",
16197
16198
  [v("r"), v("f")],
16198
- [lit("call", v("r"), v("c")), lit("binds", v("c"), v("f"))]
16199
+ [lit("binds", v("c"), v("f")), lit("call", v("r"), v("c"))]
16199
16200
  ),
16200
16201
  rule(
16201
16202
  "callsFunction",
16202
16203
  [v("r"), v("f")],
16203
16204
  [
16204
- lit("call", v("r"), v("c")),
16205
+ lit("moduleExport", v("m"), v("n"), v("f")),
16205
16206
  lit("imports", v("c"), v("m"), v("n")),
16206
- lit("moduleExport", v("m"), v("n"), v("f"))
16207
+ lit("call", v("r"), v("c"))
16207
16208
  ]
16208
16209
  ),
16209
16210
  // A function whose return is itself a call arrives at the same object
@@ -16897,11 +16898,85 @@ function chainsIn(calls) {
16897
16898
  root: call,
16898
16899
  last,
16899
16900
  subject: subjectOf(call),
16900
- operation: methodName(last)
16901
+ operation: methodName(last),
16902
+ between: calls.filter(
16903
+ (other) => other !== call && other !== last && other.startIndex >= call.startIndex && other.endIndex <= last.endIndex
16904
+ )
16901
16905
  });
16902
16906
  }
16903
16907
  return chains;
16904
16908
  }
16909
+ function importedQueryFunction(options, chain) {
16910
+ const callee = field(chain.root, "function");
16911
+ if (callee === null || callee.type !== "identifier") {
16912
+ return void 0;
16913
+ }
16914
+ const from = options.facts.facts("pyImportedName").find((row) => String(row[0]) === `${options.filePath}#${callee.text}`);
16915
+ if (from === void 0) {
16916
+ return void 0;
16917
+ }
16918
+ return options.patterns.find(
16919
+ (pattern) => String(from[1]) === pattern.module && (pattern.queryFunctions ?? []).includes(String(from[2]))
16920
+ );
16921
+ }
16922
+ function fieldsOf(chain) {
16923
+ const args = field(chain.root, "arguments");
16924
+ const named = [];
16925
+ for (const argument of args === null ? [] : children3(args)) {
16926
+ if (argument.type === "keyword_argument") {
16927
+ const name = field(argument, "name");
16928
+ if (name !== null) {
16929
+ named.push(name.text);
16930
+ }
16931
+ continue;
16932
+ }
16933
+ if (argument.type === "attribute") {
16934
+ const property = field(argument, "attribute");
16935
+ if (property !== null) {
16936
+ named.push(property.text);
16937
+ }
16938
+ }
16939
+ }
16940
+ return named;
16941
+ }
16942
+ function selectorOf(call) {
16943
+ const args = field(call, "arguments");
16944
+ const picked = [];
16945
+ for (const argument of args === null ? [] : children3(args)) {
16946
+ if (argument.type === "keyword_argument") {
16947
+ const name = field(argument, "name");
16948
+ if (name !== null) {
16949
+ picked.push(name.text);
16950
+ }
16951
+ }
16952
+ }
16953
+ return picked;
16954
+ }
16955
+ function effectFor(pattern, chain, options) {
16956
+ const picked = [
16957
+ ...new Set([chain.last, ...chain.between].flatMap(selectorOf))
16958
+ ];
16959
+ return {
16960
+ type: "interaction",
16961
+ binding: storageRelationalBinding({
16962
+ recognition: "python-storage",
16963
+ storageSystem: pattern.storageSystem,
16964
+ scope: pattern.module,
16965
+ table: chain.subject
16966
+ }),
16967
+ callee: chain.last.text,
16968
+ interaction: {
16969
+ class: "storage-access",
16970
+ kind: pattern.writes.includes(chain.operation) ? "write" : "read",
16971
+ fields: fieldsOf(chain),
16972
+ operation: chain.operation,
16973
+ ...picked.length > 0 ? { selector: picked } : {}
16974
+ }
16975
+ };
16976
+ }
16977
+ function children3(node) {
16978
+ return node.namedChildren.filter((child) => child !== null);
16979
+ }
16905
16980
  function fileOf(key) {
16906
16981
  const at = key.lastIndexOf(":");
16907
16982
  return at === -1 ? key : key.slice(0, at);
@@ -16916,26 +16991,90 @@ function returnTypeName(node) {
16916
16991
  }
16917
16992
  return annotation.type === "subscript" ? field(annotation, "value")?.text ?? null : annotation.text;
16918
16993
  }
16994
+ function callsIn(node, found = []) {
16995
+ for (const child of node.namedChildren) {
16996
+ if (child === null) {
16997
+ continue;
16998
+ }
16999
+ if (child.type === "call") {
17000
+ found.push(child);
17001
+ }
17002
+ callsIn(child, found);
17003
+ }
17004
+ return found;
17005
+ }
17006
+ function storageEffects(calls, options) {
17007
+ const seen = /* @__PURE__ */ new Set();
17008
+ const asked = new Set(
17009
+ options.facts.facts("wanted").map((row) => String(row[0]))
17010
+ );
17011
+ const gather = (inCalls, filePath) => {
17012
+ const found = [...directStorageEffects(inCalls, { ...options, filePath })];
17013
+ resolveCalls(
17014
+ options.facts,
17015
+ inCalls.filter((call) => options.leadsToStorage.has(methodName(call))).map((call) => field(call, "function")).filter((callee) => callee !== null).map((callee) => calleeKeyOf(filePath, callee)).filter((key) => {
17016
+ const fresh = !asked.has(key);
17017
+ asked.add(key);
17018
+ return fresh;
17019
+ })
17020
+ );
17021
+ for (const call of inCalls) {
17022
+ const callee = field(call, "function");
17023
+ if (callee === null || !options.leadsToStorage.has(methodName(call))) {
17024
+ continue;
17025
+ }
17026
+ const key = resolvedOnce(options, calleeKeyOf(filePath, callee));
17027
+ const body = key === void 0 ? void 0 : options.definitionAt(key);
17028
+ if (key === void 0 || body === void 0 || seen.has(key)) {
17029
+ continue;
17030
+ }
17031
+ seen.add(key);
17032
+ const name = field(body, "name")?.text;
17033
+ const origin = {
17034
+ file: fileOf(key),
17035
+ line: body.startPosition.row + 1,
17036
+ ...name === void 0 ? {} : { function: name }
17037
+ };
17038
+ found.push(
17039
+ ...gather(callsIn(body), fileOf(key)).map(
17040
+ (effect) => effect.type === "interaction" && effect.origin === void 0 ? { ...effect, origin } : effect
17041
+ )
17042
+ );
17043
+ }
17044
+ return found;
17045
+ };
17046
+ return gather(calls, options.filePath);
17047
+ }
17048
+ function calleeKeyOf(filePath, callee) {
17049
+ return callee.type === "identifier" ? `${filePath}#${callee.text}` : nodeId(filePath, callee);
17050
+ }
17051
+ function resolvedOnce(options, calleeKey) {
17052
+ const resolved = options.facts.facts("wantedResolves").filter((row) => String(row[0]) === calleeKey).map((row) => String(row[1]));
17053
+ return resolved.length === 1 ? resolved[0] : void 0;
17054
+ }
16919
17055
  function storageCallees(calls, filePath, couldMatch) {
16920
17056
  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
17057
  }
16922
- function storageEffects(calls, options) {
17058
+ function directStorageEffects(calls, options) {
16923
17059
  if (options.patterns.length === 0) {
16924
17060
  return [];
16925
17061
  }
17062
+ const named = new Set(
17063
+ options.patterns.flatMap((pattern) => pattern.queryFunctions ?? [])
17064
+ );
16926
17065
  const chains = chainsIn(calls).filter(
16927
- (chain) => options.couldMatch.has(methodName(chain.root))
17066
+ (chain) => options.couldMatch.has(methodName(chain.root)) || named.has(methodName(chain.root))
16928
17067
  );
16929
17068
  if (chains.length === 0) {
16930
17069
  return [];
16931
17070
  }
16932
- const asked = new Set(
17071
+ const alreadyAsked = new Set(
16933
17072
  options.facts.facts("wanted").map((row) => String(row[0]))
16934
17073
  );
16935
17074
  resolveCalls(
16936
17075
  options.facts,
16937
17076
  storageCallees(calls, options.filePath, options.couldMatch).filter(
16938
- (key) => !asked.has(key)
17077
+ (key) => !alreadyAsked.has(key)
16939
17078
  )
16940
17079
  );
16941
17080
  const effects = [];
@@ -16944,6 +17083,11 @@ function storageEffects(calls, options) {
16944
17083
  if (callee === null) {
16945
17084
  continue;
16946
17085
  }
17086
+ const imported = importedQueryFunction(options, chain);
17087
+ if (imported !== void 0) {
17088
+ effects.push(effectFor(imported, chain, options));
17089
+ continue;
17090
+ }
16947
17091
  const resolved = options.facts.facts("wantedResolves").filter((row) => String(row[0]) === nodeId(options.filePath, callee)).map((row) => String(row[1]));
16948
17092
  const settled = resolved.length === 1 ? resolved[0] : void 0;
16949
17093
  if (settled === void 0) {
@@ -16961,22 +17105,7 @@ function storageEffects(calls, options) {
16961
17105
  if (pattern === void 0) {
16962
17106
  continue;
16963
17107
  }
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
- });
17108
+ effects.push(effectFor(pattern, chain, options));
16980
17109
  }
16981
17110
  return effects;
16982
17111
  }
@@ -17553,7 +17682,7 @@ function buildRouteUnit(options) {
17553
17682
  const branches = (perReturn ?? []).map(
17554
17683
  (branch) => storage.length === 0 ? branch : { ...branch, extraEffects: storage }
17555
17684
  );
17556
- if (perReturn === null && (responseShape.reading.kind !== "absent" || statusCode.reading.kind !== "absent" || storage.length > 0)) {
17685
+ if (perReturn === null && (responseShape.reading.kind !== "absent" || statusCode.reading.kind !== "absent" || storage.length > 0 || effects.length > 0)) {
17557
17686
  branches.push({
17558
17687
  conditions: [],
17559
17688
  terminal: {
@@ -17853,6 +17982,11 @@ function emitModuleImportFacts(db, filePath, module, resolverOptions) {
17853
17982
  moduleText,
17854
17983
  resolution.status === "resolved" ? "resolved" : resolution.reason
17855
17984
  ]);
17985
+ db.add("pyImportedName", [
17986
+ `${filePath}#${localName}`,
17987
+ moduleText,
17988
+ binding.kind === "import" ? binding.localName : binding.importedName
17989
+ ]);
17856
17990
  if (resolution.status === "resolved") {
17857
17991
  db.add("pyImportResolved", [filePath, moduleText, resolution.file]);
17858
17992
  db.add("imports", [
@@ -18003,6 +18137,11 @@ function unmountedReason(index) {
18003
18137
  if (loops.length === 0) {
18004
18138
  return "is never mounted through a single variable binding in the files read";
18005
18139
  }
18140
+ const stopped = loops.find((loop) => loop.unmatched !== void 0);
18141
+ if (stopped?.unmatched !== void 0) {
18142
+ const { name, resolvedTo, matched, of } = stopped.unmatched;
18143
+ 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`;
18144
+ }
18006
18145
  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
18146
  }
18008
18147
  function rivalRegistration(index, site) {
@@ -18430,25 +18569,32 @@ function mountCallOf(stmt, scope, scan) {
18430
18569
  function constructionsReturnedBy(target, index) {
18431
18570
  const facts = index.facts;
18432
18571
  if (facts === void 0) {
18433
- return [];
18572
+ return { kind: "unread" };
18434
18573
  }
18435
18574
  const callKey = nodeId(target.file, target.call);
18436
18575
  resolveCalls(facts, [callKey]);
18437
18576
  const returned = objectReturnedBy(facts, callKey);
18438
18577
  if (returned === null) {
18439
- return [];
18578
+ return { kind: "unread" };
18440
18579
  }
18441
18580
  const contained = containedValues(facts, returned);
18442
18581
  resolveCalls(facts, contained);
18443
18582
  const found = [];
18444
18583
  for (const value of contained) {
18445
- const construction = index.byValueKey.get(resolvedValueKey(facts, value));
18584
+ const resolved = resolvedValueKey(facts, value);
18585
+ const construction = index.byValueKey.get(resolved);
18446
18586
  if (construction === void 0) {
18447
- return [];
18587
+ return {
18588
+ kind: "unmatched",
18589
+ name: value,
18590
+ resolvedTo: resolved,
18591
+ matched: found.length,
18592
+ of: contained.length
18593
+ };
18448
18594
  }
18449
18595
  found.push(construction);
18450
18596
  }
18451
- return found;
18597
+ return { kind: "settled", found };
18452
18598
  }
18453
18599
  function resolvedValueKey(facts, value) {
18454
18600
  const row = facts.facts("wantedIsWrittenAs").find((entry) => String(entry[0]) === value);
@@ -18470,10 +18616,16 @@ function mountedConstructions(arg, position, scan) {
18470
18616
  return target.elements.flatMap(named);
18471
18617
  }
18472
18618
  if (target.kind === "call") {
18473
- const settled = constructionsReturnedBy(target, scan.index);
18474
- if (settled.length > 0) {
18475
- return settled;
18476
- }
18619
+ const returned = constructionsReturnedBy(target, scan.index);
18620
+ if (returned.kind === "settled" && returned.found.length > 0) {
18621
+ return returned.found;
18622
+ }
18623
+ scan.index.unenumerableLoops.set(target.location, {
18624
+ site: position.site,
18625
+ location: target.location,
18626
+ ...returned.kind === "unmatched" ? { unmatched: returned } : {}
18627
+ });
18628
+ return [];
18477
18629
  }
18478
18630
  scan.index.unenumerableLoops.set(target.location, {
18479
18631
  site: position.site,
@@ -18791,6 +18943,11 @@ async function extractPythonProject(options) {
18791
18943
  }
18792
18944
  }
18793
18945
  const couldMatch = methodsDeclaredNear(db, storagePatterns, definitions);
18946
+ const startsAQuery = /* @__PURE__ */ new Set([
18947
+ ...couldMatch,
18948
+ ...storagePatterns.flatMap((pattern) => pattern.queryFunctions ?? [])
18949
+ ]);
18950
+ const leadsToStorage = functionsReachingStorage(definitions, startsAQuery);
18794
18951
  const routerIndex = buildRouterIndex(bound, options.packs, {
18795
18952
  roots: options.roots,
18796
18953
  ...mountsRouters ? { facts: db } : {}
@@ -18808,7 +18965,8 @@ async function extractPythonProject(options) {
18808
18965
  factsPath: file2,
18809
18966
  patterns: storagePatterns,
18810
18967
  definitionAt: (key) => definitions.get(key),
18811
- couldMatch
18968
+ couldMatch,
18969
+ leadsToStorage
18812
18970
  }
18813
18971
  } : {}
18814
18972
  });
@@ -18874,6 +19032,52 @@ function methodsDeclaredNear(db, patterns, definitions) {
18874
19032
  }
18875
19033
  return found;
18876
19034
  }
19035
+ function calledName(call) {
19036
+ const callee = field(call, "function");
19037
+ if (callee === null) {
19038
+ return "";
19039
+ }
19040
+ return callee.type === "attribute" ? field(callee, "attribute")?.text ?? "" : callee.text;
19041
+ }
19042
+ function callsUnder(node, found = []) {
19043
+ for (const child of node.namedChildren) {
19044
+ if (child === null) {
19045
+ continue;
19046
+ }
19047
+ if (child.type === "call") {
19048
+ found.push(child);
19049
+ }
19050
+ callsUnder(child, found);
19051
+ }
19052
+ return found;
19053
+ }
19054
+ function functionsReachingStorage(definitions, couldMatch) {
19055
+ const bodies = [];
19056
+ for (const node of definitions.values()) {
19057
+ const name = field(node, "name");
19058
+ if (name === null) {
19059
+ continue;
19060
+ }
19061
+ bodies.push({ name: name.text, calls: callsUnder(node).map(calledName) });
19062
+ }
19063
+ const reaching = /* @__PURE__ */ new Set();
19064
+ for (const body of bodies) {
19065
+ if (body.calls.some((called) => couldMatch.has(called))) {
19066
+ reaching.add(body.name);
19067
+ }
19068
+ }
19069
+ for (let grew = true; grew; ) {
19070
+ grew = false;
19071
+ for (const body of bodies) {
19072
+ if (reaching.has(body.name) || !body.calls.some((c) => reaching.has(c))) {
19073
+ continue;
19074
+ }
19075
+ reaching.add(body.name);
19076
+ grew = true;
19077
+ }
19078
+ }
19079
+ return reaching;
19080
+ }
18877
19081
  export {
18878
19082
  annotationToShape,
18879
19083
  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.0",
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.0",
51
+ "@suss/datalog": "0.5.0",
52
+ "@suss/extractor": "0.5.0",
53
53
  "web-tree-sitter": "^0.26.11"
54
54
  },
55
55
  "devDependencies": {