bmad-module-skill-forge 4.0.0 → 5.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 (85) 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/architecture.md +2 -2
  5. package/docs/concepts.md +4 -4
  6. package/docs/examples.md +32 -25
  7. package/docs/getting-started.md +3 -3
  8. package/docs/how-it-works.md +2 -2
  9. package/docs/index.md +1 -1
  10. package/docs/skill-model.md +41 -39
  11. package/docs/verifying-a-skill.md +41 -20
  12. package/docs/why-skf.md +1 -1
  13. package/docs/workflows.md +2 -2
  14. package/package.json +1 -1
  15. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
  16. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  17. package/src/shared/scripts/skf-build-change-manifest.py +881 -168
  18. package/src/shared/scripts/skf-count-tokens.py +54 -10
  19. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  20. package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
  21. package/src/shared/scripts/skf-extract-public-api.py +439 -107
  22. package/src/shared/scripts/skf-extraction-inventory.py +10 -6
  23. package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
  24. package/src/shared/scripts/skf-parse-gaps.py +135 -26
  25. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  26. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  27. package/src/shared/scripts/skf-shard-body.py +239 -41
  28. package/src/shared/scripts/skf-validate-output.py +12 -3
  29. package/src/shared/scripts/skf-verify-provenance-completeness.py +470 -38
  30. package/src/shared/tool-requirements.yaml +8 -2
  31. package/src/skf-analyze-source/SKILL.md +2 -2
  32. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  33. package/src/skf-audit-skill/SKILL.md +2 -2
  34. package/src/skf-audit-skill/references/re-index.md +2 -2
  35. package/src/skf-brief-skill/SKILL.md +2 -2
  36. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  37. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  38. package/src/skf-campaign/SKILL.md +2 -2
  39. package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
  40. package/src/skf-create-skill/SKILL.md +2 -2
  41. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  42. package/src/skf-create-skill/references/extract.md +2 -1
  43. package/src/skf-create-skill/references/extraction-patterns.md +3 -3
  44. package/src/skf-create-skill/references/report.md +6 -1
  45. package/src/skf-create-stack-skill/SKILL.md +2 -2
  46. package/src/skf-create-stack-skill/references/validate.md +1 -1
  47. package/src/skf-drop-skill/SKILL.md +2 -2
  48. package/src/skf-export-skill/SKILL.md +2 -2
  49. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  50. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  51. package/src/skf-export-skill/references/load-skill.md +1 -1
  52. package/src/skf-export-skill/references/token-report.md +29 -9
  53. package/src/skf-forger/SKILL.md +2 -2
  54. package/src/skf-quick-skill/SKILL.md +2 -2
  55. package/src/skf-refine-architecture/SKILL.md +6 -8
  56. package/src/skf-rename-skill/SKILL.md +2 -2
  57. package/src/skf-setup/SKILL.md +2 -2
  58. package/src/skf-test-skill/SKILL.md +2 -2
  59. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  60. package/src/skf-test-skill/references/coherence-check.md +4 -3
  61. package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
  62. package/src/skf-test-skill/references/coverage-check-tiers.md +1 -1
  63. package/src/skf-test-skill/references/coverage-check.md +45 -69
  64. package/src/skf-test-skill/references/init.md +2 -1
  65. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  66. package/src/skf-test-skill/references/report.md +2 -2
  67. package/src/skf-test-skill/references/scoring-rules.md +5 -4
  68. package/src/skf-test-skill/references/source-access-protocol.md +6 -6
  69. package/src/skf-test-skill/scripts/gap-ledger.py +378 -163
  70. package/src/skf-test-skill/scripts/load-coverage-inputs.py +171 -70
  71. package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
  72. package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
  73. package/src/skf-test-skill/scripts/score-signatures.py +329 -54
  74. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  75. package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
  76. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  77. package/src/skf-update-skill/SKILL.md +2 -2
  78. package/src/skf-update-skill/references/detect-changes.md +1 -1
  79. package/src/skf-update-skill/references/gap-driven.md +36 -21
  80. package/src/skf-update-skill/references/merge.md +15 -10
  81. package/src/skf-update-skill/references/re-extract.md +3 -1
  82. package/src/skf-update-skill/references/report.md +5 -4
  83. package/src/skf-update-skill/references/write.md +39 -18
  84. package/src/skf-verify-stack/SKILL.md +2 -2
  85. package/tools/cli/lib/tool-check.js +158 -2
@@ -350,7 +350,11 @@ scope.type and language; --include, --exclude, --tier-a-include,
350
350
  Which recipes. --recipe-set picks the recipes whose metadata.sets lists it:
351
351
  `component-library` when the scope type is component-library, else
352
352
  `standard`. Each recipe runs in the languages its metadata.languages
353
- lists. A match whose name starts with `_` is never an export. --head-cap
353
+ lists. A Python or Rust match whose name starts with `_` is never an
354
+ export, nor public anywhere below: Python's by the Source API Surface
355
+ Definition's rule, Rust's because a `_` item is hidden or unnamed (a
356
+ `#[doc(hidden)] pub mod __private`, which the runner cannot read, or a
357
+ `pub const _`); a TS/JS `_` name is an export like any other. --head-cap
354
358
  keeps at most N matches of each recipe, in file and line order (0: no
355
359
  cap); without it the cap is 200, or at Forge+ and Deep tiers 500 for a
356
360
  full-library scope and 300 for a component-library one. `truncated` is
@@ -423,40 +427,75 @@ module names.
423
427
  A chain the trace cannot follow (a module no file here holds, a `#[path]`
424
428
  module) is listed in `unresolved`. The diff against the recipe inventory,
425
429
  one language family at a time (JavaScript/TypeScript, Python, Rust, Go):
426
- `public` (the entry points' names), `internal` (names the recipes found
427
- that no entry point exports or makes reachable), `extraction_gaps` (names
428
- an entry point exports that no recipe found, defined in a file in scope or
429
- in none the trace reached: read them by eye) and `outside_scope` (such
430
- names defined in a file out of scope: widen scope.include to document
430
+ `public` (the entry points' names, each at the file and line the first
431
+ entry point naming it gives, in path order, a JS/TS entry point's own and
432
+ named exports all coming before any `export *`, unless that file is out
433
+ of scope, or it gives none, and another entry point gives a file in
434
+ scope, other than through a star or glob its own export of the name
435
+ shadows),
436
+ `other_definitions` (each other definition of a public name in a file in
437
+ scope, one row per file and `local` name: the file and line an entry
438
+ point gives, never a namespace nor one beside a public row that is a
439
+ namespace, nor a star or glob through which an entry point passes on a
440
+ name it already exports (its own export shadows it), while two exports
441
+ of the name by one entry point, such as Rust `pub use` items a `cfg`
442
+ gates, are both kept; in every language family, so a name each
443
+ framework package of a monorepo defines keeps
444
+ each package's file, while a gap and an outside_scope row still go by
445
+ name, from `public` alone),
446
+ `internal` (names the recipes found that no entry point exports or makes
447
+ reachable), `extraction_gaps` (names an entry point exports that no
448
+ recipe found, defined in a file in scope or in none the trace reached:
449
+ read them by eye) and `outside_scope` (names an entry point exports that
450
+ a file out of scope defines, even when a recipe found the name in another
451
+ file, which is another definition: widen scope.include to document
431
452
  them). A namespace with `members` is diffed as its members: each is found
432
453
  when a recipe found its name, or its `local` one, in its file (one with
433
454
  no file by its name alone, as a public name is), and has no row of its
434
455
  own when a public name (or another namespace's member) has its name and
435
456
  file; one with null `members`, and a member that is such a module, is no
436
457
  gap. In a family with no entry point, or only empty ones, every name the
437
- recipes found is public.
458
+ recipes found is public. A JS/TS module exports, besides the names the
459
+ recipes match, those its column-0 `export` statements declare in a form
460
+ no recipe matches, read from its text: `export let`, `export declare
461
+ const`, `export namespace`, and each name a destructuring pattern that
462
+ opens an `export const`, `let` or `var` statement binds (`export const
463
+ { a, b: c } = obj` exports a and c), at the line the name is on. Such a
464
+ name that an entry point exports, or passes on through `export *`, is
465
+ public and, no recipe having found it, an `extraction_gaps` row
466
+ (`outside_scope`, in a file out of scope) at that line. A pattern after
467
+ the statement's first declarator (`export const a = 1, { b } = o`), or
468
+ one the reader cannot read (one that does not close, or a default
469
+ holding a template literal over several lines), binds nothing here and
470
+ no warning names it: read it by eye.
438
471
 
439
472
  Counts. `exports_public_api` counts the public names (the package's whole
440
473
  public API, `outside_scope` names included), each distinct name and file
441
- once: a namespace with `members` counts as its members (none for an empty
442
- list, and a member that is also a public name, or another namespace's
443
- member, once), one with null `members` as one. `exports_internal` counts
444
- the internal names. `effective_denominator` counts those public names
445
- whose definition file matches scope.include (any file when it has no
446
- glob), scope.tier_a_include when given, and no scope.exclude glob: each
447
- name once, so a type counts once whatever its methods, a barrel that
448
- only re-exports adds nothing of its own, and a name the entry points
449
- pass on from two files (a namespace member named like another public
450
- name, or like another namespace's member, at another file) or in two
451
- languages counts once, when any one of its files matches, as Test
452
- Skill's sets count it. A tier A list that counts no public name (each
453
- glob matches no file, or only files scope.exclude drops, that
454
- scope.include leaves out or that define no public name) narrows nothing
455
- on a run that counted a public name and read every file (no `errors`
456
- item): the count falls back to scope.include (else every file), with a
457
- warning.
474
+ `public` gives once (`other_definitions` adds none): a namespace with
475
+ `members` counts as its members (none for an empty list, and a member
476
+ that is also a public name, or another namespace's member, once), one
477
+ with null `members` as one. `exports_internal` counts the internal names.
478
+ `effective_denominator` counts those public names with a definition file
479
+ (the `public` row's, or one of its `other_definitions`') that matches
480
+ scope.include (any file when it has no glob), scope.tier_a_include when
481
+ given, and no scope.exclude glob: each name once, however many in-scope
482
+ files define it, so a type counts once whatever its methods, a barrel
483
+ that only re-exports adds nothing of its own, and a name the entry points
484
+ pass on from two files (two packages' entry points that each define it,
485
+ or a namespace member named like another public name, or like another
486
+ namespace's member, at another file) or in two languages counts once,
487
+ when any one of its files matches, as Test Skill's sets count it. A tier
488
+ A list that counts no public name (each glob matches no file, or only
489
+ files scope.exclude drops, that scope.include leaves out or that define
490
+ no public name) narrows nothing on a run that counted a public name and
491
+ read every file (no `errors` item): the count falls back to scope.include
492
+ (else every file), with a warning.
458
493
  `arms` gives compile's effective_denominator arms:
459
- (a) `monorepo` from skf-detect-workspaces.py, (b) `specific_modules`
494
+ (a) `monorepo` from skf-detect-workspaces.py over the tree the brief
495
+ keeps (its scoped_tree: a member counts only through the files no
496
+ scope.exclude glob matches, so excluding its sources drops it, excluding
497
+ only its manifest does not, root manifests always stay, and a workspace
498
+ with no member left is none), (b) `specific_modules`
460
499
  (scope type specific-modules on a repository that is not a monorepo) and
461
500
  (c) `multi_subpath_exports` (an in-scope package.json whose `exports` map
462
501
  has more than one non-root subpath without a `*`, on a repository that is
@@ -466,20 +505,22 @@ Store or not. `curated_subset` is true when the files the count's basis
466
505
  globs list (scope.include's, else every file; a tier A count's, those
467
506
  among them a tier A glob matches too; under arm (b), less those a
468
507
  scope.exclude glob matches) leave out a file that defines a counted public
469
- name (an `outside_scope` name's included) or, in a monorepo, every source
470
- file of a skf-detect-workspaces.py workspace; a basis of `all-files` lists
508
+ name (an `outside_scope` name's file and an `other_definitions` file
509
+ included) or every source file of a
510
+ workspace skf-detect-workspaces.py finds over the whole tree
511
+ (scope.exclude aside, unlike arm (a): a member the brief drops is still
512
+ part of the repository it chose a subset of); a basis of `all-files` lists
471
513
  none. `effective_denominator_decision` says whether create-skill and
472
514
  update-skill store `effective_denominator` as stats.effective_denominator,
473
515
  the brief's scope.notes aside (documenting the stratification is the one
474
- judgment left to them): `store` is true only when none of these holds,
475
- and `reason` names the first that does, else `curated-subset`:
476
- `reference-app` (scope type reference-app: pattern_surfaces_documented is
477
- its basis), `incomplete-run` (an `errors` item: a file a failed call left
478
- unread can hide a public name, so the count can be 0 or partial),
479
- `no-arm` (no arm is true), `full-library` (scope type full-library),
480
- `all-files` (the basis), `not-curated` (`curated_subset` false) and
481
- `zero-count` (a stored 0 is no denominator: Test Skill reads it as
482
- deflation).
516
+ judgment left to them): `store` is true only when none of these holds, and
517
+ `reason` names the first that does, else `curated-subset`: `reference-app`
518
+ (scope type reference-app: pattern_surfaces_documented is its basis),
519
+ `incomplete-run` (an `errors` item: a file a failed call left unread can
520
+ hide a public name, so the count can be 0 or partial), `no-arm` (no arm is
521
+ true), `full-library` (scope type full-library), `all-files` (the basis),
522
+ `not-curated` (`curated_subset` false) and `zero-count` (a stored 0 is no
523
+ denominator: Test Skill reads it as deflation).
483
524
 
484
525
  Output JSON (stdout, or -o):
485
526
 
@@ -530,6 +571,8 @@ Output JSON (stdout, or -o):
530
571
  "file", "line",
531
572
  "members": [{"name", "local", "file", "line"}, ...]
532
573
  | null}, ...], # members: via namespace only
574
+ "other_definitions": [{"name", "language", "entry", "via", "from",
575
+ "local", "file", "line"}, ...], # by name, then file
533
576
  "internal": [{"name", "language", "source_file", "source_line"}, ...],
534
577
  "extraction_gaps": [{"name", "language", "entry", "file", "line"}, ...],
535
578
  "outside_scope": [{"name", "language", "entry", "file", "line"}, ...]},
@@ -618,9 +661,16 @@ the entry points export by name, which counts as the names its module
618
661
  passes on), each export of a --quick output at its `defined_in` (else its
619
662
  `source_file`), and the --extraction runner's `entry_point_diff.public`;
620
663
  a payload name replaces a --quick or runner one of its family and name.
664
+ The runner's `other_definitions` count in `effective_denominator` and
665
+ `curated_subset` as full mode counts them, never in `exports_public_api`,
666
+ each only while the record kept for its name names the file of the
667
+ runner's `public` row (a row whose name has no runner row, or one a
668
+ payload or --quick name moves to another file, adds nothing), and none
669
+ beside a namespace with members, as in full mode.
621
670
  `exports_internal` counts the names of the payload's `exports` (the
622
- exports extraction found; `_` names left out) and of the runner's
623
- `internal` that no public name or member has. `effective_denominator`,
671
+ exports extraction found; Python, Rust and Dart `_` names left out, those
672
+ of every other language counted) and of the runner's `internal` that no
673
+ public name or member has. `effective_denominator`,
624
674
  its basis, `curated_subset` and `effective_denominator_decision` follow
625
675
  full mode's rules, over the tree under --source-root, with the scope and
626
676
  arms full mode works out there; an --extraction that is `incomplete` is
@@ -638,10 +688,12 @@ and `arms`.
638
688
 
639
689
  `entry_point_diff` replaces a compare by eye, each item {"name",
640
690
  "language", "file"}: `public`, the names `exports_public_api` counts (a
641
- namespace as its `members`); `internal`, the exports `exports_internal`
642
- counts; `extraction_gaps`, the public names the payload and --quick give
643
- that no export found has (the payload's `exports`, the --extraction
644
- runner's names), to trace (the runner lists its own gaps).
691
+ namespace as its `members`); `other_definitions`, the runner's other
692
+ definitions the counts read, by name then file; `internal`, the exports
693
+ `exports_internal` counts; `extraction_gaps`, the public names the
694
+ payload and --quick give that no export found has (the payload's
695
+ `exports`, the --extraction runner's names), to trace (the runner lists
696
+ its own gaps).
645
697
 
646
698
  Output JSON: `mode` "denominator", `source_root`, `scope` (as full mode
647
699
  writes it), `files_in_scope`, `files_without_recipes`, `entry_points`,
@@ -2627,11 +2679,22 @@ def _preferred(at_line: list[dict], forms: dict, order: dict[str, int]) -> dict:
2627
2679
  return min(left, key=lambda m: (order[m["rule"]], m["language"]))
2628
2680
 
2629
2681
 
2682
+ def _private(family: str | None, name: str) -> bool:
2683
+ """A name full mode and denominator mode never count: one that starts
2684
+ with `_` in Python (the Source API Surface Definition leaves it out),
2685
+ Rust (a `#[doc(hidden)] pub mod __private`, which the runner cannot
2686
+ read, or a `pub const _`) or Dart (private to its library). A TS/JS `_`
2687
+ name is exported like any other, and Go's recipe reads only a
2688
+ capitalized name. Quick mode drops only Python's `_` names."""
2689
+ return family in ("python", "rust", "dart") and name.startswith("_")
2690
+
2691
+
2630
2692
  def merge_exports(matches: list[dict], recipes: list[dict], recipe_set: str,
2631
2693
  head_cap: int | None) -> tuple[list[dict], list[dict]]:
2632
2694
  """(exports, per-recipe stats) from the matches of one recipe set: at
2633
2695
  most `head_cap` matches of each recipe, in file and line order, merged
2634
- by (file, name) at the first line that names it."""
2696
+ by (file, name) at the first line that names it. A Python or Rust `_`
2697
+ name is no export."""
2635
2698
  forms = _forms(recipes)
2636
2699
  order: dict[str, int] = {}
2637
2700
  languages: dict[str, list[str]] = {}
@@ -2642,7 +2705,8 @@ def merge_exports(matches: list[dict], recipes: list[dict], recipe_set: str,
2642
2705
  langs.extend(lang for lang in recipe["metadata"]["languages"] if lang not in langs)
2643
2706
  per_recipe: dict[str, list[dict]] = {rid: [] for rid in order}
2644
2707
  for m in matches:
2645
- if m["rule"] in per_recipe and m["name"] and not m["name"].startswith("_") and (m["rule"], m["language"]) in forms:
2708
+ if (m["rule"] in per_recipe and m["name"] and not _private(FAMILY_OF.get(m["language"]), m["name"])
2709
+ and (m["rule"], m["language"]) in forms):
2646
2710
  per_recipe[m["rule"]].append(m)
2647
2711
  stats, kept = [], []
2648
2712
  for rid, found in per_recipe.items():
@@ -2839,14 +2903,190 @@ _JS_DEFAULT_NAME_RE = re.compile(r"^export\s+default\s+([A-Za-z_$][\w$]*)\s*;?\s
2839
2903
  _JS_DEFAULT_EXPORT_RE = re.compile(r"export\s+default\b")
2840
2904
 
2841
2905
 
2906
+ # Keep identical to _js_pattern_bindings in skf-verify-provenance-completeness.py.
2907
+ def _js_pattern_bindings(
2908
+ text: str, start: int
2909
+ ) -> tuple[list[tuple[str, int]], int | None]:
2910
+ """The names the destructuring pattern whose `{` or `[` is at
2911
+ `text[start]` binds, each with its offset in `text`, and the offset
2912
+ just past the pattern's closing bracket.
2913
+
2914
+ An object pattern's property is `...target`, a shorthand key or
2915
+ `key: target` (a key is a name, a string, a number or a computed
2916
+ `[...]`, and only a shorthand key binds itself); an array pattern's
2917
+ element is `...target`, a target or a hole; a target is a name or a
2918
+ nested pattern. A `= default` is skipped to the next comma at the
2919
+ pattern's depth, and so is a property or element of any other shape,
2920
+ which binds nothing. '...', "..." and `...` strings are skipped
2921
+ whole, so no bracket or comma inside one counts. Whitespace, newlines
2922
+ included, may sit anywhere between the parts.
2923
+
2924
+ Returns ([], None) when `text[start]` opens no pattern, the pattern
2925
+ does not close in `text`, a string in it does not end, or a bracket in
2926
+ it closes one it did not open. A pure function of its arguments (no
2927
+ module state), so another script can carry a copy of it.
2928
+ """
2929
+ n = len(text)
2930
+ names: list[tuple[str, int]] = []
2931
+
2932
+ def space(i: int) -> int:
2933
+ while i < n and text[i].isspace():
2934
+ i += 1
2935
+ return i
2936
+
2937
+ def name_end(i: int) -> int:
2938
+ """The end of the identifier at `i`, `i` itself when none starts there."""
2939
+ if i < n and (text[i].isalpha() or text[i] in "_$"):
2940
+ i += 1
2941
+ while i < n and (text[i].isalnum() or text[i] in "_$"):
2942
+ i += 1
2943
+ return i
2944
+
2945
+ def string_end(i: int) -> int | None:
2946
+ """The offset past the string whose quote is at `i`."""
2947
+ quote = text[i]
2948
+ i += 1
2949
+ while i < n:
2950
+ if text[i] == "\\":
2951
+ i += 2
2952
+ elif text[i] == quote:
2953
+ return i + 1
2954
+ else:
2955
+ i += 1
2956
+ return None
2957
+
2958
+ def skip(i: int, to_comma: bool = True) -> int | None:
2959
+ """From `i`, the offset of the next comma (unless not `to_comma`)
2960
+ or closing bracket at depth 0; with `to_comma` False, `text[i]`
2961
+ opens a bracket and the offset past the one closing it."""
2962
+ depth = 0
2963
+ while i < n:
2964
+ ch = text[i]
2965
+ if ch in "'\"`":
2966
+ end = string_end(i)
2967
+ if end is None:
2968
+ return None
2969
+ i = end
2970
+ continue
2971
+ if ch in "([{":
2972
+ depth += 1
2973
+ elif ch in ")]}":
2974
+ if depth == 0:
2975
+ return i
2976
+ depth -= 1
2977
+ if depth == 0 and not to_comma:
2978
+ return i + 1
2979
+ elif ch == "," and depth == 0 and to_comma:
2980
+ return i
2981
+ i += 1
2982
+ return None
2983
+
2984
+ def default(i: int) -> int | None:
2985
+ """After a target: past its `= default`, when it has one."""
2986
+ i = space(i)
2987
+ if i < n and text[i] == "=" and text[i + 1 : i + 2] not in ("=", ">"):
2988
+ return skip(i + 1)
2989
+ return i
2990
+
2991
+ def bind(i: int, end: int, close: str) -> int | None:
2992
+ """The name `text[i:end]` is bound when its property or element
2993
+ ends after it or takes a default (any other text: another shape)."""
2994
+ after = space(end)
2995
+ if after < n and (
2996
+ text[after] in (",", close)
2997
+ or text[after] == "=" and text[after + 1 : after + 2] not in ("=", ">")
2998
+ ):
2999
+ names.append((text[i:end], i))
3000
+ return default(after)
3001
+ return after
3002
+
3003
+ def target(i: int, close: str) -> int | None:
3004
+ if i < n and text[i] in "{[":
3005
+ end = pattern(i)
3006
+ return None if end is None else default(end)
3007
+ end = name_end(i)
3008
+ return i if end == i else bind(i, end, close)
3009
+
3010
+ def element(i: int, close: str) -> int | None:
3011
+ """One property or element at `i`: the offset after it."""
3012
+ if text.startswith("...", i):
3013
+ return target(space(i + 3), close)
3014
+ if close == "]":
3015
+ return target(i, close)
3016
+ if text[i] in "'\"":
3017
+ end = string_end(i)
3018
+ elif text[i] == "[":
3019
+ end = skip(i, to_comma=False) # a computed key
3020
+ else:
3021
+ end = name_end(i)
3022
+ if end > i:
3023
+ after = space(end)
3024
+ if after < n and text[after] == ":":
3025
+ return target(space(after + 1), close)
3026
+ return bind(i, end, close) # a shorthand key
3027
+ while end < n and (text[end].isalnum() or text[end] in "._"):
3028
+ end += 1 # a number key
3029
+ if end is None:
3030
+ return None
3031
+ after = space(end)
3032
+ if end > i and after < n and text[after] == ":":
3033
+ return target(space(after + 1), close)
3034
+ return after
3035
+
3036
+ def pattern(i: int) -> int | None:
3037
+ """The pattern opening at `i`: the offset past its closing bracket."""
3038
+ close = "}" if text[i] == "{" else "]"
3039
+ i += 1
3040
+ while True:
3041
+ i = space(i)
3042
+ if i >= n:
3043
+ return None
3044
+ if text[i] == close:
3045
+ return i + 1
3046
+ if text[i] in ")]}":
3047
+ return None
3048
+ if text[i] == ",":
3049
+ i += 1 # a hole, or a stray comma
3050
+ continue
3051
+ end = element(i, close)
3052
+ if end is None:
3053
+ return None
3054
+ end = space(end)
3055
+ if end < n and text[end] not in (",", close):
3056
+ end = skip(end) # any other shape binds nothing
3057
+ if end is None:
3058
+ return None
3059
+ i = end + 1 if end < n and text[end] == "," else end
3060
+
3061
+ if not (0 <= start < n and text[start] in "{["):
3062
+ return [], None
3063
+ end = pattern(start)
3064
+ return (names, end) if end is not None else ([], None)
3065
+
3066
+
2842
3067
  def _js_text_names(text: str) -> tuple[list[tuple[str, int, bool]], list[tuple[str, int]]]:
2843
3068
  """The names a JS/TS file's column-0 `export` declarations bind, read
2844
3069
  from its text for the forms no recipe matches (`export let`, `export
2845
- declare const`, `export namespace`), as (name, line, is the default
2846
- export), and its `export default <name>;` statements, as (name, line)."""
3070
+ declare const`, `export namespace`, and each name a destructuring
3071
+ pattern that opens an `export const`, `let` or `var` statement binds:
3072
+ `export const { a, b: c, ...d } = obj` binds a, c and d), as (name,
3073
+ line, is the default export), and its `export default <name>;`
3074
+ statements, as (name, line).
3075
+
3076
+ A pattern is read from its `{` or `[` on (`_JS_DESTRUCTURE_RE`) with
3077
+ `_js_pattern_bindings`, so it may span lines, and each name it binds
3078
+ takes the line the name is on. A pattern after the first declarator
3079
+ is not read, and one that does not close, or that nests deeper than
3080
+ the interpreter's recursion limit, binds nothing."""
2847
3081
  code = _blank(text, "js")
2848
3082
  declared = [(m.group(2), _line_of(code, m.start(2)), bool(m.group(1)))
2849
3083
  for rx in (_JS_DECLARATION_RE, _JS_IMPORT_EQUALS_RE) for m in rx.finditer(code)]
3084
+ for m in _JS_DESTRUCTURE_RE.finditer(code):
3085
+ try:
3086
+ names, _ = _js_pattern_bindings(code, m.end() - 1)
3087
+ except RecursionError:
3088
+ continue # patterns nested past the interpreter's limit: none bound
3089
+ declared += [(name, _line_of(code, offset), False) for name, offset in names]
2850
3090
  defaults = [(m.group(1), _line_of(code, m.start(1))) for m in _JS_DEFAULT_NAME_RE.finditer(code)]
2851
3091
  return declared, defaults
2852
3092
 
@@ -3084,12 +3324,13 @@ def _member(name: str, local: str | None, file: str | None, line: int | None) ->
3084
3324
  return {"name": name, "local": local if local and local != name else None, "file": file, "line": line}
3085
3325
 
3086
3326
 
3087
- def _flatten(start, direct: Callable[[object], list[tuple[str, str, object]]]) -> list[dict]:
3088
- """The members of the namespace module `start`: what `direct` says it
3089
- passes on, (name, "member", member) or (name, "module", the module),
3090
- each nested namespace module's members spliced in its place (each
3091
- module once, so a cycle is cut), each (name, file) once, and no name
3092
- that starts with `_`, which is never public."""
3327
+ def _flatten(start, direct: Callable[[object], list[tuple[str, str, object]]], family: str) -> list[dict]:
3328
+ """The members of the namespace module `start`, of language family
3329
+ `family`: what `direct` says it passes on, (name, "member", member) or
3330
+ (name, "module", the module), each nested namespace module's members
3331
+ spliced in its place (each module once, so a cycle is cut), each
3332
+ (name, file) once, and no Python or Rust name that starts with `_`,
3333
+ which is never public."""
3093
3334
  out: list[dict] = []
3094
3335
  seen: set[tuple[str, str | None]] = set()
3095
3336
  visited: set = set()
@@ -3097,7 +3338,7 @@ def _flatten(start, direct: Callable[[object], list[tuple[str, str, object]]]) -
3097
3338
  def walk(module) -> None:
3098
3339
  visited.add(module)
3099
3340
  for name, kind, value in direct(module):
3100
- if not name or name.startswith("_"):
3341
+ if not name or _private(family, name):
3101
3342
  continue
3102
3343
  if kind == "module":
3103
3344
  if value not in visited:
@@ -3111,17 +3352,35 @@ def _flatten(start, direct: Callable[[object], list[tuple[str, str, object]]]) -
3111
3352
 
3112
3353
 
3113
3354
  class _Surface:
3114
- """The public names one language family's entry points declare:
3115
- {name: info}, the first entry point naming it wins; the names, and the
3116
- files, a namespace export (a module) makes reachable, which are not
3117
- internal; and the re-exports the trace could not follow. A namespace
3118
- item also carries its `members`, the names its module passes on (None
3119
- when the trace cannot read that module); `opaque` holds the (name,
3120
- file) of each member that is itself a module the trace cannot read,
3121
- which, as such a namespace, is no recipe's target."""
3122
-
3123
- def __init__(self) -> None:
3355
+ """The public names the entry points of language family `family`
3356
+ declare: {name: info}, the first `add` of a name wins (the JS/TS trace
3357
+ adds every entry point's own and named exports before any `export *`),
3358
+ unless the file it gives is out of scope (or it gives none) and another
3359
+ entry point gives a file in `in_scope`, which then replaces it (one
3360
+ entry point never replaces its own, and a star or glob that passes on a
3361
+ name its entry point already exports replaces none: an explicit export
3362
+ shadows it; with no `in_scope`, the first always wins); `others`,
3363
+ {name: [info]}, the other definitions of a held name in files in
3364
+ `in_scope` (#830), each an add that does not become the held record,
3365
+ is no such shadowed star or glob, is no namespace, beside a held record
3366
+ that is none, and gives a file in scope whose (file, `local` or name)
3367
+ neither the held record nor an earlier one of `others` gives; the
3368
+ names, and the files, a namespace export (a module) makes reachable,
3369
+ which are not internal; and the re-exports the trace could not follow.
3370
+ A namespace item also carries its `members`, the names its module
3371
+ passes on (None when the trace cannot read that module); `opaque` holds
3372
+ the (name, file) of each member that is itself a module the trace
3373
+ cannot read, which, as such a namespace, is no recipe's target. A
3374
+ Python or Rust `_` name is never added."""
3375
+
3376
+ def __init__(self, family: str, in_scope: set[str] | None = None) -> None:
3377
+ self.family = family
3378
+ self.in_scope = in_scope
3124
3379
  self.names: dict[str, dict] = {}
3380
+ self.others: dict[str, list[dict]] = {}
3381
+ self._other_at: dict[str, set[tuple[str, str]]] = {} # name: the (file, local or name) of its others
3382
+ # (entry, name) of each add: a star or glob an entry point passes an exported name on through is shadowed
3383
+ self._added: set[tuple[str, str]] = set()
3125
3384
  self.reachable: set[str] = set()
3126
3385
  self.reachable_files: set[str] = set()
3127
3386
  self.unresolved: list[dict] = []
@@ -3130,14 +3389,25 @@ class _Surface:
3130
3389
  def add(self, name: str, entry: str, via: str, file: str | None, line: int | None,
3131
3390
  source: str | None = None, local: str | None = None,
3132
3391
  members: list[dict] | None = None) -> dict | None:
3133
- if not name or name.startswith("_"):
3392
+ if not name or _private(self.family, name):
3134
3393
  return None
3135
- if name not in self.names:
3136
- self.names[name] = {"name": name, "entry": entry, "via": via, "from": source,
3137
- "local": local if local and local != name else None,
3138
- "file": file, "line": line}
3394
+ held = self.names.get(name)
3395
+ shadowed = via == "star" and (entry, name) in self._added
3396
+ self._added.add((entry, name))
3397
+ info = {"name": name, "entry": entry, "via": via, "from": source,
3398
+ "local": local if local and local != name else None, "file": file, "line": line}
3399
+ at = (file, info["local"] or name)
3400
+ if held is None or (not shadowed and self.in_scope is not None and file in self.in_scope
3401
+ and entry != held["entry"]
3402
+ and (held["file"] is None or held["file"] not in self.in_scope)):
3403
+ self.names[name] = info
3139
3404
  if via == "namespace":
3140
3405
  self.names[name]["members"] = [dict(m) for m in members] if members is not None else None
3406
+ elif (not shadowed and via != "namespace" and held["via"] != "namespace" and self.in_scope is not None
3407
+ and file in self.in_scope and at != (held["file"], held["local"] or name)
3408
+ and at not in self._other_at.get(name, ())):
3409
+ self.others.setdefault(name, []).append(info)
3410
+ self._other_at.setdefault(name, set()).add(at)
3141
3411
  return self.names[name]
3142
3412
 
3143
3413
  def unresolve(self, entry: str, name: str, source: str | None, file: str) -> None:
@@ -3341,7 +3611,7 @@ def _js_surface(root: Path, entries: list[dict], tree_set: set[str], forms: dict
3341
3611
 
3342
3612
  def members(file: str) -> list[dict]:
3343
3613
  if file not in member_lists:
3344
- member_lists[file] = _flatten(file, direct)
3614
+ member_lists[file] = _flatten(file, direct, "javascript")
3345
3615
  return member_lists[file]
3346
3616
 
3347
3617
  def publish(entry: str, name: str, via: str, module: str, source: str | None) -> None:
@@ -3712,7 +3982,7 @@ def _py_surface(root: Path, entries: list[dict], tree_set: set[str], surface: _S
3712
3982
 
3713
3983
  def members(rel: str, top: str) -> list[dict]:
3714
3984
  if (rel, top) not in member_lists:
3715
- member_lists[(rel, top)] = _flatten(rel, lambda module: direct(module, top))
3985
+ member_lists[(rel, top)] = _flatten(rel, lambda module: direct(module, top), "python")
3716
3986
  return member_lists[(rel, top)]
3717
3987
 
3718
3988
  for entry in [e["file"] for e in entries]:
@@ -4030,7 +4300,7 @@ def _rust_surface(root: Path, entries: list[dict], tree_set: set[str], surface:
4030
4300
  return None
4031
4301
  key = (lib, tuple(here))
4032
4302
  if key not in member_lists:
4033
- member_lists[key] = _flatten(tuple(here), lambda path: direct(lib, path))
4303
+ member_lists[key] = _flatten(tuple(here), lambda path: direct(lib, path), "rust")
4034
4304
  return member_lists[key]
4035
4305
 
4036
4306
  def publish(lib: str, here: list[str], name: str, via: str, source: str | None,
@@ -4130,19 +4400,25 @@ def _go_surface(root: Path, files: list[str], surface: _Surface, entries: list[d
4130
4400
 
4131
4401
  def entry_point_diff(exports: list[dict], surfaces: dict[str, _Surface], entries: list[dict],
4132
4402
  in_scope: set[str]) -> tuple[dict[str, str], dict[tuple[str, str], dict], list[dict],
4133
- list[tuple[str, dict]], list[tuple[str, dict]]]:
4403
+ list[tuple[str, dict]], list[tuple[str, dict]],
4404
+ list[tuple[str, dict]]]:
4134
4405
  """The entry-point diff, one language family at a time: (status by
4135
4406
  family, public names {(family, name): info}, internal export records,
4136
- and (family, info) of the extraction gaps and of the names outside
4137
- scope). A family whose entry points declare names diffs them with its
4407
+ and (family, info) of the extraction gaps, of the names outside scope
4408
+ and of the other in-scope definitions of public names, `_Surface`'s
4409
+ `others`, which no gap or outside_scope row reads: those go by name).
4410
+ A family whose entry points declare names diffs them with its
4138
4411
  exports: an export no entry point names, nor traces back to, nor makes
4139
4412
  reachable (a name, or any export of a reachable file), is internal. A
4140
- declared name no recipe found is a gap when a file in scope defines it
4141
- (or the trace found no file), and is outside scope when a file out of
4142
- scope does; a module is no recipe's target, so neither: a namespace
4143
- with `members` has each member diffed in its place, found when a
4144
- recipe found its name, or its `local` one, in its file, and given no
4145
- row of its own when a declared name has its name and file (or another
4413
+ declared name, at the file `_Surface` kept for it (not always the first
4414
+ entry point's in path order), is outside scope when a file out of scope
4415
+ defines it, even when a recipe found its name in another file (another
4416
+ definition: no recipe reads a file out of scope), and a gap when no
4417
+ recipe found it and a file in scope defines it (or the trace found no
4418
+ file); a module is no recipe's target, so neither: a namespace with
4419
+ `members` has each member diffed in its place, found when a recipe
4420
+ found its name, or its `local` one, in its file, and given no row of
4421
+ its own when a declared name has its name and file (or another
4146
4422
  namespace gave it one); one whose members are unknown (None), or a
4147
4423
  member that is such a module (`opaque`), is no gap. In a family with
4148
4424
  no entry point, or only empty ones, every export is public."""
@@ -4151,6 +4427,7 @@ def entry_point_diff(exports: list[dict], surfaces: dict[str, _Surface], entries
4151
4427
  internal: list[dict] = []
4152
4428
  gaps: list[tuple[str, dict]] = []
4153
4429
  outside: list[tuple[str, dict]] = []
4430
+ others: list[tuple[str, dict]] = []
4154
4431
  entry_families = {FAMILY_OF[e["language"]] for e in entries}
4155
4432
  families = {FAMILY_OF[e["language"]] for e in exports} | entry_families
4156
4433
  for family in sorted(families | {f for f, s in surfaces.items() if s.names}):
@@ -4168,6 +4445,7 @@ def entry_point_diff(exports: list[dict], surfaces: dict[str, _Surface], entries
4168
4445
  names = {e["export_name"] for e in records}
4169
4446
  traced = {info["local"] for info in declared.values() if info["local"]} | surface.reachable
4170
4447
  public.update(((family, name), info) for name, info in declared.items())
4448
+ others.extend((family, dict(info)) for name in sorted(surface.others) for info in surface.others[name])
4171
4449
  seen: set[str] = set()
4172
4450
  for e in records:
4173
4451
  name = e["export_name"]
@@ -4176,9 +4454,13 @@ def entry_point_diff(exports: list[dict], surfaces: dict[str, _Surface], entries
4176
4454
  seen.add(name)
4177
4455
  internal.append(e)
4178
4456
  for name, info in declared.items():
4179
- if name in names or (info["local"] or name) in names or info["via"] == "namespace":
4457
+ if info["via"] == "namespace":
4180
4458
  continue
4181
- (gaps if info["file"] is None or info["file"] in in_scope else outside).append((family, info))
4459
+ # A recipe never reads a file out of scope: a name it found elsewhere is another definition.
4460
+ if info["file"] is not None and info["file"] not in in_scope:
4461
+ outside.append((family, info))
4462
+ elif name not in names and (info["local"] or name) not in names:
4463
+ gaps.append((family, info))
4182
4464
  # A namespace's members, each by its name (or `local`) and file, or
4183
4465
  # by name alone when it has no file, as a declared name is: a name
4184
4466
  # at a file a declared name already gives counts once.
@@ -4195,7 +4477,7 @@ def entry_point_diff(exports: list[dict], surfaces: dict[str, _Surface], entries
4195
4477
  row = {"name": member["name"], "entry": info["entry"], "file": member["file"],
4196
4478
  "line": member["line"]}
4197
4479
  (gaps if member["file"] is None or member["file"] in in_scope else outside).append((family, row))
4198
- return statuses, public, internal, gaps, outside
4480
+ return statuses, public, internal, gaps, outside, others
4199
4481
 
4200
4482
 
4201
4483
  def _has_members(info: dict) -> bool:
@@ -4204,9 +4486,9 @@ def _has_members(info: dict) -> bool:
4204
4486
 
4205
4487
 
4206
4488
  def _public_keys(public: dict[tuple[str, str], dict]) -> set[tuple[str, str, str | None]]:
4207
- """The (family, name, file) the counts count: each public name but a
4208
- namespace with members, in its place each member, and so a member that
4209
- is also a declared name (or another namespace's member) once."""
4489
+ """The (family, name, file) exports_public_api counts: each public name
4490
+ but a namespace with members, in its place each member, and so a member
4491
+ that is also a declared name (or another namespace's member) once."""
4210
4492
  keys: set[tuple[str, str, str | None]] = set()
4211
4493
  for (family, _), info in public.items():
4212
4494
  if _has_members(info):
@@ -4216,6 +4498,16 @@ def _public_keys(public: dict[tuple[str, str], dict]) -> set[tuple[str, str, str
4216
4498
  return keys
4217
4499
 
4218
4500
 
4501
+ def _other_keys(others: list[tuple[str, dict]]) -> set[tuple[str, str, str | None]]:
4502
+ """The (family, name, file) of the other in-scope definitions of public
4503
+ names (#830), which join the public keys in the set effective_denominator
4504
+ and curated_subset read, never exports_public_api: a name still counts
4505
+ once, when any of its files matches."""
4506
+ return {(family, info["name"], info["file"]) for family, info in others
4507
+ if isinstance(info.get("name"), str) and info["name"] and isinstance(info.get("file"), str)
4508
+ and info["file"]}
4509
+
4510
+
4219
4511
  def _head_cap(value: int | None, tier: str | None, scope_type: str | None) -> int | None:
4220
4512
  if value is not None:
4221
4513
  return value or None
@@ -4223,7 +4515,10 @@ def _head_cap(value: int | None, tier: str | None, scope_type: str | None) -> in
4223
4515
 
4224
4516
 
4225
4517
  def _workspaces(root: Path, tree: list[str], tree_set: set[str]) -> dict:
4226
- """skf-detect-workspaces.py's verdict on the source root."""
4518
+ """skf-detect-workspaces.py's result over `tree` (its workspaces
4519
+ resolved against those paths), with the content of each root manifest
4520
+ of WORKSPACE_MANIFESTS that `tree_set` lists. No brief scope applies:
4521
+ the monorepo arm reads it through _monorepo."""
4227
4522
  manifests = {}
4228
4523
  for name in WORKSPACE_MANIFESTS:
4229
4524
  if name in tree_set:
@@ -4233,15 +4528,24 @@ def _workspaces(root: Path, tree: list[str], tree_set: set[str]) -> dict:
4233
4528
  return _sibling("skf-detect-workspaces.py").detect({"tree": tree, "manifests": manifests})
4234
4529
 
4235
4530
 
4236
- def _monorepo(root: Path, tree: list[str], tree_set: set[str]) -> tuple[bool, str | None]:
4237
- """skf-detect-workspaces.py's verdict on the source root: (is it a monorepo, its manifest kind)."""
4238
- result = _workspaces(root, tree, tree_set)
4531
+ def _monorepo(root: Path, tree: list[str], excludes: list[str],
4532
+ glob_match: Callable[[str, str], bool]) -> tuple[bool, str | None]:
4533
+ """Arm (a): (is the source a monorepo, its manifest kind), from
4534
+ skf-detect-workspaces.py over the tree the brief keeps, its
4535
+ scoped_tree: a workspace member counts only through the files no
4536
+ scope.exclude glob matches, every root manifest stays, and a workspace
4537
+ with no member left is none (#780)."""
4538
+ kept = _sibling("skf-detect-workspaces.py").scoped_tree(tree, excludes, glob_match)
4539
+ result = _workspaces(root, kept, set(kept))
4239
4540
  return bool(result.get("is_monorepo")), result.get("manifest_kind")
4240
4541
 
4241
4542
 
4242
4543
  def _workspace_sources(result: dict, tree: list[str], wanted: set[str]) -> list[set[str]]:
4243
- """The source files (of a language --language selects) of each workspace
4244
- skf-detect-workspaces.py found, one set per workspace that holds any."""
4544
+ """The source files (of a language --language selects) under each
4545
+ workspace of `result`, one set per workspace that holds any. _scope_facts
4546
+ passes the workspaces skf-detect-workspaces.py finds over the whole
4547
+ tree, scope.exclude aside (unlike arm (a)): a member the brief drops is
4548
+ still part of the repository the brief chose a subset of."""
4245
4549
  sources = []
4246
4550
  for workspace in result.get("workspaces") or [] if result.get("is_monorepo") else []:
4247
4551
  folder = _norm(workspace.get("path") if isinstance(workspace, dict) else "").strip("/")
@@ -4288,8 +4592,10 @@ def curated_subset(counted: set[tuple[str, str, str | None]], basis: str, tree:
4288
4592
  whole workspace (in a single-package repository, the whole package): the
4289
4593
  files the basis globs list (a tier A list's, among those scope.include
4290
4594
  lists; under arm (b), less those scope.exclude drops) leave out a file
4291
- that defines a counted public name or, in a monorepo, every source file
4292
- of a workspace. A basis of all-files lists none."""
4595
+ that defines a counted public name or every source file of one of
4596
+ `workspace_sources` (the workspaces found over the whole tree,
4597
+ scope.exclude aside, so excluding a member the include globs leave out
4598
+ anyway changes nothing). A basis of all-files lists none."""
4293
4599
  if basis == "all-files":
4294
4600
  return False
4295
4601
  files = {f for f in tree if not includes or any(glob_match(f, g) for g in includes)}
@@ -4413,7 +4719,7 @@ def _scope_facts(args: argparse.Namespace, root: Path) -> dict:
4413
4719
 
4414
4720
  # The entry points and the arms need no ast-grep.
4415
4721
  in_scope_set = set(in_scope)
4416
- surfaces = {family: _Surface() for family in FAMILIES}
4722
+ surfaces = {family: _Surface(family, in_scope_set) for family in FAMILIES}
4417
4723
  js_entries, maps, unresolved_subpaths = _js_entries(root, in_scope, tree, tree_set, warnings)
4418
4724
  py_entries = _py_entries(in_scope, tree_set)
4419
4725
  rust_entries = _rust_entries(root, in_scope, tree, tree_set)
@@ -4428,11 +4734,10 @@ def _scope_facts(args: argparse.Namespace, root: Path) -> dict:
4428
4734
  f"{entry['package']}) is outside the files in scope: widen scope.include to document "
4429
4735
  "the names it exports (entry_point_diff.outside_scope), which effective_denominator "
4430
4736
  "leaves out")
4431
- workspaces = _workspaces(root, tree, tree_set)
4432
- monorepo = bool(workspaces.get("is_monorepo"))
4737
+ monorepo, monorepo_kind = _monorepo(root, tree, excludes, glob_match)
4433
4738
  arms = {
4434
4739
  "monorepo": monorepo,
4435
- "monorepo_kind": workspaces.get("manifest_kind"),
4740
+ "monorepo_kind": monorepo_kind,
4436
4741
  "specific_modules": scope_type == "specific-modules" and not monorepo,
4437
4742
  "multi_subpath_exports": not monorepo and any(m["non_root_subpaths"] > 1 for m in maps),
4438
4743
  }
@@ -4442,7 +4747,8 @@ def _scope_facts(args: argparse.Namespace, root: Path) -> dict:
4442
4747
  "in_scope": in_scope, "without_recipes": without_recipes, "unmatched_include": unmatched_include,
4443
4748
  "unmatched_tier_a": unmatched_tier_a, "surfaces": surfaces, "js_entries": js_entries, "maps": maps,
4444
4749
  "unresolved_subpaths": unresolved_subpaths, "py_entries": py_entries, "rust_entries": rust_entries,
4445
- "entries": entries, "arms": arms, "workspace_sources": _workspace_sources(workspaces, tree, wanted),
4750
+ "entries": entries, "arms": arms,
4751
+ "workspace_sources": _workspace_sources(_workspaces(root, tree, tree_set), tree, wanted),
4446
4752
  }
4447
4753
 
4448
4754
 
@@ -4586,7 +4892,7 @@ def run_full(args: argparse.Namespace) -> tuple[dict, int]:
4586
4892
  for record in exports:
4587
4893
  if record["from"] and record["language"] in JS_FAMILY:
4588
4894
  record["from_file"] = resolve_js_module(record["from"], record["source_file"], tree_set)
4589
- statuses, public, internal, gaps, outside = entry_point_diff(exports, surfaces, entries, in_scope_set)
4895
+ statuses, public, internal, gaps, outside, others = entry_point_diff(exports, surfaces, entries, in_scope_set)
4590
4896
  if "barrel" in statuses.values():
4591
4897
  status = "barrel"
4592
4898
  else:
@@ -4596,8 +4902,10 @@ def run_full(args: argparse.Namespace) -> tuple[dict, int]:
4596
4902
  # only files an exclude drops, that scope.include leaves out or that
4597
4903
  # define no public name) narrows nothing, once the run counted a name
4598
4904
  # and read every file (no error).
4599
- counted = _public_keys(public)
4600
- counts = _denominator_counts(facts, counted, len(counted), len(internal), bool(errors), warnings)
4905
+ # Each other in-scope definition of a public name counts its name where any of its files is counted (#830).
4906
+ public_keys = _public_keys(public)
4907
+ counted = public_keys | _other_keys(others)
4908
+ counts = _denominator_counts(facts, counted, len(public_keys), len(internal), bool(errors), warnings)
4601
4909
 
4602
4910
  by_type: Counter = Counter(e["export_type"] for e in exports)
4603
4911
  ordered = sorted(public.items(), key=lambda item: (item[0][1], item[0][0]))
@@ -4611,6 +4919,8 @@ def run_full(args: argparse.Namespace) -> tuple[dict, int]:
4611
4919
  "unresolved": unresolved_subpaths + [u for s in surfaces.values() for u in s.unresolved]},
4612
4920
  "entry_point_diff": {
4613
4921
  "public": [dict(info, language=family) for (family, _), info in ordered],
4922
+ "other_definitions": [dict(info, language=family) for family, info in
4923
+ sorted(others, key=lambda o: (o[1]["name"], o[1]["file"], o[0]))],
4614
4924
  "internal": [{"name": e["export_name"], "language": FAMILY_OF[e["language"]],
4615
4925
  "source_file": e["source_file"], "source_line": e["source_line"]} for e in internal],
4616
4926
  "extraction_gaps": [{"name": info["name"], "language": family, "entry": info["entry"],
@@ -4730,6 +5040,8 @@ def run_denominator(args: argparse.Namespace, payload: dict) -> dict:
4730
5040
  # The exports found, each key at its file: the payload's and the runner's.
4731
5041
  found: dict[tuple[str, str], str | None] = {}
4732
5042
  internal: set[tuple[str, str]] = set()
5043
+ # The runner's other in-scope definitions of its public names (#830), counted where the names are.
5044
+ others: list[tuple[str, dict]] = []
4733
5045
  incomplete = False
4734
5046
  runner_status = None
4735
5047
  if args.extraction:
@@ -4744,6 +5056,8 @@ def run_denominator(args: argparse.Namespace, payload: dict) -> dict:
4744
5056
  key = (str(info.get("language") or ""), info["name"])
4745
5057
  public[key] = runner_public[key] = info
4746
5058
  found[key] = info.get("file")
5059
+ others = [(str(info.get("language") or ""), info) for info in diff.get("other_definitions") or []
5060
+ if isinstance(info, dict)]
4747
5061
  for item in diff.get("internal") or []:
4748
5062
  if isinstance(item, dict) and isinstance(item.get("name"), str):
4749
5063
  internal.add((str(item.get("language") or ""), item["name"]))
@@ -4767,11 +5081,27 @@ def run_denominator(args: argparse.Namespace, payload: dict) -> dict:
4767
5081
  for item in _payload_names(payload.get("exports"), "exports"):
4768
5082
  key = (_name_family(item["language"], item["file"]), item["name"])
4769
5083
  found.setdefault(key, item["file"])
4770
- if not item["name"].startswith("_"):
5084
+ if not _private(key[0], item["name"]):
4771
5085
  internal.add(key)
4772
5086
  named = set(public) | {(family, m["name"]) for (family, _), info in public.items() if _has_members(info)
4773
5087
  for m in info["members"]}
4774
- counted = _public_keys(public)
5088
+ public_keys = _public_keys(public)
5089
+ def still_held(key: tuple[str, str]) -> bool:
5090
+ """Whether the record kept for the runner's public name `key` still names the runner row's file, a
5091
+ namespace with members in neither (full mode keeps no other definition beside one)."""
5092
+ runner_row, kept = runner_public.get(key), public.get(key)
5093
+ if runner_row is None or kept is None or _has_members(runner_row) or _has_members(kept):
5094
+ return False
5095
+ files = [_norm(row["file"]) if isinstance(row.get("file"), str) and row["file"] else None
5096
+ for row in (runner_row, kept)]
5097
+ return files[0] == files[1]
5098
+
5099
+ # A runner row counts only while the record kept for its name names the runner's file: an orphan row, or
5100
+ # one of a name a payload or --quick record moved to another file, adds nothing.
5101
+ others = [(family, info) for family, info in others
5102
+ if isinstance(info.get("name"), str) and still_held((family, info["name"]))]
5103
+ other_keys = _other_keys(others)
5104
+ counted = public_keys | other_keys
4775
5105
 
4776
5106
  if args.source_root:
4777
5107
  root = Path(args.source_root)
@@ -4807,7 +5137,7 @@ def run_denominator(args: argparse.Namespace, payload: dict) -> dict:
4807
5137
  outside_tree = sorted((name, file) for _, name, file in counted if file and file not in tree_set)
4808
5138
  warnings += [f"public name {name} is defined in {file}, which is not in the tree: effective_denominator "
4809
5139
  "and curated_subset leave it out" for name, file in outside_tree]
4810
- counts = _denominator_counts(facts, counted, len(counted), len(internal - named), incomplete, warnings)
5140
+ counts = _denominator_counts(facts, counted, len(public_keys), len(internal - named), incomplete, warnings)
4811
5141
  if not args.source_root and counts["effective_denominator_decision"]["reason"] not in ("reference-app",
4812
5142
  "incomplete-run"):
4813
5143
  counts["effective_denominator_decision"] = {"store": False, "reason": "no-source-tree"}
@@ -4838,7 +5168,9 @@ def run_denominator(args: argparse.Namespace, payload: dict) -> dict:
4838
5168
  "counts": counts,
4839
5169
  "entry_point_diff": {
4840
5170
  "public": [row((family, name), file)
4841
- for family, name, file in sorted(counted, key=lambda k: (k[1], k[0], k[2] or ""))],
5171
+ for family, name, file in sorted(public_keys, key=lambda k: (k[1], k[0], k[2] or ""))],
5172
+ "other_definitions": [row((family, name), file)
5173
+ for family, name, file in sorted(other_keys, key=lambda k: (k[1], k[2], k[0]))],
4842
5174
  "internal": [row(key, found.get(key)) for key in by_name(internal - named)],
4843
5175
  "extraction_gaps": [row(key, given[key]) for key in by_name(given) if key not in found],
4844
5176
  },
@@ -5586,7 +5918,7 @@ def _py_defined_in(root: Path, tree: set[str]) -> Callable[[str, str], str | Non
5586
5918
 
5587
5919
  def resolve(file: str, name: str) -> str | None:
5588
5920
  if file not in traced:
5589
- surface = _Surface()
5921
+ surface = _Surface("python")
5590
5922
  _py_surface(root, [{"file": file}], tree, surface, [])
5591
5923
  traced[file] = {n: info["file"] for n, info in surface.names.items()}
5592
5924
  return traced[file].get(name)