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
@@ -39,13 +39,21 @@ reading files and writing one JSON object:
39
39
  surface the enumerated source surface, from every input given (their
40
40
  union):
41
41
  --extraction skf-extract-public-api.py --mode full output:
42
- the names in entry_point_diff.public, a
42
+ the names in entry_point_diff.public, each
43
+ other in-scope definition of one
44
+ (entry_point_diff.other_definitions, #830) a
45
+ record of its own at its file, a
43
46
  namespace with a `members` list given as its
44
47
  members (the names its module passes on, each
45
48
  at its file, once with a public name of the
46
49
  same name and file), less the outside_scope
47
- names (defined in a file the brief scopes
48
- out); exports[] only gives each name's
50
+ names (defined in a file out of scope), which
51
+ `excluded.outsideScope` lists with their file,
52
+ line and entry point and, when the brief
53
+ scopes the file out, why: the scope.exclude
54
+ glob that matches it (`excludeGlob`) or no
55
+ scope.include glob covering it
56
+ (`outsideInclude`); exports[] only gives each name's
49
57
  kind, line and signature line, looked up by the
50
58
  name its defining file gives it (an aliased
51
59
  re-export's `local`) and that file.
@@ -112,10 +120,11 @@ reading files and writing one JSON object:
112
120
  Name sets (surface `sets`, each sorted): `all` (the union above, less a
113
121
  nested Python top's names);
114
122
  `scope.include` and `tier_a_include` when the scope has those globs and a
115
- record names a file: the names whose file (an extraction's definition file,
123
+ record names a file: the names with a file (any of the name's records: an
124
+ extraction's definition file, each other in-scope definition's included,
116
125
  the quick output's defined_in, else its source_file, a per-file result's
117
- file, a provenance entry's source_file) matches a glob of the list and no
118
- scope.exclude glob,
126
+ file, a provenance entry's source_file) that matches a glob of the list
127
+ and no scope.exclude glob,
119
128
  and for `tier_a_include` a scope.include glob too when the scope has one,
120
129
  so a name with no file is in `all` only and a restored name is in no
121
130
  tier_a_include set (a tier_a_include glob an extraction lists as matching
@@ -127,29 +136,39 @@ exclude of `src/gen/**`; a list that counts no name, each glob left out
127
136
  or matching only excluded files, files scope.include leaves out or files
128
137
  no record names, gives no tier_a_include set); and, from an
129
138
  extraction only, `subpaths` (the names a non-root `exports` map subpath
130
- without `*` reaches) and `root` (the names the root entry points reach).
139
+ without `*` reaches) and `root` (the names the root entry points reach),
140
+ each through the entry point of any of the name's definitions.
131
141
  reconcile-coverage.py and score-signatures.py --surface-set pick one.
132
142
 
133
143
  Candidates and guards (with an extraction, --brief or --metadata): each
134
144
  set's count, the metadata `stats.effective_denominator` (--metadata), and
135
145
  the extractor's own effective_denominator and its basis. Both guards run
136
- only on a surface read from the source. The deflation guard compares the
137
- re-derived scope.include set (the `all` set without one) with
138
- `stats.effective_denominator` and fires above DEFLATION_PCT when there is
139
- no tier A list (with an extraction or --fallback-scan, no tier_a_include
140
- set; otherwise no tier_a_include glob left in the list, since a name read
141
- by eye at Quick tier or State 4, and a quick name with no defined_in,
142
- carries its entry file, not the file that defines it); a stored 0 is
143
- deflation, not a
144
- denominator (`pct` null), and fires under that condition when any name is
145
- re-derived. The inflation guard compares the scope.include set with the
146
- provenance entry count (--provenance) and fires above INFLATION_PCT under
147
- the same condition; `guards.tierAInclude` lists the scope's
146
+ only on a surface read from the source. The deflation guard compares a
147
+ re-derived set with `stats.effective_denominator` and names it in `basis`.
148
+ It is the tier_a_include set (`tier_a_include`) when the clause's ladder
149
+ holds a stored count (stratified-scope, specific-modules, multi-entry), the
150
+ surface has that set and each name sits at the file that defines it (a
151
+ runner extraction, --fallback-scan, or quick output whose every name
152
+ carries its defined_in, with no --per-file or --name beside it): it fires
153
+ when the stored count differs from the set by more than DEFLATION_PCT
154
+ either way, since Create Skill stores the runner's tier A count. Otherwise
155
+ it is the scope.include set (`scope.include`, else `all` when the surface
156
+ has no scope.include set), firing above DEFLATION_PCT when there is no tier
157
+ A list (with an extraction or --fallback-scan, no tier_a_include set;
158
+ otherwise no tier_a_include glob left in the list, since a name read by eye
159
+ at Quick tier or State 4, and a quick name with no defined_in, carries its
160
+ entry file, not the file that defines it). A stored 0 is deflation, not a
161
+ denominator (`pct` null), and fires under its basis's condition when any
162
+ name is re-derived. The inflation guard compares the scope.include set
163
+ with the provenance entry count (--provenance) and fires above
164
+ INFLATION_PCT when there is no tier A list, as the scope.include basis
165
+ reads it; `guards.tierAInclude` lists the scope's
148
166
  tier_a_include globs ([] when it has none) for gap-ledger.py's wording;
149
167
  the umbrella ratio (an
150
168
  extraction only) is the share of the root entry points' names that are
151
169
  re-exports (a namespace with `members` counting as its members there
152
- too, each a re-export), and above UMBRELLA_RATIO the barrel is an
170
+ too, each a re-export, and an other in-scope definition left out), and
171
+ above UMBRELLA_RATIO the barrel is an
153
172
  umbrella; the
154
173
  stale-scope guard (an extraction with scope.include or tier_a_include only)
155
174
  fires when the runner lists an include or tier_a_include glob that matches
@@ -162,7 +181,14 @@ included, and `*` and `?` stay inside one.
162
181
  Denominator (surface `denominator`): the Source API Surface Definition's
163
182
  clause (source-access-protocol.md), the branch reconcile-coverage.py takes
164
183
  and the set it and score-signatures.py --surface-set read: {clause, branch,
165
- surfaceSet, ladderStep, absentSet, rebuild, barrel}. The inputs: the scope
184
+ surfaceSet, ladderStep, absentSet, rebuild, barrel, setCounted}.
185
+ `setCounted` (null off the scalar branch) says whether the scalar branch
186
+ counts its `surfaceSet` as the numerator's set: true only for a scope set
187
+ (not `all`) whose every name sits at the file that defines it, as the
188
+ deflation guard's tier A basis requires (a runner extraction, the fallback
189
+ scan, quick output whose every name carries its defined_in), read whole (an
190
+ extraction neither `incomplete` nor truncated); otherwise the scalar branch
191
+ keeps its looked-up numerator. The inputs: the scope
166
192
  type (the brief's, else the extraction's, else metadata.json's
167
193
  `scope_type`), metadata.json's `skill_type` and `stats.effective_denominator`,
168
194
  the extraction's `arms`, the deflation guard, and:
@@ -200,7 +226,13 @@ The last three walk a ladder, `ladderStep` naming the rung taken: rung 1,
200
226
  `stats.effective_denominator` when metadata.json has one above 0 (a stored
201
227
  0 is no denominator), is the `scalar` branch, whose `surfaceSet` (the set score-signatures.py scores) is
202
228
  tier_a_include, else scope.include, else all, unless the deflation guard
203
- fires: then the barrel branch over scope.include. The next rungs are sets,
229
+ fires: then the barrel branch over tier_a_include when the ladder has that
230
+ rung (stratified-scope, multi-entry) and the surface has the set, else over
231
+ scope.include (when the inflation guard fires too, that union is itself
232
+ inflated, and both gaps ask for the brief's tier A list, never a stored
233
+ count set by hand). On an incomplete extraction, which can miss tier A
234
+ names, a tier A set below the stored count fires the guard and leaves the
235
+ scalar branch. The next rungs are sets,
204
236
  each taken when the surface has it, the last taken in any case:
205
237
  tier_a_include then scope.include (stratified-scope), scope.include
206
238
  (specific-modules), tier_a_include then subpaths (multi-entry). A set the
@@ -211,9 +243,13 @@ found no ast-grep (`no-ast-grep`), read no file in scope, read files only
211
243
  in languages it has no recipe for (`files_without_recipes` and no export),
212
244
  or found files no recipe reads while the skill's language (the --brief
213
245
  `language`, else the metadata.json `language`) is not one RECIPE_LANGUAGES
214
- holds, so the per-file scan has to run instead. An `incomplete` run keeps
215
- its exports; `extraction.readByEye` lists the files its errors and file
216
- issues name.
246
+ holds, so the per-file scan has to run instead. A run keeps its exports
247
+ whatever its status; `extraction.readByEye` lists the files to read by eye,
248
+ at any status: each file the runner could not parse or decode (a
249
+ `syntax-errors` or `not-utf8` file issue) and, on an `incomplete` run, each
250
+ file a failed call names. `extraction.unread` lists the files nobody can
251
+ read (a `missing` or `unreadable` file issue); a `no-recipes` file, such as
252
+ a stylesheet, holds nothing to read and is in neither list.
217
253
 
218
254
  Usage:
219
255
  uv run load-coverage-inputs.py census --skill-dir <dir> [--output <file>]
@@ -607,9 +643,14 @@ def _members_in_place(public: list[dict]) -> list[dict]:
607
643
  return expanded
608
644
 
609
645
 
646
+ # The runner's file issues for a file it could not open at all: no one can read it by eye either.
647
+ UNREADABLE_ISSUES = frozenset({"missing", "unreadable"})
648
+
649
+
610
650
  def _extraction_summary(data: dict, language: str | None) -> dict:
611
651
  """The surface's `extraction` object: the runner's status and counts,
612
- the files to read by eye and the fallback verdict."""
652
+ the files to read by eye, the files no one can read and the fallback
653
+ verdict."""
613
654
  counts = data.get("counts") if isinstance(data.get("counts"), dict) else {}
614
655
  arms = data.get("arms") if isinstance(data.get("arms"), dict) else {}
615
656
  entry_points = data.get("entry_points") if isinstance(data.get("entry_points"), dict) else {}
@@ -626,8 +667,11 @@ def _extraction_summary(data: dict, language: str | None) -> dict:
626
667
  reason = (f"no recipe reads the skill's language, {language} "
627
668
  f"({', '.join(sorted(without))} in scope)")
628
669
  errors = [e for e in data.get("errors") or [] if isinstance(e, dict)]
629
- read_by_eye = sorted({str(i["file"]) for i in data.get("file_issues") or []
630
- if isinstance(i, dict) and i.get("file") and i.get("issue") != "no-recipes"}
670
+ issues = [i for i in data.get("file_issues") or [] if isinstance(i, dict) and i.get("file")]
671
+ # A file missing from disk or one the runner could not open is no file to read by eye.
672
+ unread = sorted({str(i["file"]) for i in issues if i.get("issue") in UNREADABLE_ISSUES})
673
+ read_by_eye = sorted({str(i["file"]) for i in issues
674
+ if i.get("issue") != "no-recipes" and i.get("issue") not in UNREADABLE_ISSUES}
631
675
  | {str(e["first_file"]) for e in errors if e.get("first_file")}
632
676
  | {str(f) for e in errors if isinstance(e.get("unread"), list) for f in e["unread"] if f})
633
677
  return {
@@ -644,6 +688,7 @@ def _extraction_summary(data: dict, language: str | None) -> dict:
644
688
  "entryPointsFound": (bool(entry_points["files"]) if isinstance(entry_points.get("files"), list)
645
689
  and data.get("files_in_scope") else None),
646
690
  "readByEye": read_by_eye,
691
+ "unread": unread,
647
692
  "fallback": {"needed": reason is not None, "reason": reason},
648
693
  }
649
694
 
@@ -651,6 +696,21 @@ def _extraction_summary(data: dict, language: str | None) -> dict:
651
696
  NO_UMBRELLA = {"entries": [], "names": 0, "reexported": 0, "ratio": None, "umbrella": False}
652
697
 
653
698
 
699
+ def _outside_row(row: dict, scope: dict) -> dict:
700
+ """An outside_scope row as `excluded.outsideScope` lists it: its name,
701
+ file, line and entry point, and why the brief scopes the file out when
702
+ it does (#796): `excludeGlob`, the first scope.exclude glob that matches
703
+ the file, and `outsideInclude`, true when scope.include has globs and
704
+ none matches it. Neither (null and false) when the brief does not
705
+ explain it, such as a file no recipe reads or no file at all."""
706
+ file = row.get("file") if isinstance(row.get("file"), str) and row.get("file") else None
707
+ path = _norm(file) if file else None
708
+ return {"name": row.get("name"), "file": file, "line": row.get("line"), "entry": row.get("entry"),
709
+ "excludeGlob": next((g for g in scope["exclude"] if glob_match(path, g)), None) if path else None,
710
+ "outsideInclude": bool(path and scope["include"]
711
+ and not any(glob_match(path, g) for g in scope["include"]))}
712
+
713
+
654
714
  def from_extraction(data: dict, language: str | None = None, brief: dict | None = None) -> dict:
655
715
  """The public surface of an extraction, less what lies outside scope,
656
716
  plus the root exports a stale scope.include glob left out. A nested
@@ -678,8 +738,12 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
678
738
  "extraction": extraction}
679
739
 
680
740
  diff = data.get("entry_point_diff") if isinstance(data.get("entry_point_diff"), dict) else {}
681
- public = _members_in_place([p for p in diff.get("public") or []
682
- if isinstance(p, dict) and isinstance(p.get("name"), str)])
741
+ # Each in-scope definition of a public name is a record of its own (#830): the runner's other
742
+ # definitions join the public rows, so a set holds a name when any of its files matches, and
743
+ # the signature plan reads every declaration.
744
+ rows = {key: [p for p in diff.get(key) or [] if isinstance(p, dict) and isinstance(p.get("name"), str)]
745
+ for key in ("public", "other_definitions")}
746
+ public = _members_in_place(rows["public"] + rows["other_definitions"])
683
747
  outside = [o for o in diff.get("outside_scope") or [] if isinstance(o, dict)]
684
748
  gaps = [g for g in diff.get("extraction_gaps") or [] if isinstance(g, dict)]
685
749
 
@@ -690,7 +754,7 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
690
754
  # records (so the scope sets a specific-modules or stratified brief
691
755
  # counts) and their outside_scope and extraction_gaps rows.
692
756
  nested = _nested_tops(entry_files, set(scope["include"]) | set(scope["tier_a_include"]),
693
- {p.get("entry") for p in public})
757
+ {p.get("entry") for p in rows["public"]})
694
758
  nested_names = {top: sorted({p["name"] for p in public if p.get("entry") == top}) for top in nested}
695
759
 
696
760
  subpath_files = {e.get("file") for e in entry_files
@@ -731,20 +795,21 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
731
795
  found.get("source_line") if found else info.get("line"),
732
796
  found.get("signature_line") if found else None, "extraction"))
733
797
 
734
- root_names = [p for p in public if p.get("entry") in root_files]
798
+ # The umbrella ratio reads the public rows alone: an other definition is no root barrel name there.
799
+ root_names = [p for p in _members_in_place(rows["public"]) if p.get("entry") in root_files]
735
800
  reexported = [p for p in root_names if p.get("via") not in (None, "declaration")]
736
801
 
737
802
  sets = {}
738
803
  if subpath_files:
739
804
  sets["subpaths"] = sorted({p["name"] for p in public if p.get("entry") in subpath_files})
740
805
  if root_files:
741
- sets["root"] = sorted({p["name"] for p in root_names})
806
+ sets["root"] = sorted({p["name"] for p in public if p.get("entry") in root_files})
742
807
 
743
808
  return {
744
809
  "records": records,
745
810
  "nestedRecords": nested_records,
746
811
  "sets": sets,
747
- "outside": [{"name": o.get("name"), "file": o.get("file")} for o in outside],
812
+ "outside": [_outside_row(o, scope) for o in outside],
748
813
  "nested": [{"entry": top, "within": nested[top], "names": nested_names[top]} for top in sorted(nested)],
749
814
  "staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
750
815
  "restored": [{"name": o["name"], "file": o["file"], "line": o.get("line")}
@@ -828,12 +893,16 @@ def _over(clause: str, sets: dict, name: str, step: int | None, rebuild: str | N
828
893
 
829
894
  def pick_denominator(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str | None, arms: dict,
830
895
  scope_type: str | None, skill_type: str | None, monorepo: bool, pattern_reference: bool,
831
- curated: bool, source_read: bool) -> dict:
896
+ curated: bool, source_read: bool, set_trusted: bool = False) -> dict:
832
897
  """The Source API Surface Definition's clause for a skill, with the branch
833
- and the name set reconcile-coverage.py and score-signatures.py read, and
834
- the entry points' state it was picked by (see "Denominator" above)."""
835
- return {**_pick(sets, stats_eff, deflation_fires, barrel, arms, scope_type, skill_type, monorepo,
836
- pattern_reference, curated, source_read), "barrel": barrel}
898
+ and the name set reconcile-coverage.py and score-signatures.py read, the
899
+ entry points' state it was picked by and, on the scalar branch, whether
900
+ it counts that set (`set_trusted`: each name at its defining file, read
901
+ whole; see "Denominator" above)."""
902
+ picked = _pick(sets, stats_eff, deflation_fires, barrel, arms, scope_type, skill_type, monorepo,
903
+ pattern_reference, curated, source_read)
904
+ counted = (set_trusted and picked["surfaceSet"] not in (None, "all")) if picked["branch"] == "scalar" else None
905
+ return {**picked, "barrel": barrel, "setCounted": counted}
837
906
 
838
907
 
839
908
  def _pick(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str | None, arms: dict,
@@ -868,7 +937,9 @@ def _pick(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str
868
937
  elif stats_eff:
869
938
  # A stored 0 is no denominator: the next rung.
870
939
  if deflation_fires:
871
- return _over(clause, sets, "scope.include", step)
940
+ # The ladder's tier A rung when it has one and the surface has the set, else scope.include.
941
+ rung = "tier_a_include" if "tier_a_include" in ladder and "tier_a_include" in sets else "scope.include"
942
+ return _over(clause, sets, rung, step)
872
943
  scored = next((name for name in ("tier_a_include", "scope.include") if name in sets), "all")
873
944
  return {"clause": clause, "branch": "scalar", "surfaceSet": scored, "ladderStep": step,
874
945
  "absentSet": None, "rebuild": None}
@@ -1002,6 +1073,45 @@ def surface(args: argparse.Namespace) -> dict:
1002
1073
  if tier_a_names:
1003
1074
  sets["tier_a_include"] = tier_a_names
1004
1075
 
1076
+ # The clause: an extraction's entry-point status (one the per-file scan replaces says only that it found no
1077
+ # entry point), else a quick output's (an export is a barrel; none, and no warning, an empty one), else what
1078
+ # the workflow read (--barrel).
1079
+ barrel = None
1080
+ summary = extracted["extraction"] if extracted is not None else None
1081
+ if summary is not None and not summary["fallback"]["needed"]:
1082
+ barrel = summary["entryPointStatus"]
1083
+ elif summary is not None and summary["entryPointsFound"] is False:
1084
+ barrel = "no-entry-point"
1085
+ elif quick_outputs:
1086
+ barrel = "barrel" if any(q["exports"] for q in quick_outputs) else (
1087
+ None if any(q.get("warnings") for q in quick_outputs) else "empty-barrel")
1088
+ if barrel is None and args.barrel:
1089
+ barrel = BARREL_FLAG[args.barrel]
1090
+ recorded_type = extracted["scopeType"] if extracted is not None else None
1091
+ meta_type = meta.get("scope_type") if isinstance(meta, dict) and isinstance(meta.get("scope_type"), str) else None
1092
+ arms = summary["arms"] if summary is not None else {}
1093
+ # The extraction's verdict, the rule Create Skill stores the count by, else the workflow's judgment.
1094
+ curated = summary["curatedSubset"] if summary is not None and summary["curatedSubset"] is not None \
1095
+ else bool(args.curated_subset)
1096
+ scope_type = (brief or {}).get("type") or recorded_type or meta_type
1097
+ skill_type = meta.get("skill_type") if isinstance(meta, dict) else None
1098
+ monorepo = bool(args.monorepo or arms.get("monorepo"))
1099
+ # The clause alone (no stored count, no guard): only a clause whose ladder holds a stored count measures it
1100
+ # against the tier A set.
1101
+ clause = _pick(sets, None, False, barrel, arms, scope_type, skill_type, monorepo,
1102
+ bool(args.pattern_reference_intent), curated, source_read)["clause"]
1103
+ swap = False
1104
+ # Each name sits at the file that defines it (a runner extraction, the fallback scan, quick output whose every
1105
+ # name carries its defined_in); a name read by eye, or a quick name with no defined_in, sits at its entry file.
1106
+ runner = extracted is not None and not extracted["extraction"]["fallback"]["needed"]
1107
+ quick_traced = (bool(quick_outputs) and not args.per_file and not args.name
1108
+ and all(isinstance(e.get("defined_in"), str) and e["defined_in"]
1109
+ for q in quick_outputs for e in q["exports"] if isinstance(e, dict)))
1110
+ defining = runner or bool(args.fallback_scan) or quick_traced
1111
+ # The scalar branch counts its scope set only from such names read whole: an incomplete or truncated
1112
+ # extraction can miss names (#791, #792).
1113
+ whole = summary is None or (summary["status"] != "incomplete" and not summary["truncated"])
1114
+
1005
1115
  if extracted is not None or brief is not None or meta is not None:
1006
1116
  include_union = len(sets["scope.include"]) if "scope.include" in sets else len(sets["all"])
1007
1117
  extraction = extracted["extraction"] if extracted is not None else {}
@@ -1021,14 +1131,27 @@ def surface(args: argparse.Namespace) -> dict:
1021
1131
  # file, not the file that defines it).
1022
1132
  no_tier_a = ("tier_a_include" not in sets if extracted is not None or args.fallback_scan
1023
1133
  else not tier_a)
1024
- deflation = {"applicable": source_read and stats_eff is not None, "effectiveDenominator": stats_eff,
1025
- "rederived": include_union if source_read else None, "pct": None, "fires": False}
1134
+ # A clause whose ladder holds a stored count checks it against the tier A set, both ways, when each name
1135
+ # sits at the file that defines it (a runner extraction, the fallback scan, quick output whose every name
1136
+ # carries its defined_in): Create Skill stores the runner's tier A count, so one beyond the threshold is
1137
+ # stale (#813). A name read by eye, or a quick name with no defined_in, sits at its entry file.
1138
+ tier_a_basis = defining and clause in CLAUSE_LADDERS and "tier_a_include" in sets
1139
+ rederived = len(sets["tier_a_include"]) if tier_a_basis else include_union
1140
+ basis = "tier_a_include" if tier_a_basis else "scope.include" if "scope.include" in sets else "all"
1141
+ deflation = {"applicable": source_read and stats_eff is not None, "basis": basis,
1142
+ "effectiveDenominator": stats_eff, "rederived": rederived if source_read else None, "pct": None,
1143
+ "fires": False}
1026
1144
  if deflation["applicable"] and stats_eff:
1027
- deflation["pct"] = round2((include_union - stats_eff) / stats_eff * 100)
1028
- deflation["fires"] = no_tier_a and deflation["pct"] > DEFLATION_PCT
1145
+ deflation["pct"] = round2((rederived - stats_eff) / stats_eff * 100)
1146
+ deflation["fires"] = (abs(deflation["pct"]) > DEFLATION_PCT if tier_a_basis
1147
+ else no_tier_a and deflation["pct"] > DEFLATION_PCT)
1029
1148
  elif deflation["applicable"]:
1030
1149
  # A stored 0 is deflation, not a denominator, once a name is re-derived.
1031
- deflation["fires"] = no_tier_a and include_union > 0
1150
+ deflation["fires"] = (tier_a_basis or no_tier_a) and rederived > 0
1151
+ # An incomplete extraction can miss tier A names: a tier A set below the stored count is drift to report,
1152
+ # not a denominator to take.
1153
+ incomplete = runner and extracted["extraction"]["status"] == "incomplete"
1154
+ swap = deflation["fires"] and not (incomplete and tier_a_basis and (deflation["pct"] or 0) < 0)
1032
1155
  entry_count = len(_entries(provenance)) if provenance is not None else 0
1033
1156
  inflation = {"applicable": source_read and no_tier_a and bool(scope["include"]) and entry_count > 0,
1034
1157
  "scopeIncludeUnion": include_union if scope["include"] else None,
@@ -1052,31 +1175,9 @@ def surface(args: argparse.Namespace) -> dict:
1052
1175
  "umbrellaRatio": UMBRELLA_RATIO},
1053
1176
  }
1054
1177
 
1055
- # The clause: an extraction's entry-point status (one the per-file scan replaces says only that it found no
1056
- # entry point), else a quick output's (an export is a barrel; none, and no warning, an empty one), else what
1057
- # the workflow read (--barrel).
1058
- barrel = None
1059
- summary = extracted["extraction"] if extracted is not None else None
1060
- if summary is not None and not summary["fallback"]["needed"]:
1061
- barrel = summary["entryPointStatus"]
1062
- elif summary is not None and summary["entryPointsFound"] is False:
1063
- barrel = "no-entry-point"
1064
- elif quick_outputs:
1065
- barrel = "barrel" if any(q["exports"] for q in quick_outputs) else (
1066
- None if any(q.get("warnings") for q in quick_outputs) else "empty-barrel")
1067
- if barrel is None and args.barrel:
1068
- barrel = BARREL_FLAG[args.barrel]
1069
- recorded_type = extracted["scopeType"] if extracted is not None else None
1070
- meta_type = meta.get("scope_type") if isinstance(meta, dict) and isinstance(meta.get("scope_type"), str) else None
1071
- arms = summary["arms"] if summary is not None else {}
1072
- # The extraction's verdict, the rule Create Skill stores the count by, else the workflow's judgment.
1073
- curated = summary["curatedSubset"] if summary is not None and summary["curatedSubset"] is not None \
1074
- else bool(args.curated_subset)
1075
- out["denominator"] = pick_denominator(
1076
- sets, stats_eff, bool((out["guards"] or {}).get("deflation", {}).get("fires")), barrel, arms,
1077
- (brief or {}).get("type") or recorded_type or meta_type,
1078
- meta.get("skill_type") if isinstance(meta, dict) else None, bool(args.monorepo or arms.get("monorepo")),
1079
- bool(args.pattern_reference_intent), curated, source_read)
1178
+ out["denominator"] = pick_denominator(sets, stats_eff, swap, barrel, arms, scope_type, skill_type, monorepo,
1179
+ bool(args.pattern_reference_intent), curated, source_read,
1180
+ defining and whole)
1080
1181
 
1081
1182
  return {
1082
1183
  "inputs": {
@@ -31,7 +31,7 @@ answer per input and judges no meaning. For each export name it lists:
31
31
  as data or output (`json`, `yaml`, `text`, `console` ...) holds none.
32
32
 
33
33
  A call is `awaited` when `await` stands before it (through its receiver:
34
- `await client.fetchData(...)`), `.await` follows it (Rust), an asynchronous
34
+ `await client.fetchData(...)`), `.await` or `?.await` follows it (Rust), an asynchronous
35
35
  loop or context manager consumes it (`async for row in stream(...)`, `async
36
36
  with session(...)`, `for await (const row of stream(...))`), or it sits in
37
37
  the arguments of a call that is itself awaited or is an event-loop runner
@@ -40,6 +40,26 @@ crossing into a function body. `chainedMember` names the member read or
40
40
  called on its result right after it (`then`, `catch`, `join`, `get`), else
41
41
  null: whether that waits for the result is the caller's call.
42
42
 
43
+ `awaitedVia` says how an awaited call is awaited, null when it is not:
44
+
45
+ - "direct": the await applies to the call itself (`directlyAwaited`):
46
+ `.await` follows it, or `await` stands before it with nothing applied to
47
+ its result (`await client.fetchData(...)`) but a member that waits for an
48
+ awaitable (`then`, `catch`, `finally`, `ConfigureAwait`), or it alone
49
+ fills the groups that `await` stands before (`await ((fetchData(x)))`,
50
+ `await (fetchData(x) as T)`) and nothing follows them but the end of the
51
+ statement, of a list or of a bracket.
52
+ - "chain": the await applies to an expression the call starts or sits in:
53
+ a member, subscript or call applied to its result
54
+ (`await use_context(e).embed(t)`, `await fetchData(x)[0]`) or a group
55
+ around it (`await (fetchData(x) or other())`).
56
+ - "consumer": an asynchronous loop or context manager consumes it.
57
+ - "argument" or "runner": it sits in the arguments of an awaited call, or of
58
+ an event-loop runner; `enclosingCallee` names that callee, whitespace
59
+ removed (`asyncio.gather`, `Promise.all`, `asyncio.run`), else null.
60
+
61
+ A backslash continuation counts as whitespace throughout.
62
+
43
63
  Name match: the name as a fixed string, case-sensitive, never inside a
44
64
  longer identifier (the Export match rule of skf-scan-skill-md-structure.py
45
65
  usage-scope). A qualified name (`client.fetch`, `Store::load`) matches only
@@ -74,9 +94,11 @@ Output (stdout, one object):
74
94
  "name": "fetchData",
75
95
  "descriptionCount": 3, # all found; descriptions[] holds the first 12
76
96
  "descriptions": [{"file", "line", "endLine", "kind", "section", "header", "text", "truncated"}],
77
- "calls": [{"file", "line", "fence", "language", "awaited", "chainedMember", "text"}],
97
+ "calls": [{"file", "line", "fence", "language", "awaited", "directlyAwaited", "awaitedVia",
98
+ "enclosingCallee", "chainedMember", "text"}],
78
99
  "callCount": 2,
79
- "awaitedCount": 1
100
+ "awaitedCount": 1, # calls whose `awaited` is true
101
+ "directlyAwaitedCount": 1 # calls whose `directlyAwaited` is true
80
102
  }
81
103
  ],
82
104
  "notCalled": ["parseConfig"], # names no example calls
@@ -126,9 +148,20 @@ LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d{1,9}[.)])(?:[ \t]+|$)")
126
148
  TABLE_ROW_RE = re.compile(r"^\s*\|")
127
149
  TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?\s*$")
128
150
  GENERIC_OR_PATH = r"(?:[ \t]*::)?(?:[ \t]*<[^<>()\n]*(?:<[^<>()\n]*>[^<>()\n]*)*>)?"
129
- AFTER_CALL_RE = re.compile(f"\\s*\\??\\.\\s*((?:[^\\W\\d]|\\$)[{IDENT_CHARS}]*)")
151
+ # Whitespace, a Python backslash continuation included; LINE_SPACE stays on its line.
152
+ SPACE = r"(?:\s|\\\n)*"
153
+ LINE_SPACE = r"(?:[ \t]|\\\n)*"
154
+ AFTER_CALL_RE = re.compile(f"{SPACE}[?!]?\\.{SPACE}((?:[^\\W\\d]|\\$)[{IDENT_CHARS}]*)")
130
155
  AWAIT_BEFORE_RE = re.compile(f"(?<![{IDENT_CHARS}])await$")
131
- AWAIT_AFTER_RE = re.compile(f"\\s*\\.\\s*await(?![{IDENT_CHARS}])")
156
+ AWAIT_AFTER_RE = re.compile(f"{SPACE}\\??\\.{SPACE}await(?![{IDENT_CHARS}])")
157
+ # A subscript or call applied to a call's result: `f(x)[0]`, `f(x)(y)`, `f(x)?.[0]`, `f(x)!(y)`.
158
+ APPLIED_RE = re.compile(f"{LINE_SPACE}(?:\\?\\.|!)?[\\[(]")
159
+ # What may close a group that holds only the call (`(f(x) as T)`, `(f(x)!)`), and what may follow
160
+ # the outermost group: the end of the statement, of a list or of a bracket.
161
+ GROUP_TAIL_RE = re.compile(f"{SPACE}(?:!|as(?![{IDENT_CHARS}])[^()]*)?{SPACE}")
162
+ GROUP_END_RE = re.compile(f"{LINE_SPACE}(?:[;,)\\]}}]|\\Z|\\n\\s*(?:[^\\s.?\\[(]|\\Z))")
163
+ # Members whose call waits for an awaitable: under `await`, the export returns one.
164
+ WAIT_MEMBERS = frozenset({"then", "catch", "finally", "ConfigureAwait"})
132
165
  # An expression an asynchronous loop or context manager consumes: `async for
133
166
  # x in NAME(...)`, `async with NAME(...)`, `for await (const x of NAME(...))`,
134
167
  # `await foreach (var x in NAME(...))`. Matched against its line up to it.
@@ -515,7 +548,8 @@ def _matching_open(text, close_idx):
515
548
 
516
549
 
517
550
  def _skip_space_back(text, i):
518
- while i > 0 and text[i - 1] in " \t\n":
551
+ """Back over whitespace, a Python backslash continuation included."""
552
+ while i > 0 and (text[i - 1] in " \t\n" or (text[i - 1] == "\\" and text.startswith("\n", i))):
519
553
  i -= 1
520
554
  return i
521
555
 
@@ -543,14 +577,37 @@ def _chain_start(text, pos):
543
577
  i = start
544
578
 
545
579
 
580
+ def _await_before(text, start):
581
+ """True when `await` stands right before the expression that starts at `start`."""
582
+ return bool(AWAIT_BEFORE_RE.search(text, 0, _skip_space_back(text, start)))
583
+
584
+
546
585
  def _awaited_at(text, start):
547
586
  """True when `await` stands right before the expression that starts at `start`, or an
548
587
  asynchronous loop or context manager consumes it."""
549
- if AWAIT_BEFORE_RE.search(text, 0, _skip_space_back(text, start)):
588
+ if _await_before(text, start):
550
589
  return True
551
590
  return bool(ASYNC_CONSUMER_RE.search(text[text.rfind("\n", 0, start) + 1:start]))
552
591
 
553
592
 
593
+ def _grouped_await(text, chain, close):
594
+ """True for `await (call(...))` and `await ((call(...) as T))`: the call alone fills each group
595
+ around it (a trailing `as T` or `!` allowed), `await` stands before the outermost one, and
596
+ nothing but the end of the statement, of a list or of a bracket follows it."""
597
+ start, end = chain, close
598
+ while end is not None:
599
+ group = _skip_space_back(text, start) - 1
600
+ if group < 0 or text[group] != "(":
601
+ return False
602
+ end_group = _matching_close(text, group)
603
+ if end_group is None or not GROUP_TAIL_RE.fullmatch(text, end + 1, end_group):
604
+ return False
605
+ if _await_before(text, group):
606
+ return bool(GROUP_END_RE.match(text, end_group + 1))
607
+ start, end = group, end_group
608
+ return False
609
+
610
+
554
611
  def _is_runner(callee):
555
612
  """True for `asyncio.run`, `loop.run_until_complete`, `rt.block_on` and the like."""
556
613
  parts = [p for p in re.split(r"\.|::", callee) if p]
@@ -568,7 +625,12 @@ def _opens_body(text, brace_idx):
568
625
 
569
626
 
570
627
  def _enclosing_awaits(text, start):
571
- """True when a call around the expression at `start` is awaited or runs it, within one function body."""
628
+ """How a call or group around the expression at `start` awaits it, within one function body.
629
+
630
+ ("runner", callee) for a runner it is an argument of, ("argument", callee) for an awaited or
631
+ consumed call it is an argument of, ("chain", None) for an awaited group around it and
632
+ ("consumer", None) for a consumed one; None when nothing around it awaits it.
633
+ """
572
634
  i, depth = start, 0
573
635
  while i > 0:
574
636
  i -= 1
@@ -583,17 +645,39 @@ def _enclosing_awaits(text, start):
583
645
  elif c == "(":
584
646
  k = _skip_space_back(text, i)
585
647
  word = WORD_BEFORE_RE.search(text, 0, k)
648
+ callee = None
586
649
  if word and word.group(1) not in EXPRESSION_WORDS:
587
650
  expr = _chain_start(text, word.start())
588
- if _is_runner(re.sub(r"\s+", "", text[expr:k])):
589
- return True
651
+ callee = re.sub(r"(?:\s|\\\n)+", "", text[expr:k])
652
+ if _is_runner(callee):
653
+ return "runner", callee
590
654
  else:
591
655
  expr = i # a grouping `(`: `await (fetch())`
592
656
  close = _matching_close(text, i)
593
- if _awaited_at(text, expr) or (close is not None and AWAIT_AFTER_RE.match(text, close + 1)):
594
- return True
657
+ awaits = _await_before(text, expr) or (close is not None and AWAIT_AFTER_RE.match(text, close + 1))
658
+ if awaits or _awaited_at(text, expr):
659
+ return ("argument", callee) if callee else ("chain" if awaits else "consumer", None)
595
660
  i = expr
596
- return False
661
+ return None
662
+
663
+
664
+ def _how_awaited(text, chain, close):
665
+ """(awaitedVia, enclosingCallee, chainedMember) of the call whose expression starts at `chain`
666
+ and whose argument list closes at `close`."""
667
+ before = _await_before(text, chain)
668
+ member = AFTER_CALL_RE.match(text, close + 1) if close is not None else None
669
+ chained = member.group(1) if member else None
670
+ if close is not None and AWAIT_AFTER_RE.match(text, close + 1):
671
+ return "direct", None, None
672
+ if member or (close is not None and APPLIED_RE.match(text, close + 1)):
673
+ if before:
674
+ return ("direct" if chained in WAIT_MEMBERS else "chain"), None, chained
675
+ elif before or _grouped_await(text, chain, close):
676
+ return "direct", None, None
677
+ if _awaited_at(text, chain):
678
+ return "consumer", None, chained
679
+ via, callee = _enclosing_awaits(text, chain) or (None, None)
680
+ return via, callee, chained
597
681
 
598
682
 
599
683
  def _closes_generic(tail):
@@ -670,15 +754,11 @@ def scan_block(rel, lines, opening, closing, info, patterns):
670
754
  continue
671
755
  if regex is not call_re:
672
756
  continue # the last part of a qualified name counts only as a declaration
673
- awaited, chained = _awaited_at(text, chain), None
674
- if close is not None:
675
- if AWAIT_AFTER_RE.match(text, close + 1):
676
- awaited = True
677
- elif member := AFTER_CALL_RE.match(text, close + 1):
678
- chained = member.group(1)
757
+ via, callee, chained = _how_awaited(text, chain, close)
679
758
  found[name]["calls"].append({
680
759
  "file": rel, "line": opening + row + 2, "fence": opening + 1, "language": language or None,
681
- "awaited": awaited or _enclosing_awaits(text, chain), "chainedMember": chained,
760
+ "awaited": via is not None, "directlyAwaited": via == "direct", "awaitedVia": via,
761
+ "enclosingCallee": callee, "chainedMember": chained,
682
762
  "text": _cap(body[row].strip(), LINE_TEXT_CAP)[0],
683
763
  })
684
764
  return found
@@ -734,6 +814,7 @@ def locate(inp):
734
814
  "calls": calls[name],
735
815
  "callCount": len(calls[name]),
736
816
  "awaitedCount": sum(1 for c in calls[name] if c["awaited"]),
817
+ "directlyAwaitedCount": sum(1 for c in calls[name] if c["directlyAwaited"]),
737
818
  } for name in names if calls[name]]
738
819
  not_called = [name for name in names if not calls[name]]
739
820
  return {