bmad-module-skill-forge 4.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +3 -3
  3. package/docs/_data/pinned.yaml +19 -13
  4. package/docs/agents.md +1 -1
  5. package/docs/architecture.md +5 -5
  6. package/docs/campaign.md +1 -1
  7. package/docs/concepts.md +4 -4
  8. package/docs/examples.md +32 -25
  9. package/docs/getting-started.md +3 -3
  10. package/docs/how-it-works.md +2 -2
  11. package/docs/index.md +1 -1
  12. package/docs/skill-model.md +41 -39
  13. package/docs/troubleshooting.md +9 -3
  14. package/docs/verifying-a-skill.md +42 -21
  15. package/docs/why-skf.md +1 -1
  16. package/docs/workflows.md +6 -6
  17. package/package.json +1 -1
  18. package/src/knowledge/tool-resolution.md +6 -6
  19. package/src/shared/references/headless-gate-convention.md +4 -1
  20. package/src/shared/references/output-contract-schema.md +3 -3
  21. package/src/shared/references/pipeline-contracts.md +2 -1
  22. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  23. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  25. package/src/shared/scripts/skf-build-change-manifest.py +1180 -189
  26. package/src/shared/scripts/skf-classify-changed-files.py +15 -10
  27. package/src/shared/scripts/skf-count-tokens.py +54 -10
  28. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  29. package/src/shared/scripts/skf-emit-result-envelope.py +64 -20
  30. package/src/shared/scripts/skf-extract-public-api.py +472 -120
  31. package/src/shared/scripts/skf-extraction-inventory.py +10 -6
  32. package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
  33. package/src/shared/scripts/skf-parse-gaps.py +136 -26
  34. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  35. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  36. package/src/shared/scripts/skf-shard-body.py +239 -41
  37. package/src/shared/scripts/skf-validate-output.py +12 -3
  38. package/src/shared/scripts/skf-verify-provenance-completeness.py +574 -45
  39. package/src/shared/tool-requirements.yaml +8 -2
  40. package/src/skf-analyze-source/SKILL.md +3 -2
  41. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  42. package/src/skf-analyze-source/references/generate-briefs.md +1 -1
  43. package/src/skf-analyze-source/references/headless-contract.md +1 -1
  44. package/src/skf-audit-skill/SKILL.md +4 -3
  45. package/src/skf-audit-skill/references/re-index.md +2 -2
  46. package/src/skf-audit-skill/references/report.md +1 -1
  47. package/src/skf-brief-skill/SKILL.md +3 -2
  48. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  49. package/src/skf-brief-skill/references/invocation-contract.md +2 -2
  50. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  51. package/src/skf-campaign/SKILL.md +3 -2
  52. package/src/skf-campaign/references/campaign-contracts.md +2 -0
  53. package/src/skf-campaign/references/step-05-skill-loop.md +8 -3
  54. package/src/skf-campaign/references/step-10-export.md +3 -2
  55. package/src/skf-create-skill/SKILL.md +4 -3
  56. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  57. package/src/skf-create-skill/references/ccc-index-check.md +1 -1
  58. package/src/skf-create-skill/references/component-extraction.md +1 -1
  59. package/src/skf-create-skill/references/extract.md +3 -2
  60. package/src/skf-create-skill/references/extraction-patterns.md +3 -3
  61. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  62. package/src/skf-create-skill/references/report.md +8 -3
  63. package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
  64. package/src/skf-create-skill/references/validate.md +1 -1
  65. package/src/skf-create-stack-skill/SKILL.md +3 -2
  66. package/src/skf-create-stack-skill/references/generate-output.md +1 -1
  67. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  68. package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
  69. package/src/skf-create-stack-skill/references/report.md +1 -1
  70. package/src/skf-create-stack-skill/references/validate.md +1 -1
  71. package/src/skf-drop-skill/SKILL.md +3 -2
  72. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  73. package/src/skf-drop-skill/references/report.md +1 -1
  74. package/src/skf-export-skill/SKILL.md +3 -2
  75. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  76. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  77. package/src/skf-export-skill/references/invocation-contract.md +1 -1
  78. package/src/skf-export-skill/references/load-skill.md +1 -1
  79. package/src/skf-export-skill/references/summary.md +1 -1
  80. package/src/skf-export-skill/references/token-report.md +29 -9
  81. package/src/skf-forger/SKILL.md +3 -2
  82. package/src/skf-forger/references/pipeline-mode.md +2 -2
  83. package/src/skf-quick-skill/SKILL.md +4 -3
  84. package/src/skf-quick-skill/references/finalize.md +1 -1
  85. package/src/skf-quick-skill/references/halt-contract.md +2 -2
  86. package/src/skf-quick-skill/references/write-and-validate.md +1 -1
  87. package/src/skf-refine-architecture/SKILL.md +8 -9
  88. package/src/skf-refine-architecture/customize.toml +1 -1
  89. package/src/skf-refine-architecture/references/report.md +1 -1
  90. package/src/skf-rename-skill/SKILL.md +3 -2
  91. package/src/skf-rename-skill/references/invocation-contract.md +2 -2
  92. package/src/skf-setup/SKILL.md +3 -2
  93. package/src/skf-setup/references/ccc-index.md +1 -1
  94. package/src/skf-setup/references/invocation-contract.md +1 -1
  95. package/src/skf-test-skill/SKILL.md +3 -2
  96. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  97. package/src/skf-test-skill/references/coherence-check.md +11 -8
  98. package/src/skf-test-skill/references/coverage-check-branches.md +81 -0
  99. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  100. package/src/skf-test-skill/references/coverage-check.md +60 -97
  101. package/src/skf-test-skill/references/init.md +2 -1
  102. package/src/skf-test-skill/references/inventory-contract.md +27 -0
  103. package/src/skf-test-skill/references/invocation-contract.md +3 -3
  104. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  105. package/src/skf-test-skill/references/report.md +5 -5
  106. package/src/skf-test-skill/references/scoring-rules.md +8 -7
  107. package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
  108. package/src/skf-test-skill/references/source-access-protocol.md +14 -10
  109. package/src/skf-test-skill/references/step-hard-gate.md +1 -0
  110. package/src/skf-test-skill/scripts/build-result-context.py +20 -3
  111. package/src/skf-test-skill/scripts/gap-ledger.py +456 -176
  112. package/src/skf-test-skill/scripts/load-coverage-inputs.py +516 -79
  113. package/src/skf-test-skill/scripts/locate-export-segments.py +685 -76
  114. package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
  115. package/src/skf-test-skill/scripts/score-signatures.py +1205 -65
  116. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  117. package/src/skf-test-skill/scripts/validate-inventory.py +350 -35
  118. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  119. package/src/skf-update-skill/SKILL.md +3 -2
  120. package/src/skf-update-skill/references/detect-changes.md +1 -1
  121. package/src/skf-update-skill/references/gap-driven.md +54 -33
  122. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  123. package/src/skf-update-skill/references/merge.md +15 -10
  124. package/src/skf-update-skill/references/re-extract.md +3 -1
  125. package/src/skf-update-skill/references/report.md +6 -5
  126. package/src/skf-update-skill/references/write.md +42 -20
  127. package/src/skf-verify-stack/SKILL.md +4 -3
  128. package/tools/cli/lib/tool-check.js +158 -2
@@ -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
@@ -381,7 +385,11 @@ the line cap, say), which the caller then reads by eye. An item of a local `expo
381
385
  list takes instead the type of the top-level binding it names in its file:
382
386
  what the recipes record for that declaration exported in place (a const
383
387
  bound to an arrow function or a function expression is a function), or
384
- `re-export` for an import.
388
+ `re-export` for an import. When that binding is declared in the file (not
389
+ an import or a destructured name), the item also gets `declaration_line`
390
+ and `declaration_signature_line`, its `$NAME` line and the source line
391
+ holding it (an overload set's first signature); its `source_line`,
392
+ `signature_line` and `citation` stay at the list.
385
393
 
386
394
  Entry points. The export surface each package declares: every subpath of a
387
395
  package.json `exports` map (committed .d.ts/.d.mts targets outside src/
@@ -423,40 +431,75 @@ module names.
423
431
  A chain the trace cannot follow (a module no file here holds, a `#[path]`
424
432
  module) is listed in `unresolved`. The diff against the recipe inventory,
425
433
  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
434
+ `public` (the entry points' names, each at the file and line the first
435
+ entry point naming it gives, in path order, a JS/TS entry point's own and
436
+ named exports all coming before any `export *`, unless that file is out
437
+ of scope, or it gives none, and another entry point gives a file in
438
+ scope, other than through a star or glob its own export of the name
439
+ shadows),
440
+ `other_definitions` (each other definition of a public name in a file in
441
+ scope, one row per file and `local` name: the file and line an entry
442
+ point gives, never a namespace nor one beside a public row that is a
443
+ namespace, nor a star or glob through which an entry point passes on a
444
+ name it already exports (its own export shadows it), while two exports
445
+ of the name by one entry point, such as Rust `pub use` items a `cfg`
446
+ gates, are both kept; in every language family, so a name each
447
+ framework package of a monorepo defines keeps
448
+ each package's file, while a gap and an outside_scope row still go by
449
+ name, from `public` alone),
450
+ `internal` (names the recipes found that no entry point exports or makes
451
+ reachable), `extraction_gaps` (names an entry point exports that no
452
+ recipe found, defined in a file in scope or in none the trace reached:
453
+ read them by eye) and `outside_scope` (names an entry point exports that
454
+ a file out of scope defines, even when a recipe found the name in another
455
+ file, which is another definition: widen scope.include to document
431
456
  them). A namespace with `members` is diffed as its members: each is found
432
457
  when a recipe found its name, or its `local` one, in its file (one with
433
458
  no file by its name alone, as a public name is), and has no row of its
434
459
  own when a public name (or another namespace's member) has its name and
435
460
  file; one with null `members`, and a member that is such a module, is no
436
461
  gap. In a family with no entry point, or only empty ones, every name the
437
- recipes found is public.
462
+ recipes found is public. A JS/TS module exports, besides the names the
463
+ recipes match, those its column-0 `export` statements declare in a form
464
+ no recipe matches, read from its text: `export let`, `export declare
465
+ const`, `export namespace`, and each name a destructuring pattern that
466
+ opens an `export const`, `let` or `var` statement binds (`export const
467
+ { a, b: c } = obj` exports a and c), at the line the name is on. Such a
468
+ name that an entry point exports, or passes on through `export *`, is
469
+ public and, no recipe having found it, an `extraction_gaps` row
470
+ (`outside_scope`, in a file out of scope) at that line. A pattern after
471
+ the statement's first declarator (`export const a = 1, { b } = o`), or
472
+ one the reader cannot read (one that does not close, or a default
473
+ holding a template literal over several lines), binds nothing here and
474
+ no warning names it: read it by eye.
438
475
 
439
476
  Counts. `exports_public_api` counts the public names (the package's whole
440
477
  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.
478
+ `public` gives once (`other_definitions` adds none): a namespace with
479
+ `members` counts as its members (none for an empty list, and a member
480
+ that is also a public name, or another namespace's member, once), one
481
+ with null `members` as one. `exports_internal` counts the internal names.
482
+ `effective_denominator` counts those public names with a definition file
483
+ (the `public` row's, or one of its `other_definitions`') that matches
484
+ scope.include (any file when it has no glob), scope.tier_a_include when
485
+ given, and no scope.exclude glob: each name once, however many in-scope
486
+ files define it, so a type counts once whatever its methods, a barrel
487
+ that only re-exports adds nothing of its own, and a name the entry points
488
+ pass on from two files (two packages' entry points that each define it,
489
+ or a namespace member named like another public name, or like another
490
+ namespace's member, at another file) or in two languages counts once,
491
+ when any one of its files matches, as Test Skill's sets count it. A tier
492
+ A list that counts no public name (each glob matches no file, or only
493
+ files scope.exclude drops, that scope.include leaves out or that define
494
+ no public name) narrows nothing on a run that counted a public name and
495
+ read every file (no `errors` item): the count falls back to scope.include
496
+ (else every file), with a warning.
458
497
  `arms` gives compile's effective_denominator arms:
459
- (a) `monorepo` from skf-detect-workspaces.py, (b) `specific_modules`
498
+ (a) `monorepo` from skf-detect-workspaces.py over the tree the brief
499
+ keeps (its scoped_tree: a member counts only through the files no
500
+ scope.exclude glob matches, so excluding its sources drops it, excluding
501
+ only its manifest does not, root manifests always stay, and a workspace
502
+ with no member left is none), (b) `specific_modules`
460
503
  (scope type specific-modules on a repository that is not a monorepo) and
461
504
  (c) `multi_subpath_exports` (an in-scope package.json whose `exports` map
462
505
  has more than one non-root subpath without a `*`, on a repository that is
@@ -466,20 +509,22 @@ Store or not. `curated_subset` is true when the files the count's basis
466
509
  globs list (scope.include's, else every file; a tier A count's, those
467
510
  among them a tier A glob matches too; under arm (b), less those a
468
511
  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
512
+ name (an `outside_scope` name's file and an `other_definitions` file
513
+ included) or every source file of a
514
+ workspace skf-detect-workspaces.py finds over the whole tree
515
+ (scope.exclude aside, unlike arm (a): a member the brief drops is still
516
+ part of the repository it chose a subset of); a basis of `all-files` lists
471
517
  none. `effective_denominator_decision` says whether create-skill and
472
518
  update-skill store `effective_denominator` as stats.effective_denominator,
473
519
  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).
520
+ judgment left to them): `store` is true only when none of these holds, and
521
+ `reason` names the first that does, else `curated-subset`: `reference-app`
522
+ (scope type reference-app: pattern_surfaces_documented is its basis),
523
+ `incomplete-run` (an `errors` item: a file a failed call left unread can
524
+ hide a public name, so the count can be 0 or partial), `no-arm` (no arm is
525
+ true), `full-library` (scope type full-library), `all-files` (the basis),
526
+ `not-curated` (`curated_subset` false) and `zero-count` (a stored 0 is no
527
+ denominator: Test Skill reads it as deflation).
483
528
 
484
529
  Output JSON (stdout, or -o):
485
530
 
@@ -510,7 +555,10 @@ Output JSON (stdout, or -o):
510
555
  "citation": "[AST:<file>:L<line>]", "ast_recipe",
511
556
  "ast_node_type", "export_type", "language",
512
557
  "from": "<module>" | null, "from_file": "<file>" | null,
513
- "confidence": "T1", "extraction_method": "ast-grep"}, ...],
558
+ "confidence": "T1", "extraction_method": "ast-grep",
559
+ # only on a local export list item whose binding is
560
+ # declared in the file:
561
+ "declaration_line": N, "declaration_signature_line": "<line>"}, ...],
514
562
  "aggregates": {"exports": N, "by_type": {"function": N, ...},
515
563
  "t1": N, "t1_low": 0},
516
564
  "entry_points": {"status": "barrel" | "empty-barrel" | "no-entry-point"
@@ -530,6 +578,8 @@ Output JSON (stdout, or -o):
530
578
  "file", "line",
531
579
  "members": [{"name", "local", "file", "line"}, ...]
532
580
  | null}, ...], # members: via namespace only
581
+ "other_definitions": [{"name", "language", "entry", "via", "from",
582
+ "local", "file", "line"}, ...], # by name, then file
533
583
  "internal": [{"name", "language", "source_file", "source_line"}, ...],
534
584
  "extraction_gaps": [{"name", "language", "entry", "file", "line"}, ...],
535
585
  "outside_scope": [{"name", "language", "entry", "file", "line"}, ...]},
@@ -618,9 +668,16 @@ the entry points export by name, which counts as the names its module
618
668
  passes on), each export of a --quick output at its `defined_in` (else its
619
669
  `source_file`), and the --extraction runner's `entry_point_diff.public`;
620
670
  a payload name replaces a --quick or runner one of its family and name.
671
+ The runner's `other_definitions` count in `effective_denominator` and
672
+ `curated_subset` as full mode counts them, never in `exports_public_api`,
673
+ each only while the record kept for its name names the file of the
674
+ runner's `public` row (a row whose name has no runner row, or one a
675
+ payload or --quick name moves to another file, adds nothing), and none
676
+ beside a namespace with members, as in full mode.
621
677
  `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`,
678
+ exports extraction found; Python, Rust and Dart `_` names left out, those
679
+ of every other language counted) and of the runner's `internal` that no
680
+ public name or member has. `effective_denominator`,
624
681
  its basis, `curated_subset` and `effective_denominator_decision` follow
625
682
  full mode's rules, over the tree under --source-root, with the scope and
626
683
  arms full mode works out there; an --extraction that is `incomplete` is
@@ -638,10 +695,12 @@ and `arms`.
638
695
 
639
696
  `entry_point_diff` replaces a compare by eye, each item {"name",
640
697
  "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).
698
+ namespace as its `members`); `other_definitions`, the runner's other
699
+ definitions the counts read, by name then file; `internal`, the exports
700
+ `exports_internal` counts; `extraction_gaps`, the public names the
701
+ payload and --quick give that no export found has (the payload's
702
+ `exports`, the --extraction runner's names), to trace (the runner lists
703
+ its own gaps).
645
704
 
646
705
  Output JSON: `mode` "denominator", `source_root`, `scope` (as full mode
647
706
  writes it), `files_in_scope`, `files_without_recipes`, `entry_points`,
@@ -1957,18 +2016,22 @@ def _binding_rules(language: str) -> list[dict]:
1957
2016
  return rules + [r for r in _trace_rules(language) if r["id"] in IMPORT_RULES]
1958
2017
 
1959
2018
 
1960
- def _binding_types(matches: list[dict]) -> dict[tuple[str, str], str]:
1961
- """(file, name) -> the export type of the top-level binding `name` has
1962
- in that file, from the matches of _binding_rules."""
2019
+ def _bindings(matches: list[dict]) -> dict[tuple[str, str], dict]:
2020
+ """(file, name) -> the top-level binding `name` has in that file, from
2021
+ the matches of _binding_rules: its form, the export type BINDING_TYPES
2022
+ gives it (`type`), and the `line` and `signature_line` of its match. Of
2023
+ several matches of the winning form (an overload set's signatures), the
2024
+ lowest line wins, the line merge_exports keeps for a name."""
1963
2025
  rank = list(BINDING_TYPES)
1964
- best: dict[tuple[str, str], str] = {}
2026
+ best: dict[tuple[str, str], tuple[tuple[int, int], dict]] = {}
1965
2027
  for m in matches:
1966
2028
  form = "import" if m["rule"] in IMPORT_RULES else m["rule"].removeprefix(f"{BINDING_RULE}-")
1967
2029
  if form in BINDING_TYPES and m["name"]:
1968
- key = (m["file"], m["name"])
1969
- if key not in best or rank.index(form) < rank.index(best[key]):
1970
- best[key] = form
1971
- return {key: BINDING_TYPES[form] for key, form in best.items()}
2030
+ key, order = (m["file"], m["name"]), (rank.index(form), m["line"])
2031
+ if key not in best or order < best[key][0]:
2032
+ best[key] = (order, {"form": form, "type": BINDING_TYPES[form], "line": m["line"],
2033
+ "signature_line": m["signature_line"]})
2034
+ return {key: binding for key, (_, binding) in best.items()}
1972
2035
 
1973
2036
 
1974
2037
  # --------------------------------------------------------------------------
@@ -2627,11 +2690,22 @@ def _preferred(at_line: list[dict], forms: dict, order: dict[str, int]) -> dict:
2627
2690
  return min(left, key=lambda m: (order[m["rule"]], m["language"]))
2628
2691
 
2629
2692
 
2693
+ def _private(family: str | None, name: str) -> bool:
2694
+ """A name full mode and denominator mode never count: one that starts
2695
+ with `_` in Python (the Source API Surface Definition leaves it out),
2696
+ Rust (a `#[doc(hidden)] pub mod __private`, which the runner cannot
2697
+ read, or a `pub const _`) or Dart (private to its library). A TS/JS `_`
2698
+ name is exported like any other, and Go's recipe reads only a
2699
+ capitalized name. Quick mode drops only Python's `_` names."""
2700
+ return family in ("python", "rust", "dart") and name.startswith("_")
2701
+
2702
+
2630
2703
  def merge_exports(matches: list[dict], recipes: list[dict], recipe_set: str,
2631
2704
  head_cap: int | None) -> tuple[list[dict], list[dict]]:
2632
2705
  """(exports, per-recipe stats) from the matches of one recipe set: at
2633
2706
  most `head_cap` matches of each recipe, in file and line order, merged
2634
- by (file, name) at the first line that names it."""
2707
+ by (file, name) at the first line that names it. A Python or Rust `_`
2708
+ name is no export."""
2635
2709
  forms = _forms(recipes)
2636
2710
  order: dict[str, int] = {}
2637
2711
  languages: dict[str, list[str]] = {}
@@ -2642,7 +2716,8 @@ def merge_exports(matches: list[dict], recipes: list[dict], recipe_set: str,
2642
2716
  langs.extend(lang for lang in recipe["metadata"]["languages"] if lang not in langs)
2643
2717
  per_recipe: dict[str, list[dict]] = {rid: [] for rid in order}
2644
2718
  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:
2719
+ if (m["rule"] in per_recipe and m["name"] and not _private(FAMILY_OF.get(m["language"]), m["name"])
2720
+ and (m["rule"], m["language"]) in forms):
2646
2721
  per_recipe[m["rule"]].append(m)
2647
2722
  stats, kept = [], []
2648
2723
  for rid, found in per_recipe.items():
@@ -2679,7 +2754,10 @@ def _type_list_items(exe: str, root: Path, folder: Path, matches: list[dict], ex
2679
2754
  export_specifier with no `from`) the type of the top-level binding it
2680
2755
  names in its file, as BINDING_TYPES has it; the recipe's own
2681
2756
  export_type stays when no such binding has the name (a destructured
2682
- one)."""
2757
+ one). A binding declared in the file (not an import) also gives the
2758
+ item `declaration_line` and `declaration_signature_line`, its line and
2759
+ source line; the item's own `source_line`, `signature_line` and
2760
+ citation stay at the list."""
2683
2761
  items = [e for e in exports if e["ast_node_type"] == "export_specifier" and e["from"] is None]
2684
2762
  if not items:
2685
2763
  return
@@ -2691,10 +2769,16 @@ def _type_list_items(exe: str, root: Path, folder: Path, matches: list[dict], ex
2691
2769
  languages = sorted({lang for f in files for lang in _rule_languages(_file_language(f) or "")}
2692
2770
  & set(RECIPE_LANGUAGES))
2693
2771
  rules = [rule for language in languages for rule in _binding_rules(language)]
2694
- types = _binding_types(_scan(exe, root, folder, rules, files, errors, clock))
2772
+ bindings = _bindings(_scan(exe, root, folder, rules, files, errors, clock))
2695
2773
  for e in items:
2696
2774
  name = local.get((e["source_file"], e["ast_recipe"], e["export_name"]), e["export_name"])
2697
- e["export_type"] = types.get((e["source_file"], name), e["export_type"])
2775
+ binding = bindings.get((e["source_file"], name))
2776
+ if binding is None:
2777
+ continue
2778
+ e["export_type"] = binding["type"]
2779
+ if binding["form"] != "import":
2780
+ e["declaration_line"] = binding["line"]
2781
+ e["declaration_signature_line"] = binding["signature_line"]
2698
2782
 
2699
2783
 
2700
2784
  # --------------------------------------------------------------------------
@@ -2839,14 +2923,190 @@ _JS_DEFAULT_NAME_RE = re.compile(r"^export\s+default\s+([A-Za-z_$][\w$]*)\s*;?\s
2839
2923
  _JS_DEFAULT_EXPORT_RE = re.compile(r"export\s+default\b")
2840
2924
 
2841
2925
 
2926
+ # Keep identical to _js_pattern_bindings in skf-verify-provenance-completeness.py.
2927
+ def _js_pattern_bindings(
2928
+ text: str, start: int
2929
+ ) -> tuple[list[tuple[str, int]], int | None]:
2930
+ """The names the destructuring pattern whose `{` or `[` is at
2931
+ `text[start]` binds, each with its offset in `text`, and the offset
2932
+ just past the pattern's closing bracket.
2933
+
2934
+ An object pattern's property is `...target`, a shorthand key or
2935
+ `key: target` (a key is a name, a string, a number or a computed
2936
+ `[...]`, and only a shorthand key binds itself); an array pattern's
2937
+ element is `...target`, a target or a hole; a target is a name or a
2938
+ nested pattern. A `= default` is skipped to the next comma at the
2939
+ pattern's depth, and so is a property or element of any other shape,
2940
+ which binds nothing. '...', "..." and `...` strings are skipped
2941
+ whole, so no bracket or comma inside one counts. Whitespace, newlines
2942
+ included, may sit anywhere between the parts.
2943
+
2944
+ Returns ([], None) when `text[start]` opens no pattern, the pattern
2945
+ does not close in `text`, a string in it does not end, or a bracket in
2946
+ it closes one it did not open. A pure function of its arguments (no
2947
+ module state), so another script can carry a copy of it.
2948
+ """
2949
+ n = len(text)
2950
+ names: list[tuple[str, int]] = []
2951
+
2952
+ def space(i: int) -> int:
2953
+ while i < n and text[i].isspace():
2954
+ i += 1
2955
+ return i
2956
+
2957
+ def name_end(i: int) -> int:
2958
+ """The end of the identifier at `i`, `i` itself when none starts there."""
2959
+ if i < n and (text[i].isalpha() or text[i] in "_$"):
2960
+ i += 1
2961
+ while i < n and (text[i].isalnum() or text[i] in "_$"):
2962
+ i += 1
2963
+ return i
2964
+
2965
+ def string_end(i: int) -> int | None:
2966
+ """The offset past the string whose quote is at `i`."""
2967
+ quote = text[i]
2968
+ i += 1
2969
+ while i < n:
2970
+ if text[i] == "\\":
2971
+ i += 2
2972
+ elif text[i] == quote:
2973
+ return i + 1
2974
+ else:
2975
+ i += 1
2976
+ return None
2977
+
2978
+ def skip(i: int, to_comma: bool = True) -> int | None:
2979
+ """From `i`, the offset of the next comma (unless not `to_comma`)
2980
+ or closing bracket at depth 0; with `to_comma` False, `text[i]`
2981
+ opens a bracket and the offset past the one closing it."""
2982
+ depth = 0
2983
+ while i < n:
2984
+ ch = text[i]
2985
+ if ch in "'\"`":
2986
+ end = string_end(i)
2987
+ if end is None:
2988
+ return None
2989
+ i = end
2990
+ continue
2991
+ if ch in "([{":
2992
+ depth += 1
2993
+ elif ch in ")]}":
2994
+ if depth == 0:
2995
+ return i
2996
+ depth -= 1
2997
+ if depth == 0 and not to_comma:
2998
+ return i + 1
2999
+ elif ch == "," and depth == 0 and to_comma:
3000
+ return i
3001
+ i += 1
3002
+ return None
3003
+
3004
+ def default(i: int) -> int | None:
3005
+ """After a target: past its `= default`, when it has one."""
3006
+ i = space(i)
3007
+ if i < n and text[i] == "=" and text[i + 1 : i + 2] not in ("=", ">"):
3008
+ return skip(i + 1)
3009
+ return i
3010
+
3011
+ def bind(i: int, end: int, close: str) -> int | None:
3012
+ """The name `text[i:end]` is bound when its property or element
3013
+ ends after it or takes a default (any other text: another shape)."""
3014
+ after = space(end)
3015
+ if after < n and (
3016
+ text[after] in (",", close)
3017
+ or text[after] == "=" and text[after + 1 : after + 2] not in ("=", ">")
3018
+ ):
3019
+ names.append((text[i:end], i))
3020
+ return default(after)
3021
+ return after
3022
+
3023
+ def target(i: int, close: str) -> int | None:
3024
+ if i < n and text[i] in "{[":
3025
+ end = pattern(i)
3026
+ return None if end is None else default(end)
3027
+ end = name_end(i)
3028
+ return i if end == i else bind(i, end, close)
3029
+
3030
+ def element(i: int, close: str) -> int | None:
3031
+ """One property or element at `i`: the offset after it."""
3032
+ if text.startswith("...", i):
3033
+ return target(space(i + 3), close)
3034
+ if close == "]":
3035
+ return target(i, close)
3036
+ if text[i] in "'\"":
3037
+ end = string_end(i)
3038
+ elif text[i] == "[":
3039
+ end = skip(i, to_comma=False) # a computed key
3040
+ else:
3041
+ end = name_end(i)
3042
+ if end > i:
3043
+ after = space(end)
3044
+ if after < n and text[after] == ":":
3045
+ return target(space(after + 1), close)
3046
+ return bind(i, end, close) # a shorthand key
3047
+ while end < n and (text[end].isalnum() or text[end] in "._"):
3048
+ end += 1 # a number key
3049
+ if end is None:
3050
+ return None
3051
+ after = space(end)
3052
+ if end > i and after < n and text[after] == ":":
3053
+ return target(space(after + 1), close)
3054
+ return after
3055
+
3056
+ def pattern(i: int) -> int | None:
3057
+ """The pattern opening at `i`: the offset past its closing bracket."""
3058
+ close = "}" if text[i] == "{" else "]"
3059
+ i += 1
3060
+ while True:
3061
+ i = space(i)
3062
+ if i >= n:
3063
+ return None
3064
+ if text[i] == close:
3065
+ return i + 1
3066
+ if text[i] in ")]}":
3067
+ return None
3068
+ if text[i] == ",":
3069
+ i += 1 # a hole, or a stray comma
3070
+ continue
3071
+ end = element(i, close)
3072
+ if end is None:
3073
+ return None
3074
+ end = space(end)
3075
+ if end < n and text[end] not in (",", close):
3076
+ end = skip(end) # any other shape binds nothing
3077
+ if end is None:
3078
+ return None
3079
+ i = end + 1 if end < n and text[end] == "," else end
3080
+
3081
+ if not (0 <= start < n and text[start] in "{["):
3082
+ return [], None
3083
+ end = pattern(start)
3084
+ return (names, end) if end is not None else ([], None)
3085
+
3086
+
2842
3087
  def _js_text_names(text: str) -> tuple[list[tuple[str, int, bool]], list[tuple[str, int]]]:
2843
3088
  """The names a JS/TS file's column-0 `export` declarations bind, read
2844
3089
  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)."""
3090
+ declare const`, `export namespace`, and each name a destructuring
3091
+ pattern that opens an `export const`, `let` or `var` statement binds:
3092
+ `export const { a, b: c, ...d } = obj` binds a, c and d), as (name,
3093
+ line, is the default export), and its `export default <name>;`
3094
+ statements, as (name, line).
3095
+
3096
+ A pattern is read from its `{` or `[` on (`_JS_DESTRUCTURE_RE`) with
3097
+ `_js_pattern_bindings`, so it may span lines, and each name it binds
3098
+ takes the line the name is on. A pattern after the first declarator
3099
+ is not read, and one that does not close, or that nests deeper than
3100
+ the interpreter's recursion limit, binds nothing."""
2847
3101
  code = _blank(text, "js")
2848
3102
  declared = [(m.group(2), _line_of(code, m.start(2)), bool(m.group(1)))
2849
3103
  for rx in (_JS_DECLARATION_RE, _JS_IMPORT_EQUALS_RE) for m in rx.finditer(code)]
3104
+ for m in _JS_DESTRUCTURE_RE.finditer(code):
3105
+ try:
3106
+ names, _ = _js_pattern_bindings(code, m.end() - 1)
3107
+ except RecursionError:
3108
+ continue # patterns nested past the interpreter's limit: none bound
3109
+ declared += [(name, _line_of(code, offset), False) for name, offset in names]
2850
3110
  defaults = [(m.group(1), _line_of(code, m.start(1))) for m in _JS_DEFAULT_NAME_RE.finditer(code)]
2851
3111
  return declared, defaults
2852
3112
 
@@ -3084,12 +3344,13 @@ def _member(name: str, local: str | None, file: str | None, line: int | None) ->
3084
3344
  return {"name": name, "local": local if local and local != name else None, "file": file, "line": line}
3085
3345
 
3086
3346
 
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."""
3347
+ def _flatten(start, direct: Callable[[object], list[tuple[str, str, object]]], family: str) -> list[dict]:
3348
+ """The members of the namespace module `start`, of language family
3349
+ `family`: what `direct` says it passes on, (name, "member", member) or
3350
+ (name, "module", the module), each nested namespace module's members
3351
+ spliced in its place (each module once, so a cycle is cut), each
3352
+ (name, file) once, and no Python or Rust name that starts with `_`,
3353
+ which is never public."""
3093
3354
  out: list[dict] = []
3094
3355
  seen: set[tuple[str, str | None]] = set()
3095
3356
  visited: set = set()
@@ -3097,7 +3358,7 @@ def _flatten(start, direct: Callable[[object], list[tuple[str, str, object]]]) -
3097
3358
  def walk(module) -> None:
3098
3359
  visited.add(module)
3099
3360
  for name, kind, value in direct(module):
3100
- if not name or name.startswith("_"):
3361
+ if not name or _private(family, name):
3101
3362
  continue
3102
3363
  if kind == "module":
3103
3364
  if value not in visited:
@@ -3111,17 +3372,35 @@ def _flatten(start, direct: Callable[[object], list[tuple[str, str, object]]]) -
3111
3372
 
3112
3373
 
3113
3374
  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:
3375
+ """The public names the entry points of language family `family`
3376
+ declare: {name: info}, the first `add` of a name wins (the JS/TS trace
3377
+ adds every entry point's own and named exports before any `export *`),
3378
+ unless the file it gives is out of scope (or it gives none) and another
3379
+ entry point gives a file in `in_scope`, which then replaces it (one
3380
+ entry point never replaces its own, and a star or glob that passes on a
3381
+ name its entry point already exports replaces none: an explicit export
3382
+ shadows it; with no `in_scope`, the first always wins); `others`,
3383
+ {name: [info]}, the other definitions of a held name in files in
3384
+ `in_scope` (#830), each an add that does not become the held record,
3385
+ is no such shadowed star or glob, is no namespace, beside a held record
3386
+ that is none, and gives a file in scope whose (file, `local` or name)
3387
+ neither the held record nor an earlier one of `others` gives; the
3388
+ names, and the files, a namespace export (a module) makes reachable,
3389
+ which are not internal; and the re-exports the trace could not follow.
3390
+ A namespace item also carries its `members`, the names its module
3391
+ passes on (None when the trace cannot read that module); `opaque` holds
3392
+ the (name, file) of each member that is itself a module the trace
3393
+ cannot read, which, as such a namespace, is no recipe's target. A
3394
+ Python or Rust `_` name is never added."""
3395
+
3396
+ def __init__(self, family: str, in_scope: set[str] | None = None) -> None:
3397
+ self.family = family
3398
+ self.in_scope = in_scope
3124
3399
  self.names: dict[str, dict] = {}
3400
+ self.others: dict[str, list[dict]] = {}
3401
+ self._other_at: dict[str, set[tuple[str, str]]] = {} # name: the (file, local or name) of its others
3402
+ # (entry, name) of each add: a star or glob an entry point passes an exported name on through is shadowed
3403
+ self._added: set[tuple[str, str]] = set()
3125
3404
  self.reachable: set[str] = set()
3126
3405
  self.reachable_files: set[str] = set()
3127
3406
  self.unresolved: list[dict] = []
@@ -3130,14 +3409,25 @@ class _Surface:
3130
3409
  def add(self, name: str, entry: str, via: str, file: str | None, line: int | None,
3131
3410
  source: str | None = None, local: str | None = None,
3132
3411
  members: list[dict] | None = None) -> dict | None:
3133
- if not name or name.startswith("_"):
3412
+ if not name or _private(self.family, name):
3134
3413
  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}
3414
+ held = self.names.get(name)
3415
+ shadowed = via == "star" and (entry, name) in self._added
3416
+ self._added.add((entry, name))
3417
+ info = {"name": name, "entry": entry, "via": via, "from": source,
3418
+ "local": local if local and local != name else None, "file": file, "line": line}
3419
+ at = (file, info["local"] or name)
3420
+ if held is None or (not shadowed and self.in_scope is not None and file in self.in_scope
3421
+ and entry != held["entry"]
3422
+ and (held["file"] is None or held["file"] not in self.in_scope)):
3423
+ self.names[name] = info
3139
3424
  if via == "namespace":
3140
3425
  self.names[name]["members"] = [dict(m) for m in members] if members is not None else None
3426
+ elif (not shadowed and via != "namespace" and held["via"] != "namespace" and self.in_scope is not None
3427
+ and file in self.in_scope and at != (held["file"], held["local"] or name)
3428
+ and at not in self._other_at.get(name, ())):
3429
+ self.others.setdefault(name, []).append(info)
3430
+ self._other_at.setdefault(name, set()).add(at)
3141
3431
  return self.names[name]
3142
3432
 
3143
3433
  def unresolve(self, entry: str, name: str, source: str | None, file: str) -> None:
@@ -3341,7 +3631,7 @@ def _js_surface(root: Path, entries: list[dict], tree_set: set[str], forms: dict
3341
3631
 
3342
3632
  def members(file: str) -> list[dict]:
3343
3633
  if file not in member_lists:
3344
- member_lists[file] = _flatten(file, direct)
3634
+ member_lists[file] = _flatten(file, direct, "javascript")
3345
3635
  return member_lists[file]
3346
3636
 
3347
3637
  def publish(entry: str, name: str, via: str, module: str, source: str | None) -> None:
@@ -3712,7 +4002,7 @@ def _py_surface(root: Path, entries: list[dict], tree_set: set[str], surface: _S
3712
4002
 
3713
4003
  def members(rel: str, top: str) -> list[dict]:
3714
4004
  if (rel, top) not in member_lists:
3715
- member_lists[(rel, top)] = _flatten(rel, lambda module: direct(module, top))
4005
+ member_lists[(rel, top)] = _flatten(rel, lambda module: direct(module, top), "python")
3716
4006
  return member_lists[(rel, top)]
3717
4007
 
3718
4008
  for entry in [e["file"] for e in entries]:
@@ -4030,7 +4320,7 @@ def _rust_surface(root: Path, entries: list[dict], tree_set: set[str], surface:
4030
4320
  return None
4031
4321
  key = (lib, tuple(here))
4032
4322
  if key not in member_lists:
4033
- member_lists[key] = _flatten(tuple(here), lambda path: direct(lib, path))
4323
+ member_lists[key] = _flatten(tuple(here), lambda path: direct(lib, path), "rust")
4034
4324
  return member_lists[key]
4035
4325
 
4036
4326
  def publish(lib: str, here: list[str], name: str, via: str, source: str | None,
@@ -4130,19 +4420,25 @@ def _go_surface(root: Path, files: list[str], surface: _Surface, entries: list[d
4130
4420
 
4131
4421
  def entry_point_diff(exports: list[dict], surfaces: dict[str, _Surface], entries: list[dict],
4132
4422
  in_scope: set[str]) -> tuple[dict[str, str], dict[tuple[str, str], dict], list[dict],
4133
- list[tuple[str, dict]], list[tuple[str, dict]]]:
4423
+ list[tuple[str, dict]], list[tuple[str, dict]],
4424
+ list[tuple[str, dict]]]:
4134
4425
  """The entry-point diff, one language family at a time: (status by
4135
4426
  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
4427
+ and (family, info) of the extraction gaps, of the names outside scope
4428
+ and of the other in-scope definitions of public names, `_Surface`'s
4429
+ `others`, which no gap or outside_scope row reads: those go by name).
4430
+ A family whose entry points declare names diffs them with its
4138
4431
  exports: an export no entry point names, nor traces back to, nor makes
4139
4432
  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
4433
+ declared name, at the file `_Surface` kept for it (not always the first
4434
+ entry point's in path order), is outside scope when a file out of scope
4435
+ defines it, even when a recipe found its name in another file (another
4436
+ definition: no recipe reads a file out of scope), and a gap when no
4437
+ recipe found it and a file in scope defines it (or the trace found no
4438
+ file); a module is no recipe's target, so neither: a namespace with
4439
+ `members` has each member diffed in its place, found when a recipe
4440
+ found its name, or its `local` one, in its file, and given no row of
4441
+ its own when a declared name has its name and file (or another
4146
4442
  namespace gave it one); one whose members are unknown (None), or a
4147
4443
  member that is such a module (`opaque`), is no gap. In a family with
4148
4444
  no entry point, or only empty ones, every export is public."""
@@ -4151,6 +4447,7 @@ def entry_point_diff(exports: list[dict], surfaces: dict[str, _Surface], entries
4151
4447
  internal: list[dict] = []
4152
4448
  gaps: list[tuple[str, dict]] = []
4153
4449
  outside: list[tuple[str, dict]] = []
4450
+ others: list[tuple[str, dict]] = []
4154
4451
  entry_families = {FAMILY_OF[e["language"]] for e in entries}
4155
4452
  families = {FAMILY_OF[e["language"]] for e in exports} | entry_families
4156
4453
  for family in sorted(families | {f for f, s in surfaces.items() if s.names}):
@@ -4168,6 +4465,7 @@ def entry_point_diff(exports: list[dict], surfaces: dict[str, _Surface], entries
4168
4465
  names = {e["export_name"] for e in records}
4169
4466
  traced = {info["local"] for info in declared.values() if info["local"]} | surface.reachable
4170
4467
  public.update(((family, name), info) for name, info in declared.items())
4468
+ others.extend((family, dict(info)) for name in sorted(surface.others) for info in surface.others[name])
4171
4469
  seen: set[str] = set()
4172
4470
  for e in records:
4173
4471
  name = e["export_name"]
@@ -4176,9 +4474,13 @@ def entry_point_diff(exports: list[dict], surfaces: dict[str, _Surface], entries
4176
4474
  seen.add(name)
4177
4475
  internal.append(e)
4178
4476
  for name, info in declared.items():
4179
- if name in names or (info["local"] or name) in names or info["via"] == "namespace":
4477
+ if info["via"] == "namespace":
4180
4478
  continue
4181
- (gaps if info["file"] is None or info["file"] in in_scope else outside).append((family, info))
4479
+ # A recipe never reads a file out of scope: a name it found elsewhere is another definition.
4480
+ if info["file"] is not None and info["file"] not in in_scope:
4481
+ outside.append((family, info))
4482
+ elif name not in names and (info["local"] or name) not in names:
4483
+ gaps.append((family, info))
4182
4484
  # A namespace's members, each by its name (or `local`) and file, or
4183
4485
  # by name alone when it has no file, as a declared name is: a name
4184
4486
  # at a file a declared name already gives counts once.
@@ -4195,7 +4497,7 @@ def entry_point_diff(exports: list[dict], surfaces: dict[str, _Surface], entries
4195
4497
  row = {"name": member["name"], "entry": info["entry"], "file": member["file"],
4196
4498
  "line": member["line"]}
4197
4499
  (gaps if member["file"] is None or member["file"] in in_scope else outside).append((family, row))
4198
- return statuses, public, internal, gaps, outside
4500
+ return statuses, public, internal, gaps, outside, others
4199
4501
 
4200
4502
 
4201
4503
  def _has_members(info: dict) -> bool:
@@ -4204,9 +4506,9 @@ def _has_members(info: dict) -> bool:
4204
4506
 
4205
4507
 
4206
4508
  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."""
4509
+ """The (family, name, file) exports_public_api counts: each public name
4510
+ but a namespace with members, in its place each member, and so a member
4511
+ that is also a declared name (or another namespace's member) once."""
4210
4512
  keys: set[tuple[str, str, str | None]] = set()
4211
4513
  for (family, _), info in public.items():
4212
4514
  if _has_members(info):
@@ -4216,6 +4518,16 @@ def _public_keys(public: dict[tuple[str, str], dict]) -> set[tuple[str, str, str
4216
4518
  return keys
4217
4519
 
4218
4520
 
4521
+ def _other_keys(others: list[tuple[str, dict]]) -> set[tuple[str, str, str | None]]:
4522
+ """The (family, name, file) of the other in-scope definitions of public
4523
+ names (#830), which join the public keys in the set effective_denominator
4524
+ and curated_subset read, never exports_public_api: a name still counts
4525
+ once, when any of its files matches."""
4526
+ return {(family, info["name"], info["file"]) for family, info in others
4527
+ if isinstance(info.get("name"), str) and info["name"] and isinstance(info.get("file"), str)
4528
+ and info["file"]}
4529
+
4530
+
4219
4531
  def _head_cap(value: int | None, tier: str | None, scope_type: str | None) -> int | None:
4220
4532
  if value is not None:
4221
4533
  return value or None
@@ -4223,7 +4535,10 @@ def _head_cap(value: int | None, tier: str | None, scope_type: str | None) -> in
4223
4535
 
4224
4536
 
4225
4537
  def _workspaces(root: Path, tree: list[str], tree_set: set[str]) -> dict:
4226
- """skf-detect-workspaces.py's verdict on the source root."""
4538
+ """skf-detect-workspaces.py's result over `tree` (its workspaces
4539
+ resolved against those paths), with the content of each root manifest
4540
+ of WORKSPACE_MANIFESTS that `tree_set` lists. No brief scope applies:
4541
+ the monorepo arm reads it through _monorepo."""
4227
4542
  manifests = {}
4228
4543
  for name in WORKSPACE_MANIFESTS:
4229
4544
  if name in tree_set:
@@ -4233,15 +4548,24 @@ def _workspaces(root: Path, tree: list[str], tree_set: set[str]) -> dict:
4233
4548
  return _sibling("skf-detect-workspaces.py").detect({"tree": tree, "manifests": manifests})
4234
4549
 
4235
4550
 
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)
4551
+ def _monorepo(root: Path, tree: list[str], excludes: list[str],
4552
+ glob_match: Callable[[str, str], bool]) -> tuple[bool, str | None]:
4553
+ """Arm (a): (is the source a monorepo, its manifest kind), from
4554
+ skf-detect-workspaces.py over the tree the brief keeps, its
4555
+ scoped_tree: a workspace member counts only through the files no
4556
+ scope.exclude glob matches, every root manifest stays, and a workspace
4557
+ with no member left is none (#780)."""
4558
+ kept = _sibling("skf-detect-workspaces.py").scoped_tree(tree, excludes, glob_match)
4559
+ result = _workspaces(root, kept, set(kept))
4239
4560
  return bool(result.get("is_monorepo")), result.get("manifest_kind")
4240
4561
 
4241
4562
 
4242
4563
  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."""
4564
+ """The source files (of a language --language selects) under each
4565
+ workspace of `result`, one set per workspace that holds any. _scope_facts
4566
+ passes the workspaces skf-detect-workspaces.py finds over the whole
4567
+ tree, scope.exclude aside (unlike arm (a)): a member the brief drops is
4568
+ still part of the repository the brief chose a subset of."""
4245
4569
  sources = []
4246
4570
  for workspace in result.get("workspaces") or [] if result.get("is_monorepo") else []:
4247
4571
  folder = _norm(workspace.get("path") if isinstance(workspace, dict) else "").strip("/")
@@ -4288,8 +4612,10 @@ def curated_subset(counted: set[tuple[str, str, str | None]], basis: str, tree:
4288
4612
  whole workspace (in a single-package repository, the whole package): the
4289
4613
  files the basis globs list (a tier A list's, among those scope.include
4290
4614
  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."""
4615
+ that defines a counted public name or every source file of one of
4616
+ `workspace_sources` (the workspaces found over the whole tree,
4617
+ scope.exclude aside, so excluding a member the include globs leave out
4618
+ anyway changes nothing). A basis of all-files lists none."""
4293
4619
  if basis == "all-files":
4294
4620
  return False
4295
4621
  files = {f for f in tree if not includes or any(glob_match(f, g) for g in includes)}
@@ -4413,7 +4739,7 @@ def _scope_facts(args: argparse.Namespace, root: Path) -> dict:
4413
4739
 
4414
4740
  # The entry points and the arms need no ast-grep.
4415
4741
  in_scope_set = set(in_scope)
4416
- surfaces = {family: _Surface() for family in FAMILIES}
4742
+ surfaces = {family: _Surface(family, in_scope_set) for family in FAMILIES}
4417
4743
  js_entries, maps, unresolved_subpaths = _js_entries(root, in_scope, tree, tree_set, warnings)
4418
4744
  py_entries = _py_entries(in_scope, tree_set)
4419
4745
  rust_entries = _rust_entries(root, in_scope, tree, tree_set)
@@ -4428,11 +4754,10 @@ def _scope_facts(args: argparse.Namespace, root: Path) -> dict:
4428
4754
  f"{entry['package']}) is outside the files in scope: widen scope.include to document "
4429
4755
  "the names it exports (entry_point_diff.outside_scope), which effective_denominator "
4430
4756
  "leaves out")
4431
- workspaces = _workspaces(root, tree, tree_set)
4432
- monorepo = bool(workspaces.get("is_monorepo"))
4757
+ monorepo, monorepo_kind = _monorepo(root, tree, excludes, glob_match)
4433
4758
  arms = {
4434
4759
  "monorepo": monorepo,
4435
- "monorepo_kind": workspaces.get("manifest_kind"),
4760
+ "monorepo_kind": monorepo_kind,
4436
4761
  "specific_modules": scope_type == "specific-modules" and not monorepo,
4437
4762
  "multi_subpath_exports": not monorepo and any(m["non_root_subpaths"] > 1 for m in maps),
4438
4763
  }
@@ -4442,7 +4767,8 @@ def _scope_facts(args: argparse.Namespace, root: Path) -> dict:
4442
4767
  "in_scope": in_scope, "without_recipes": without_recipes, "unmatched_include": unmatched_include,
4443
4768
  "unmatched_tier_a": unmatched_tier_a, "surfaces": surfaces, "js_entries": js_entries, "maps": maps,
4444
4769
  "unresolved_subpaths": unresolved_subpaths, "py_entries": py_entries, "rust_entries": rust_entries,
4445
- "entries": entries, "arms": arms, "workspace_sources": _workspace_sources(workspaces, tree, wanted),
4770
+ "entries": entries, "arms": arms,
4771
+ "workspace_sources": _workspace_sources(_workspaces(root, tree, tree_set), tree, wanted),
4446
4772
  }
4447
4773
 
4448
4774
 
@@ -4586,7 +4912,7 @@ def run_full(args: argparse.Namespace) -> tuple[dict, int]:
4586
4912
  for record in exports:
4587
4913
  if record["from"] and record["language"] in JS_FAMILY:
4588
4914
  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)
4915
+ statuses, public, internal, gaps, outside, others = entry_point_diff(exports, surfaces, entries, in_scope_set)
4590
4916
  if "barrel" in statuses.values():
4591
4917
  status = "barrel"
4592
4918
  else:
@@ -4596,8 +4922,10 @@ def run_full(args: argparse.Namespace) -> tuple[dict, int]:
4596
4922
  # only files an exclude drops, that scope.include leaves out or that
4597
4923
  # define no public name) narrows nothing, once the run counted a name
4598
4924
  # and read every file (no error).
4599
- counted = _public_keys(public)
4600
- counts = _denominator_counts(facts, counted, len(counted), len(internal), bool(errors), warnings)
4925
+ # Each other in-scope definition of a public name counts its name where any of its files is counted (#830).
4926
+ public_keys = _public_keys(public)
4927
+ counted = public_keys | _other_keys(others)
4928
+ counts = _denominator_counts(facts, counted, len(public_keys), len(internal), bool(errors), warnings)
4601
4929
 
4602
4930
  by_type: Counter = Counter(e["export_type"] for e in exports)
4603
4931
  ordered = sorted(public.items(), key=lambda item: (item[0][1], item[0][0]))
@@ -4611,6 +4939,8 @@ def run_full(args: argparse.Namespace) -> tuple[dict, int]:
4611
4939
  "unresolved": unresolved_subpaths + [u for s in surfaces.values() for u in s.unresolved]},
4612
4940
  "entry_point_diff": {
4613
4941
  "public": [dict(info, language=family) for (family, _), info in ordered],
4942
+ "other_definitions": [dict(info, language=family) for family, info in
4943
+ sorted(others, key=lambda o: (o[1]["name"], o[1]["file"], o[0]))],
4614
4944
  "internal": [{"name": e["export_name"], "language": FAMILY_OF[e["language"]],
4615
4945
  "source_file": e["source_file"], "source_line": e["source_line"]} for e in internal],
4616
4946
  "extraction_gaps": [{"name": info["name"], "language": family, "entry": info["entry"],
@@ -4730,6 +5060,8 @@ def run_denominator(args: argparse.Namespace, payload: dict) -> dict:
4730
5060
  # The exports found, each key at its file: the payload's and the runner's.
4731
5061
  found: dict[tuple[str, str], str | None] = {}
4732
5062
  internal: set[tuple[str, str]] = set()
5063
+ # The runner's other in-scope definitions of its public names (#830), counted where the names are.
5064
+ others: list[tuple[str, dict]] = []
4733
5065
  incomplete = False
4734
5066
  runner_status = None
4735
5067
  if args.extraction:
@@ -4744,6 +5076,8 @@ def run_denominator(args: argparse.Namespace, payload: dict) -> dict:
4744
5076
  key = (str(info.get("language") or ""), info["name"])
4745
5077
  public[key] = runner_public[key] = info
4746
5078
  found[key] = info.get("file")
5079
+ others = [(str(info.get("language") or ""), info) for info in diff.get("other_definitions") or []
5080
+ if isinstance(info, dict)]
4747
5081
  for item in diff.get("internal") or []:
4748
5082
  if isinstance(item, dict) and isinstance(item.get("name"), str):
4749
5083
  internal.add((str(item.get("language") or ""), item["name"]))
@@ -4767,11 +5101,27 @@ def run_denominator(args: argparse.Namespace, payload: dict) -> dict:
4767
5101
  for item in _payload_names(payload.get("exports"), "exports"):
4768
5102
  key = (_name_family(item["language"], item["file"]), item["name"])
4769
5103
  found.setdefault(key, item["file"])
4770
- if not item["name"].startswith("_"):
5104
+ if not _private(key[0], item["name"]):
4771
5105
  internal.add(key)
4772
5106
  named = set(public) | {(family, m["name"]) for (family, _), info in public.items() if _has_members(info)
4773
5107
  for m in info["members"]}
4774
- counted = _public_keys(public)
5108
+ public_keys = _public_keys(public)
5109
+ def still_held(key: tuple[str, str]) -> bool:
5110
+ """Whether the record kept for the runner's public name `key` still names the runner row's file, a
5111
+ namespace with members in neither (full mode keeps no other definition beside one)."""
5112
+ runner_row, kept = runner_public.get(key), public.get(key)
5113
+ if runner_row is None or kept is None or _has_members(runner_row) or _has_members(kept):
5114
+ return False
5115
+ files = [_norm(row["file"]) if isinstance(row.get("file"), str) and row["file"] else None
5116
+ for row in (runner_row, kept)]
5117
+ return files[0] == files[1]
5118
+
5119
+ # A runner row counts only while the record kept for its name names the runner's file: an orphan row, or
5120
+ # one of a name a payload or --quick record moved to another file, adds nothing.
5121
+ others = [(family, info) for family, info in others
5122
+ if isinstance(info.get("name"), str) and still_held((family, info["name"]))]
5123
+ other_keys = _other_keys(others)
5124
+ counted = public_keys | other_keys
4775
5125
 
4776
5126
  if args.source_root:
4777
5127
  root = Path(args.source_root)
@@ -4807,7 +5157,7 @@ def run_denominator(args: argparse.Namespace, payload: dict) -> dict:
4807
5157
  outside_tree = sorted((name, file) for _, name, file in counted if file and file not in tree_set)
4808
5158
  warnings += [f"public name {name} is defined in {file}, which is not in the tree: effective_denominator "
4809
5159
  "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)
5160
+ counts = _denominator_counts(facts, counted, len(public_keys), len(internal - named), incomplete, warnings)
4811
5161
  if not args.source_root and counts["effective_denominator_decision"]["reason"] not in ("reference-app",
4812
5162
  "incomplete-run"):
4813
5163
  counts["effective_denominator_decision"] = {"store": False, "reason": "no-source-tree"}
@@ -4838,7 +5188,9 @@ def run_denominator(args: argparse.Namespace, payload: dict) -> dict:
4838
5188
  "counts": counts,
4839
5189
  "entry_point_diff": {
4840
5190
  "public": [row((family, name), file)
4841
- for family, name, file in sorted(counted, key=lambda k: (k[1], k[0], k[2] or ""))],
5191
+ for family, name, file in sorted(public_keys, key=lambda k: (k[1], k[0], k[2] or ""))],
5192
+ "other_definitions": [row((family, name), file)
5193
+ for family, name, file in sorted(other_keys, key=lambda k: (k[1], k[2], k[0]))],
4842
5194
  "internal": [row(key, found.get(key)) for key in by_name(internal - named)],
4843
5195
  "extraction_gaps": [row(key, given[key]) for key in by_name(given) if key not in found],
4844
5196
  },
@@ -5586,7 +5938,7 @@ def _py_defined_in(root: Path, tree: set[str]) -> Callable[[str, str], str | Non
5586
5938
 
5587
5939
  def resolve(file: str, name: str) -> str | None:
5588
5940
  if file not in traced:
5589
- surface = _Surface()
5941
+ surface = _Surface("python")
5590
5942
  _py_surface(root, [{"file": file}], tree, surface, [])
5591
5943
  traced[file] = {n: info["file"] for n, info in surface.names.items()}
5592
5944
  return traced[file].get(name)