bmad-module-skill-forge 8.1.0 → 9.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.
@@ -76,24 +76,37 @@ reading files and writing one JSON object:
76
76
  data block (json, yaml, text...) gives none.
77
77
  Signatures (#888): an origin whose place writes the name's
78
78
  signature in a fixed form that is surely complete also carries
79
- it as `signature`, {"params", "return_type"}, two strings, each
80
- one's whitespace runs read as one space, which
81
- validate-inventory.py --floor refuses an inventory for
82
- changing; any other place pins nothing, since a wrong pin would
83
- be forced into the inventory. `params` is the text inside the
84
- parameter group (score-signatures.py's _group: the first pair
85
- of parentheses outside every bracket pair and string), and
86
- `return_type` the type written after it (score-signatures.py's
87
- _signature_parts), "" for none. A form that writes neither
88
- (`f()`, `useQuery<T>()`) gives none, so an empty call claims no
89
- form. A span (a table cell's or a heading's) is complete when
90
- it is the name (or no name, as a Signature cell's `(x: A) =>
91
- B`), its type parameters, one parameter group, then nothing or
92
- a return separator (`->`, `=>`, `→` or `:`) and the return, with
93
- nothing else: `useQuery(key).data`, `f(a) / f(a, b)`, a
94
- trailing comment, a `;` or `,` outside the return's brackets,
95
- an unclosed bracket, `Meta<T>` (no group) and `new Client(opts)`
96
- give none. By form:
79
+ it as `signature`, which validate-inventory.py --floor refuses
80
+ an inventory for changing; any other place pins nothing, since
81
+ a wrong pin would be forced into the inventory. A signature is
82
+ two strings, each one's whitespace runs read as one space,
83
+ {"params", "return_type"} for a complete form or a Props cell
84
+ (below) and {"usage_signature", "return_type"} for a usage form
85
+ (and `signature_form` "partial" under a header's partial
86
+ label, below). `params` is the text inside the parameter group
87
+ (score-signatures.py's _group: the first pair of parentheses
88
+ outside every bracket pair and string), and `return_type` the
89
+ type written after it (score-signatures.py's _signature_parts),
90
+ "" for none. A form that writes neither (`f()`,
91
+ `useQuery<T>()`) gives none, so an empty call claims no form. A
92
+ span (a table cell's or a heading's) is complete when it is the
93
+ name (or no name, as a Signature cell's `(x: A) => B`), its type
94
+ parameters, one parameter group, then nothing or a return
95
+ separator (`->`, `=>`, `→` or `:`) and the return, with nothing
96
+ else: `useQuery(key).data`, `f(a) / f(a, b)`, a trailing
97
+ comment, a `;` or `,` outside the return's brackets, an
98
+ unclosed bracket, `Meta<T>` (no group) and `new Client(opts)`
99
+ give none. A span that is no complete form is a usage form when
100
+ it writes, before the name's complete form, at least one of, in
101
+ this order, an assignment (`x =`, `const { data } =`), `await`,
102
+ `new` and a receiver or module path (`cognee.`): its
103
+ `usage_signature` is the span through the parameter group, its
104
+ `return_type` the return after it (`await run(a, b):
105
+ Promise<R>` gives `await run(a, b)` and `Promise<R>`); or when
106
+ it is one self-closing JSX element of the name that sets a prop
107
+ (`<Canvas of={X} />`), its `return_type` "". An empty call
108
+ (`new Client()`) and a prop-less element (`<Canvas />`) give
109
+ none. By form:
97
110
  table-row a row whose first cell names one name (a row
98
111
  naming several gives none), each cell's `\\|`
99
112
  read as `|`: the leading code span of its
@@ -102,15 +115,38 @@ reading files and writing one JSON object:
102
115
  the one span of its first cell that gives the
103
116
  name (another export's span there, as in
104
117
  `fetch` (same as `http.request(opts)`), gives
105
- nothing). A Signature span with a group that is
106
- no complete form (`client.get(k) -> str`,
107
- `new Client(opts)`) gives none, so a shorter
108
- first-cell call never stands in for it. A header
109
- cell matches whole, so a labelled one is not a
110
- Signature column, and a Props, Params or
111
- Parameters cell is never read (its shared
112
- members and usage forms stay the reader's).
113
- heading its code span
118
+ nothing), each a complete form, else a usage
119
+ form. A Signature span with a group that is
120
+ neither (`client.get(k).data`, `f(a) //
121
+ overload`) gives none, so a shorter first-cell
122
+ call never stands in for it, and so does such a
123
+ first-cell span (`useData(key).data`). Only
124
+ then a Props, Params or Parameters cell, when
125
+ the whole cell is one code span (trailing prose
126
+ or several spans leave it the reader's): a
127
+ complete form (`(a, b)` gives `a, b`), else a
128
+ usage form, else none when it writes a
129
+ parameter group or opens a JSX element, or when
130
+ it only names a type or writes a placeholder
131
+ (`ButtonProps`, `ComponentProps<typeof Link>`,
132
+ `None`, `-`), else the span whole
133
+ as `params` with `return_type` ""
134
+ (`TitleProps = { of?: Of }`, `key: string`),
135
+ kept as written. A Signature, Props, Params
136
+ or Parameters header may give one partial
137
+ label in parentheses after its word, any case:
138
+ `Signature (simplified)`, `Props (simplified)`,
139
+ `(partial)`, `(key parameters)` or
140
+ `(key params)`. The form its cell gives then
141
+ also carries `signature_form` "partial", so the
142
+ label is the script's, not the reader's; the
143
+ first-cell form never does, as the label heads
144
+ that column only. Any other text after the word
145
+ (`Signature (TypeScript)`, `Props / Usage`)
146
+ leaves the column unread.
147
+ heading its code span, a complete form, else a usage
148
+ form (`new Client(opts)`: a name is cut as
149
+ above, so no other usage names one there)
114
150
  declaration a function's (`function`, `def`, `fn` or Go's
115
151
  `func` before its name; a `const` holding an
116
152
  arrow gives none): its lines as one, as
@@ -174,10 +210,12 @@ reading files and writing one JSON object:
174
210
  same name and file), less the outside_scope
175
211
  names (defined in a file out of scope), which
176
212
  `excluded.outsideScope` lists with their file,
177
- line and entry point and, when the brief
178
- scopes the file out, why: the scope.exclude
179
- glob that matches it (`excludeGlob`) or no
180
- scope.include glob covering it
213
+ line and entry point and, for a namespace's
214
+ member, the namespace (`namespace`) and, when
215
+ the brief scopes the file out, why: the
216
+ scope.exclude glob that matches it
217
+ (`excludeGlob`) or no scope.include glob
218
+ covering it
181
219
  (`outsideInclude`); exports[] only gives each name's
182
220
  kind, line and signature line (a local export
183
221
  list's item: its binding's declaration_line and
@@ -204,7 +242,10 @@ reading files and writing one JSON object:
204
242
  line of its outside_scope row, when no include,
205
243
  exclude or declined scope-expansion amendment
206
244
  (latest action skipped or demoted-include, from
207
- --brief) matches its file. A tier_a_include glob
245
+ --brief) matches its file; no recipe reads its
246
+ file, so its kind is the one --provenance gives
247
+ the name at that file (PROVENANCE_KINDS), else
248
+ none. A tier_a_include glob
208
249
  the runner lists as matching no file
209
250
  (scope.unmatched_tier_a_include, [] when the key
210
251
  is absent) counts no name. An extraction the
@@ -225,11 +266,13 @@ reading files and writing one JSON object:
225
266
  its entry file): as with an extraction, no
226
267
  tier_a_include set is no tier A list
227
268
  --name a name read by eye whose file is not known
228
- --provenance the provenance entries' named exports (State 2)
269
+ --provenance the provenance entries' named exports (State 2),
270
+ and the kinds of restored names
229
271
  --metadata the metadata.json exports[] names (States 2, 3)
230
272
  A surface read from the source (--extraction, --quick or
231
273
  --per-file) takes no name from --provenance and --metadata:
232
- they are the baselines of the candidates and guards. Without
274
+ they are the baselines of the candidates and guards, and the
275
+ map gives only a restored name its kind. Without
233
276
  one, with --provenance and --metadata both, `state2` gives the
234
277
  provenance count, the metadata count and their union count,
235
278
  which compute-score.py turns into the State 2 divergence. --fold
@@ -268,6 +311,23 @@ without `*` reaches) and `root` (the names the root entry points reach),
268
311
  each through the entry point of any of the name's definitions.
269
312
  reconcile-coverage.py and score-signatures.py --surface-set pick one.
270
313
 
314
+ Other declarations (surface `otherDeclarations`, #918): for each name with
315
+ a record (a nested Python top's included), each declaration of the name
316
+ the extraction's exports[] holds (under the name, or under the name an
317
+ aliased record's defining file gives it, `local`, as the record lookup
318
+ reads it; a re-export row declares nothing) in a file none of the name's
319
+ records names and no scope.exclude glob matches, as {name, kind, file,
320
+ line, signatureLine}, its file with forward slashes and no leading `./`,
321
+ its line and signature line taken as a record's are; sorted, each
322
+ declaration once however its path is written, and [] with none, with no
323
+ extraction or with one the per-file scan replaces. Such a declaration is a
324
+ same-named function no entry point exports, as cognee's lightweight
325
+ `run_pipeline`, which a lazy `__getattr__` passes on beside the
326
+ orchestrator a namespace exports. They are no records: they join no set,
327
+ no count and no guard, and only score-signatures.py reads them, plan
328
+ binding a documented form to one when the form's own citations name it and
329
+ no other declaration of the name.
330
+
271
331
  Candidates and guards (with an extraction, --brief or --metadata): each
272
332
  set's count, the metadata `stats.effective_denominator` (--metadata), and
273
333
  the extractor's own effective_denominator and its basis; for the
@@ -437,9 +497,32 @@ FLOOR_TABLE_HEADERS = frozenset({
437
497
  "export", "exports", "symbol", "symbols", "function", "functions", "class", "classes", "interface", "interfaces",
438
498
  "type", "types", "hook", "hooks", "component", "components", "helper", "helpers", "constant", "constants",
439
499
  "enum", "enums", "api", "apis"})
440
- # The header words (as _header_word reads them, whole) of a floor table's column that writes the row's signature
441
- # (#888); a labelled header, such as `Signature (simplified)`, is not one.
500
+ # The header words (as _header_label reads them, a partial label cut) of a floor table's column that writes the row's
501
+ # signature (#888); a header with any other text after the word, such as `Signature (TypeScript)`, is not one.
442
502
  FLOOR_SIGNATURE_HEADERS = frozenset({"signature", "signatures"})
503
+ # The header words (as _header_label reads them, a partial label cut) of a floor table's column that writes the row's
504
+ # props or parameters (#888): its cell is read only when it is exactly one code span, and only after the Signature
505
+ # and first-cell forms; a `Props / Usage` or `Key params` header is not one.
506
+ FLOOR_PROPS_HEADERS = frozenset({"props", "params", "parameters"})
507
+ # The partial labels (lower case, whitespace runs as one space) a header may give in parentheses after its word, as
508
+ # `Signature (simplified)` and `Props (simplified)` do (#888): each form read from that Signature, Props, Params or
509
+ # Parameters column is partial, so an optional parameter it leaves out is Info, not Critical, and the floor pins the
510
+ # label with the form.
511
+ FLOOR_PARTIAL_LABELS = frozenset({"simplified", "partial", "key parameters", "key params"})
512
+ # What a usage form writes before its name's complete form (#888), each part optional, in this order: an assignment
513
+ # (`x =`, `a, b =`, `const { data } =`, `let [a, b] =`), `await`, `new`, and a receiver or module path (`cognee.`,
514
+ # `client.messages.`).
515
+ USAGE_PREFIX_RE = re.compile(
516
+ r"(?:(?:(?:const|let|var)\s+)?(?:[A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*"
517
+ r"|\{(?:[^{}]|\{[^{}]*\})*\}|\[(?:[^\[\]]|\[[^\[\]]*\])*\])\s*=(?![=>])\s*)?"
518
+ r"(?:await\s+)?(?:new\s+)?(?:[A-Za-z_$][\w$]*\.)*")
519
+ # A span that opens a JSX element (`<Canvas`), which a Props, Params or Parameters cell writes as a usage, not as
520
+ # the row's props.
521
+ ELEMENT_OPEN_RE = re.compile(r"<[A-Za-z_$]")
522
+ # A Props, Params or Parameters span that only names a type or writes a placeholder, not the row's props or
523
+ # parameters (`ButtonProps`, `ComponentProps<typeof Link>`, `None`, `-`): a lone dash, or one identifier or dotted
524
+ # path, then at most one `<...>` type-argument list (group 1, which _type_parameters_only reads).
525
+ TYPE_ONLY_RE = re.compile(r"[-\u2013\u2014]|[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*(<.*>)?")
443
526
  # A complete form's name, before its type parameters and parameter group (group 1, absent for none).
444
527
  SPAN_NAME_RE = re.compile(r"\s*([A-Za-z_$][\w$]*)?\s*")
445
528
  # What a complete form writes after its parameter group before its return: `->`, `=>`, `→` or `:` (not `::`).
@@ -635,6 +718,19 @@ def _header_word(cell: str) -> str:
635
718
  return (m.group(2) if m else cell).strip().lower()
636
719
 
637
720
 
721
+ def _header_label(cell: str) -> tuple[str, bool]:
722
+ """A header cell's word as _header_word reads it, and whether it carries a partial label (#888): one of
723
+ FLOOR_PARTIAL_LABELS in parentheses after the word (`Signature (simplified)`, `**Signatures (Key
724
+ Parameters)**`), any case, whitespace runs inside them as one space, the label then cut from the word. Any other
725
+ text after the word (`Signature (TypeScript)`) stays in it, so that column is read as before."""
726
+ word = _header_word(cell)
727
+ if word.endswith(")"):
728
+ head, opened, label = word[:-1].rpartition("(")
729
+ if opened and " ".join(label.split()) in FLOOR_PARTIAL_LABELS:
730
+ return head.rstrip(), True
731
+ return word, False
732
+
733
+
638
734
  def _cells(row: str) -> list[str]:
639
735
  """A table row's cells, split on each unescaped `|`, each stripped."""
640
736
  row = row.strip()
@@ -790,6 +886,64 @@ def _call_form(span: str, name: str | None) -> dict | None:
790
886
  return _form(text, group)
791
887
 
792
888
 
889
+ def _element_form(text: str, name: str) -> dict | None:
890
+ """{"usage_signature", "return_type"} of a span that is one self-closing JSX element of `name` setting at least
891
+ one prop (`<Canvas of={X} />`): the element, whitespace runs as one space, and ""; None for any other span, a
892
+ prop-less `<Canvas />`, an element with children, two elements and an unclosed brace or string among them."""
893
+ head = re.match(rf"<{re.escape(name)}\s+", text)
894
+ if head is None or not text.endswith("/>") or not text[head.end():-2].strip():
895
+ return None
896
+ depth, quote = 0, None # brace depth, and the quote a string is open on
897
+ for ch in text[head.end():-2]:
898
+ if quote:
899
+ quote = None if ch == quote else quote
900
+ elif ch in "\"'`":
901
+ quote = ch
902
+ elif ch in "{}":
903
+ depth += 1 if ch == "{" else -1
904
+ if depth < 0:
905
+ return None
906
+ elif depth == 0 and ch in "<>/":
907
+ return None
908
+ return None if depth or quote else {"usage_signature": " ".join(text.split()), "return_type": ""}
909
+
910
+
911
+ def _usage_form(span: str, name: str) -> dict | None:
912
+ """The signature a span writes as a usage of `name` (#888), whitespace runs as one space: at least one of, in this
913
+ order, an assignment (`x =`, `const { data } =`), `await`, `new` and a receiver or module path (`cognee.`), then
914
+ `name`'s complete form (_call_form): {"usage_signature": the span through the parameter group, "return_type": the
915
+ return after it, "" for none}; or one JSX element of `name` that sets a prop (_element_form). None for any other
916
+ span, an empty call (`new Client()`) and a prop-less element (`<Canvas />`) among them."""
917
+ text = span.strip()
918
+ if text.startswith("<"):
919
+ return _element_form(text, name)
920
+ prefix = USAGE_PREFIX_RE.match(text)
921
+ rest = text[prefix.end():]
922
+ if not prefix.group() or not re.match(rf"{re.escape(name)}(?![\w$])", rest):
923
+ return None
924
+ form = _call_form(rest, name)
925
+ if form is None:
926
+ return None
927
+ group = _sibling("score-signatures.py")._group(rest)
928
+ return {"usage_signature": " ".join(text[:prefix.end() + group[1] + 1].split()), "return_type": form["return_type"]}
929
+
930
+
931
+ def _span_form(span: str, name: str | None) -> dict | None:
932
+ """The signature a span writes: its complete form (_call_form), else its usage form (_usage_form)."""
933
+ return _call_form(span, name) or (_usage_form(span, name) if name else None)
934
+
935
+
936
+ def _labelled(form: dict | None, partial: bool) -> dict | None:
937
+ """`form`, with `signature_form` "partial" when its column's header gives a partial label (#888)."""
938
+ return {**form, "signature_form": "partial"} if form and partial else form
939
+
940
+
941
+ def _column(labels: list[tuple[str, bool]], words: frozenset) -> tuple[int | None, bool]:
942
+ """The first header cell whose word (_header_label) is one of `words`, and its partial label; (None, False) for
943
+ none."""
944
+ return next(((i, labelled) for i, (word, labelled) in enumerate(labels) if word in words), (None, False))
945
+
946
+
793
947
  def _leading_span(cell: str) -> str | None:
794
948
  """The code span a table cell opens with, None when it opens with none."""
795
949
  run = len(cell) - len(cell.lstrip("`"))
@@ -801,22 +955,44 @@ def _row_form(header: list[str], row: list[str]) -> dict | None:
801
955
  """The signature a floor table's row writes for the one name its first cell names (a row naming several gives
802
956
  none), each cell's `\\|` read as `|`: the leading code span of its Signature or Signatures cell, else, when that
803
957
  span writes no parameter group, the one span of its first cell that gives that name (another export's span there
804
- gives nothing), each a complete form (_call_form). A Signature span with a group that is no complete form gives
805
- none. A Props, Params or Parameters cell is never read."""
958
+ gives nothing), each a complete form (_call_form), else a usage form (_usage_form, #888). A Signature span with a
959
+ group that is neither gives none, and so does such a first-cell span. Only then its Props, Params or Parameters
960
+ cell, when the whole cell is one code span (trailing prose or several spans leave it the reader's): a complete
961
+ form, else a usage form, else none when it writes a parameter group or opens a JSX element, or when it only names
962
+ a type or writes a placeholder (`ButtonProps`, `ComponentProps<typeof Link>`, `None`, `-`), else the span whole as
963
+ `params`, `return_type` "", kept as written. A Signature, Props, Params or Parameters header may carry a partial
964
+ label (_header_label, #888): the form its cell gives then also gives `signature_form` "partial", the first-cell
965
+ form never."""
806
966
  segments = _sibling("locate-export-segments.py")
967
+ scorer = _sibling("score-signatures.py")
807
968
  names = {name for name in map(_floor_name, segments._inline_code(row[0])) if name}
808
969
  if len(names) != 1:
809
970
  return None
810
971
  [name] = names
811
972
  cells = [cell.replace("\\|", "|") for cell in row]
812
- at = next((i for i, cell in enumerate(header) if _header_word(cell) in FLOOR_SIGNATURE_HEADERS), None)
973
+ labels = [_header_label(cell) for cell in header]
974
+ at, partial = _column(labels, FLOOR_SIGNATURE_HEADERS)
813
975
  span = _leading_span(cells[at]) if at is not None and at < len(cells) else None
814
- if span is not None and (form := _call_form(span, name)):
815
- return form
816
- if span is not None and _sibling("score-signatures.py")._group(span.strip()) is not None:
976
+ if span is not None and (form := _span_form(span, name)):
977
+ return _labelled(form, partial)
978
+ if span is not None and scorer._group(span.strip()) is not None:
817
979
  return None # the cell writes a call the floor cannot pin: a shorter first-cell form would hide it
818
980
  own = [span for span in segments._inline_code(cells[0]) if _floor_name(span) == name]
819
- return _call_form(own[0], name) if len(own) == 1 else None
981
+ if len(own) == 1 and (form := _span_form(own[0], name)):
982
+ return form
983
+ if any(scorer._group(span.strip()) is not None for span in own):
984
+ return None # the first cell writes a call the floor cannot pin: the Props cell would stand in for it
985
+ at, partial = _column(labels, FLOOR_PROPS_HEADERS)
986
+ span = _whole_span(cells[at], segments) if at is not None and at < len(cells) else None
987
+ if span is None:
988
+ return None
989
+ if form := _span_form(span, name):
990
+ return _labelled(form, partial)
991
+ if scorer._group(span.strip()) is not None or ELEMENT_OPEN_RE.match(span.strip()) or not span.strip():
992
+ return None
993
+ if (only := TYPE_ONLY_RE.fullmatch(span.strip())) and _type_parameters_only(only.group(1) or ""):
994
+ return None # the span names a type or writes a placeholder, no props or parameters
995
+ return _labelled({"params": " ".join(span.split()), "return_type": ""}, partial)
820
996
 
821
997
 
822
998
  def _function_declaration(line: str, language: str, js_family) -> int | None:
@@ -924,7 +1100,7 @@ def floor(skill_dir: str) -> dict:
924
1100
  for index, _level, text, _above in headings:
925
1101
  span = _whole_span(text, segments)
926
1102
  if span is not None:
927
- add(_floor_name(span), file_index, index, "heading", None, _call_form(span, _floor_name(span)))
1103
+ add(_floor_name(span), file_index, index, "heading", None, _span_form(span, _floor_name(span)))
928
1104
  comments = _html_comment_lines(lines, start, code)
929
1105
  for opening, closing, info in blocks:
930
1106
  language = segments._language(info)
@@ -1066,6 +1242,37 @@ def _record(name, kind, file, line, signature_line, origin) -> dict:
1066
1242
  "origin": origin}
1067
1243
 
1068
1244
 
1245
+ # A provenance map's export_type, read case-insensitively, as the kind of a restored name (#913): the type kinds
1246
+ # score-signatures.py's Type Coverage counts (a dataclass, NamedTuple or TypedDict is a class, a Protocol an
1247
+ # interface, a type alias a type), and `function` and `const`, which are no type (a TypeVar is no type). Any
1248
+ # other export_type (`re-export`, `module`, `decorator_namespace`, ...) gives no kind.
1249
+ PROVENANCE_KINDS = {
1250
+ **dict.fromkeys(("class", "dataclass", "namedtuple", "typeddict"), "class"),
1251
+ **dict.fromkeys(("protocol", "interface"), "interface"),
1252
+ "enum": "enum",
1253
+ **dict.fromkeys(("type", "type_alias"), "type"),
1254
+ "struct": "struct",
1255
+ "trait": "trait",
1256
+ **dict.fromkeys(("function", "method", "staticmethod", "classmethod"), "function"),
1257
+ **dict.fromkeys(("const", "constant", "variable", "sentinel", "typevar"), "const"),
1258
+ }
1259
+
1260
+
1261
+ def _restored_kinds(records: list[dict], provenance: dict | None) -> None:
1262
+ """Give each restored record with no kind the kind PROVENANCE_KINDS reads
1263
+ from the first provenance entry, in map order, with its name and its
1264
+ file (both paths `_norm`ed) and a known kind; no other record changes."""
1265
+ entries = _entries(provenance)
1266
+ for record in records:
1267
+ if record["origin"] != "restored" or record["kind"] or not record["file"]:
1268
+ continue
1269
+ file = _norm(record["file"])
1270
+ record["kind"] = next(
1271
+ (kind for e in entries
1272
+ if e.get("export_name") == record["name"] and _norm(e.get("source_file")) == file
1273
+ and (kind := PROVENANCE_KINDS.get(str(e.get("export_type")).strip().lower()))), None)
1274
+
1275
+
1069
1276
  def _globs(value) -> list[str]:
1070
1277
  return [g for g in (_norm(v) for v in value if isinstance(v, str)) if g] if isinstance(value, list) else []
1071
1278
 
@@ -1186,6 +1393,50 @@ def _members_in_place(public: list[dict]) -> list[dict]:
1186
1393
  return expanded
1187
1394
 
1188
1395
 
1396
+ def _declared_at(row: dict) -> tuple:
1397
+ """(line, signature line) of an extraction's exports[] row, as a record
1398
+ takes them: a local `export { ... }` list's item at the declaration it
1399
+ names (#836), its `declaration_line` and `declaration_signature_line`
1400
+ (both from that one pair), when it has that line; else its own
1401
+ `source_line` and `signature_line`."""
1402
+ if isinstance(row.get("declaration_line"), int):
1403
+ return row["declaration_line"], row.get("declaration_signature_line")
1404
+ return row.get("source_line"), row.get("signature_line")
1405
+
1406
+
1407
+ OTHER_DECLARATION_KEYS = ("name", "kind", "file", "line", "signatureLine")
1408
+
1409
+
1410
+ def other_declarations(records: list[dict], declarations: list[dict], aliases: dict[str, set[str]],
1411
+ excludes: list[str]) -> list[dict]:
1412
+ """The other declarations of the surface names (#918): for each name
1413
+ `records` holds, each of `declarations` (an extraction's exports[] rows,
1414
+ a re-export left out) recorded under the name, or under a name an
1415
+ aliased record's defining file gives it (`aliases`), in a file no
1416
+ record of the name names and no exclude glob matches, as {name, kind,
1417
+ file, line, signatureLine}, the file normalised (_norm), sorted, each
1418
+ declaration once however its path is written. They are no records:
1419
+ only score-signatures.py reads them."""
1420
+ files: dict[str, set[str]] = {}
1421
+ for record in records:
1422
+ files.setdefault(record["name"], set()).update([_norm(record["file"])] if record.get("file") else [])
1423
+ by_export: dict[str, list[dict]] = {}
1424
+ for declaration in declarations:
1425
+ by_export.setdefault(declaration["exportName"], []).append(declaration)
1426
+ found = set()
1427
+ for name, own in files.items():
1428
+ for looked in {name} | aliases.get(name, set()):
1429
+ for d in by_export.get(looked, []):
1430
+ path = _norm(d["file"])
1431
+ if path in own or any(glob_match(path, g) for g in excludes):
1432
+ continue
1433
+ line = d["line"] if isinstance(d["line"], int) and not isinstance(d["line"], bool) else None
1434
+ found.add((name, d["kind"] if isinstance(d["kind"], str) else None, path, line,
1435
+ d["signatureLine"] if isinstance(d["signatureLine"], str) else None))
1436
+ ordered = sorted(found, key=lambda t: (t[0], t[2], t[3] or 0, t[1] or "", t[4] or ""))
1437
+ return [dict(zip(OTHER_DECLARATION_KEYS, t)) for t in ordered]
1438
+
1439
+
1189
1440
  # The runner's file issues for a file it could not open at all: no one can read it by eye either.
1190
1441
  UNREADABLE_ISSUES = frozenset({"missing", "unreadable"})
1191
1442
 
@@ -1245,13 +1496,19 @@ def _outside_row(row: dict, scope: dict) -> dict:
1245
1496
  it does (#796): `excludeGlob`, the first scope.exclude glob that matches
1246
1497
  the file, and `outsideInclude`, true when scope.include has globs and
1247
1498
  none matches it. Neither (null and false) when the brief does not
1248
- explain it, such as a file no recipe reads or no file at all."""
1499
+ explain it, such as a file no recipe reads or no file at all. A
1500
+ namespace's member also keeps the namespace it came through
1501
+ (`namespace`, #969) when the row gives it as a non-empty string, and
1502
+ no such key otherwise."""
1249
1503
  file = row.get("file") if isinstance(row.get("file"), str) and row.get("file") else None
1250
1504
  path = _norm(file) if file else None
1251
- return {"name": row.get("name"), "file": file, "line": row.get("line"), "entry": row.get("entry"),
1252
- "excludeGlob": next((g for g in scope["exclude"] if glob_match(path, g)), None) if path else None,
1253
- "outsideInclude": bool(path and scope["include"]
1254
- and not any(glob_match(path, g) for g in scope["include"]))}
1505
+ out = {"name": row.get("name"), "file": file, "line": row.get("line"), "entry": row.get("entry"),
1506
+ "excludeGlob": next((g for g in scope["exclude"] if glob_match(path, g)), None) if path else None,
1507
+ "outsideInclude": bool(path and scope["include"]
1508
+ and not any(glob_match(path, g) for g in scope["include"]))}
1509
+ if isinstance(row.get("namespace"), str) and row["namespace"]:
1510
+ out["namespace"] = row["namespace"]
1511
+ return out
1255
1512
 
1256
1513
 
1257
1514
  def from_extraction(data: dict, language: str | None = None, brief: dict | None = None) -> dict:
@@ -1274,7 +1531,8 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
1274
1531
  unmatched_tier_a = _globs(recorded.get("unmatched_tier_a_include"))
1275
1532
  extraction = _extraction_summary(data, language)
1276
1533
  if extraction["fallback"]["needed"]:
1277
- return {"records": [], "nestedRecords": [], "sets": {}, "outside": [], "nested": [],
1534
+ return {"records": [], "nestedRecords": [], "declarations": [], "aliases": {}, "sets": {}, "outside": [],
1535
+ "nested": [],
1278
1536
  "staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
1279
1537
  "restored": []},
1280
1538
  "gaps": [], "scope": scope, "scopeType": recorded.get("type"), "umbrella": dict(NO_UMBRELLA),
@@ -1325,6 +1583,8 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
1325
1583
  index = {(e.get("export_name"), e.get("source_file")): e
1326
1584
  for e in data.get("exports") or [] if isinstance(e, dict)}
1327
1585
  records, nested_records = [], []
1586
+ # The names an aliased record's defining file gives it, for the other declarations (#918).
1587
+ aliases: dict[str, set[str]] = {}
1328
1588
  for info in public:
1329
1589
  name, file = info["name"], info.get("file")
1330
1590
  if (name, file) in outside_keys:
@@ -1336,19 +1596,27 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
1336
1596
  continue
1337
1597
  # An aliased re-export (`export { Options as Config }`) is recorded
1338
1598
  # under the name its defining file gives it, `local`.
1339
- found = index.get((info.get("local") or name, file)) or index.get((name, file))
1599
+ local = info.get("local")
1600
+ if isinstance(local, str) and local and local != name:
1601
+ aliases.setdefault(name, set()).add(local)
1602
+ found = index.get((local or name, file)) or index.get((name, file))
1340
1603
  kind = found.get("export_type") if found else None
1341
- # A local `export { ... }` list's item is planned at the declaration it
1342
- # names (#836): the line and the signature line both come from that pair.
1343
- if found and isinstance(found.get("declaration_line"), int):
1344
- line, signature_line = found["declaration_line"], found.get("declaration_signature_line")
1345
- elif found:
1346
- line, signature_line = found.get("source_line"), found.get("signature_line")
1347
- else:
1348
- line, signature_line = info.get("line"), None
1604
+ line, signature_line = _declared_at(found) if found else (info.get("line"), None)
1349
1605
  (nested_records if info.get("entry") in nested else records).append(
1350
1606
  _record(name, None if kind == "re-export" else kind, file, line, signature_line, "extraction"))
1351
1607
 
1608
+ # Every declaration exports[] records, a re-export row left out (it declares nothing): the other
1609
+ # declarations of a surface name come from these (#918).
1610
+ declarations = []
1611
+ for e in data.get("exports") or []:
1612
+ if not isinstance(e, dict) or e.get("export_type") == "re-export":
1613
+ continue
1614
+ name, file = e.get("export_name"), e.get("source_file")
1615
+ if isinstance(name, str) and name and isinstance(file, str) and file.strip():
1616
+ line, signature_line = _declared_at(e)
1617
+ declarations.append({"exportName": name, "kind": e.get("export_type"), "file": file, "line": line,
1618
+ "signatureLine": signature_line})
1619
+
1352
1620
  # The umbrella ratio reads the public rows alone: an other definition is no root barrel name there.
1353
1621
  root_names = [p for p in _members_in_place(rows["public"]) if p.get("entry") in root_files]
1354
1622
  reexported = [p for p in root_names if p.get("via") not in (None, "declaration")]
@@ -1362,6 +1630,8 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
1362
1630
  return {
1363
1631
  "records": records,
1364
1632
  "nestedRecords": nested_records,
1633
+ "declarations": declarations,
1634
+ "aliases": aliases,
1365
1635
  "sets": sets,
1366
1636
  "outside": [_outside_row(o, scope) for o in outside],
1367
1637
  "nested": [{"entry": top, "within": nested[top], "names": nested_names[top]} for top in sorted(nested)],
@@ -1600,12 +1870,18 @@ def surface(args: argparse.Namespace) -> dict:
1600
1870
  sets["all"] = sorted({r["name"] for r in records})
1601
1871
  # A nested Python top's records count in the scope sets, never in `all`.
1602
1872
  records += extracted["nestedRecords"] if extracted is not None else []
1873
+ # A restored name takes its kind from the provenance map at its file (#913): no set, count or guard changes.
1874
+ if provenance is not None:
1875
+ _restored_kinds(records, provenance)
1603
1876
  records.sort(key=lambda r: (r["name"], str(r["file"] or ""), r["origin"]))
1604
1877
 
1605
1878
  # The scope: the one the extractor recorded, else the brief's.
1606
1879
  scope = extracted["scope"] if extracted is not None and any(extracted["scope"].values()) else None
1607
1880
  if scope is None:
1608
1881
  scope = brief["scope"] if brief is not None else _scope(None, None, None)
1882
+ # Each other declaration of a surface name (#918), for the signature plan alone: no record, set or count.
1883
+ others = (other_declarations(records, extracted["declarations"], extracted["aliases"], scope["exclude"])
1884
+ if extracted is not None else [])
1609
1885
  # A tier A glob the runner lists as matching no file counts no name, and
1610
1886
  # so does one whose folder or file differs from every scope.include
1611
1887
  # glob's before any `*` or `?` (`gen/**` beside `src/**`) or one a
@@ -1749,6 +2025,7 @@ def surface(args: argparse.Namespace) -> dict:
1749
2025
  "brief": args.brief, "fold": bool(args.fold),
1750
2026
  },
1751
2027
  "exports": records,
2028
+ "otherDeclarations": others,
1752
2029
  "sets": sets,
1753
2030
  **out,
1754
2031
  "warnings": warnings,