bmad-module-skill-forge 5.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 (91) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/agents.md +1 -1
  4. package/docs/architecture.md +3 -3
  5. package/docs/campaign.md +1 -1
  6. package/docs/troubleshooting.md +9 -3
  7. package/docs/verifying-a-skill.md +9 -9
  8. package/docs/workflows.md +4 -4
  9. package/package.json +1 -1
  10. package/src/knowledge/tool-resolution.md +6 -6
  11. package/src/shared/references/headless-gate-convention.md +4 -1
  12. package/src/shared/references/output-contract-schema.md +3 -3
  13. package/src/shared/references/pipeline-contracts.md +2 -1
  14. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  15. package/src/shared/scripts/skf-build-change-manifest.py +332 -54
  16. package/src/shared/scripts/skf-classify-changed-files.py +15 -10
  17. package/src/shared/scripts/skf-emit-result-envelope.py +58 -15
  18. package/src/shared/scripts/skf-extract-public-api.py +33 -13
  19. package/src/shared/scripts/skf-parse-gaps.py +3 -2
  20. package/src/shared/scripts/skf-verify-provenance-completeness.py +108 -11
  21. package/src/skf-analyze-source/SKILL.md +1 -0
  22. package/src/skf-analyze-source/references/generate-briefs.md +1 -1
  23. package/src/skf-analyze-source/references/headless-contract.md +1 -1
  24. package/src/skf-audit-skill/SKILL.md +2 -1
  25. package/src/skf-audit-skill/references/report.md +1 -1
  26. package/src/skf-brief-skill/SKILL.md +1 -0
  27. package/src/skf-brief-skill/references/invocation-contract.md +2 -2
  28. package/src/skf-campaign/SKILL.md +1 -0
  29. package/src/skf-campaign/references/campaign-contracts.md +2 -0
  30. package/src/skf-campaign/references/step-05-skill-loop.md +2 -2
  31. package/src/skf-campaign/references/step-10-export.md +3 -2
  32. package/src/skf-create-skill/SKILL.md +2 -1
  33. package/src/skf-create-skill/references/ccc-index-check.md +1 -1
  34. package/src/skf-create-skill/references/component-extraction.md +1 -1
  35. package/src/skf-create-skill/references/extract.md +1 -1
  36. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  37. package/src/skf-create-skill/references/report.md +2 -2
  38. package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
  39. package/src/skf-create-skill/references/validate.md +1 -1
  40. package/src/skf-create-stack-skill/SKILL.md +1 -0
  41. package/src/skf-create-stack-skill/references/generate-output.md +1 -1
  42. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  43. package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
  44. package/src/skf-create-stack-skill/references/report.md +1 -1
  45. package/src/skf-drop-skill/SKILL.md +1 -0
  46. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  47. package/src/skf-drop-skill/references/report.md +1 -1
  48. package/src/skf-export-skill/SKILL.md +1 -0
  49. package/src/skf-export-skill/references/invocation-contract.md +1 -1
  50. package/src/skf-export-skill/references/summary.md +1 -1
  51. package/src/skf-forger/SKILL.md +1 -0
  52. package/src/skf-forger/references/pipeline-mode.md +2 -2
  53. package/src/skf-quick-skill/SKILL.md +2 -1
  54. package/src/skf-quick-skill/references/finalize.md +1 -1
  55. package/src/skf-quick-skill/references/halt-contract.md +2 -2
  56. package/src/skf-quick-skill/references/write-and-validate.md +1 -1
  57. package/src/skf-refine-architecture/SKILL.md +2 -1
  58. package/src/skf-refine-architecture/customize.toml +1 -1
  59. package/src/skf-refine-architecture/references/report.md +1 -1
  60. package/src/skf-rename-skill/SKILL.md +1 -0
  61. package/src/skf-rename-skill/references/invocation-contract.md +2 -2
  62. package/src/skf-setup/SKILL.md +1 -0
  63. package/src/skf-setup/references/ccc-index.md +1 -1
  64. package/src/skf-setup/references/invocation-contract.md +1 -1
  65. package/src/skf-test-skill/SKILL.md +1 -0
  66. package/src/skf-test-skill/assets/output-section-formats.md +4 -4
  67. package/src/skf-test-skill/references/coherence-check.md +8 -6
  68. package/src/skf-test-skill/references/coverage-check-branches.md +23 -3
  69. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  70. package/src/skf-test-skill/references/coverage-check.md +30 -43
  71. package/src/skf-test-skill/references/init.md +1 -1
  72. package/src/skf-test-skill/references/inventory-contract.md +27 -0
  73. package/src/skf-test-skill/references/invocation-contract.md +3 -3
  74. package/src/skf-test-skill/references/report.md +3 -3
  75. package/src/skf-test-skill/references/scoring-rules.md +6 -6
  76. package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
  77. package/src/skf-test-skill/references/source-access-protocol.md +12 -8
  78. package/src/skf-test-skill/references/step-hard-gate.md +1 -0
  79. package/src/skf-test-skill/scripts/build-result-context.py +20 -3
  80. package/src/skf-test-skill/scripts/gap-ledger.py +78 -13
  81. package/src/skf-test-skill/scripts/load-coverage-inputs.py +345 -9
  82. package/src/skf-test-skill/scripts/locate-export-segments.py +591 -63
  83. package/src/skf-test-skill/scripts/score-signatures.py +939 -74
  84. package/src/skf-test-skill/scripts/validate-inventory.py +245 -29
  85. package/src/skf-update-skill/SKILL.md +1 -0
  86. package/src/skf-update-skill/references/gap-driven.md +28 -22
  87. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  88. package/src/skf-update-skill/references/merge.md +4 -4
  89. package/src/skf-update-skill/references/report.md +1 -1
  90. package/src/skf-update-skill/references/write.md +5 -4
  91. package/src/skf-verify-stack/SKILL.md +2 -1
@@ -29,6 +29,50 @@ 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
78
  `await client.fetchData(...)`), `.await` or `?.await` follows it (Rust), an asynchronous
@@ -66,6 +110,15 @@ usage-scope). A qualified name (`client.fetch`, `Store::load`) matches only
66
110
  as written, except that a declaration also matches its last part
67
111
  (`def fetch(`): a declaration never carries the qualifier.
68
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
+
69
122
  Line model: UTF-8 with a leading byte order mark dropped, split on `\\n`
70
123
  (a `\\r\\n` pair loses its `\\r`), so each `line` matches `grep -n`. The
71
124
  frontmatter is a leading `---` ... `---` block. A fence opens on three or
@@ -77,13 +130,33 @@ indent); its section runs to the next heading of the same or a higher level.
77
130
  Input (one JSON object):
78
131
  {
79
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
80
135
  "skillPackagePath": "/path/to/skill" # holds SKILL.md and references/
81
136
  }
82
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
+
83
143
  or the files: --inventory <file> --skill-dir <dir>. --inventory reads the
84
- validated inventory validate-inventory.py wrote (its --output) and takes the
85
- name of each entry whose kind is `function` or `method`, so no name is
86
- 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.
87
160
 
88
161
  Output (stdout, one object):
89
162
  {
@@ -94,15 +167,19 @@ Output (stdout, one object):
94
167
  "name": "fetchData",
95
168
  "descriptionCount": 3, # all found; descriptions[] holds the first 12
96
169
  "descriptions": [{"file", "line", "endLine", "kind", "section", "header", "text", "truncated"}],
97
- "calls": [{"file", "line", "fence", "language", "awaited", "directlyAwaited", "awaitedVia",
98
- "enclosingCallee", "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"}],
99
173
  "callCount": 2,
100
174
  "awaitedCount": 1, # calls whose `awaited` is true
101
- "directlyAwaitedCount": 1 # calls whose `directlyAwaited` 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
102
178
  }
103
179
  ],
104
- "notCalled": ["parseConfig"], # names no example calls
105
- "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
106
183
  }
107
184
  or {"error": ..., "code": "INVALID_INPUT"} on a schema violation.
108
185
 
@@ -124,6 +201,7 @@ Exit codes:
124
201
  from __future__ import annotations
125
202
 
126
203
  import argparse
204
+ import bisect
127
205
  import json
128
206
  import re
129
207
  import sys
@@ -202,6 +280,21 @@ TYPED_DECLARATIONS = C_FAMILY | frozenset({"groovy", ""})
202
280
  # A `{` after a call at the start of a line opens a method body here; in
203
281
  # Kotlin, Swift or Ruby it can be a trailing closure of a call.
204
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}]*")
205
298
 
206
299
  # A word right before the name that declares it.
207
300
  DECLARATION_WORDS = frozenset({
@@ -225,6 +318,36 @@ RUN_OWNERS = frozenset({"asyncio", "trio", "anyio"})
225
318
  BRACKETS = {"(": ")", "[": "]", "{": "}"}
226
319
  CLOSERS = {v: k for k, v in BRACKETS.items()}
227
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
+
228
351
 
229
352
  def make_error(message):
230
353
  return {"error": message, "code": "INVALID_INPUT"}
@@ -563,6 +686,8 @@ def _chain_start(text, pos):
563
686
  if accessor is None:
564
687
  return i
565
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()`
566
691
  start = k
567
692
  while start > 0 and text[start - 1] in ")]":
568
693
  opened = _matching_open(text, start - 1)
@@ -577,6 +702,222 @@ def _chain_start(text, pos):
577
702
  i = start
578
703
 
579
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
+
580
921
  def _await_before(text, start):
581
922
  """True when `await` stands right before the expression that starts at `start`."""
582
923
  return bool(AWAIT_BEFORE_RE.search(text, 0, _skip_space_back(text, start)))
@@ -696,8 +1037,41 @@ def _closes_generic(tail):
696
1037
  return False
697
1038
 
698
1039
 
699
- 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):
700
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)`
701
1075
  tail = text[line_start:chain].rstrip(" \t")
702
1076
  if chain == name_start and tail:
703
1077
  word = WORD_BEFORE_RE.search(tail)
@@ -725,42 +1099,81 @@ def _is_declaration(text, line_start, chain, name_start, close, language):
725
1099
  return False
726
1100
 
727
1101
 
728
- def scan_block(rel, lines, opening, closing, info, patterns):
729
- """{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
1121
+
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.
730
1125
 
731
- `patterns` maps each name to (its call regex, the regexes of the other
732
- forms a declaration of it may take).
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.
733
1134
  """
734
1135
  language = _language(info)
735
- 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}
736
1138
  if language in DATA_LANGUAGES:
737
1139
  return found
738
1140
  body = lines[opening + 1:closing]
739
1141
  text = mask_code("\n".join(body), language)
740
- for name, (call_re, declaration_res) in patterns.items():
741
- seen = set()
742
- for regex in (call_re, *declaration_res):
743
- for m in regex.finditer(text):
744
- name_start, paren = m.start(), m.end() - 1
745
- if name_start in seen:
746
- continue
747
- seen.add(name_start)
748
- close = _matching_close(text, paren)
749
- chain = _chain_start(text, name_start)
750
- line_start = text.rfind("\n", 0, name_start) + 1
751
- row = text.count("\n", 0, name_start)
752
- if _is_declaration(text, line_start, chain, name_start, close, language):
753
- found[name]["signatures"].append({"line": opening + row + 2, "text": body[row].strip()})
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))
754
1168
  continue
755
- if regex is not call_re:
756
- continue # the last part of a qualified name counts only as a declaration
757
- via, callee, chained = _how_awaited(text, chain, close)
758
- found[name]["calls"].append({
759
- "file": rel, "line": opening + row + 2, "fence": opening + 1, "language": language or None,
760
- "awaited": via is not None, "directlyAwaited": via == "direct", "awaitedVia": via,
761
- "enclosingCallee": callee, "chainedMember": chained,
762
- "text": _cap(body[row].strip(), LINE_TEXT_CAP)[0],
763
- })
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))
764
1177
  return found
765
1178
 
766
1179
 
@@ -774,6 +1187,10 @@ def _validate(inp):
774
1187
  return "Input must be a JSON object"
775
1188
  if not isinstance(inp.get("names"), list):
776
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"
777
1194
  path = inp.get("skillPackagePath")
778
1195
  if not isinstance(path, str) or not path:
779
1196
  return "skillPackagePath is required: the folder that holds SKILL.md and references/"
@@ -782,47 +1199,92 @@ def _validate(inp):
782
1199
  return None
783
1200
 
784
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
+
785
1229
  def locate(inp):
786
1230
  """Pure location over SKILL.md and references/**/*.md under skillPackagePath."""
787
1231
  err = _validate(inp)
788
1232
  if err:
789
1233
  return make_error(err)
790
- 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))
791
1239
  bounded = {name: _bounded(name) for name in names}
792
1240
  patterns = {name: (_call_pattern(name), [_call_pattern(p)] if (p := _last_part(name)) else [])
793
1241
  for name in names}
1242
+ keys_by_name = {name: [key for key in keys if key[1] == name] for name in names}
794
1243
  files = skill_files(inp["skillPackagePath"])
795
- descriptions = {name: [] for name in names}
796
- 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)
797
1248
  for rel, lines in files:
798
1249
  start = _frontmatter_end(lines)
799
1250
  fenced, code = _fenced_blocks(lines, start)
800
1251
  headings, blocks = _prose(lines, start, code)
801
- 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}
802
1254
  for opening, closing, info in fenced:
803
- for name, got in scan_block(rel, lines, opening, closing, info, patterns).items():
804
- calls[name] += got["calls"]
805
- per_file[name] += [_entry(rel, s["line"], s["line"], "signature",
806
- _heading_path(headings, s["line"] - 1), None, s["text"], LINE_TEXT_CAP)
807
- for s in got["signatures"]]
808
- for name in names:
809
- 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"])
810
1264
  exports = [{
811
1265
  "name": name,
812
- "descriptionCount": len(descriptions[name]),
813
- "descriptions": descriptions[name][:MAX_DESCRIPTIONS],
814
- "calls": calls[name],
815
- "callCount": len(calls[name]),
816
- "awaitedCount": sum(1 for c in calls[name] if c["awaited"]),
817
- "directlyAwaitedCount": sum(1 for c in calls[name] if c["directlyAwaited"]),
818
- } for name in names if calls[name]]
819
- 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)]]
820
1281
  return {
821
1282
  "skillPackagePath": inp["skillPackagePath"],
822
1283
  "files": [rel for rel, _ in files],
823
1284
  "exports": exports,
824
1285
  "notCalled": not_called,
825
- "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)},
826
1288
  }
827
1289
 
828
1290
 
@@ -851,26 +1313,92 @@ def _build_parser():
851
1313
  help="JSON object passed via flag (overrides positional).")
852
1314
  src.add_argument("--stdin", action="store_true", help="Read the JSON object from stdin.")
853
1315
  src.add_argument("--inventory", metavar="PATH",
854
- 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)")
855
1318
  parser.add_argument("--skill-dir", metavar="DIR", help="with --inventory: the skill package")
856
1319
  return parser
857
1320
 
858
1321
 
859
- # 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.
860
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
861
1368
 
862
1369
 
863
1370
  def inventory_input(path, skill_dir):
864
- """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
+ """
865
1379
  data = json.loads(Path(path).read_bytes().decode("utf-8-sig"))
866
1380
  if isinstance(data, dict) and isinstance(data.get("inventory"), dict):
867
1381
  data = data["inventory"] # validate-inventory.py's whole result
868
1382
  exports = data.get("exports") if isinstance(data, dict) else None
869
1383
  if not isinstance(exports, list):
870
1384
  raise ValueError(f"--inventory {path} holds no `exports` list")
871
- names = [e["name"] for e in exports
872
- if isinstance(e, dict) and e.get("kind") in CALLABLE_KINDS and isinstance(e.get("name"), str)]
873
- 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}
874
1402
 
875
1403
 
876
1404
  def _resolve_input(args):