@suss/adapter-ruby 0.24.4 → 0.26.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/dist/index.cjs CHANGED
@@ -308,7 +308,7 @@ function autoloadDirectories(root) {
308
308
  // src/scope.ts
309
309
  function qualifyConstantRef(node, nesting) {
310
310
  if (node.type === "scope_resolution") {
311
- return node.text;
311
+ return compoundName(node);
312
312
  }
313
313
  if (node.type === "constant") {
314
314
  const innermost = nesting[0] ?? null;
@@ -318,13 +318,16 @@ function qualifyConstantRef(node, nesting) {
318
318
  }
319
319
  function constantRefCandidates(node, nesting) {
320
320
  if (node.type === "scope_resolution") {
321
- return [node.text];
321
+ return [compoundName(node)];
322
322
  }
323
323
  if (node.type === "constant") {
324
324
  return [...nesting.map((level) => `${level}::${node.text}`), node.text];
325
325
  }
326
326
  return [];
327
327
  }
328
+ function compoundName(node) {
329
+ return node.text.replace(/^::/, "");
330
+ }
328
331
  function shadowingClassFor(node, nesting, knownClasses) {
329
332
  if (node.type !== "constant") {
330
333
  return null;
@@ -850,7 +853,7 @@ var Lowerer = class {
850
853
  return [nested];
851
854
  }
852
855
  /**
853
- * `unless ok` runs its body when `ok` is false, so it reads as an `if` on
856
+ * `unless ok` runs its body when `ok` is false, so it lowers to an `if` on
854
857
  * the same test with the two arms the other way around. Without the swap
855
858
  * a summary would say a branch runs on the test that skips it.
856
859
  */
@@ -866,7 +869,7 @@ var Lowerer = class {
866
869
  exitKind: exitKindOf(node)
867
870
  };
868
871
  }
869
- /** `render :gone if expired?` gates one statement on one test, so it reads as an if with one arm, on whichever side the test puts it. */
872
+ /** `render :gone if expired?` gates one statement on one test, so it lowers to an if with one arm, on whichever side the test puts it. */
870
873
  lowerIfModifier(node) {
871
874
  const body = field(node, "body");
872
875
  const gated = body === null ? [] : [this.lower(body)];
@@ -3044,8 +3047,11 @@ function receiverPrefix(receiver, method, root, pattern, options) {
3044
3047
  function trimmed(path7) {
3045
3048
  return path7 === "/" ? "" : path7.replace(/\/+$/, "");
3046
3049
  }
3047
- function namesConstant(receiver, constantName) {
3048
- return (receiver.type === "constant" || receiver.type === "scope_resolution") && receiver.text === constantName;
3050
+ function namesConstant(receiver, constantName2) {
3051
+ if (receiver.type === "constant") {
3052
+ return receiver.text === constantName2;
3053
+ }
3054
+ return receiver.type === "scope_resolution" && compoundName(receiver) === constantName2;
3049
3055
  }
3050
3056
  function builtBy(name, method, root, pattern) {
3051
3057
  const builders = pattern.receiverBuilders ?? [];
@@ -3477,6 +3483,12 @@ function rootConstant(node) {
3477
3483
  }
3478
3484
  return rootConstant(receiver);
3479
3485
  }
3486
+ function isConstant(node) {
3487
+ return node.type === "constant" || node.type === "scope_resolution";
3488
+ }
3489
+ function constantName(constant2) {
3490
+ return constant2.type === "constant" ? constant2.text : compoundName(constant2);
3491
+ }
3480
3492
  var ANCESTRY_PROGRAM = (0, import_datalog2.deriveOnDemand)(
3481
3493
  [...import_resolution3.RESOLUTION_RULES, ...import_resolution3.RESOLUTION_QUESTIONS],
3482
3494
  import_resolution3.ANSWER_RELATIONS
@@ -3486,13 +3498,22 @@ function reachesBase(facts, classKey, bases) {
3486
3498
  (0, import_datalog2.evaluate)(facts, ANCESTRY_PROGRAM.rules);
3487
3499
  return facts.lookup("wantedBaseName", 0, classKey).some((row) => bases.includes(String(row[1])));
3488
3500
  }
3489
- function classBehind(facts, constant2) {
3490
- const wanted = `#${constant2.text}`;
3501
+ function classBehind(facts, file, constant2) {
3502
+ const key = constant2.type === "constant" ? `${file}#${constant2.text}` : nodeId(file, constant2);
3491
3503
  const bound = new Set(
3492
- facts.facts("binds").filter((row) => String(row[0]).endsWith(wanted)).map((row) => String(row[1]))
3504
+ facts.lookup("binds", 0, key).map((row) => String(row[1]))
3493
3505
  );
3494
3506
  return bound.size === 1 ? [...bound][0] : void 0;
3495
3507
  }
3508
+ function modelPattern(constant2, file, options) {
3509
+ const classKey = classBehind(options.facts, file, constant2);
3510
+ if (classKey === void 0) {
3511
+ return void 0;
3512
+ }
3513
+ return options.patterns.find(
3514
+ (candidate) => reachesBase(options.facts, classKey, candidate.baseClasses)
3515
+ );
3516
+ }
3496
3517
  function selectorOf(node) {
3497
3518
  const picked = [];
3498
3519
  const walk = (current) => {
@@ -3514,7 +3535,87 @@ function selectorOf(node) {
3514
3535
  walk(node);
3515
3536
  return [...new Set(picked)];
3516
3537
  }
3517
- function storageEffects(calls, options) {
3538
+ function storageEffect(call, model, pattern, kind, selector) {
3539
+ const operation = methodOf(call);
3540
+ return {
3541
+ type: "interaction",
3542
+ binding: (0, import_ir_core.storageBinding)({
3543
+ recognition: "ruby-storage",
3544
+ storageSystem: pattern.storageSystem,
3545
+ scope: pattern.baseClasses[0] ?? "",
3546
+ container: constantName(model)
3547
+ }),
3548
+ callee: call.text,
3549
+ interaction: {
3550
+ class: "storage-access",
3551
+ kind,
3552
+ fields: [],
3553
+ operation,
3554
+ ...selector.length > 0 ? { selector } : {}
3555
+ }
3556
+ };
3557
+ }
3558
+ function modelCallEffects(call, file, options) {
3559
+ const constant2 = rootConstant(call);
3560
+ if (constant2 === null) {
3561
+ return [];
3562
+ }
3563
+ const pattern = modelPattern(constant2, file, options);
3564
+ if (pattern === void 0) {
3565
+ return [];
3566
+ }
3567
+ const kind = pattern.writes.includes(methodOf(call)) ? "write" : "read";
3568
+ return [storageEffect(call, constant2, pattern, kind, selectorOf(call))];
3569
+ }
3570
+ function isLoader(node, loader) {
3571
+ if (node === null) {
3572
+ return false;
3573
+ }
3574
+ if (node.type === "identifier") {
3575
+ return node.text === loader.loader;
3576
+ }
3577
+ return node.type === "call" && receiverOf(node) === null && methodOf(node) === loader.loader;
3578
+ }
3579
+ function loaderPick(call, loader) {
3580
+ const method = methodOf(call);
3581
+ const receiver = receiverOf(call);
3582
+ if (receiver === null) {
3583
+ return loader.shortcuts.includes(method) ? call : null;
3584
+ }
3585
+ if (!loader.reads.includes(method) || receiver.type !== "call") {
3586
+ return null;
3587
+ }
3588
+ const picks = methodOf(receiver) === loader.pick && isLoader(receiverOf(receiver), loader);
3589
+ return picks ? receiver : null;
3590
+ }
3591
+ function loaderCallEffects(call, file, options) {
3592
+ const effects = [];
3593
+ for (const loader of options.loaders ?? []) {
3594
+ const pick = loaderPick(call, loader);
3595
+ if (pick === null) {
3596
+ continue;
3597
+ }
3598
+ const args = field(pick, "arguments");
3599
+ for (const argument of args === null ? [] : children3(args)) {
3600
+ if (!isConstant(argument)) {
3601
+ continue;
3602
+ }
3603
+ const pattern = modelPattern(argument, file, options);
3604
+ if (pattern !== void 0) {
3605
+ effects.push(storageEffect(call, argument, pattern, "read", []));
3606
+ }
3607
+ }
3608
+ }
3609
+ return effects;
3610
+ }
3611
+ function effectsOfCall(call, file, options) {
3612
+ const onModel = modelCallEffects(call, file, options);
3613
+ return onModel.length > 0 ? onModel : loaderCallEffects(call, file, options);
3614
+ }
3615
+ function storageClaims(call, file, options) {
3616
+ return options.patterns.length > 0 && effectsOfCall(call, file, options).length > 0;
3617
+ }
3618
+ function storageEffects(calls, file, options) {
3518
3619
  if (options.patterns.length === 0) {
3519
3620
  return [];
3520
3621
  }
@@ -3530,39 +3631,7 @@ function storageEffects(calls, options) {
3530
3631
  if (partOfOne.has(call.id)) {
3531
3632
  continue;
3532
3633
  }
3533
- const constant2 = rootConstant(call);
3534
- if (constant2 === null) {
3535
- continue;
3536
- }
3537
- const classKey = classBehind(options.facts, constant2);
3538
- if (classKey === void 0) {
3539
- continue;
3540
- }
3541
- const pattern = options.patterns.find(
3542
- (candidate) => reachesBase(options.facts, classKey, candidate.baseClasses)
3543
- );
3544
- if (pattern === void 0) {
3545
- continue;
3546
- }
3547
- const operation = methodOf(call);
3548
- const picked = selectorOf(call);
3549
- effects.push({
3550
- type: "interaction",
3551
- binding: (0, import_ir_core.storageBinding)({
3552
- recognition: "ruby-storage",
3553
- storageSystem: pattern.storageSystem,
3554
- scope: pattern.baseClasses[0] ?? "",
3555
- container: constant2.text
3556
- }),
3557
- callee: call.text,
3558
- interaction: {
3559
- class: "storage-access",
3560
- kind: pattern.writes.includes(operation) ? "write" : "read",
3561
- fields: [],
3562
- operation,
3563
- ...picked.length > 0 ? { selector: picked } : {}
3564
- }
3565
- });
3634
+ effects.push(...effectsOfCall(call, file, options));
3566
3635
  }
3567
3636
  return effects;
3568
3637
  }
@@ -3604,7 +3673,7 @@ function typeShapeFromNode(node, ctx) {
3604
3673
  };
3605
3674
  }
3606
3675
  if (node.type === "scope_resolution") {
3607
- const qualified = node.text;
3676
+ const qualified = compoundName(node);
3608
3677
  const scalar = ctx.scalars[scalarLookupName(qualified, ctx.scalarNamePrefixes)];
3609
3678
  if (scalar !== void 0) {
3610
3679
  return scalar;
@@ -3771,7 +3840,7 @@ async function controllerActionUnits(pattern, pack, info, ownBlocks, fileBlocks,
3771
3840
  filter,
3772
3841
  pattern,
3773
3842
  displayPath,
3774
- bodyOfMethod(filter.method, options)
3843
+ bodyOfMethod(filter.method, filter.file, options)
3775
3844
  );
3776
3845
  units.push(raw);
3777
3846
  options.onReachSeed?.(raw, {
@@ -3803,7 +3872,7 @@ async function controllerActionUnits(pattern, pack, info, ownBlocks, fileBlocks,
3803
3872
  info.qualifiedName,
3804
3873
  actionName,
3805
3874
  method,
3806
- options.filePath,
3875
+ { display: options.filePath, absolute: block.file },
3807
3876
  options,
3808
3877
  around
3809
3878
  );
@@ -3817,10 +3886,10 @@ async function controllerActionUnits(pattern, pack, info, ownBlocks, fileBlocks,
3817
3886
  }
3818
3887
  return units;
3819
3888
  }
3820
- function buildControllerActionUnit(pack, pattern, controllerQualifiedName, actionName, method, filePath, bodyRead, wrappers = []) {
3889
+ function buildControllerActionUnit(pack, pattern, controllerQualifiedName, actionName, method, file, bodyRead, wrappers = []) {
3821
3890
  const range = rangeOf(method);
3822
3891
  const route = pattern.routeFor(controllerQualifiedName, actionName);
3823
- const body = bodyOfMethod(method, bodyRead);
3892
+ const body = bodyOfMethod(method, file.absolute, bodyRead);
3824
3893
  const perResponse = responseBranches(
3825
3894
  method,
3826
3895
  pattern,
@@ -3832,7 +3901,7 @@ function buildControllerActionUnit(pack, pattern, controllerQualifiedName, actio
3832
3901
  name: actionName,
3833
3902
  nameKind: "binding",
3834
3903
  kind: "handler",
3835
- file: filePath,
3904
+ file: file.display,
3836
3905
  range,
3837
3906
  span: spanOf(method),
3838
3907
  exportName: actionName,
@@ -3951,12 +4020,12 @@ function branchesFor(body, range) {
3951
4020
  }
3952
4021
  ];
3953
4022
  }
3954
- function bodyOfMethod(method, bodyRead = {}) {
4023
+ function bodyOfMethod(method, file, bodyRead = {}) {
3955
4024
  const effects = invocationEffects(method, bodyRead.inheritedMethods);
3956
4025
  const storage = bodyRead.storage;
3957
4026
  const extra = [
3958
4027
  ...envReadEffects(method),
3959
- ...storage === void 0 ? [] : storageEffects(callsUnder2(method), storage)
4028
+ ...storage === void 0 ? [] : storageEffects(callsUnder2(method), file, storage)
3960
4029
  ];
3961
4030
  return {
3962
4031
  bodyContent: methodHasStatements(method) ? "statements" : "empty",
@@ -3987,7 +4056,7 @@ function methodNotSettled(reason, range) {
3987
4056
  function bodyFromLookup(found, range, subject, nothingThere, bodyRead) {
3988
4057
  const table = {
3989
4058
  found: (lookup) => ({
3990
- ...bodyOfMethod(lookup.method, bodyRead),
4059
+ ...bodyOfMethod(lookup.method, lookup.block.file, bodyRead),
3991
4060
  reachSeed: {
3992
4061
  file: lookup.block.file,
3993
4062
  node: lookup.method,
@@ -4351,11 +4420,10 @@ function collectFileConstants(filePath, root) {
4351
4420
  if ((node.type === "constant" || node.type === "scope_resolution") && !isDeclaration(node, parent)) {
4352
4421
  const written = writtenName(node);
4353
4422
  if (written !== null) {
4354
- references.push({
4355
- key: node.type === "constant" ? `${filePath}#${node.text}` : nodeId(filePath, node),
4356
- written,
4357
- nesting
4358
- });
4423
+ const key = node.type === "constant" ? `${filePath}#${node.text}` : nodeId(filePath, node);
4424
+ references.push(
4425
+ written.startsWith("::") ? { key, written: written.slice(2), nesting: [] } : { key, written, nesting }
4426
+ );
4359
4427
  }
4360
4428
  }
4361
4429
  for (const child of children4(node)) {
@@ -4916,7 +4984,8 @@ async function scanBody(source, ctx, options) {
4916
4984
  );
4917
4985
  if (outcome.kind === "stopped") {
4918
4986
  const stopKey = `${outcome.reason}:${callee}`;
4919
- if (!seen.has(stopKey) && (0, import_behavioral_ir4.worthRecording)(outcome.reason)) {
4987
+ const claimed = options.storage !== void 0 && storageClaims(call, source.file, options.storage);
4988
+ if (!seen.has(stopKey) && !claimed && (0, import_behavioral_ir4.worthRecording)(outcome.reason)) {
4920
4989
  seen.add(stopKey);
4921
4990
  stops.push({ callee, reason: outcome.reason });
4922
4991
  }
@@ -4947,7 +5016,7 @@ async function scanBody(source, ctx, options) {
4947
5016
  }
4948
5017
  function libraryUnit(target, options) {
4949
5018
  const { file, node, name, exportPath } = target;
4950
- const body = bodyOfMethod(node, options);
5019
+ const body = bodyOfMethod(node, file, options);
4951
5020
  const range = rangeOf(node);
4952
5021
  return {
4953
5022
  identity: {
@@ -5104,6 +5173,8 @@ async function extractRubyProject(options) {
5104
5173
  bindEvaluator(db, { files: parsed, definitions });
5105
5174
  });
5106
5175
  const storagePatterns = options.packs.flatMap((pack) => pack.storage ?? []);
5176
+ const loaderPatterns = options.packs.flatMap((pack) => pack.loaders ?? []);
5177
+ const storage = storagePatterns.length > 0 ? { facts: db, patterns: storagePatterns, loaders: loaderPatterns } : void 0;
5107
5178
  const inheritedMethods = inheritedMethodsIn(options.packs);
5108
5179
  const displayPathOf = (file) => options.workspaceRoot !== void 0 ? import_node_path4.default.relative(options.workspaceRoot, file) : file;
5109
5180
  const seeds = [];
@@ -5119,7 +5190,7 @@ async function extractRubyProject(options) {
5119
5190
  filePath: displayPath,
5120
5191
  absoluteFile: file,
5121
5192
  cache,
5122
- ...storagePatterns.length > 0 ? { storage: { facts: db, patterns: storagePatterns } } : {},
5193
+ ...storage === void 0 ? {} : { storage },
5123
5194
  inheritedMethods,
5124
5195
  displayPathOf,
5125
5196
  facts: db,
@@ -5198,7 +5269,7 @@ async function extractRubyProject(options) {
5198
5269
  () => reachedFunctions(seeds, {
5199
5270
  files: parsed,
5200
5271
  displayPathOf,
5201
- ...storagePatterns.length > 0 ? { storage: { facts: db, patterns: storagePatterns } } : {},
5272
+ ...storage === void 0 ? {} : { storage },
5202
5273
  inheritedMethods
5203
5274
  })
5204
5275
  );
package/dist/index.d.cts CHANGED
@@ -244,6 +244,8 @@ interface RubyPack {
244
244
  clients?: RbClientCall[];
245
245
  /** What the library's own database calls look like. The README says how one is matched. */
246
246
  storage?: RbStoragePattern[];
247
+ /** Calls the library gives a project for reading a model through a batching loader, rather than on the model itself. */
248
+ loaders?: RbLoaderPattern[];
247
249
  }
248
250
  /**
249
251
  * The calls a library gives a project for making a request. A method
@@ -264,7 +266,7 @@ interface RbClientCall {
264
266
  receiverBuilders?: string[];
265
267
  /** The keyword such a builder takes the base URL under, which comes in front of the path of a call on it. */
266
268
  builderUrlKeyword?: string;
267
- /** What the response object gives a caller, so a test on it reads as a test on a status. */
269
+ /** What the response object gives a caller, so a test on one of its members counts as a test on a status. */
268
270
  response?: RbClientResponse;
269
271
  /** A call that sends a request object built somewhere else, which is how Net::HTTP sends anything with a body. */
270
272
  requestObject?: {
@@ -304,6 +306,23 @@ interface RbStoragePattern {
304
306
  /** Which database is behind the connection, which the project settles. */
305
307
  storageSystem: "postgresql" | "mysql" | "sqlite";
306
308
  }
309
+ /**
310
+ * A loader that batches reads of a model on the caller's behalf, graphql-ruby's
311
+ * dataloader for one. The call that picks what to load is given the model as
312
+ * a constant argument, so the read is recorded against that model the same
313
+ * way a call on the model itself would be, and a storage pattern from another
314
+ * pack in the run says whether the constant is a model at all.
315
+ */
316
+ interface RbLoaderPattern {
317
+ /** The receiverless call that gives the loader, `dataloader`. */
318
+ loader: string;
319
+ /** The method on the loader that picks a source and its arguments, `with`. */
320
+ pick: string;
321
+ /** The methods on a picked source that perform the read, `load` and `load_all`. */
322
+ reads: string[];
323
+ /** Receiverless calls that pick and read in one, whose arguments include the model, `dataload` and `dataload_record`. */
324
+ shortcuts: string[];
325
+ }
307
326
  type RubyDiscoveryPattern = GraphqlObjectFields | ControllerActions;
308
327
  /** A class whose ancestry reaches one of `baseClassNames` is a controller, and every instance method it defines directly is one of its actions, bound by `routeFor` to the method and path a project's own routing gives it, or discovered with no boundary binding when `routeFor` finds none. */
309
328
  interface ControllerActions {
@@ -356,7 +375,7 @@ interface RbControllerFilter {
356
375
  * `rescue_from ActiveRecord::RecordNotFound, with: :not_found`.
357
376
  */
358
377
  methodFrom: "argument" | "withKeyword";
359
- /** Set when the library runs the method only for an action that raised, which is what `rescue_from` does. */
378
+ /** Set when the library runs the method only for an action that raised, as `rescue_from` does. */
360
379
  onThrow?: boolean;
361
380
  /** The name of the call that takes a filter back off, `skip_before_action` for `before_action`. */
362
381
  skippedBy?: string;
@@ -369,7 +388,7 @@ interface RbControllerFilter {
369
388
  /**
370
389
  * One call an action writes to send a response, and where that call takes
371
390
  * the status. A call may take it both ways, and then the keyword wins,
372
- * which is what Rails does with `head :ok, status: :created`.
391
+ * as it does in Rails for `head :ok, status: :created`.
373
392
  */
374
393
  interface RbStatusCall {
375
394
  /** The call's own name as an action writes it, `render` for Rails. */
@@ -436,9 +455,19 @@ interface GraphqlObjectFields {
436
455
  };
437
456
  }
438
457
 
458
+ /**
459
+ * storage.ts: which calls in a body talk to the database, for Ruby.
460
+ *
461
+ * A pack says which base class the library gives a model, and a call on a
462
+ * class that reaches that base is a database call. The README says why
463
+ * ancestry. A loader pattern from another pack adds calls that are given
464
+ * the model as an argument instead, and those go through the same test.
465
+ */
466
+
439
467
  interface RbStorageOptions {
440
468
  readonly facts: Database;
441
469
  readonly patterns: readonly RbStoragePattern[];
470
+ readonly loaders?: readonly RbLoaderPattern[];
442
471
  }
443
472
 
444
473
  /** The methods a pack declared the library defines itself, so a receiverless call to one of them is not work the project does. */
package/dist/index.d.ts CHANGED
@@ -244,6 +244,8 @@ interface RubyPack {
244
244
  clients?: RbClientCall[];
245
245
  /** What the library's own database calls look like. The README says how one is matched. */
246
246
  storage?: RbStoragePattern[];
247
+ /** Calls the library gives a project for reading a model through a batching loader, rather than on the model itself. */
248
+ loaders?: RbLoaderPattern[];
247
249
  }
248
250
  /**
249
251
  * The calls a library gives a project for making a request. A method
@@ -264,7 +266,7 @@ interface RbClientCall {
264
266
  receiverBuilders?: string[];
265
267
  /** The keyword such a builder takes the base URL under, which comes in front of the path of a call on it. */
266
268
  builderUrlKeyword?: string;
267
- /** What the response object gives a caller, so a test on it reads as a test on a status. */
269
+ /** What the response object gives a caller, so a test on one of its members counts as a test on a status. */
268
270
  response?: RbClientResponse;
269
271
  /** A call that sends a request object built somewhere else, which is how Net::HTTP sends anything with a body. */
270
272
  requestObject?: {
@@ -304,6 +306,23 @@ interface RbStoragePattern {
304
306
  /** Which database is behind the connection, which the project settles. */
305
307
  storageSystem: "postgresql" | "mysql" | "sqlite";
306
308
  }
309
+ /**
310
+ * A loader that batches reads of a model on the caller's behalf, graphql-ruby's
311
+ * dataloader for one. The call that picks what to load is given the model as
312
+ * a constant argument, so the read is recorded against that model the same
313
+ * way a call on the model itself would be, and a storage pattern from another
314
+ * pack in the run says whether the constant is a model at all.
315
+ */
316
+ interface RbLoaderPattern {
317
+ /** The receiverless call that gives the loader, `dataloader`. */
318
+ loader: string;
319
+ /** The method on the loader that picks a source and its arguments, `with`. */
320
+ pick: string;
321
+ /** The methods on a picked source that perform the read, `load` and `load_all`. */
322
+ reads: string[];
323
+ /** Receiverless calls that pick and read in one, whose arguments include the model, `dataload` and `dataload_record`. */
324
+ shortcuts: string[];
325
+ }
307
326
  type RubyDiscoveryPattern = GraphqlObjectFields | ControllerActions;
308
327
  /** A class whose ancestry reaches one of `baseClassNames` is a controller, and every instance method it defines directly is one of its actions, bound by `routeFor` to the method and path a project's own routing gives it, or discovered with no boundary binding when `routeFor` finds none. */
309
328
  interface ControllerActions {
@@ -356,7 +375,7 @@ interface RbControllerFilter {
356
375
  * `rescue_from ActiveRecord::RecordNotFound, with: :not_found`.
357
376
  */
358
377
  methodFrom: "argument" | "withKeyword";
359
- /** Set when the library runs the method only for an action that raised, which is what `rescue_from` does. */
378
+ /** Set when the library runs the method only for an action that raised, as `rescue_from` does. */
360
379
  onThrow?: boolean;
361
380
  /** The name of the call that takes a filter back off, `skip_before_action` for `before_action`. */
362
381
  skippedBy?: string;
@@ -369,7 +388,7 @@ interface RbControllerFilter {
369
388
  /**
370
389
  * One call an action writes to send a response, and where that call takes
371
390
  * the status. A call may take it both ways, and then the keyword wins,
372
- * which is what Rails does with `head :ok, status: :created`.
391
+ * as it does in Rails for `head :ok, status: :created`.
373
392
  */
374
393
  interface RbStatusCall {
375
394
  /** The call's own name as an action writes it, `render` for Rails. */
@@ -436,9 +455,19 @@ interface GraphqlObjectFields {
436
455
  };
437
456
  }
438
457
 
458
+ /**
459
+ * storage.ts: which calls in a body talk to the database, for Ruby.
460
+ *
461
+ * A pack says which base class the library gives a model, and a call on a
462
+ * class that reaches that base is a database call. The README says why
463
+ * ancestry. A loader pattern from another pack adds calls that are given
464
+ * the model as an argument instead, and those go through the same test.
465
+ */
466
+
439
467
  interface RbStorageOptions {
440
468
  readonly facts: Database;
441
469
  readonly patterns: readonly RbStoragePattern[];
470
+ readonly loaders?: readonly RbLoaderPattern[];
442
471
  }
443
472
 
444
473
  /** The methods a pack declared the library defines itself, so a receiverless call to one of them is not work the project does. */
package/dist/index.js CHANGED
@@ -229,7 +229,7 @@ function autoloadDirectories(root) {
229
229
  // src/scope.ts
230
230
  function qualifyConstantRef(node, nesting) {
231
231
  if (node.type === "scope_resolution") {
232
- return node.text;
232
+ return compoundName(node);
233
233
  }
234
234
  if (node.type === "constant") {
235
235
  const innermost = nesting[0] ?? null;
@@ -239,13 +239,16 @@ function qualifyConstantRef(node, nesting) {
239
239
  }
240
240
  function constantRefCandidates(node, nesting) {
241
241
  if (node.type === "scope_resolution") {
242
- return [node.text];
242
+ return [compoundName(node)];
243
243
  }
244
244
  if (node.type === "constant") {
245
245
  return [...nesting.map((level) => `${level}::${node.text}`), node.text];
246
246
  }
247
247
  return [];
248
248
  }
249
+ function compoundName(node) {
250
+ return node.text.replace(/^::/, "");
251
+ }
249
252
  function shadowingClassFor(node, nesting, knownClasses) {
250
253
  if (node.type !== "constant") {
251
254
  return null;
@@ -775,7 +778,7 @@ var Lowerer = class {
775
778
  return [nested];
776
779
  }
777
780
  /**
778
- * `unless ok` runs its body when `ok` is false, so it reads as an `if` on
781
+ * `unless ok` runs its body when `ok` is false, so it lowers to an `if` on
779
782
  * the same test with the two arms the other way around. Without the swap
780
783
  * a summary would say a branch runs on the test that skips it.
781
784
  */
@@ -791,7 +794,7 @@ var Lowerer = class {
791
794
  exitKind: exitKindOf(node)
792
795
  };
793
796
  }
794
- /** `render :gone if expired?` gates one statement on one test, so it reads as an if with one arm, on whichever side the test puts it. */
797
+ /** `render :gone if expired?` gates one statement on one test, so it lowers to an if with one arm, on whichever side the test puts it. */
795
798
  lowerIfModifier(node) {
796
799
  const body = field(node, "body");
797
800
  const gated = body === null ? [] : [this.lower(body)];
@@ -3006,8 +3009,11 @@ function receiverPrefix(receiver, method, root, pattern, options) {
3006
3009
  function trimmed(path7) {
3007
3010
  return path7 === "/" ? "" : path7.replace(/\/+$/, "");
3008
3011
  }
3009
- function namesConstant(receiver, constantName) {
3010
- return (receiver.type === "constant" || receiver.type === "scope_resolution") && receiver.text === constantName;
3012
+ function namesConstant(receiver, constantName2) {
3013
+ if (receiver.type === "constant") {
3014
+ return receiver.text === constantName2;
3015
+ }
3016
+ return receiver.type === "scope_resolution" && compoundName(receiver) === constantName2;
3011
3017
  }
3012
3018
  function builtBy(name, method, root, pattern) {
3013
3019
  const builders = pattern.receiverBuilders ?? [];
@@ -3443,6 +3449,12 @@ function rootConstant(node) {
3443
3449
  }
3444
3450
  return rootConstant(receiver);
3445
3451
  }
3452
+ function isConstant(node) {
3453
+ return node.type === "constant" || node.type === "scope_resolution";
3454
+ }
3455
+ function constantName(constant2) {
3456
+ return constant2.type === "constant" ? constant2.text : compoundName(constant2);
3457
+ }
3446
3458
  var ANCESTRY_PROGRAM = deriveOnDemand2(
3447
3459
  [...RESOLUTION_RULES2, ...RESOLUTION_QUESTIONS2],
3448
3460
  ANSWER_RELATIONS2
@@ -3452,13 +3464,22 @@ function reachesBase(facts, classKey, bases) {
3452
3464
  evaluate2(facts, ANCESTRY_PROGRAM.rules);
3453
3465
  return facts.lookup("wantedBaseName", 0, classKey).some((row) => bases.includes(String(row[1])));
3454
3466
  }
3455
- function classBehind(facts, constant2) {
3456
- const wanted = `#${constant2.text}`;
3467
+ function classBehind(facts, file, constant2) {
3468
+ const key = constant2.type === "constant" ? `${file}#${constant2.text}` : nodeId(file, constant2);
3457
3469
  const bound = new Set(
3458
- facts.facts("binds").filter((row) => String(row[0]).endsWith(wanted)).map((row) => String(row[1]))
3470
+ facts.lookup("binds", 0, key).map((row) => String(row[1]))
3459
3471
  );
3460
3472
  return bound.size === 1 ? [...bound][0] : void 0;
3461
3473
  }
3474
+ function modelPattern(constant2, file, options) {
3475
+ const classKey = classBehind(options.facts, file, constant2);
3476
+ if (classKey === void 0) {
3477
+ return void 0;
3478
+ }
3479
+ return options.patterns.find(
3480
+ (candidate) => reachesBase(options.facts, classKey, candidate.baseClasses)
3481
+ );
3482
+ }
3462
3483
  function selectorOf(node) {
3463
3484
  const picked = [];
3464
3485
  const walk = (current) => {
@@ -3480,7 +3501,87 @@ function selectorOf(node) {
3480
3501
  walk(node);
3481
3502
  return [...new Set(picked)];
3482
3503
  }
3483
- function storageEffects(calls, options) {
3504
+ function storageEffect(call, model, pattern, kind, selector) {
3505
+ const operation = methodOf(call);
3506
+ return {
3507
+ type: "interaction",
3508
+ binding: storageBinding({
3509
+ recognition: "ruby-storage",
3510
+ storageSystem: pattern.storageSystem,
3511
+ scope: pattern.baseClasses[0] ?? "",
3512
+ container: constantName(model)
3513
+ }),
3514
+ callee: call.text,
3515
+ interaction: {
3516
+ class: "storage-access",
3517
+ kind,
3518
+ fields: [],
3519
+ operation,
3520
+ ...selector.length > 0 ? { selector } : {}
3521
+ }
3522
+ };
3523
+ }
3524
+ function modelCallEffects(call, file, options) {
3525
+ const constant2 = rootConstant(call);
3526
+ if (constant2 === null) {
3527
+ return [];
3528
+ }
3529
+ const pattern = modelPattern(constant2, file, options);
3530
+ if (pattern === void 0) {
3531
+ return [];
3532
+ }
3533
+ const kind = pattern.writes.includes(methodOf(call)) ? "write" : "read";
3534
+ return [storageEffect(call, constant2, pattern, kind, selectorOf(call))];
3535
+ }
3536
+ function isLoader(node, loader) {
3537
+ if (node === null) {
3538
+ return false;
3539
+ }
3540
+ if (node.type === "identifier") {
3541
+ return node.text === loader.loader;
3542
+ }
3543
+ return node.type === "call" && receiverOf(node) === null && methodOf(node) === loader.loader;
3544
+ }
3545
+ function loaderPick(call, loader) {
3546
+ const method = methodOf(call);
3547
+ const receiver = receiverOf(call);
3548
+ if (receiver === null) {
3549
+ return loader.shortcuts.includes(method) ? call : null;
3550
+ }
3551
+ if (!loader.reads.includes(method) || receiver.type !== "call") {
3552
+ return null;
3553
+ }
3554
+ const picks = methodOf(receiver) === loader.pick && isLoader(receiverOf(receiver), loader);
3555
+ return picks ? receiver : null;
3556
+ }
3557
+ function loaderCallEffects(call, file, options) {
3558
+ const effects = [];
3559
+ for (const loader of options.loaders ?? []) {
3560
+ const pick = loaderPick(call, loader);
3561
+ if (pick === null) {
3562
+ continue;
3563
+ }
3564
+ const args = field(pick, "arguments");
3565
+ for (const argument of args === null ? [] : children3(args)) {
3566
+ if (!isConstant(argument)) {
3567
+ continue;
3568
+ }
3569
+ const pattern = modelPattern(argument, file, options);
3570
+ if (pattern !== void 0) {
3571
+ effects.push(storageEffect(call, argument, pattern, "read", []));
3572
+ }
3573
+ }
3574
+ }
3575
+ return effects;
3576
+ }
3577
+ function effectsOfCall(call, file, options) {
3578
+ const onModel = modelCallEffects(call, file, options);
3579
+ return onModel.length > 0 ? onModel : loaderCallEffects(call, file, options);
3580
+ }
3581
+ function storageClaims(call, file, options) {
3582
+ return options.patterns.length > 0 && effectsOfCall(call, file, options).length > 0;
3583
+ }
3584
+ function storageEffects(calls, file, options) {
3484
3585
  if (options.patterns.length === 0) {
3485
3586
  return [];
3486
3587
  }
@@ -3496,39 +3597,7 @@ function storageEffects(calls, options) {
3496
3597
  if (partOfOne.has(call.id)) {
3497
3598
  continue;
3498
3599
  }
3499
- const constant2 = rootConstant(call);
3500
- if (constant2 === null) {
3501
- continue;
3502
- }
3503
- const classKey = classBehind(options.facts, constant2);
3504
- if (classKey === void 0) {
3505
- continue;
3506
- }
3507
- const pattern = options.patterns.find(
3508
- (candidate) => reachesBase(options.facts, classKey, candidate.baseClasses)
3509
- );
3510
- if (pattern === void 0) {
3511
- continue;
3512
- }
3513
- const operation = methodOf(call);
3514
- const picked = selectorOf(call);
3515
- effects.push({
3516
- type: "interaction",
3517
- binding: storageBinding({
3518
- recognition: "ruby-storage",
3519
- storageSystem: pattern.storageSystem,
3520
- scope: pattern.baseClasses[0] ?? "",
3521
- container: constant2.text
3522
- }),
3523
- callee: call.text,
3524
- interaction: {
3525
- class: "storage-access",
3526
- kind: pattern.writes.includes(operation) ? "write" : "read",
3527
- fields: [],
3528
- operation,
3529
- ...picked.length > 0 ? { selector: picked } : {}
3530
- }
3531
- });
3600
+ effects.push(...effectsOfCall(call, file, options));
3532
3601
  }
3533
3602
  return effects;
3534
3603
  }
@@ -3570,7 +3639,7 @@ function typeShapeFromNode(node, ctx) {
3570
3639
  };
3571
3640
  }
3572
3641
  if (node.type === "scope_resolution") {
3573
- const qualified = node.text;
3642
+ const qualified = compoundName(node);
3574
3643
  const scalar = ctx.scalars[scalarLookupName(qualified, ctx.scalarNamePrefixes)];
3575
3644
  if (scalar !== void 0) {
3576
3645
  return scalar;
@@ -3737,7 +3806,7 @@ async function controllerActionUnits(pattern, pack, info, ownBlocks, fileBlocks,
3737
3806
  filter,
3738
3807
  pattern,
3739
3808
  displayPath,
3740
- bodyOfMethod(filter.method, options)
3809
+ bodyOfMethod(filter.method, filter.file, options)
3741
3810
  );
3742
3811
  units.push(raw);
3743
3812
  options.onReachSeed?.(raw, {
@@ -3769,7 +3838,7 @@ async function controllerActionUnits(pattern, pack, info, ownBlocks, fileBlocks,
3769
3838
  info.qualifiedName,
3770
3839
  actionName,
3771
3840
  method,
3772
- options.filePath,
3841
+ { display: options.filePath, absolute: block.file },
3773
3842
  options,
3774
3843
  around
3775
3844
  );
@@ -3783,10 +3852,10 @@ async function controllerActionUnits(pattern, pack, info, ownBlocks, fileBlocks,
3783
3852
  }
3784
3853
  return units;
3785
3854
  }
3786
- function buildControllerActionUnit(pack, pattern, controllerQualifiedName, actionName, method, filePath, bodyRead, wrappers = []) {
3855
+ function buildControllerActionUnit(pack, pattern, controllerQualifiedName, actionName, method, file, bodyRead, wrappers = []) {
3787
3856
  const range = rangeOf(method);
3788
3857
  const route = pattern.routeFor(controllerQualifiedName, actionName);
3789
- const body = bodyOfMethod(method, bodyRead);
3858
+ const body = bodyOfMethod(method, file.absolute, bodyRead);
3790
3859
  const perResponse = responseBranches(
3791
3860
  method,
3792
3861
  pattern,
@@ -3798,7 +3867,7 @@ function buildControllerActionUnit(pack, pattern, controllerQualifiedName, actio
3798
3867
  name: actionName,
3799
3868
  nameKind: "binding",
3800
3869
  kind: "handler",
3801
- file: filePath,
3870
+ file: file.display,
3802
3871
  range,
3803
3872
  span: spanOf(method),
3804
3873
  exportName: actionName,
@@ -3917,12 +3986,12 @@ function branchesFor(body, range) {
3917
3986
  }
3918
3987
  ];
3919
3988
  }
3920
- function bodyOfMethod(method, bodyRead = {}) {
3989
+ function bodyOfMethod(method, file, bodyRead = {}) {
3921
3990
  const effects = invocationEffects(method, bodyRead.inheritedMethods);
3922
3991
  const storage = bodyRead.storage;
3923
3992
  const extra = [
3924
3993
  ...envReadEffects(method),
3925
- ...storage === void 0 ? [] : storageEffects(callsUnder2(method), storage)
3994
+ ...storage === void 0 ? [] : storageEffects(callsUnder2(method), file, storage)
3926
3995
  ];
3927
3996
  return {
3928
3997
  bodyContent: methodHasStatements(method) ? "statements" : "empty",
@@ -3953,7 +4022,7 @@ function methodNotSettled(reason, range) {
3953
4022
  function bodyFromLookup(found, range, subject, nothingThere, bodyRead) {
3954
4023
  const table = {
3955
4024
  found: (lookup) => ({
3956
- ...bodyOfMethod(lookup.method, bodyRead),
4025
+ ...bodyOfMethod(lookup.method, lookup.block.file, bodyRead),
3957
4026
  reachSeed: {
3958
4027
  file: lookup.block.file,
3959
4028
  node: lookup.method,
@@ -4317,11 +4386,10 @@ function collectFileConstants(filePath, root) {
4317
4386
  if ((node.type === "constant" || node.type === "scope_resolution") && !isDeclaration(node, parent)) {
4318
4387
  const written = writtenName(node);
4319
4388
  if (written !== null) {
4320
- references.push({
4321
- key: node.type === "constant" ? `${filePath}#${node.text}` : nodeId(filePath, node),
4322
- written,
4323
- nesting
4324
- });
4389
+ const key = node.type === "constant" ? `${filePath}#${node.text}` : nodeId(filePath, node);
4390
+ references.push(
4391
+ written.startsWith("::") ? { key, written: written.slice(2), nesting: [] } : { key, written, nesting }
4392
+ );
4325
4393
  }
4326
4394
  }
4327
4395
  for (const child of children4(node)) {
@@ -4913,7 +4981,8 @@ async function scanBody(source, ctx, options) {
4913
4981
  );
4914
4982
  if (outcome.kind === "stopped") {
4915
4983
  const stopKey = `${outcome.reason}:${callee}`;
4916
- if (!seen.has(stopKey) && worthRecording(outcome.reason)) {
4984
+ const claimed = options.storage !== void 0 && storageClaims(call, source.file, options.storage);
4985
+ if (!seen.has(stopKey) && !claimed && worthRecording(outcome.reason)) {
4917
4986
  seen.add(stopKey);
4918
4987
  stops.push({ callee, reason: outcome.reason });
4919
4988
  }
@@ -4944,7 +5013,7 @@ async function scanBody(source, ctx, options) {
4944
5013
  }
4945
5014
  function libraryUnit(target, options) {
4946
5015
  const { file, node, name, exportPath } = target;
4947
- const body = bodyOfMethod(node, options);
5016
+ const body = bodyOfMethod(node, file, options);
4948
5017
  const range = rangeOf(node);
4949
5018
  return {
4950
5019
  identity: {
@@ -5101,6 +5170,8 @@ async function extractRubyProject(options) {
5101
5170
  bindEvaluator(db, { files: parsed, definitions });
5102
5171
  });
5103
5172
  const storagePatterns = options.packs.flatMap((pack) => pack.storage ?? []);
5173
+ const loaderPatterns = options.packs.flatMap((pack) => pack.loaders ?? []);
5174
+ const storage = storagePatterns.length > 0 ? { facts: db, patterns: storagePatterns, loaders: loaderPatterns } : void 0;
5104
5175
  const inheritedMethods = inheritedMethodsIn(options.packs);
5105
5176
  const displayPathOf = (file) => options.workspaceRoot !== void 0 ? path4.relative(options.workspaceRoot, file) : file;
5106
5177
  const seeds = [];
@@ -5116,7 +5187,7 @@ async function extractRubyProject(options) {
5116
5187
  filePath: displayPath,
5117
5188
  absoluteFile: file,
5118
5189
  cache,
5119
- ...storagePatterns.length > 0 ? { storage: { facts: db, patterns: storagePatterns } } : {},
5190
+ ...storage === void 0 ? {} : { storage },
5120
5191
  inheritedMethods,
5121
5192
  displayPathOf,
5122
5193
  facts: db,
@@ -5195,7 +5266,7 @@ async function extractRubyProject(options) {
5195
5266
  () => reachedFunctions(seeds, {
5196
5267
  files: parsed,
5197
5268
  displayPathOf,
5198
- ...storagePatterns.length > 0 ? { storage: { facts: db, patterns: storagePatterns } } : {},
5269
+ ...storage === void 0 ? {} : { storage },
5199
5270
  inheritedMethods
5200
5271
  })
5201
5272
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suss/adapter-ruby",
3
- "version": "0.24.4",
3
+ "version": "0.26.0",
4
4
  "type": "module",
5
5
  "description": "Read a Ruby codebase with suss and check what each Rails action or graphql-ruby resolver returns against the code that calls it.",
6
6
  "keywords": [
@@ -49,12 +49,12 @@
49
49
  "typecheck": "tsc --noEmit"
50
50
  },
51
51
  "dependencies": {
52
- "@suss/behavioral-ir": "0.24.4",
53
- "@suss/datalog": "0.24.4",
54
- "@suss/extractor": "0.24.4",
55
- "@suss/ir-core": "0.24.4",
56
- "@suss/resolution": "0.24.4",
57
- "@suss/values": "0.24.4",
52
+ "@suss/behavioral-ir": "0.26.0",
53
+ "@suss/datalog": "0.26.0",
54
+ "@suss/extractor": "0.26.0",
55
+ "@suss/ir-core": "0.26.0",
56
+ "@suss/resolution": "0.26.0",
57
+ "@suss/values": "0.26.0",
58
58
  "web-tree-sitter": "^0.26.11"
59
59
  },
60
60
  "devDependencies": {