@descryy/adapter-python 0.4.1 → 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/dist/extract.js CHANGED
@@ -30,7 +30,7 @@
30
30
  * because `a/__init__.py` importing from `b/__init__.py` importing back is legal
31
31
  * Python and must not hang the run.
32
32
  */
33
- import { databaseTableNodesFromModel, edgeId, endpointQsp, isCallableNodeType, isTypeLikeNodeType, nodeId, normaliseEndpointPath, symbolQsp, testCaseQsp, } from "@descryy/ir";
33
+ import { databaseTableNodesFromModel, edgeId, endpointQsp, externalQsp, isCallableNodeType, isTypeLikeNodeType, nodeId, normaliseEndpointPath, symbolQsp, testCaseQsp, } from "@descryy/ir";
34
34
  import { importRoots, modulePathOf, moduleNameOf, resolveImport, } from "./module.js";
35
35
  import { attributeExternal, externalImportTable, ownNamespaces, STDLIB_BASIS, } from "./external.js";
36
36
  import { DECLARATIVE_BASE_FACTORIES, ORM_BASE_MODULES, isFrameworkBaseName, ormShapeOf, } from "./orm.js";
@@ -39,7 +39,16 @@ import { drfShapeOf, dtoShapeOf, isDrfSerializerRoot, isPydanticRoot, isValidato
39
39
  import { DRF_ACTIONS, drfRegisterCallsIn, drfRouterLocalsIn, djangoUrlEntriesIn, HTTP_METHODS, isApplicationRouter, joinPath, mountsIn, odooRouteFromDecorator, routeFromDecorator, routersIn, } from "./routes.js";
40
40
  import { GRAPHQL_METHOD, fieldFromMethod, fieldsFromAttributes, operationPath, schemaDeclsIn, } from "./graphql.js";
41
41
  import { clientCallsIn, clientLocalsIn } from "./client.js";
42
+ import { modelParsing, validateCalls } from "./response-shape.js";
42
43
  export const LANGUAGE = "python";
44
+ /** Which packaging world an external module name belongs to. The name is the
45
+ * ecosystem's, not an installed path's: `pyyaml` installs `yaml`, and `yaml`
46
+ * is what the source writes and what this node is identified by. */
47
+ const EXTERNAL_ECOSYSTEM = "pypi";
48
+ /** An external node is attributed from the file's own `import`/`from`
49
+ * statements, which is module resolution and nothing more. R1 exactly: never
50
+ * R0 (the import *is* the resolution), never R4 (nothing observed it run). */
51
+ const EXTERNAL_RESOLUTION = 1;
43
52
  /**
44
53
  * Confidence by the level an edge's evidence earned — never by the level the
45
54
  * run reached. DEC-058: those were two different notions in the first adapter,
@@ -79,10 +88,6 @@ const REASONS = {
79
88
  callBaseUnresolved: "an HTTP call written as f\"{BASE}/path\" whose base this file does not bind to a string " +
80
89
  "literal. The path half is readable and the base half is not, so the served template cannot " +
81
90
  "be assembled — the named base is the piece of work that would close it.",
82
- callBaseIsExternalHost: "an HTTP call written as f\"{BASE}/path\" whose base resolves to an absolute URL on another " +
83
- "host. Read correctly and out of scope for the join: an endpoint is identified by method and " +
84
- "path with no host (DEC-014), so claiming one here would let this caller join a local route " +
85
- "of the same path that it never reaches.",
86
91
  callPathFromLocal: "an HTTP call whose path is a local assembled earlier in the function. Distinguished from an " +
87
92
  "opaque path because it is reachable by further work, and filing the two together would " +
88
93
  "report one number for two populations.",
@@ -354,6 +359,24 @@ export function extract(input) {
354
359
  const edges = [];
355
360
  const unresolved = [];
356
361
  const seenEdge = new Set();
362
+ /**
363
+ * DEC-164's `attrs.clientBase`, per caller node id. First writer wins, so a
364
+ * function making two calls is described by the first one read rather than
365
+ * by whichever happened to be walked last.
366
+ *
367
+ * **Declared HERE, beside `nodes`, and the reason is a bug this already
368
+ * caused.** It sat next to the R2 guard, which reads as the right place —
369
+ * the client half is R2 work. But the client half runs inside `emitRoutes`,
370
+ * and `emitRoutes` is *called* above that line, so every run threw
371
+ * `ReferenceError: Cannot access 'clientBases' before initialization`,
372
+ * `extractOrDegrade` turned the throw into a degraded one-node-per-file
373
+ * graph, and the corpus reported all nineteen patterns as role-unbound
374
+ * failures rather than as a crash. A temporal-dead-zone error inside a
375
+ * catch-all degrade path looks exactly like an adapter that read nothing.
376
+ * The map stays empty below R2 because nothing writes to it there, which is
377
+ * the honest version of what the old placement was trying to say.
378
+ */
379
+ const clientBases = new Map();
357
380
  /** file -> declaration name (first segment) -> Declared */
358
381
  const declaredIn = new Map();
359
382
  /** node id -> the entry owning it, where it sits in `nodes`, and how many
@@ -430,19 +453,26 @@ export function extract(input) {
430
453
  }));
431
454
  assignedOf.set(file, new Set(parsed.assignedNames ?? []));
432
455
  }
456
+ /** Node ids already minted for a dependency symbol — one node per symbol, not
457
+ * per call site. Measured node growth is +3.1% (plane) to +4.5% (dispatch). */
458
+ const externalIds = new Map();
433
459
  /**
434
- * The module and symbol behind a call that left the repository, as ledger
435
- * `attrs`, or `undefined` when no binding the developer wrote settles it.
460
+ * The id of the `FUNCTION` node standing for the symbol behind a call that
461
+ * left the repository, or `undefined` when no binding the developer wrote
462
+ * settles it — or when this run did not reach the resolution such a node
463
+ * requires.
436
464
  *
437
- * **This does not mint a node or an edge.** `@descryy/ir` gained the
438
- * discriminator that lets a `FUNCTION` stand for a symbol in a dependency
439
- * only at 0.7.0; this package is pinned to the release before it, and a node
440
- * emitted against that release is rejected with every edge to it — losing the
441
- * ledger row as well, which is strictly worse than the boundary being
442
- * unnamed. The attribution is computed at the site that files the row so that
443
- * minting is the only step left.
465
+ * Three settled rules govern the node
466
+ * (`DEC-NEXT-external-symbol-nodes-at-the-dependency-boundary`): the qsp
467
+ * carries an `ext:` marker (`externalQsp`), the node may claim R1–R3 but
468
+ * never R0 or R4, and it owns no outgoing edge, because nothing read its
469
+ * body. `thirdParty` rides in `attrs`, never in the hash: a module that moved
470
+ * between the standard library and an installed package would otherwise
471
+ * change identity with no line of source changing.
444
472
  */
445
- const externalAttrs = (file, receiver, name) => {
473
+ const mintExternal = (file, receiver, name) => {
474
+ if (input.reached < EXTERNAL_RESOLUTION)
475
+ return undefined;
446
476
  const attribution = attributeExternal({
447
477
  receiver,
448
478
  name,
@@ -451,18 +481,49 @@ export function extract(input) {
451
481
  });
452
482
  if (attribution === null)
453
483
  return undefined;
454
- return {
455
- external: {
456
- // Identity, and it is the module path the source imported — never a
457
- // site-packages directory, never a distribution name, never a version.
458
- moduleOrNamespace: attribution.moduleOrNamespace,
459
- symbol: attribution.symbolPath.join("."),
484
+ // Identity, and it is the module path the source imported — never a
485
+ // site-packages directory, never a distribution name, never a version.
486
+ const qsp = externalQsp(attribution.moduleOrNamespace, attribution.symbolPath);
487
+ const existing = externalIds.get(qsp);
488
+ if (existing !== undefined)
489
+ return existing;
490
+ const id = nodeId(scope, "FUNCTION", qsp, LANGUAGE);
491
+ externalIds.set(qsp, id);
492
+ nodes.push({
493
+ id,
494
+ type: "FUNCTION",
495
+ name: attribution.symbolPath[attribution.symbolPath.length - 1] ?? "",
496
+ // No file and no range: the symbol lives in a dependency, and a node that
497
+ // named a file here would be invalidated by a source file it has nothing
498
+ // to do with.
499
+ file: null,
500
+ range: null,
501
+ language: LANGUAGE,
502
+ producedBy: input.producedBy,
503
+ resolution: EXTERNAL_RESOLUTION,
504
+ attrs: {
460
505
  thirdParty: attribution.thirdParty,
461
506
  // Honest degradation: `thirdParty` is decided against a static list,
462
- // not against anything installed, and the row says which list.
507
+ // not against anything installed, and the node says which list.
463
508
  thirdPartyBasis: STDLIB_BASIS,
464
509
  },
465
- };
510
+ external: {
511
+ ecosystem: EXTERNAL_ECOSYSTEM,
512
+ moduleOrNamespace: attribution.moduleOrNamespace,
513
+ },
514
+ });
515
+ return id;
516
+ };
517
+ /**
518
+ * The edge into a dependency is **always `CALLS`, never `TESTS`**, even when
519
+ * the container is a `TEST_CASE` and every in-repo edge it owns is a `TESTS`.
520
+ * `TESTS` asserts coverage — that this case exercises that node — and this
521
+ * run read nothing of the dependency's body, so it cannot assert it (rule 4,
522
+ * rule 7). A test that reaches `yaml.safe_load` calls it; it does not test
523
+ * it. Filed as `DEC-NEXT-external-edge-is-calls-not-tests`.
524
+ */
525
+ const callExternal = (from, to) => {
526
+ push({ from, to, type: "CALLS" }, EXTERNAL_RESOLUTION);
466
527
  };
467
528
  // --- which classes are test suites, decided from evidence not from names ---
468
529
  /** file -> top-level class name -> the bases it was written with. */
@@ -1477,7 +1538,12 @@ export function extract(input) {
1477
1538
  // left into is readable. This is the half `adapter-go` has no
1478
1539
  // analogue for (Go has no from-import), and on measurement it is the
1479
1540
  // larger half: 2,790 of 4,836 attributions on dispatch.
1480
- disclose(source, edgeType, reference.name, file, reference.line, "outside", externalAttrs(file, undefined, reference.name));
1541
+ const external = mintExternal(file, undefined, reference.name);
1542
+ if (external !== undefined) {
1543
+ callExternal(source.id, external);
1544
+ continue;
1545
+ }
1546
+ disclose(source, edgeType, reference.name, file, reference.line, "outside");
1481
1547
  continue;
1482
1548
  }
1483
1549
  if ((target.type === "FUNCTION" || target.type === "TEST_CASE") && target.id !== source.id) {
@@ -1560,8 +1626,16 @@ export function extract(input) {
1560
1626
  // about this row — there is one, and it leaves the repository. So the
1561
1627
  // reason becomes `outside`, which is what the row actually is. An
1562
1628
  // unattributed row is untouched and stays `unknown`.
1563
- const external = externalAttrs(file, reference.receiver, reference.name);
1564
- disclose(source, edgeType, `${reference.receiver}.${reference.name}`, file, reference.line, external === undefined ? "unknown" : "outside", external);
1629
+ const external = mintExternal(file, reference.receiver, reference.name);
1630
+ if (external !== undefined) {
1631
+ // `import yaml` ... `yaml.safe_load(raw)`. When an import statement
1632
+ // binds the receiver, "no resolvable binding" is a FALSE sentence
1633
+ // about this row — there is one, and it leaves the repository. It
1634
+ // is now a node and an edge rather than a better-worded refusal.
1635
+ callExternal(source.id, external);
1636
+ continue;
1637
+ }
1638
+ disclose(source, edgeType, `${reference.receiver}.${reference.name}`, file, reference.line, "unknown");
1565
1639
  continue;
1566
1640
  }
1567
1641
  const method = byFullName
@@ -1781,6 +1855,11 @@ export function extract(input) {
1781
1855
  const mounts = [];
1782
1856
  const routeDecls = [];
1783
1857
  const clientCalls = [];
1858
+ /**
1859
+ * Every file's client half, read but not yet judged — see the push site
1860
+ * for why deciding is deferred to after the loop.
1861
+ */
1862
+ const pendingClients = [];
1784
1863
  const djangoRouteEntries = [];
1785
1864
  const djangoIncludeEntries = [];
1786
1865
  const drfRegisterEntries = [];
@@ -1849,27 +1928,22 @@ export function extract(input) {
1849
1928
  const moduleId = moduleNodeId.get(file);
1850
1929
  return moduleId === undefined ? undefined : { id: moduleId };
1851
1930
  };
1852
- for (const refusal of read.refusals) {
1853
- const container = containerFor([]);
1854
- if (container === undefined)
1855
- continue;
1856
- disclose(container, "USES_API", refusal.rawTarget, refusal.file, refusal.line, refusal.reason,
1857
- // Unset `refusalClass` stays legal and means unclassified — DEC-242 — so
1858
- // `attrs` is omitted entirely rather than sent with an `undefined` field.
1859
- refusal.refusalClass === undefined
1860
- ? undefined
1861
- : {
1862
- blockedBy: refusal.blockedBy,
1863
- refusalClass: refusal.refusalClass,
1864
- ...(refusal.argumentKind === undefined ? {} : { argumentKind: refusal.argumentKind }),
1865
- });
1866
- }
1867
- for (const call of read.calls) {
1868
- const container = containerFor(call.scope);
1869
- if (container === undefined)
1870
- continue;
1871
- clientCalls.push({ call, containerId: container.id });
1872
- }
1931
+ // EMITTED AFTER THE LOOP, NOT HERE — DEC-164's origin rule needs every
1932
+ // file's bases before any one call site can be judged, and a base three
1933
+ // files away is still evidence about this call. Reading is per file;
1934
+ // deciding is per program. File order is preserved, so the ledger reads
1935
+ // the same as it did.
1936
+ pendingClients.push({
1937
+ file,
1938
+ calls,
1939
+ moduleOfImport: moduleOfImportIn(file),
1940
+ clientLocals,
1941
+ constants,
1942
+ localStrings,
1943
+ functionParams,
1944
+ containerFor,
1945
+ read,
1946
+ });
1873
1947
  for (const declaration of parsed.declarations) {
1874
1948
  for (const call of declaration.decoratorCalls ?? []) {
1875
1949
  const route = routeFromDecorator(file, call, declaration.path);
@@ -1893,6 +1967,81 @@ export function extract(input) {
1893
1967
  drfRegisterEntries.push(reg);
1894
1968
  }
1895
1969
  }
1970
+ // --- the client half, decided now that every file has been read --------
1971
+ //
1972
+ // DEC-164's own-origin rule admits an absolute call path only at an origin
1973
+ // some base in this same program already resolves to. The first read
1974
+ // produced those origins; the second re-reads only the files that could
1975
+ // change, and with no absolute base anywhere `origins` is empty and no
1976
+ // file is re-read at all.
1977
+ const origins = new Set();
1978
+ for (const pending of pendingClients) {
1979
+ for (const call of pending.read.calls) {
1980
+ if (call.baseOrigin !== undefined)
1981
+ origins.add(call.baseOrigin);
1982
+ }
1983
+ }
1984
+ for (const pending of pendingClients) {
1985
+ const couldChange = origins.size > 0 && pending.read.refusals.some((refusal) => refusal.reason === "callPathNotRelative");
1986
+ const read = couldChange
1987
+ ? clientCallsIn(pending.file, pending.calls, pending.moduleOfImport, pending.clientLocals, pending.constants, pending.localStrings, pending.functionParams, origins)
1988
+ : pending.read;
1989
+ for (const refusal of read.refusals) {
1990
+ // DEC-164 is attributed to the caller that wrote the call; the ledger
1991
+ // row's own container is unchanged and still the module.
1992
+ const owner = pending.containerFor(refusal.scope);
1993
+ if (owner !== undefined && refusal.clientBase !== undefined && !clientBases.has(owner.id)) {
1994
+ clientBases.set(owner.id, refusal.clientBase);
1995
+ }
1996
+ const container = pending.containerFor([]);
1997
+ if (container === undefined)
1998
+ continue;
1999
+ disclose(container, "USES_API", refusal.rawTarget, refusal.file, refusal.line, refusal.reason,
2000
+ // Unset `refusalClass` stays legal and means unclassified — DEC-242 — so
2001
+ // `attrs` is omitted entirely rather than sent with an `undefined` field.
2002
+ refusal.refusalClass === undefined
2003
+ ? undefined
2004
+ : {
2005
+ blockedBy: refusal.blockedBy,
2006
+ refusalClass: refusal.refusalClass,
2007
+ ...(refusal.argumentKind === undefined ? {} : { argumentKind: refusal.argumentKind }),
2008
+ });
2009
+ }
2010
+ // Pydantic parses written in this file, read once rather than per call
2011
+ // — golden 18's consumer half. Read HERE and not at the first pass
2012
+ // because `read` may be the SECOND read of this file (DEC-164's origin
2013
+ // rule re-reads a file whose refusals could change), and a model
2014
+ // resolved against the first read's calls would describe calls that no
2015
+ // longer exist.
2016
+ const parses = validateCalls(pending.calls);
2017
+ for (const call of read.calls) {
2018
+ const container = pending.containerFor(call.scope);
2019
+ if (container === undefined)
2020
+ continue;
2021
+ if (!clientBases.has(container.id))
2022
+ clientBases.set(container.id, call.clientBase);
2023
+ clientCalls.push({
2024
+ call,
2025
+ containerId: container.id,
2026
+ responseModel: modelParsing(parses, call.scope, call.client, call.rawPath),
2027
+ responseModelFile: pending.file,
2028
+ });
2029
+ }
2030
+ }
2031
+ // DEC-164, patched onto the caller now rather than at the end of the
2032
+ // function. `adapter-csharp` patches its own at the end and can, because
2033
+ // it has one exit; this function returns early at `reached < 3`, and a
2034
+ // patch after that line was **absent at R2 while the golden requires it
2035
+ // there** — the conformance sweep caught it as three `node-attr-mismatch`
2036
+ // findings at R2 only, which a single-level run would never have shown.
2037
+ // Here is the earliest point where every caller node exists and every
2038
+ // base has been decided.
2039
+ for (let i = 0; i < nodes.length; i += 1) {
2040
+ const base = clientBases.get(nodes[i].id);
2041
+ if (base === undefined)
2042
+ continue;
2043
+ nodes[i] = { ...nodes[i], attrs: { ...nodes[i].attrs, clientBase: base } };
2044
+ }
1896
2045
  /** child -> its parent and the prefix mounting it there. */
1897
2046
  const parentOf = new Map();
1898
2047
  for (const mount of mounts) {
@@ -2664,7 +2813,9 @@ export function extract(input) {
2664
2813
  // rejected at the boundary as RESOLUTION_EXCEEDS_BATCH — the batch
2665
2814
  // contract catching a claim stronger than the run that produced it, which
2666
2815
  // is exactly what it is for.
2667
- for (const { call, containerId } of input.reached >= 2 ? clientCalls : []) {
2816
+ for (const { call, containerId, responseModel, responseModelFile } of input.reached >= 2
2817
+ ? clientCalls
2818
+ : []) {
2668
2819
  const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp(call.method, call.path), null);
2669
2820
  if (!routeSeen.has(endpointId)) {
2670
2821
  routeSeen.add(endpointId);
@@ -2680,6 +2831,26 @@ export function extract(input) {
2680
2831
  attrs: { method: call.method, pathTemplate: normaliseEndpointPath(call.path) },
2681
2832
  });
2682
2833
  }
2834
+ /**
2835
+ * Golden 18's consumer half. Gated on `input.reached >= 3` INSIDE the
2836
+ * attrs rather than by an early return above: this loop runs from R2, so
2837
+ * a guard placed one level up would be green at R3 and silently wrong at
2838
+ * R2 — the monotonic sweep's own failure mode. At R2 the key is simply
2839
+ * absent, which is the honest answer.
2840
+ *
2841
+ * R3 because the shape is a Pydantic DECLARATION — types stated, not
2842
+ * inferred — which is DEC-202's rule and the same ground `adapter-java`'s
2843
+ * JPA shapes and `adapter-sql`'s schema stand on. The edge itself stays
2844
+ * at 2 (path resolution); `shapeResolution` carries the shape's own
2845
+ * evidence so the contract engine does not cap it at the weaker of the
2846
+ * two facts this edge holds (DEC-058).
2847
+ */
2848
+ const declaredShape = input.reached >= 3 && responseModel !== null
2849
+ ? followName(responseModelFile, responseModel)
2850
+ : undefined;
2851
+ const shapeAttrs = declaredShape !== undefined && declaredShape.type === "DTO"
2852
+ ? { responseType: declaredShape.id, shapeResolution: 3 }
2853
+ : {};
2683
2854
  push({
2684
2855
  from: containerId,
2685
2856
  to: endpointId,
@@ -2689,6 +2860,7 @@ export function extract(input) {
2689
2860
  file: call.file,
2690
2861
  line: call.line,
2691
2862
  written: call.rawPath,
2863
+ ...shapeAttrs,
2692
2864
  },
2693
2865
  }, 2);
2694
2866
  }