bmad-module-skill-forge 4.0.0 → 6.0.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.
Files changed (128) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +3 -3
  3. package/docs/_data/pinned.yaml +19 -13
  4. package/docs/agents.md +1 -1
  5. package/docs/architecture.md +5 -5
  6. package/docs/campaign.md +1 -1
  7. package/docs/concepts.md +4 -4
  8. package/docs/examples.md +32 -25
  9. package/docs/getting-started.md +3 -3
  10. package/docs/how-it-works.md +2 -2
  11. package/docs/index.md +1 -1
  12. package/docs/skill-model.md +41 -39
  13. package/docs/troubleshooting.md +9 -3
  14. package/docs/verifying-a-skill.md +42 -21
  15. package/docs/why-skf.md +1 -1
  16. package/docs/workflows.md +6 -6
  17. package/package.json +1 -1
  18. package/src/knowledge/tool-resolution.md +6 -6
  19. package/src/shared/references/headless-gate-convention.md +4 -1
  20. package/src/shared/references/output-contract-schema.md +3 -3
  21. package/src/shared/references/pipeline-contracts.md +2 -1
  22. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  23. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  25. package/src/shared/scripts/skf-build-change-manifest.py +1180 -189
  26. package/src/shared/scripts/skf-classify-changed-files.py +15 -10
  27. package/src/shared/scripts/skf-count-tokens.py +54 -10
  28. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  29. package/src/shared/scripts/skf-emit-result-envelope.py +64 -20
  30. package/src/shared/scripts/skf-extract-public-api.py +472 -120
  31. package/src/shared/scripts/skf-extraction-inventory.py +10 -6
  32. package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
  33. package/src/shared/scripts/skf-parse-gaps.py +136 -26
  34. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  35. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  36. package/src/shared/scripts/skf-shard-body.py +239 -41
  37. package/src/shared/scripts/skf-validate-output.py +12 -3
  38. package/src/shared/scripts/skf-verify-provenance-completeness.py +574 -45
  39. package/src/shared/tool-requirements.yaml +8 -2
  40. package/src/skf-analyze-source/SKILL.md +3 -2
  41. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  42. package/src/skf-analyze-source/references/generate-briefs.md +1 -1
  43. package/src/skf-analyze-source/references/headless-contract.md +1 -1
  44. package/src/skf-audit-skill/SKILL.md +4 -3
  45. package/src/skf-audit-skill/references/re-index.md +2 -2
  46. package/src/skf-audit-skill/references/report.md +1 -1
  47. package/src/skf-brief-skill/SKILL.md +3 -2
  48. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  49. package/src/skf-brief-skill/references/invocation-contract.md +2 -2
  50. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  51. package/src/skf-campaign/SKILL.md +3 -2
  52. package/src/skf-campaign/references/campaign-contracts.md +2 -0
  53. package/src/skf-campaign/references/step-05-skill-loop.md +8 -3
  54. package/src/skf-campaign/references/step-10-export.md +3 -2
  55. package/src/skf-create-skill/SKILL.md +4 -3
  56. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  57. package/src/skf-create-skill/references/ccc-index-check.md +1 -1
  58. package/src/skf-create-skill/references/component-extraction.md +1 -1
  59. package/src/skf-create-skill/references/extract.md +3 -2
  60. package/src/skf-create-skill/references/extraction-patterns.md +3 -3
  61. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  62. package/src/skf-create-skill/references/report.md +8 -3
  63. package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
  64. package/src/skf-create-skill/references/validate.md +1 -1
  65. package/src/skf-create-stack-skill/SKILL.md +3 -2
  66. package/src/skf-create-stack-skill/references/generate-output.md +1 -1
  67. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  68. package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
  69. package/src/skf-create-stack-skill/references/report.md +1 -1
  70. package/src/skf-create-stack-skill/references/validate.md +1 -1
  71. package/src/skf-drop-skill/SKILL.md +3 -2
  72. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  73. package/src/skf-drop-skill/references/report.md +1 -1
  74. package/src/skf-export-skill/SKILL.md +3 -2
  75. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  76. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  77. package/src/skf-export-skill/references/invocation-contract.md +1 -1
  78. package/src/skf-export-skill/references/load-skill.md +1 -1
  79. package/src/skf-export-skill/references/summary.md +1 -1
  80. package/src/skf-export-skill/references/token-report.md +29 -9
  81. package/src/skf-forger/SKILL.md +3 -2
  82. package/src/skf-forger/references/pipeline-mode.md +2 -2
  83. package/src/skf-quick-skill/SKILL.md +4 -3
  84. package/src/skf-quick-skill/references/finalize.md +1 -1
  85. package/src/skf-quick-skill/references/halt-contract.md +2 -2
  86. package/src/skf-quick-skill/references/write-and-validate.md +1 -1
  87. package/src/skf-refine-architecture/SKILL.md +8 -9
  88. package/src/skf-refine-architecture/customize.toml +1 -1
  89. package/src/skf-refine-architecture/references/report.md +1 -1
  90. package/src/skf-rename-skill/SKILL.md +3 -2
  91. package/src/skf-rename-skill/references/invocation-contract.md +2 -2
  92. package/src/skf-setup/SKILL.md +3 -2
  93. package/src/skf-setup/references/ccc-index.md +1 -1
  94. package/src/skf-setup/references/invocation-contract.md +1 -1
  95. package/src/skf-test-skill/SKILL.md +3 -2
  96. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  97. package/src/skf-test-skill/references/coherence-check.md +11 -8
  98. package/src/skf-test-skill/references/coverage-check-branches.md +81 -0
  99. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  100. package/src/skf-test-skill/references/coverage-check.md +60 -97
  101. package/src/skf-test-skill/references/init.md +2 -1
  102. package/src/skf-test-skill/references/inventory-contract.md +27 -0
  103. package/src/skf-test-skill/references/invocation-contract.md +3 -3
  104. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  105. package/src/skf-test-skill/references/report.md +5 -5
  106. package/src/skf-test-skill/references/scoring-rules.md +8 -7
  107. package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
  108. package/src/skf-test-skill/references/source-access-protocol.md +14 -10
  109. package/src/skf-test-skill/references/step-hard-gate.md +1 -0
  110. package/src/skf-test-skill/scripts/build-result-context.py +20 -3
  111. package/src/skf-test-skill/scripts/gap-ledger.py +456 -176
  112. package/src/skf-test-skill/scripts/load-coverage-inputs.py +516 -79
  113. package/src/skf-test-skill/scripts/locate-export-segments.py +685 -76
  114. package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
  115. package/src/skf-test-skill/scripts/score-signatures.py +1205 -65
  116. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  117. package/src/skf-test-skill/scripts/validate-inventory.py +350 -35
  118. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  119. package/src/skf-update-skill/SKILL.md +3 -2
  120. package/src/skf-update-skill/references/detect-changes.md +1 -1
  121. package/src/skf-update-skill/references/gap-driven.md +54 -33
  122. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  123. package/src/skf-update-skill/references/merge.md +15 -10
  124. package/src/skf-update-skill/references/re-extract.md +3 -1
  125. package/src/skf-update-skill/references/report.md +6 -5
  126. package/src/skf-update-skill/references/write.md +42 -20
  127. package/src/skf-verify-stack/SKILL.md +4 -3
  128. package/tools/cli/lib/tool-check.js +158 -2
@@ -29,9 +29,53 @@ answer per input and judges no meaning. For each export name it lists:
29
29
  declaration. Comments and string literals are blanked first in the
30
30
  languages this script knows by the fence's info string; a block tagged
31
31
  as data or output (`json`, `yaml`, `text`, `console` ...) holds none.
32
+ `receiver` is the expression before the name and its accessor
33
+ (`client.api` in `client.api.fetchData(`), null when none stands there,
34
+ as the blanked code holds it: whitespace beside an accessor, a bracket or
35
+ a comma dropped, any other run cut to one space, and a non-null `!!` or
36
+ `!` dropped.
37
+
38
+ A method given with its `owner` (the class or object the skill documents
39
+ it on) is called only on that owner. A receiver names an owner when its
40
+ last member, or the callee of its last call, is the owner's last part, in
41
+ any case (`client.messages` names `Messages`), and, when both have several
42
+ parts, the owner's parts are the receiver's last ones (`other_lib.agents`
43
+ does not name `cognee.agents`). A trailing `new` is dropped
44
+ (`UuidGenerator::new()`, Ruby's `UuidGenerator.new`), and so are an
45
+ owner's type parameters (`Repository` for `Repository<T>`). Each match of
46
+ the method's name is classed by its receiver (`receiverMatch`):
47
+
48
+ - "owner": the receiver names the owner (`cognee.agents.get(`,
49
+ `coco.App(...).update(`).
50
+ - "bound": the receiver is one identifier whose last binding on a line
51
+ before the call, in the same fenced block, is to the owner. Only a
52
+ constructor-like form binds: `x = Owner(...)`, `x = await Owner(...)`,
53
+ `x = new Owner(...)`, `x = Owner::new(...)`, `x = Owner.new`,
54
+ `x := Owner{`, an annotation `x: Owner`, `with Owner(...) as x`, a Java-
55
+ or C#-style `Owner x =` and Go's `var x Owner` or `var x *Owner`. Any
56
+ other assignment (`agent = await cognee.agents.create(n)`, the result of
57
+ a member call) binds the identifier to nothing, and an assignment in an
58
+ argument list (`f(gen=UuidGenerator())`) is no binding.
59
+ - "other": the receiver names, or its binding is to, another owner the
60
+ input lists. It is no call of the method, only counted
61
+ (`otherReceiverCount`).
62
+ - "unknown": no receiver (`get(...)`: an import or an implicit `this` may
63
+ tie it, the text does not), or any other one (`settings.get(`,
64
+ `self.gen.next_uuid(`). The call cannot be tied to the owner, so it goes
65
+ to `unverifiedCalls[]`, never to calls[].
66
+
67
+ A method no call is tied to is in `notCalled[]`, written `owner.name`, and
68
+ its unverified calls in `notCalledUnverified[]`. A declaration is an owned
69
+ method's only on its owner or a variable bound to it, inside the body of
70
+ its class (`class UuidGenerator:` ... `def next_uuid(self)`), or on a Go
71
+ receiver of its type (`func (g *UuidGenerator) next_uuid(`): neither
72
+ `cognee.config.get(key: str)` nor a free `def get(` is `agents.get`'s. Its
73
+ descriptions that name the owner come first, before the cap. A name given
74
+ without an owner is matched on any receiver, and its calls carry a null
75
+ `receiverMatch`.
32
76
 
33
77
  A call is `awaited` when `await` stands before it (through its receiver:
34
- `await client.fetchData(...)`), `.await` follows it (Rust), an asynchronous
78
+ `await client.fetchData(...)`), `.await` or `?.await` follows it (Rust), an asynchronous
35
79
  loop or context manager consumes it (`async for row in stream(...)`, `async
36
80
  with session(...)`, `for await (const row of stream(...))`), or it sits in
37
81
  the arguments of a call that is itself awaited or is an event-loop runner
@@ -40,12 +84,41 @@ crossing into a function body. `chainedMember` names the member read or
40
84
  called on its result right after it (`then`, `catch`, `join`, `get`), else
41
85
  null: whether that waits for the result is the caller's call.
42
86
 
87
+ `awaitedVia` says how an awaited call is awaited, null when it is not:
88
+
89
+ - "direct": the await applies to the call itself (`directlyAwaited`):
90
+ `.await` follows it, or `await` stands before it with nothing applied to
91
+ its result (`await client.fetchData(...)`) but a member that waits for an
92
+ awaitable (`then`, `catch`, `finally`, `ConfigureAwait`), or it alone
93
+ fills the groups that `await` stands before (`await ((fetchData(x)))`,
94
+ `await (fetchData(x) as T)`) and nothing follows them but the end of the
95
+ statement, of a list or of a bracket.
96
+ - "chain": the await applies to an expression the call starts or sits in:
97
+ a member, subscript or call applied to its result
98
+ (`await use_context(e).embed(t)`, `await fetchData(x)[0]`) or a group
99
+ around it (`await (fetchData(x) or other())`).
100
+ - "consumer": an asynchronous loop or context manager consumes it.
101
+ - "argument" or "runner": it sits in the arguments of an awaited call, or of
102
+ an event-loop runner; `enclosingCallee` names that callee, whitespace
103
+ removed (`asyncio.gather`, `Promise.all`, `asyncio.run`), else null.
104
+
105
+ A backslash continuation counts as whitespace throughout.
106
+
43
107
  Name match: the name as a fixed string, case-sensitive, never inside a
44
108
  longer identifier (the Export match rule of skf-scan-skill-md-structure.py
45
109
  usage-scope). A qualified name (`client.fetch`, `Store::load`) matches only
46
110
  as written, except that a declaration also matches its last part
47
111
  (`def fetch(`): a declaration never carries the qualifier.
48
112
 
113
+ Annotated parameters: in a block tagged Python, the JavaScript family,
114
+ Kotlin or Scala, an argument list that holds an annotated parameter
115
+ (`name: Type`; `name?: Type` in the JavaScript family; `*args: T`,
116
+ `...rest: T`) is a signature, with or without a receiver:
117
+ `get(key: str, reveal_secrets: bool = False)`. A lambda argument (`lambda
118
+ a, b: a + b`), a walrus (`x := 1`), a ternary (`dev ? 'x' : y`), `::`,
119
+ Kotlin's `object : T` and Scala's `xs: _*` and `_: T` stay calls. Swift, C# and Dart write named arguments that way
120
+ (`f(id: 1)`), so their blocks, and untagged ones, keep these calls.
121
+
49
122
  Line model: UTF-8 with a leading byte order mark dropped, split on `\\n`
50
123
  (a `\\r\\n` pair loses its `\\r`), so each `line` matches `grep -n`. The
51
124
  frontmatter is a leading `---` ... `---` block. A fence opens on three or
@@ -57,13 +130,33 @@ indent); its section runs to the next heading of the same or a higher level.
57
130
  Input (one JSON object):
58
131
  {
59
132
  "names": ["fetchData", "Client.fetch", ...], # the §1 inventory's function and method names
133
+ "methods": [{"name": "get", "owner": "agents"}], # optional: each method with its owner
134
+ "owners": ["agents", "config"], # optional: the classes and namespaces the skill documents
60
135
  "skillPackagePath": "/path/to/skill" # holds SKILL.md and references/
61
136
  }
62
137
 
138
+ Each method's owner is one of the owners. A `methods` item without a
139
+ non-empty string `name` and `owner` is skipped. An owner applies only to a
140
+ plain name: a qualified one (`agents.get`) is matched as written, as in
141
+ `names`.
142
+
63
143
  or the files: --inventory <file> --skill-dir <dir>. --inventory reads the
64
- validated inventory validate-inventory.py wrote (its --output) and takes the
65
- name of each entry whose kind is `function` or `method`, so no name is
66
- copied by hand.
144
+ validated inventory validate-inventory.py wrote (its --output), so no name
145
+ is copied by hand:
146
+
147
+ - names: each entry whose kind is `function`, or `method` with no
148
+ non-empty string `owner`.
149
+ - methods: each other `method` entry, with its `owner`, and each string
150
+ item of the `methods` list of a class, interface, struct, trait or type
151
+ entry, owned by that entry (no other kind's list is read). An item names
152
+ the identifier before its first `(` (the last part of a qualified one),
153
+ else its leading identifier, once its leading `.` or `#`, its decorators
154
+ (`@property`, `@lru_cache(maxsize=1)`), a Go receiver
155
+ (`func (c *Client)`) and its modifier words (`async`, `static`, `def`
156
+ ...) are dropped. A dunder (`__init__`), an item that starts with
157
+ `await` and one of modifiers only (`async`) name no method.
158
+ - owners: the name of each class, struct, interface, trait, type, enum or
159
+ adapter entry, and each method's owner.
67
160
 
68
161
  Output (stdout, one object):
69
162
  {
@@ -74,13 +167,19 @@ Output (stdout, one object):
74
167
  "name": "fetchData",
75
168
  "descriptionCount": 3, # all found; descriptions[] holds the first 12
76
169
  "descriptions": [{"file", "line", "endLine", "kind", "section", "header", "text", "truncated"}],
77
- "calls": [{"file", "line", "fence", "language", "awaited", "chainedMember", "text"}],
170
+ "owner": null, # a method's owner, null for a name
171
+ "calls": [{"file", "line", "fence", "language", "receiver", "receiverMatch", "awaited",
172
+ "directlyAwaited", "awaitedVia", "enclosingCallee", "chainedMember", "text"}],
78
173
  "callCount": 2,
79
- "awaitedCount": 1
174
+ "awaitedCount": 1, # calls whose `awaited` is true
175
+ "directlyAwaitedCount": 1, # calls whose `directlyAwaited` is true
176
+ "unverifiedCalls": [], # a method's calls on an "unknown" receiver, as calls[]
177
+ "otherReceiverCount": 0 # a method's matches on an "other" receiver
80
178
  }
81
179
  ],
82
- "notCalled": ["parseConfig"], # names no example calls
83
- "summary": {"names": N, "called": N, "notCalled": N}
180
+ "notCalled": ["parseConfig", "agents.get"], # names, and owner.method, no example calls
181
+ "notCalledUnverified": [{"name": "get", "owner": "agents", "unverifiedCalls": [...]}], # their unverified calls
182
+ "summary": {"names": N, "called": N, "notCalled": N} # names: the names and methods, deduplicated
84
183
  }
85
184
  or {"error": ..., "code": "INVALID_INPUT"} on a schema violation.
86
185
 
@@ -102,6 +201,7 @@ Exit codes:
102
201
  from __future__ import annotations
103
202
 
104
203
  import argparse
204
+ import bisect
105
205
  import json
106
206
  import re
107
207
  import sys
@@ -126,9 +226,20 @@ LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d{1,9}[.)])(?:[ \t]+|$)")
126
226
  TABLE_ROW_RE = re.compile(r"^\s*\|")
127
227
  TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?\s*$")
128
228
  GENERIC_OR_PATH = r"(?:[ \t]*::)?(?:[ \t]*<[^<>()\n]*(?:<[^<>()\n]*>[^<>()\n]*)*>)?"
129
- AFTER_CALL_RE = re.compile(f"\\s*\\??\\.\\s*((?:[^\\W\\d]|\\$)[{IDENT_CHARS}]*)")
229
+ # Whitespace, a Python backslash continuation included; LINE_SPACE stays on its line.
230
+ SPACE = r"(?:\s|\\\n)*"
231
+ LINE_SPACE = r"(?:[ \t]|\\\n)*"
232
+ AFTER_CALL_RE = re.compile(f"{SPACE}[?!]?\\.{SPACE}((?:[^\\W\\d]|\\$)[{IDENT_CHARS}]*)")
130
233
  AWAIT_BEFORE_RE = re.compile(f"(?<![{IDENT_CHARS}])await$")
131
- AWAIT_AFTER_RE = re.compile(f"\\s*\\.\\s*await(?![{IDENT_CHARS}])")
234
+ AWAIT_AFTER_RE = re.compile(f"{SPACE}\\??\\.{SPACE}await(?![{IDENT_CHARS}])")
235
+ # A subscript or call applied to a call's result: `f(x)[0]`, `f(x)(y)`, `f(x)?.[0]`, `f(x)!(y)`.
236
+ APPLIED_RE = re.compile(f"{LINE_SPACE}(?:\\?\\.|!)?[\\[(]")
237
+ # What may close a group that holds only the call (`(f(x) as T)`, `(f(x)!)`), and what may follow
238
+ # the outermost group: the end of the statement, of a list or of a bracket.
239
+ GROUP_TAIL_RE = re.compile(f"{SPACE}(?:!|as(?![{IDENT_CHARS}])[^()]*)?{SPACE}")
240
+ GROUP_END_RE = re.compile(f"{LINE_SPACE}(?:[;,)\\]}}]|\\Z|\\n\\s*(?:[^\\s.?\\[(]|\\Z))")
241
+ # Members whose call waits for an awaitable: under `await`, the export returns one.
242
+ WAIT_MEMBERS = frozenset({"then", "catch", "finally", "ConfigureAwait"})
132
243
  # An expression an asynchronous loop or context manager consumes: `async for
133
244
  # x in NAME(...)`, `async with NAME(...)`, `for await (const x of NAME(...))`,
134
245
  # `await foreach (var x in NAME(...))`. Matched against its line up to it.
@@ -169,6 +280,21 @@ TYPED_DECLARATIONS = C_FAMILY | frozenset({"groovy", ""})
169
280
  # A `{` after a call at the start of a line opens a method body here; in
170
281
  # Kotlin, Swift or Ruby it can be a trailing closure of a call.
171
282
  BRACE_DECLARATIONS = JS_FAMILY | C_FAMILY
283
+ # Languages where no call's argument is `name: ...`, so an argument list that
284
+ # holds one declares the name. Swift, C# and Dart write named arguments that
285
+ # way, and untagged code can be any of them.
286
+ ANNOTATED_PARAMS = JS_FAMILY | frozenset({"python", "py", "python3", "kotlin", "kt", "kts", "scala"})
287
+ # An annotated parameter: `name: Type`, `*args: T`, `...rest: T`, and `name?: T` (group 2, the `?` right
288
+ # before the `:`) in the JavaScript family, where no `?:` operator exists. Not `::`, nor a walrus `:=`, nor a
289
+ # ternary (`dev ? 'x' : y`).
290
+ ANNOTATED_PARAM_RE = re.compile(
291
+ f"\\s*(?:\\*{{1,2}}|\\.\\.\\.)?((?:[^\\W\\d]|\\$)[{IDENT_CHARS}]*)(?:(\\?)|\\s*):(?![:=])(.*)", re.S)
292
+ # A Python lambda's parameters, which hold commas: `lambda a, b:`.
293
+ LAMBDA_PARAMS_RE = re.compile(f"(?<![{IDENT_CHARS}])lambda(?![{IDENT_CHARS}])[^:]*:")
294
+ PYTHON = frozenset({"python", "py", "python3"})
295
+ # The accessors between a receiver and the member it holds, longest first.
296
+ ACCESSORS = ("?.", "!.", "::", "->", ".")
297
+ IDENTIFIER_RE = re.compile(f"(?:[^\\W\\d]|\\$)[{IDENT_CHARS}]*")
172
298
 
173
299
  # A word right before the name that declares it.
174
300
  DECLARATION_WORDS = frozenset({
@@ -192,6 +318,36 @@ RUN_OWNERS = frozenset({"asyncio", "trio", "anyio"})
192
318
  BRACKETS = {"(": ")", "[": "]", "{": "}"}
193
319
  CLOSERS = {v: k for k, v in BRACKETS.items()}
194
320
 
321
+ # Receivers and the bindings that tie them to an owner.
322
+ IDENT = f"(?:[^\\W\\d]|\\$)[{IDENT_CHARS}]*"
323
+ TYPE_PATH = f"{IDENT}(?:(?:\\.|::){IDENT})*"
324
+ GENERIC = r"(?:[ \t]*<[^<>()\n]*(?:<[^<>()\n]*>[^<>()\n]*)*>)?"
325
+ SEGMENT_HEAD_RE = re.compile(f"({IDENT}){GENERIC}")
326
+ # `x = ...` or `x := ...`, not `==`, `=>` or `=~`.
327
+ ASSIGN_RE = re.compile(f"(?<![{IDENT_CHARS}.])({IDENT})[ \\t]*(?::=|=(?![=>~]))\\s*")
328
+ RHS_RE = re.compile(f"(?:await\\s+)?(new\\s+)?(?:&[ \\t]*(?:mut\\s+)?)?({TYPE_PATH}){GENERIC}[ \\t]*([({{]?)")
329
+ # A member, subscript or call applied to a new object (`.await` is no member).
330
+ AFTER_CONSTRUCTOR_RE = re.compile(f"[ \\t]*(?:\\?\\.|!\\.|\\.(?!await(?![{IDENT_CHARS}]))|::|->|\\[|\\()")
331
+ ANNOTATION_RE = re.compile(f"(?<![{IDENT_CHARS}.])({IDENT})\\??[ \\t]*:[ \\t]*(?:&[ \\t]*(?:mut[ \\t]+)?|\\*)?"
332
+ f"({TYPE_PATH})(?![{IDENT_CHARS}(.:])")
333
+ TYPED_BINDING_RE = re.compile(f"(?<![{IDENT_CHARS}.])({TYPE_PATH}){GENERIC}(?:\\[\\])*[ \\t]+({IDENT})[ \\t]*"
334
+ f"(?:=(?![=>~])|;)")
335
+ GO_VAR_RE = re.compile(f"(?<![{IDENT_CHARS}.])var[ \\t]+({IDENT})[ \\t]+\\*?({TYPE_PATH})(?![{IDENT_CHARS}(.:])")
336
+ WITH_AS_RE = re.compile(f"\\)[ \\t]*as[ \\t]+({IDENT})(?![{IDENT_CHARS}])")
337
+ CALLEE_END_RE = re.compile(f"({TYPE_PATH}){GENERIC}[ \\t]*$")
338
+ # Words that stand where TYPED_BINDING_RE reads a type: `const x = ...`, `return x;`.
339
+ BINDING_WORDS = DECLARATION_WORDS | EXPRESSION_WORDS | frozenset({
340
+ "var", "let", "const", "val", "auto", "mut", "dim", "my", "local", "global", "nonlocal",
341
+ })
342
+ # A class-like body: `class Agents`, `impl Store`, `impl<T> Trait for Store<T>`, Go's `type Client struct`.
343
+ CLASS_RE = re.compile(
344
+ f"(?<![{IDENT_CHARS}.])(?:(?:class|struct|interface|trait|object|protocol|record|enum|extension)[ \\t]+"
345
+ f"|impl{GENERIC}[ \\t]+(?:{TYPE_PATH}{GENERIC}[ \\t]+for[ \\t]+)?)({TYPE_PATH})"
346
+ f"|(?<![{IDENT_CHARS}.])type[ \\t]+({IDENT})[ \\t]+(?:struct|interface)(?![{IDENT_CHARS}])")
347
+ BRACE_BODY_RE = re.compile(r"[^{};\n]*(?:\n[ \t]*)?\{")
348
+ GO_RECEIVER_TAIL_RE = re.compile(
349
+ f"func[ \\t]*\\([ \\t]*(?:{IDENT}[ \\t]+)?\\*?({TYPE_PATH}){GENERIC}(?:\\[[^\\]]*\\])?[ \\t]*\\)[ \\t]*$")
350
+
195
351
 
196
352
  def make_error(message):
197
353
  return {"error": message, "code": "INVALID_INPUT"}
@@ -515,7 +671,8 @@ def _matching_open(text, close_idx):
515
671
 
516
672
 
517
673
  def _skip_space_back(text, i):
518
- while i > 0 and text[i - 1] in " \t\n":
674
+ """Back over whitespace, a Python backslash continuation included."""
675
+ while i > 0 and (text[i - 1] in " \t\n" or (text[i - 1] == "\\" and text.startswith("\n", i))):
519
676
  i -= 1
520
677
  return i
521
678
 
@@ -529,6 +686,8 @@ def _chain_start(text, pos):
529
686
  if accessor is None:
530
687
  return i
531
688
  k = _skip_space_back(text, k - len(accessor))
689
+ while k > 0 and text[k - 1] == "!":
690
+ k -= 1 # Kotlin's `!!` before the accessor: `gen!!.next_uuid()`
532
691
  start = k
533
692
  while start > 0 and text[start - 1] in ")]":
534
693
  opened = _matching_open(text, start - 1)
@@ -543,14 +702,253 @@ def _chain_start(text, pos):
543
702
  i = start
544
703
 
545
704
 
705
+ def _receiver(text, chain, name_start):
706
+ """The expression before the member at `name_start`, without its accessor: `cognee.agents` in
707
+ `cognee.agents.get(`; None when the name has no receiver. Whitespace (a line break in a fluent chain, a
708
+ blanked string) is dropped beside an accessor, a bracket or a comma, and cut to one space elsewhere."""
709
+ k = _skip_space_back(text, name_start)
710
+ accessor = next((a for a in ACCESSORS if text.endswith(a, chain, k)), None)
711
+ if accessor is None:
712
+ return None
713
+ receiver = re.sub(r"(?:\s|\\\n)+", " ", text[chain:k - len(accessor)]).strip()
714
+ receiver = re.sub(r" ?(\?\.|!\.|::|->|[.,()\[\]]) ?", r"\1", receiver)
715
+ receiver = re.sub(r"!+(?=\?\.|\.|::|->|$)", "", receiver) # a non-null assertion: `gen!!`, `client!`
716
+ return receiver or None
717
+
718
+
719
+ def _owner_display(owner):
720
+ """An owner as the output names it: its type parameters dropped (`Repository` for `Repository<T>`)."""
721
+ owner = owner.strip()
722
+ while (bare := re.sub(r"<[^<>]*>", "", owner)) != owner:
723
+ owner = bare
724
+ return re.sub(r"\[[^\[\]]*\]$", "", owner).strip()
725
+
726
+
727
+ def _type_parts(path):
728
+ """The parts of a dotted or `::` path, lower case, a trailing `new` dropped (`Owner::new`, Ruby's
729
+ `Owner.new`); None for an empty path."""
730
+ parts = tuple(p.lower() for p in re.split(r"\.|::|#|->", path) if p)
731
+ if len(parts) > 1 and parts[-1] == "new":
732
+ parts = parts[:-1]
733
+ return parts or None
734
+
735
+
736
+ def _segment_name(segment):
737
+ """The identifier of one member of a receiver, plain or called once (`App` in `App(x)`), else None."""
738
+ m = SEGMENT_HEAD_RE.match(segment)
739
+ if not m:
740
+ return None
741
+ rest = segment[m.end():]
742
+ if not rest or (rest.startswith("(") and _matching_close(rest, 0) == len(rest) - 1):
743
+ return m.group(1).lower()
744
+ return None
745
+
746
+
747
+ def _receiver_parts(receiver):
748
+ """The member names of a receiver, lower case, None for a member that is no plain or once-called name:
749
+ ("coco", "app") for `coco.App(...)`, (None, "agents") for `get()[0].agents`; a trailing `new` dropped."""
750
+ segments, depth, start, i = [], 0, 0, 0
751
+ while i < len(receiver):
752
+ c = receiver[i]
753
+ if c in BRACKETS:
754
+ depth += 1
755
+ elif c in CLOSERS:
756
+ depth -= 1
757
+ elif depth == 0 and (accessor := next((a for a in ACCESSORS if receiver.startswith(a, i)), None)):
758
+ segments.append(receiver[start:i])
759
+ i += len(accessor)
760
+ start = i
761
+ continue
762
+ i += 1
763
+ segments.append(receiver[start:])
764
+ parts = tuple(_segment_name(s) for s in segments)
765
+ return parts[:-1] if len(parts) > 1 and parts[-1] == "new" else parts
766
+
767
+
768
+ def _ties(parts, owner_parts):
769
+ """True when a receiver or a bound type names the owner: the last parts agree, case-insensitively, and when
770
+ both have several parts the owner's are the receiver's last ones (`other_lib.agents` is not `cognee.agents`)."""
771
+ if not parts or parts[-1] is None or parts[-1] != owner_parts[-1]:
772
+ return False
773
+ if len(parts) > 1 and len(owner_parts) > 1:
774
+ return parts[-len(owner_parts):] == owner_parts
775
+ return True
776
+
777
+
778
+ class _Owners:
779
+ """Every owner of the input, looked up by its last part."""
780
+
781
+ def __init__(self, owners):
782
+ self.parts = {o: p for o in owners if (p := _type_parts(o))}
783
+ self.by_last = {}
784
+ for owner, parts in self.parts.items():
785
+ self.by_last.setdefault(parts[-1], []).append(owner)
786
+
787
+ def tied(self, parts):
788
+ """The owners `parts` names."""
789
+ if not parts or parts[-1] is None:
790
+ return frozenset()
791
+ return frozenset(o for o in self.by_last.get(parts[-1], ()) if _ties(parts, self.parts[o]))
792
+
793
+
794
+ def _constructed(text, pos):
795
+ """The parts of the type a constructor-like right-hand side at `pos` builds: `Owner(...)`, `await Owner(...)`,
796
+ `new Owner(...)`, `Owner::new(...)`, `Owner.new`, `Owner{`; None for anything else, a member call's result
797
+ (`cognee.agents.create(n)` builds a `create`, never an `agents`) or a member of the new object included."""
798
+ m = RHS_RE.match(text, pos)
799
+ if not m:
800
+ return None
801
+ new, path, follow = m.group(1), m.group(2), m.group(3)
802
+ if follow == "(":
803
+ close = _matching_close(text, m.end() - 1)
804
+ if close is not None and AFTER_CONSTRUCTOR_RE.match(text, close + 1):
805
+ return None # `Owner().build()`: the object a member returns
806
+ return _type_parts(path)
807
+ if follow == "{" or new:
808
+ return _type_parts(path)
809
+ raw = re.split(r"\.|::", path)
810
+ return _type_parts(path) if len(raw) > 1 and raw[-1] == "new" else None
811
+
812
+
813
+ def _bindings(text):
814
+ """{identifier: ([row, ...], [type parts or None, ...])} of a block, in text order: each line that binds an
815
+ identifier to a type, and each plain assignment (to None, so a later `x = make()` ends an earlier binding).
816
+
817
+ A binding is `x = Owner(...)` (see _constructed), an annotation `x: Owner`, `with Owner(...) as x`, a
818
+ Java- or C#-style `Owner x =` and Go's `var x Owner` or `var x *Owner`. An assignment inside an argument
819
+ list (`f(gen=UuidGenerator())`) binds nothing.
820
+ """
821
+ found = {}
822
+ for m in ASSIGN_RE.finditer(text):
823
+ before = _skip_space_back(text, m.start())
824
+ if before > 0 and text[before - 1] in "(,":
825
+ continue # a keyword argument or a parameter default
826
+ found[m.start(1)] = (m.group(1), _constructed(text, m.end()))
827
+ for m in TYPED_BINDING_RE.finditer(text):
828
+ if m.group(1) not in BINDING_WORDS:
829
+ found[m.start(2)] = (m.group(2), _type_parts(m.group(1)))
830
+ for regex in (ANNOTATION_RE, GO_VAR_RE):
831
+ for m in regex.finditer(text):
832
+ found[m.start(1)] = (m.group(1), _type_parts(m.group(2)))
833
+ for m in WITH_AS_RE.finditer(text):
834
+ opened = _matching_open(text, m.start())
835
+ callee = CALLEE_END_RE.search(text, max(0, opened - 300), opened) if opened is not None else None
836
+ if callee:
837
+ found[m.start(1)] = (m.group(1), _type_parts(callee.group(1)))
838
+ starts = [0] + [i + 1 for i, c in enumerate(text) if c == "\n"]
839
+ by_name = {}
840
+ for pos in sorted(found):
841
+ name, parts = found[pos]
842
+ rows, types = by_name.setdefault(name, ([], []))
843
+ rows.append(bisect.bisect_right(starts, pos) - 1)
844
+ types.append(parts)
845
+ return by_name
846
+
847
+
848
+ def _class_spans(text):
849
+ """(start, end, class parts) of each class-like body in a block: braces, else indentation (Python, Ruby)."""
850
+ spans = []
851
+ for m in CLASS_RE.finditer(text):
852
+ name = m.group(1) or m.group(2)
853
+ brace = BRACE_BODY_RE.match(text, m.end())
854
+ if brace:
855
+ close = _matching_close(text, brace.end() - 1)
856
+ spans.append((brace.end() - 1, len(text) if close is None else close, _type_parts(name)))
857
+ continue
858
+ line_start = text.rfind("\n", 0, m.start()) + 1
859
+ indent = len(text[line_start:m.start()]) - len(text[line_start:m.start()].lstrip(" \t"))
860
+ end, pos = len(text), text.find("\n", m.end())
861
+ while pos != -1:
862
+ nxt = text.find("\n", pos + 1)
863
+ line = text[pos + 1:nxt if nxt != -1 else len(text)]
864
+ if line.strip() and len(line) - len(line.lstrip(" \t")) <= indent:
865
+ end = pos + 1
866
+ break
867
+ pos = nxt
868
+ spans.append((m.end(), end, _type_parts(name)))
869
+ return spans
870
+
871
+
872
+ class _Block:
873
+ """One fenced block's receivers, read once: its bindings and class bodies, built on first use."""
874
+
875
+ def __init__(self, text, owners):
876
+ self.text, self.owners = text, owners
877
+ self._bindings = self._spans = None
878
+
879
+ def ties(self, match):
880
+ """(via a binding, the owners the receiver of `match` names), cached on the match."""
881
+ if "ties" not in match:
882
+ receiver, ties = match["receiver"], (False, frozenset())
883
+ if receiver is not None:
884
+ rows, types, last = (), (), -1
885
+ if IDENTIFIER_RE.fullmatch(receiver):
886
+ if self._bindings is None:
887
+ self._bindings = _bindings(self.text)
888
+ rows, types = self._bindings.get(receiver, ((), ()))
889
+ last = bisect.bisect_left(rows, match["row"]) - 1 # the last binding on a line before the call
890
+ if last >= 0:
891
+ ties = (True, self.owners.tied(types[last]))
892
+ else:
893
+ ties = (False, self.owners.tied(_receiver_parts(receiver)))
894
+ match["ties"] = ties
895
+ return match["ties"]
896
+
897
+ def receiver_match(self, match, owner):
898
+ """"owner", "bound", "other" or "unknown": how the receiver of `match` ties it to `owner`."""
899
+ if match["receiver"] is None:
900
+ return "unknown" # a bare call: an import or an implicit `this` may tie it, the text does not
901
+ via_binding, tied = self.ties(match)
902
+ if owner in tied:
903
+ return "bound" if via_binding else "owner"
904
+ return "other" if tied else "unknown"
905
+
906
+ def declares(self, match, owner, kind):
907
+ """True when the declaration at `match` is `owner`'s: on its receiver, in its class body, or on a Go
908
+ receiver of its type (`func (g *UuidGenerator) next_uuid(`)."""
909
+ if match["receiver"] is not None:
910
+ return kind in ("owner", "bound")
911
+ owner_parts = self.owners.parts.get(owner)
912
+ go = GO_RECEIVER_TAIL_RE.search(self.text, match["line_start"], match["chain"])
913
+ if go:
914
+ return _ties(_type_parts(go.group(1)), owner_parts)
915
+ if self._spans is None:
916
+ self._spans = _class_spans(self.text)
917
+ inside = [s for s in self._spans if s[0] <= match["name_start"] < s[1]]
918
+ return bool(inside) and _ties(max(inside, key=lambda s: s[0])[2], owner_parts) # the innermost body
919
+
920
+
921
+ def _await_before(text, start):
922
+ """True when `await` stands right before the expression that starts at `start`."""
923
+ return bool(AWAIT_BEFORE_RE.search(text, 0, _skip_space_back(text, start)))
924
+
925
+
546
926
  def _awaited_at(text, start):
547
927
  """True when `await` stands right before the expression that starts at `start`, or an
548
928
  asynchronous loop or context manager consumes it."""
549
- if AWAIT_BEFORE_RE.search(text, 0, _skip_space_back(text, start)):
929
+ if _await_before(text, start):
550
930
  return True
551
931
  return bool(ASYNC_CONSUMER_RE.search(text[text.rfind("\n", 0, start) + 1:start]))
552
932
 
553
933
 
934
+ def _grouped_await(text, chain, close):
935
+ """True for `await (call(...))` and `await ((call(...) as T))`: the call alone fills each group
936
+ around it (a trailing `as T` or `!` allowed), `await` stands before the outermost one, and
937
+ nothing but the end of the statement, of a list or of a bracket follows it."""
938
+ start, end = chain, close
939
+ while end is not None:
940
+ group = _skip_space_back(text, start) - 1
941
+ if group < 0 or text[group] != "(":
942
+ return False
943
+ end_group = _matching_close(text, group)
944
+ if end_group is None or not GROUP_TAIL_RE.fullmatch(text, end + 1, end_group):
945
+ return False
946
+ if _await_before(text, group):
947
+ return bool(GROUP_END_RE.match(text, end_group + 1))
948
+ start, end = group, end_group
949
+ return False
950
+
951
+
554
952
  def _is_runner(callee):
555
953
  """True for `asyncio.run`, `loop.run_until_complete`, `rt.block_on` and the like."""
556
954
  parts = [p for p in re.split(r"\.|::", callee) if p]
@@ -568,7 +966,12 @@ def _opens_body(text, brace_idx):
568
966
 
569
967
 
570
968
  def _enclosing_awaits(text, start):
571
- """True when a call around the expression at `start` is awaited or runs it, within one function body."""
969
+ """How a call or group around the expression at `start` awaits it, within one function body.
970
+
971
+ ("runner", callee) for a runner it is an argument of, ("argument", callee) for an awaited or
972
+ consumed call it is an argument of, ("chain", None) for an awaited group around it and
973
+ ("consumer", None) for a consumed one; None when nothing around it awaits it.
974
+ """
572
975
  i, depth = start, 0
573
976
  while i > 0:
574
977
  i -= 1
@@ -583,17 +986,39 @@ def _enclosing_awaits(text, start):
583
986
  elif c == "(":
584
987
  k = _skip_space_back(text, i)
585
988
  word = WORD_BEFORE_RE.search(text, 0, k)
989
+ callee = None
586
990
  if word and word.group(1) not in EXPRESSION_WORDS:
587
991
  expr = _chain_start(text, word.start())
588
- if _is_runner(re.sub(r"\s+", "", text[expr:k])):
589
- return True
992
+ callee = re.sub(r"(?:\s|\\\n)+", "", text[expr:k])
993
+ if _is_runner(callee):
994
+ return "runner", callee
590
995
  else:
591
996
  expr = i # a grouping `(`: `await (fetch())`
592
997
  close = _matching_close(text, i)
593
- if _awaited_at(text, expr) or (close is not None and AWAIT_AFTER_RE.match(text, close + 1)):
594
- return True
998
+ awaits = _await_before(text, expr) or (close is not None and AWAIT_AFTER_RE.match(text, close + 1))
999
+ if awaits or _awaited_at(text, expr):
1000
+ return ("argument", callee) if callee else ("chain" if awaits else "consumer", None)
595
1001
  i = expr
596
- return False
1002
+ return None
1003
+
1004
+
1005
+ def _how_awaited(text, chain, close):
1006
+ """(awaitedVia, enclosingCallee, chainedMember) of the call whose expression starts at `chain`
1007
+ and whose argument list closes at `close`."""
1008
+ before = _await_before(text, chain)
1009
+ member = AFTER_CALL_RE.match(text, close + 1) if close is not None else None
1010
+ chained = member.group(1) if member else None
1011
+ if close is not None and AWAIT_AFTER_RE.match(text, close + 1):
1012
+ return "direct", None, None
1013
+ if member or (close is not None and APPLIED_RE.match(text, close + 1)):
1014
+ if before:
1015
+ return ("direct" if chained in WAIT_MEMBERS else "chain"), None, chained
1016
+ elif before or _grouped_await(text, chain, close):
1017
+ return "direct", None, None
1018
+ if _awaited_at(text, chain):
1019
+ return "consumer", None, chained
1020
+ via, callee = _enclosing_awaits(text, chain) or (None, None)
1021
+ return via, callee, chained
597
1022
 
598
1023
 
599
1024
  def _closes_generic(tail):
@@ -612,8 +1037,41 @@ def _closes_generic(tail):
612
1037
  return False
613
1038
 
614
1039
 
615
- def _is_declaration(text, line_start, chain, name_start, close, language):
1040
+ def _top_level_args(args):
1041
+ """The arguments of an argument list's text, split at the commas outside any bracket."""
1042
+ parts, depth, start = [], 0, 0
1043
+ for i, c in enumerate(args):
1044
+ if c in BRACKETS:
1045
+ depth += 1
1046
+ elif c in CLOSERS:
1047
+ depth -= 1
1048
+ elif c == "," and depth == 0:
1049
+ parts.append(args[start:i])
1050
+ start = i + 1
1051
+ parts.append(args[start:])
1052
+ return parts
1053
+
1054
+
1055
+ def _annotated(args, language):
1056
+ """True when an argument is an annotated parameter (`key: str`): no call's argument is, in `language`."""
1057
+ if language in PYTHON:
1058
+ args = LAMBDA_PARAMS_RE.sub("lambda:", args) # `key=lambda k, v: v` is one argument
1059
+ for arg in _top_level_args(args):
1060
+ m = ANNOTATED_PARAM_RE.match(arg)
1061
+ if not m or m.group(1) == "lambda" or (m.group(2) and language not in JS_FAMILY):
1062
+ continue
1063
+ if language in ("kotlin", "kt", "kts") and m.group(1) == "object":
1064
+ continue # Kotlin's anonymous object argument: `f(object : Listener { ... })`
1065
+ if language == "scala" and (m.group(1) == "_" or m.group(3).strip() == "_*"):
1066
+ continue # Scala's typed placeholder `f(_: Int)`, and `f(xs: _*)` passing a sequence as varargs
1067
+ return True
1068
+ return False
1069
+
1070
+
1071
+ def _is_declaration(text, line_start, chain, name_start, paren, close, language):
616
1072
  """True when the name at name_start is declared there, not called."""
1073
+ if close is not None and language in ANNOTATED_PARAMS and _annotated(text[paren + 1:close], language):
1074
+ return True # a signature listing: `get(key: str, reveal_secrets: bool = False)`
617
1075
  tail = text[line_start:chain].rstrip(" \t")
618
1076
  if chain == name_start and tail:
619
1077
  word = WORD_BEFORE_RE.search(tail)
@@ -641,46 +1099,81 @@ def _is_declaration(text, line_start, chain, name_start, close, language):
641
1099
  return False
642
1100
 
643
1101
 
644
- def scan_block(rel, lines, opening, closing, info, patterns):
645
- """{name: {"calls": [...], "signatures": [...]}} for one fenced block.
1102
+ def _matches(text, call_re, declaration_res, language):
1103
+ """Each match of a name in a block, read once: where it stands, its receiver, and whether it declares the
1104
+ name (`declaration`) or is a call (`call`: the last part of a qualified name counts only as a declaration)."""
1105
+ seen, found = set(), []
1106
+ for regex in (call_re, *declaration_res):
1107
+ for m in regex.finditer(text):
1108
+ name_start, paren = m.start(), m.end() - 1
1109
+ if name_start in seen:
1110
+ continue
1111
+ seen.add(name_start)
1112
+ close = _matching_close(text, paren)
1113
+ chain = _chain_start(text, name_start)
1114
+ line_start = text.rfind("\n", 0, name_start) + 1
1115
+ declaration = _is_declaration(text, line_start, chain, name_start, paren, close, language)
1116
+ if declaration or regex is call_re:
1117
+ found.append({"name_start": name_start, "close": close, "chain": chain, "line_start": line_start,
1118
+ "row": text.count("\n", 0, name_start), "receiver": _receiver(text, chain, name_start),
1119
+ "declaration": declaration})
1120
+ return found
646
1121
 
647
- `patterns` maps each name to (its call regex, the regexes of the other
648
- forms a declaration of it may take).
1122
+
1123
+ def scan_block(rel, lines, opening, closing, info, names, keys_by_name, owners):
1124
+ """{key: {"calls": [...], "unverified": [...], "other": N, "signatures": [...]}} for one fenced block.
1125
+
1126
+ `names` maps each name to (its call regex, the regexes of the other forms
1127
+ a declaration of it may take), `keys_by_name` each name to its keys,
1128
+ (owner or None, name), and `owners` indexes every owner. A key without an
1129
+ owner takes every call and declaration. An owned key takes the calls on
1130
+ its owner or on a variable the block binds to it, lists those on an
1131
+ "unknown" receiver in `unverified`, counts those on an "other" one, and
1132
+ takes a declaration on its owner, in its class body or on a Go receiver
1133
+ of its type.
649
1134
  """
650
1135
  language = _language(info)
651
- found = {name: {"calls": [], "signatures": []} for name in patterns}
1136
+ found = {key: {"calls": [], "unverified": [], "other": 0, "signatures": []}
1137
+ for keys in keys_by_name.values() for key in keys}
652
1138
  if language in DATA_LANGUAGES:
653
1139
  return found
654
1140
  body = lines[opening + 1:closing]
655
1141
  text = mask_code("\n".join(body), language)
656
- for name, (call_re, declaration_res) in patterns.items():
657
- seen = set()
658
- for regex in (call_re, *declaration_res):
659
- for m in regex.finditer(text):
660
- name_start, paren = m.start(), m.end() - 1
661
- if name_start in seen:
1142
+ block = _Block(text, owners)
1143
+
1144
+ def call(match, receiver_match):
1145
+ if "await" not in match:
1146
+ match["await"] = _how_awaited(text, match["chain"], match["close"])
1147
+ via, callee, chained = match["await"]
1148
+ row = match["row"]
1149
+ return {
1150
+ "file": rel, "line": opening + row + 2, "fence": opening + 1, "language": language or None,
1151
+ "receiver": match["receiver"], "receiverMatch": receiver_match,
1152
+ "awaited": via is not None, "directlyAwaited": via == "direct", "awaitedVia": via,
1153
+ "enclosingCallee": callee, "chainedMember": chained,
1154
+ "text": _cap(body[row].strip(), LINE_TEXT_CAP)[0],
1155
+ }
1156
+
1157
+ for name, (call_re, declaration_res) in names.items():
1158
+ for match in _matches(text, call_re, declaration_res, language):
1159
+ signature = {"line": opening + match["row"] + 2, "text": body[match["row"]].strip()}
1160
+ for key in keys_by_name[name]:
1161
+ owner = key[0]
1162
+ got = found[key]
1163
+ if owner is None:
1164
+ if match["declaration"]:
1165
+ got["signatures"].append(signature)
1166
+ else:
1167
+ got["calls"].append(call(match, None))
662
1168
  continue
663
- seen.add(name_start)
664
- close = _matching_close(text, paren)
665
- chain = _chain_start(text, name_start)
666
- line_start = text.rfind("\n", 0, name_start) + 1
667
- row = text.count("\n", 0, name_start)
668
- if _is_declaration(text, line_start, chain, name_start, close, language):
669
- found[name]["signatures"].append({"line": opening + row + 2, "text": body[row].strip()})
670
- continue
671
- if regex is not call_re:
672
- continue # the last part of a qualified name counts only as a declaration
673
- awaited, chained = _awaited_at(text, chain), None
674
- if close is not None:
675
- if AWAIT_AFTER_RE.match(text, close + 1):
676
- awaited = True
677
- elif member := AFTER_CALL_RE.match(text, close + 1):
678
- chained = member.group(1)
679
- found[name]["calls"].append({
680
- "file": rel, "line": opening + row + 2, "fence": opening + 1, "language": language or None,
681
- "awaited": awaited or _enclosing_awaits(text, chain), "chainedMember": chained,
682
- "text": _cap(body[row].strip(), LINE_TEXT_CAP)[0],
683
- })
1169
+ kind = block.receiver_match(match, owner)
1170
+ if match["declaration"]:
1171
+ if block.declares(match, owner, kind):
1172
+ got["signatures"].append(signature)
1173
+ elif kind == "other":
1174
+ got["other"] += 1
1175
+ else:
1176
+ got["unverified" if kind == "unknown" else "calls"].append(call(match, kind))
684
1177
  return found
685
1178
 
686
1179
 
@@ -694,6 +1187,10 @@ def _validate(inp):
694
1187
  return "Input must be a JSON object"
695
1188
  if not isinstance(inp.get("names"), list):
696
1189
  return "names must be a list of export names"
1190
+ if inp.get("methods") is not None and not isinstance(inp["methods"], list):
1191
+ return "methods must be a list of {name, owner} objects when given"
1192
+ if inp.get("owners") is not None and not isinstance(inp["owners"], list):
1193
+ return "owners must be a list of class and namespace names when given"
697
1194
  path = inp.get("skillPackagePath")
698
1195
  if not isinstance(path, str) or not path:
699
1196
  return "skillPackagePath is required: the folder that holds SKILL.md and references/"
@@ -702,46 +1199,92 @@ def _validate(inp):
702
1199
  return None
703
1200
 
704
1201
 
1202
+ def _keys(inp):
1203
+ """(owner or None, name) for each name and each method of the input, in input order, deduplicated.
1204
+
1205
+ An owner applies only to a plain name: a qualified one (`agents.get`) is matched as written."""
1206
+ keys = [(None, n) for n in inp["names"] if isinstance(n, str) and n.strip()]
1207
+ for item in inp.get("methods") or []:
1208
+ if not isinstance(item, dict):
1209
+ continue
1210
+ name, owner = item.get("name"), item.get("owner")
1211
+ if not (isinstance(name, str) and name.strip() and isinstance(owner, str) and owner.strip()):
1212
+ continue
1213
+ name, owner = name.strip(), _owner_display(owner)
1214
+ if _last_part(name):
1215
+ keys.append((None, name))
1216
+ elif _type_parts(owner):
1217
+ keys.append((owner, name))
1218
+ return list(dict.fromkeys(keys))
1219
+
1220
+
1221
+ def _owner_first(descriptions, owner):
1222
+ """An owned method's descriptions, those that name its owner (`agents` for `cognee.agents`, any case) first."""
1223
+ if owner is None:
1224
+ return descriptions
1225
+ names_owner = re.compile(_bounded(_last_part(owner) or owner).pattern, re.I)
1226
+ return sorted(descriptions, key=lambda d: not names_owner.search(d["text"]))
1227
+
1228
+
705
1229
  def locate(inp):
706
1230
  """Pure location over SKILL.md and references/**/*.md under skillPackagePath."""
707
1231
  err = _validate(inp)
708
1232
  if err:
709
1233
  return make_error(err)
710
- names = list(dict.fromkeys(n for n in inp["names"] if isinstance(n, str) and n.strip()))
1234
+ keys = _keys(inp)
1235
+ owners = _Owners(dict.fromkeys(
1236
+ [d for o in inp.get("owners") or [] if isinstance(o, str) and (d := _owner_display(o))]
1237
+ + [owner for owner, _ in keys if owner is not None]))
1238
+ names = list(dict.fromkeys(name for _, name in keys))
711
1239
  bounded = {name: _bounded(name) for name in names}
712
1240
  patterns = {name: (_call_pattern(name), [_call_pattern(p)] if (p := _last_part(name)) else [])
713
1241
  for name in names}
1242
+ keys_by_name = {name: [key for key in keys if key[1] == name] for name in names}
714
1243
  files = skill_files(inp["skillPackagePath"])
715
- descriptions = {name: [] for name in names}
716
- calls = {name: [] for name in names}
1244
+ descriptions = {key: [] for key in keys}
1245
+ calls = {key: [] for key in keys}
1246
+ unverified = {key: [] for key in keys}
1247
+ other = dict.fromkeys(keys, 0)
717
1248
  for rel, lines in files:
718
1249
  start = _frontmatter_end(lines)
719
1250
  fenced, code = _fenced_blocks(lines, start)
720
1251
  headings, blocks = _prose(lines, start, code)
721
- per_file = {name: describe(rel, lines, code, headings, blocks, bounded[name]) for name in names}
1252
+ prose = {name: describe(rel, lines, code, headings, blocks, bounded[name]) for name in names}
1253
+ per_file = {key: list(prose[key[1]]) for key in keys}
722
1254
  for opening, closing, info in fenced:
723
- for name, got in scan_block(rel, lines, opening, closing, info, patterns).items():
724
- calls[name] += got["calls"]
725
- per_file[name] += [_entry(rel, s["line"], s["line"], "signature",
726
- _heading_path(headings, s["line"] - 1), None, s["text"], LINE_TEXT_CAP)
727
- for s in got["signatures"]]
728
- for name in names:
729
- descriptions[name] += sorted(per_file[name], key=lambda e: e["line"])
1255
+ for key, got in scan_block(rel, lines, opening, closing, info, patterns, keys_by_name, owners).items():
1256
+ calls[key] += got["calls"]
1257
+ unverified[key] += got["unverified"]
1258
+ other[key] += got["other"]
1259
+ per_file[key] += [_entry(rel, s["line"], s["line"], "signature",
1260
+ _heading_path(headings, s["line"] - 1), None, s["text"], LINE_TEXT_CAP)
1261
+ for s in got["signatures"]]
1262
+ for key in keys:
1263
+ descriptions[key] += sorted(per_file[key], key=lambda e: e["line"])
730
1264
  exports = [{
731
1265
  "name": name,
732
- "descriptionCount": len(descriptions[name]),
733
- "descriptions": descriptions[name][:MAX_DESCRIPTIONS],
734
- "calls": calls[name],
735
- "callCount": len(calls[name]),
736
- "awaitedCount": sum(1 for c in calls[name] if c["awaited"]),
737
- } for name in names if calls[name]]
738
- not_called = [name for name in names if not calls[name]]
1266
+ "owner": owner,
1267
+ "descriptionCount": len(descriptions[(owner, name)]),
1268
+ "descriptions": _owner_first(descriptions[(owner, name)], owner)[:MAX_DESCRIPTIONS],
1269
+ "calls": calls[(owner, name)],
1270
+ "callCount": len(calls[(owner, name)]),
1271
+ "awaitedCount": sum(1 for c in calls[(owner, name)] if c["awaited"]),
1272
+ "directlyAwaitedCount": sum(1 for c in calls[(owner, name)] if c["directlyAwaited"]),
1273
+ "unverifiedCalls": unverified[(owner, name)],
1274
+ "otherReceiverCount": other[(owner, name)],
1275
+ } for owner, name in keys if calls[(owner, name)]]
1276
+ # A method with only unverified calls is not called: §2.5's async rule cites a first call. Its unverified
1277
+ # calls still reach the output, in notCalledUnverified.
1278
+ not_called = [f"{owner}.{name}" if owner else name for owner, name in keys if not calls[(owner, name)]]
1279
+ not_called_unverified = [{"name": name, "owner": owner, "unverifiedCalls": unverified[(owner, name)]}
1280
+ for owner, name in keys if not calls[(owner, name)] and unverified[(owner, name)]]
739
1281
  return {
740
1282
  "skillPackagePath": inp["skillPackagePath"],
741
1283
  "files": [rel for rel, _ in files],
742
1284
  "exports": exports,
743
1285
  "notCalled": not_called,
744
- "summary": {"names": len(names), "called": len(exports), "notCalled": len(not_called)},
1286
+ "notCalledUnverified": not_called_unverified,
1287
+ "summary": {"names": len(keys), "called": len(exports), "notCalled": len(not_called)},
745
1288
  }
746
1289
 
747
1290
 
@@ -770,26 +1313,92 @@ def _build_parser():
770
1313
  help="JSON object passed via flag (overrides positional).")
771
1314
  src.add_argument("--stdin", action="store_true", help="Read the JSON object from stdin.")
772
1315
  src.add_argument("--inventory", metavar="PATH",
773
- help="the validated inventory file: its function and method names (needs --skill-dir)")
1316
+ help="the validated inventory file: its functions and methods, and the `methods` of each "
1317
+ "class, interface, struct, trait or type entry tied to it (needs --skill-dir)")
774
1318
  parser.add_argument("--skill-dir", metavar="DIR", help="with --inventory: the skill package")
775
1319
  return parser
776
1320
 
777
1321
 
778
- # The inventory kinds §2.5 checks: the ones a caller awaits or calls.
1322
+ # The inventory kinds §2.5 checks: the ones a caller awaits or calls. The
1323
+ # `methods` items of a class, interface, struct, trait or type entry
1324
+ # (METHOD_LIST_KINDS, and no other kind's) are checked too, each tied to its
1325
+ # entry.
779
1326
  CALLABLE_KINDS = ("function", "method")
1327
+ METHOD_LIST_KINDS = ("class", "interface", "struct", "trait", "type")
1328
+ # The kinds a method can be documented on: a receiver that names one is that owner's.
1329
+ OWNER_KINDS = ("class", "struct", "interface", "trait", "type", "enum", "adapter")
1330
+ # Words a `methods` item may write before the method's name: `async next_uuid(dep=None)`.
1331
+ METHOD_MODIFIERS = DECLARATION_WORDS | {"classmethod", "staticmethod"}
1332
+ MODIFIER_RE = re.compile(f"((?:[^\\W\\d]|\\$)[{IDENT_CHARS}]*)\\s+(?=[{IDENT_CHARS}*\\[])")
1333
+ DECORATOR_RE = re.compile(f"@{TYPE_PATH}")
1334
+ GO_RECEIVER_ITEM_RE = re.compile(r"^func[ \t]*\([^()]*\)[ \t]*")
1335
+ QUALIFIED = f"(?:[^\\W\\d]|\\$)[{IDENT_CHARS}]*(?:(?:\\.|::|#)(?:[^\\W\\d]|\\$)[{IDENT_CHARS}]*)*"
1336
+ METHOD_CALLEE_RE = re.compile(
1337
+ f"(?<![{IDENT_CHARS}.:#])({QUALIFIED})[ \\t]*(?:<[^<>()\\n]*(?:<[^<>()\\n]*>[^<>()\\n]*)*>)?[ \\t]*\\(")
1338
+ METHOD_LEAD_RE = re.compile(f"({QUALIFIED})")
1339
+ AWAIT_LEAD_RE = re.compile(f"await(?![{IDENT_CHARS}])")
1340
+ DUNDER_RE = re.compile(r"__\w+__")
1341
+
1342
+
1343
+ def _method_name(item):
1344
+ """The method a class entry's `methods` item names, as the skill writes it (`async next_uuid(self) -> UUID`
1345
+ gives `next_uuid`), or None: a dunder (`__init__`), an item that starts with `await`, and one that holds
1346
+ only modifiers (`async`, `async *[Symbol.asyncIterator]()`) name no method."""
1347
+ text = item.strip().lstrip(".#")
1348
+ while (m := DECORATOR_RE.match(text)): # `@property name`, `@lru_cache(maxsize=1) compute(x)`
1349
+ end = m.end()
1350
+ if text.startswith("(", end):
1351
+ close = _matching_close(text, end)
1352
+ if close is None:
1353
+ return None
1354
+ end = close + 1
1355
+ text = text[end:].lstrip()
1356
+ text = GO_RECEIVER_ITEM_RE.sub("", text, count=1) # `func (c *Client) Fetch(ctx) error`
1357
+ while (m := MODIFIER_RE.match(text)) and m.group(1) in METHOD_MODIFIERS:
1358
+ text = text[m.end():].lstrip("*") # `async *stream()`, a generator
1359
+ if AWAIT_LEAD_RE.match(text):
1360
+ return None
1361
+ m = METHOD_CALLEE_RE.search(text)
1362
+ if not m:
1363
+ m = METHOD_LEAD_RE.match(text)
1364
+ if not m or m.group(1) in METHOD_MODIFIERS:
1365
+ return None
1366
+ name = _last_part(m.group(1)) or m.group(1)
1367
+ return None if DUNDER_RE.fullmatch(name) else name
780
1368
 
781
1369
 
782
1370
  def inventory_input(path, skill_dir):
783
- """The JSON input for the function and method names of a validated inventory file."""
1371
+ """The JSON input for the functions and methods of a validated inventory file.
1372
+
1373
+ `names`: each `function` entry, and each `method` entry with no non-empty
1374
+ string `owner`. `methods`: each other `method` entry with its owner, and
1375
+ each method the `methods` list of a class, interface, struct, trait or
1376
+ type entry names, owned by that entry. `owners`: each class-like entry and
1377
+ each method's owner.
1378
+ """
784
1379
  data = json.loads(Path(path).read_bytes().decode("utf-8-sig"))
785
1380
  if isinstance(data, dict) and isinstance(data.get("inventory"), dict):
786
1381
  data = data["inventory"] # validate-inventory.py's whole result
787
1382
  exports = data.get("exports") if isinstance(data, dict) else None
788
1383
  if not isinstance(exports, list):
789
1384
  raise ValueError(f"--inventory {path} holds no `exports` list")
790
- names = [e["name"] for e in exports
791
- if isinstance(e, dict) and e.get("kind") in CALLABLE_KINDS and isinstance(e.get("name"), str)]
792
- return {"names": names, "skillPackagePath": skill_dir}
1385
+ names, methods, owners = [], [], []
1386
+ for entry in exports:
1387
+ if not (isinstance(entry, dict) and isinstance(entry.get("name"), str)):
1388
+ continue
1389
+ name, kind, owner = entry["name"], entry.get("kind"), entry.get("owner")
1390
+ if kind == "method" and isinstance(owner, str) and owner.strip():
1391
+ methods.append({"name": name, "owner": owner})
1392
+ owners.append(owner)
1393
+ elif kind in CALLABLE_KINDS:
1394
+ names.append(name)
1395
+ if kind in OWNER_KINDS:
1396
+ owners.append(name)
1397
+ listed = entry["methods"] if kind in METHOD_LIST_KINDS and isinstance(entry.get("methods"), list) else []
1398
+ for method in filter(None, (_method_name(item) for item in listed if isinstance(item, str))):
1399
+ methods.append({"name": method, "owner": name})
1400
+ owners.append(name)
1401
+ return {"names": names, "methods": methods, "owners": list(dict.fromkeys(owners)), "skillPackagePath": skill_dir}
793
1402
 
794
1403
 
795
1404
  def _resolve_input(args):