bmad-module-skill-forge 3.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 (141) 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 +33 -26
  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/troubleshooting.md +8 -0
  12. package/docs/verifying-a-skill.md +46 -21
  13. package/docs/why-skf.md +1 -1
  14. package/docs/workflows.md +5 -5
  15. package/package.json +1 -1
  16. package/src/knowledge/qmd-registry.md +1 -1
  17. package/src/knowledge/version-paths.md +1 -1
  18. package/src/shared/health-check.md +2 -50
  19. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
  20. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +12 -4
  21. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  22. package/src/shared/scripts/skf-atomic-write.py +212 -59
  23. package/src/shared/scripts/skf-build-change-manifest.py +1437 -178
  24. package/src/shared/scripts/skf-classify-changed-files.py +78 -0
  25. package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
  26. package/src/shared/scripts/skf-count-tokens.py +54 -10
  27. package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
  28. package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
  29. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  30. package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
  31. package/src/shared/scripts/skf-extract-public-api.py +1673 -189
  32. package/src/shared/scripts/skf-extraction-inventory.py +584 -46
  33. package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
  34. package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
  35. package/src/shared/scripts/skf-github-probe.py +47 -8
  36. package/src/shared/scripts/skf-hash-content.py +14 -8
  37. package/src/shared/scripts/skf-load-provenance.py +3 -1
  38. package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
  39. package/src/shared/scripts/skf-parse-gaps.py +135 -26
  40. package/src/shared/scripts/skf-preflight.py +15 -1
  41. package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
  42. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  43. package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
  44. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  45. package/src/shared/scripts/skf-shard-body.py +239 -41
  46. package/src/shared/scripts/skf-structural-diff.py +133 -18
  47. package/src/shared/scripts/skf-update-active-symlink.py +115 -62
  48. package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
  49. package/src/shared/scripts/skf-validate-output.py +12 -3
  50. package/src/shared/scripts/skf-verify-provenance-completeness.py +1439 -79
  51. package/src/shared/scripts/skf-write-skill-brief.py +41 -8
  52. package/src/shared/tool-requirements.yaml +8 -2
  53. package/src/skf-analyze-source/SKILL.md +2 -2
  54. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  55. package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
  56. package/src/skf-audit-skill/SKILL.md +2 -2
  57. package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
  58. package/src/skf-audit-skill/references/headless-contract.md +1 -1
  59. package/src/skf-audit-skill/references/health-check.md +2 -0
  60. package/src/skf-audit-skill/references/init.md +2 -1
  61. package/src/skf-audit-skill/references/re-index.md +14 -9
  62. package/src/skf-audit-skill/references/report.md +2 -2
  63. package/src/skf-audit-skill/references/semantic-diff.md +15 -15
  64. package/src/skf-audit-skill/references/structural-diff.md +10 -5
  65. package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
  66. package/src/skf-brief-skill/SKILL.md +3 -3
  67. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  68. package/src/skf-brief-skill/assets/skill-brief-schema.md +4 -11
  69. package/src/skf-brief-skill/references/analyze-target.md +6 -6
  70. package/src/skf-brief-skill/references/confirm-brief.md +1 -1
  71. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  72. package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
  73. package/src/skf-brief-skill/references/version-resolution.md +5 -9
  74. package/src/skf-campaign/SKILL.md +2 -2
  75. package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
  76. package/src/skf-create-skill/SKILL.md +2 -2
  77. package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
  78. package/src/skf-create-skill/assets/skill-sections.md +7 -7
  79. package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
  80. package/src/skf-create-skill/references/compile.md +31 -60
  81. package/src/skf-create-skill/references/component-extraction.md +3 -3
  82. package/src/skf-create-skill/references/enrich.md +2 -2
  83. package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
  84. package/src/skf-create-skill/references/extract.md +23 -19
  85. package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
  86. package/src/skf-create-skill/references/extraction-patterns.md +10 -11
  87. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  88. package/src/skf-create-skill/references/report.md +21 -5
  89. package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
  90. package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
  91. package/src/skf-create-stack-skill/SKILL.md +2 -2
  92. package/src/skf-create-stack-skill/references/validate.md +1 -1
  93. package/src/skf-drop-skill/SKILL.md +2 -2
  94. package/src/skf-drop-skill/references/execute.md +5 -1
  95. package/src/skf-drop-skill/references/health-check.md +2 -0
  96. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  97. package/src/skf-drop-skill/references/report.md +2 -2
  98. package/src/skf-drop-skill/references/select.md +7 -3
  99. package/src/skf-export-skill/SKILL.md +2 -2
  100. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  101. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  102. package/src/skf-export-skill/references/load-skill.md +1 -1
  103. package/src/skf-export-skill/references/token-report.md +29 -9
  104. package/src/skf-forger/SKILL.md +2 -2
  105. package/src/skf-quick-skill/SKILL.md +2 -2
  106. package/src/skf-refine-architecture/SKILL.md +6 -8
  107. package/src/skf-rename-skill/SKILL.md +2 -2
  108. package/src/skf-setup/SKILL.md +2 -2
  109. package/src/skf-test-skill/SKILL.md +2 -2
  110. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  111. package/src/skf-test-skill/references/coherence-check.md +5 -4
  112. package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
  113. package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
  114. package/src/skf-test-skill/references/coverage-check.md +69 -111
  115. package/src/skf-test-skill/references/init.md +5 -6
  116. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  117. package/src/skf-test-skill/references/report.md +11 -9
  118. package/src/skf-test-skill/references/scoring-rules.md +8 -6
  119. package/src/skf-test-skill/references/source-access-protocol.md +15 -18
  120. package/src/skf-test-skill/scripts/build-result-context.py +114 -21
  121. package/src/skf-test-skill/scripts/gap-ledger.py +580 -80
  122. package/src/skf-test-skill/scripts/load-coverage-inputs.py +646 -107
  123. package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
  124. package/src/skf-test-skill/scripts/reconcile-coverage.py +450 -23
  125. package/src/skf-test-skill/scripts/score-signatures.py +356 -61
  126. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  127. package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
  128. package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
  129. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  130. package/src/skf-update-skill/SKILL.md +2 -2
  131. package/src/skf-update-skill/references/detect-changes.md +32 -13
  132. package/src/skf-update-skill/references/gap-driven.md +37 -22
  133. package/src/skf-update-skill/references/init.md +6 -2
  134. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  135. package/src/skf-update-skill/references/merge.md +17 -12
  136. package/src/skf-update-skill/references/re-extract.md +4 -2
  137. package/src/skf-update-skill/references/report.md +7 -4
  138. package/src/skf-update-skill/references/write.md +77 -36
  139. package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
  140. package/src/skf-verify-stack/SKILL.md +2 -2
  141. package/tools/cli/lib/tool-check.js +158 -2
@@ -5,10 +5,12 @@
5
5
  # ///
6
6
  """SKF test-skill coverage inputs: the counts, name sets and candidates coverage-check reads.
7
7
 
8
- coverage-check.md decides which denominator clause applies to a skill and
9
- whether a bookkeeping variant is a real export; everything it counts, folds
10
- or unions on the way runs here, so no count is made by hand. Three
11
- subcommands, each reading files and writing one JSON object:
8
+ coverage-check.md decides whether a bookkeeping variant is a real export,
9
+ and makes the judgments the denominator clause needs where no file holds
10
+ them (--pattern-reference-intent, and --curated-subset and --barrel where
11
+ the extraction does not say); the clause, everything it counts, folds or
12
+ unions on the way runs here, so no count is made by hand. Three subcommands, each
13
+ reading files and writing one JSON object:
12
14
 
13
15
  census docs-only detection. Counts the citations SKILL.md and each .md
14
16
  file directly in references/ write, by form: `[EXT:` against
@@ -22,7 +24,10 @@ subcommands, each reading files and writing one JSON object:
22
24
  `confidence_distribution`) under the keys
23
25
  check-metadata-coherence.py reads; the provenance named exports
24
26
  (`::` impl-block methods left out); the inflation signature
25
- (`stats.exports_documented` equals `stats.effective_denominator`)
27
+ (`stats.exports_documented` equals `stats.effective_denominator`,
28
+ or, for a map with a named entry and no `::` entry, its distinct
29
+ entry names do: the stored count counts each name once, the map
30
+ holds a name passed on from two files twice)
26
31
  with the declared names verify-declared-numerator.py looks up
27
32
  (the `exports[]` names, else the provenance named exports); and,
28
33
  for a stack, its composition surface: the distinct provenance
@@ -34,17 +39,63 @@ subcommands, each reading files and writing one JSON object:
34
39
  surface the enumerated source surface, from every input given (their
35
40
  union):
36
41
  --extraction skf-extract-public-api.py --mode full output:
37
- the names in entry_point_diff.public, less the
38
- outside_scope names (defined in a file the brief
39
- scopes out); exports[] only gives each name's
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
46
+ namespace with a `members` list given as its
47
+ members (the names its module passes on, each
48
+ at its file, once with a public name of the
49
+ same name and file), less the outside_scope
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
40
57
  kind, line and signature line, looked up by the
41
58
  name its defining file gives it (an aliased
42
59
  re-export's `local`) and that file.
43
- extraction_gaps are listed to read by eye.
44
- --quick skf-extract-public-api.py --mode quick output
60
+ extraction_gaps are listed to read by eye, and
61
+ the runner's warnings join `warnings`. A Python
62
+ top __init__.py inside the folder of another top
63
+ that exports a public name (a sub-package
64
+ behind a folder with no __init__.py) is no
65
+ barrel, unless scope.include or tier_a_include
66
+ lists its file: its names leave `all`, `root`
67
+ and the umbrella ratio and are listed in
68
+ `excluded.nestedEntries`, but keep their records
69
+ (so the scope.include and tier_a_include sets)
70
+ and their outside_scope and extraction_gaps
71
+ rows. When the runner lists an
72
+ include glob that matches no file
73
+ (scope.unmatched_include: a brief older than the
74
+ source), each outside_scope name a root entry
75
+ point exports is restored, with the file and
76
+ line of its outside_scope row, when no include,
77
+ exclude or declined scope-expansion amendment
78
+ (latest action skipped or demoted-include, from
79
+ --brief) matches its file. A tier_a_include glob
80
+ the runner lists as matching no file
81
+ (scope.unmatched_tier_a_include, [] when the key
82
+ is absent) counts no name. An extraction the
83
+ per-file scan replaces (extraction.fallback.needed)
84
+ gives no name and no set: only its scope, its
85
+ stale globs and its `extraction` object, so the
86
+ fallback scan passes it beside its results.
87
+ --quick skf-extract-public-api.py --mode quick output,
88
+ each name at its defined_in (the file that
89
+ defines it), else at its source_file
45
90
  --per-file a subagent's per-file result (exports_found and
46
91
  types_found), schema-checked as
47
92
  score-signatures.py checks it
93
+ --fallback-scan
94
+ the --per-file results are the fallback
95
+ per-file scan's, each at the file that defines
96
+ its names (a Quick-tier or State 4 result is at
97
+ its entry file): as with an extraction, no
98
+ tier_a_include set is no tier A list
48
99
  --name a name read by eye whose file is not known
49
100
  --provenance the provenance entries' named exports (State 2)
50
101
  --metadata the metadata.json exports[] names (States 2, 3)
@@ -58,51 +109,157 @@ subcommands, each reading files and writing one JSON object:
58
109
  names to their base name (`X_def`, `X_exact`, `a11y_X`, and
59
110
  `<prefix>X` for each --fold-prefix when `X` is itself a name)
60
111
  unless --keep names the variant as a real export, and
61
- `canonical` gives the provenance map's fold summary. --brief
62
- gives the skill brief's scope and language; an extraction's
63
- recorded scope wins over it.
64
-
65
- Name sets (surface `sets`, each sorted): `all` (the union above);
112
+ `canonical` gives the provenance map's fold summary; a map
113
+ whose distinct named exports number
114
+ `stats.effective_denominator` (--metadata) is already canonical
115
+ and is not folded (`canonical.skipped`), and `residual` is the
116
+ canonical count less that stored count. --brief gives the
117
+ skill brief's scope, scope type, language and scope-expansion
118
+ amendments; an extraction's recorded scope wins over its scope.
119
+
120
+ Name sets (surface `sets`, each sorted): `all` (the union above, less a
121
+ nested Python top's names);
66
122
  `scope.include` and `tier_a_include` when the scope has those globs and a
67
- record names a file: the names whose file (an extraction's definition file,
68
- the quick output's source_file, a per-file result's file, a provenance
69
- entry's source_file) matches a glob of the list and no scope.exclude glob,
70
- so a name with no file is in `all` only; and, from an extraction only,
71
- `subpaths` (the names a non-root `exports` map subpath without `*` reaches)
72
- and `root` (the names the root entry points reach). reconcile-coverage.py
73
- and score-signatures.py --surface-set pick one.
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,
125
+ the quick output's defined_in, else its source_file, a per-file result's
126
+ file, a provenance entry's source_file) that matches a glob of the list
127
+ and no scope.exclude glob,
128
+ and for `tier_a_include` a scope.include glob too when the scope has one,
129
+ so a name with no file is in `all` only and a restored name is in no
130
+ tier_a_include set (a tier_a_include glob an extraction lists as matching
131
+ no file is left out of the list, and so is one whose folder or file
132
+ differs from every scope.include glob's before any `*` or `?`, such as
133
+ `gen/**` beside `src/**`, and so is one a scope.exclude glob covers
134
+ segment by segment, such as `src/gen/**` or `src/gen/extra.ts` beside an
135
+ exclude of `src/gen/**`; a list that counts no name, each glob left out
136
+ or matching only excluded files, files scope.include leaves out or files
137
+ no record names, gives no tier_a_include set); and, from an
138
+ extraction only, `subpaths` (the names a non-root `exports` map subpath
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.
141
+ reconcile-coverage.py and score-signatures.py --surface-set pick one.
74
142
 
75
143
  Candidates and guards (with an extraction, --brief or --metadata): each
76
144
  set's count, the metadata `stats.effective_denominator` (--metadata), and
77
145
  the extractor's own effective_denominator and its basis. Both guards run
78
- only on a surface read from the source. The deflation guard compares the
79
- re-derived scope.include set (the `all` set without one) with
80
- `stats.effective_denominator` and fires above DEFLATION_PCT when the scope
81
- has no tier_a_include; the inflation guard compares the scope.include set
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
82
163
  with the provenance entry count (--provenance) and fires above
83
- INFLATION_PCT when the scope has no tier_a_include; the umbrella ratio (an
164
+ INFLATION_PCT when there is no tier A list, as the scope.include basis
165
+ reads it; `guards.tierAInclude` lists the scope's
166
+ tier_a_include globs ([] when it has none) for gap-ledger.py's wording;
167
+ the umbrella ratio (an
84
168
  extraction only) is the share of the root entry points' names that are
85
- re-exports, and above UMBRELLA_RATIO the barrel is an umbrella. Globs follow
169
+ re-exports (a namespace with `members` counting as its members there
170
+ too, each a re-export, and an other in-scope definition left out), and
171
+ above UMBRELLA_RATIO the barrel is an
172
+ umbrella; the
173
+ stale-scope guard (an extraction with scope.include or tier_a_include only)
174
+ fires when the runner lists an include or tier_a_include glob that matches
175
+ no file, and lists those globs (`unmatchedInclude`,
176
+ `unmatchedTierAInclude`) and the names restored for the include globs, each
177
+ with its file and line (`restored`). Globs follow
86
178
  skf-extract-public-api.py: `**` spans any number of path segments, none
87
179
  included, and `*` and `?` stay inside one.
88
180
 
181
+ Denominator (surface `denominator`): the Source API Surface Definition's
182
+ clause (source-access-protocol.md), the branch reconcile-coverage.py takes
183
+ and the set it and score-signatures.py --surface-set read: {clause, branch,
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
192
+ type (the brief's, else the extraction's, else metadata.json's
193
+ `scope_type`), metadata.json's `skill_type` and `stats.effective_denominator`,
194
+ the extraction's `arms`, the deflation guard, and:
195
+ monorepo --monorepo (the workspace layout skf-detect-workspaces.py
196
+ found), or the extraction's `arms.monorepo`.
197
+ curated the extraction's `counts.curated_subset` (the rule Create Skill
198
+ stores the count by), else --curated-subset (the workflow's
199
+ judgment that a monorepo's scope.include lists a curated subset).
200
+ barrel (`barrel`: "barrel", "empty-barrel", "no-entry-point" or null)
201
+ a complete extraction's `entry_points.status`; for one the
202
+ per-file scan replaces, "no-entry-point" when it read a file in
203
+ scope and found no entry point (`entry_points.files` empty), as
204
+ a complete run says; a quick output's (an export is a barrel,
205
+ none and no warning an empty one); else --barrel (`barrel`,
206
+ `empty` or `none`, what the workflow read); else null.
207
+ --pattern-reference-intent the workflow's judgment that scope.notes
208
+ flags pattern-reference intent.
209
+ The first clause that applies:
210
+ stack skill_type stack: branch `stack`, no set.
211
+ pattern-reference not a monorepo, no non-empty barrel, a scope type other
212
+ than specific-modules, component-library, docs-only and
213
+ reference-app, not public-api with a multi-subpath
214
+ `exports` map, and either the intent flag or no entry
215
+ point at all with no such map: the tier_a_include set,
216
+ else `all` with `rebuild` `provenance-fold` on a surface
217
+ read from the source (build it again from the provenance
218
+ map alone, folded), `all` on one that is not.
219
+ empty-barrel an empty barrel: the scope.include set.
220
+ stratified-scope --monorepo and --curated-subset, not full-library.
221
+ specific-modules scope type specific-modules, not --monorepo.
222
+ multi-entry arms.multi_subpath_exports, not --monorepo, a
223
+ full-library or public-api scope.
224
+ standard-barrel any other: the `all` set.
225
+ The last three walk a ladder, `ladderStep` naming the rung taken: rung 1,
226
+ `stats.effective_denominator` when metadata.json has one above 0 (a stored
227
+ 0 is no denominator), is the `scalar` branch, whose `surfaceSet` (the set score-signatures.py scores) is
228
+ tier_a_include, else scope.include, else all, unless the deflation guard
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,
236
+ each taken when the surface has it, the last taken in any case:
237
+ tier_a_include then scope.include (stratified-scope), scope.include
238
+ (specific-modules), tier_a_include then subpaths (multi-entry). A set the
239
+ surface lacks is counted as `all`, and `absentSet` names it.
240
+
89
241
  Fallback verdict: `extraction.fallback.needed` is true when the extractor
90
242
  found no ast-grep (`no-ast-grep`), read no file in scope, read files only
91
243
  in languages it has no recipe for (`files_without_recipes` and no export),
92
244
  or found files no recipe reads while the skill's language (the --brief
93
245
  `language`, else the metadata.json `language`) is not one RECIPE_LANGUAGES
94
- holds, so the per-file scan has to run instead. An `incomplete` run keeps
95
- its exports; `extraction.readByEye` lists the files its errors and file
96
- 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.
97
253
 
98
254
  Usage:
99
255
  uv run load-coverage-inputs.py census --skill-dir <dir> [--output <file>]
100
256
  uv run load-coverage-inputs.py metadata --metadata <metadata.json> \\
101
257
  [--provenance <provenance-map.json>] [--output <file>]
102
258
  uv run load-coverage-inputs.py surface [--extraction <file>] [--quick <file>]... \\
103
- [--per-file <file>]... [--name <name>]... [--provenance <file>] [--metadata <file>] \\
104
- [--brief <skill-brief.yaml>] [--fold] [--fold-prefix <prefix>]... [--keep <name>]... \\
105
- [--output <file>]
259
+ [--per-file <file>]... [--fallback-scan] [--name <name>]... [--provenance <file>] \\
260
+ [--metadata <file>] [--brief <skill-brief.yaml>] [--fold] [--fold-prefix <prefix>]... \\
261
+ [--keep <name>]... [--monorepo] [--pattern-reference-intent] [--curated-subset] \\
262
+ [--barrel barrel|empty|none] [--output <file>]
106
263
 
107
264
  Exit codes:
108
265
  0 the result was printed (and written to --output)
@@ -119,6 +276,7 @@ import argparse
119
276
  import importlib.util
120
277
  import json
121
278
  import math
279
+ import posixpath
122
280
  import re
123
281
  import sys
124
282
  from pathlib import Path
@@ -140,6 +298,19 @@ LOCAL_FORMS = ("AST", "SRC", "from skill")
140
298
  FOLD_SUFFIXES = ("_def", "_exact")
141
299
  FOLD_PREFIX = "a11y_"
142
300
  ROOT_SUBPATHS = (None, ".")
301
+ # The latest action of a scope-expansion amendment that keeps its path out of scope.
302
+ DECLINED_ACTIONS = ("skipped", "demoted-include")
303
+ # The rungs each laddered clause tries in order, `scalar` being stats.effective_denominator.
304
+ CLAUSE_LADDERS = {
305
+ "stratified-scope": ("scalar", "tier_a_include", "scope.include"),
306
+ "specific-modules": ("scalar", "scope.include"),
307
+ "multi-entry": ("scalar", "tier_a_include", "subpaths"),
308
+ }
309
+ # --barrel: what the workflow read of the entry points, where surface cannot tell -> the extractor's status.
310
+ BARREL_FLAG = {"barrel": "barrel", "empty": "empty-barrel", "none": "no-entry-point"}
311
+ # The scope types the pattern-reference clause never applies to (a reference app's brief carries its own
312
+ # pattern-surface denominator).
313
+ NOT_PATTERN_REFERENCE = frozenset({"specific-modules", "component-library", "docs-only", "reference-app"})
143
314
  # The languages skf-extract-public-api.py's recipes read (its LANGUAGE_FAMILIES).
144
315
  RECIPE_LANGUAGES = frozenset({"python", "rust", "go", "golang", "typescript", "ts", "tsx", "javascript", "js",
145
316
  "jsx", "vue"})
@@ -158,17 +329,21 @@ class SchemaError(Exception):
158
329
 
159
330
 
160
331
  _SIBLINGS: dict[str, object] = {}
332
+ SHARED_SCRIPTS = Path(__file__).resolve().parent.parent.parent / "shared" / "scripts"
161
333
 
162
334
 
163
- def _sibling(filename: str):
335
+ def _sibling(filename: str, folder: Path | None = None):
164
336
  """A script beside this one, loaded once: validate-inventory.py (the doc
165
- text) and score-signatures.py (the per-file result schema)."""
337
+ text) and score-signatures.py (the per-file result schema); or, with
338
+ `folder`, one in that folder: SHARED_SCRIPTS'
339
+ skf-resolve-authoritative-files.py (a skill brief's YAML)."""
166
340
  module = _SIBLINGS.get(filename)
167
341
  if module is None:
168
- path = Path(__file__).resolve().parent / filename
342
+ path = (folder or Path(__file__).resolve().parent) / filename
169
343
  spec = importlib.util.spec_from_file_location("skf_" + filename[:-3].replace("-", "_"), path)
170
344
  if spec is None or spec.loader is None or not path.is_file():
171
- raise InputError(f"{filename} not found beside {Path(__file__).name}")
345
+ where = f"in {folder}" if folder else f"beside {Path(__file__).name}"
346
+ raise InputError(f"{filename} not found {where}")
172
347
  module = importlib.util.module_from_spec(spec)
173
348
  spec.loader.exec_module(module)
174
349
  _SIBLINGS[filename] = module
@@ -306,7 +481,13 @@ def metadata_inputs(meta: dict, provenance: dict | None) -> dict:
306
481
  "confidenceDistribution": dist or None,
307
482
  "namedExports": {"count": len(named), "names": named} if entry_names is not None else None,
308
483
  "effectiveDenominator": effective,
309
- "inflationSignature": documented is not None and effective is not None and documented == effective,
484
+ # The stored count counts each name once, while the map (so stats.exports_documented) holds an entry for
485
+ # each name and file: a name the entry points pass on from two files is two entries, one name. Only a map
486
+ # with no `::` entry takes that arm: the stored count holds no method, so a map with methods could match it
487
+ # only by chance, and its methods would stand in for an undocumented name.
488
+ "inflationSignature": effective is not None and (
489
+ documented == effective or (bool(entry_names) and not any(METHOD_SEPARATOR in n for n in entry_names)
490
+ and len(set(entry_names)) == effective)),
310
491
  "declaredNames": sorted(set(names)) if names else named,
311
492
  "stack": stack,
312
493
  }
@@ -359,13 +540,16 @@ def _scope(include, exclude, tier_a_include) -> dict:
359
540
 
360
541
 
361
542
  def read_brief(path: str) -> dict:
362
- """A skill brief's scope globs and language, the fields skf-extract-public-api.py reads."""
543
+ """A skill brief's scope globs, scope type and language, the fields skf-extract-public-api.py reads, and the
544
+ latest action of each scope-expansion amendment by its path (`amendments`), a legacy headless skip
545
+ read as a deferral as skf-resolve-authoritative-files.py reads it."""
363
546
  try:
364
547
  import yaml # --brief only; `uv run` installs it from the header
365
548
  except ImportError as exc:
366
549
  raise InputError("--brief is read with PyYAML: run the script with `uv run`") from exc
550
+ resolver = _sibling("skf-resolve-authoritative-files.py", SHARED_SCRIPTS)
367
551
  try:
368
- data = yaml.safe_load(Path(path).read_bytes().decode("utf-8-sig"))
552
+ data = resolver.parse_brief_yaml(Path(path).read_bytes().decode("utf-8-sig"))
369
553
  except (OSError, UnicodeDecodeError) as exc:
370
554
  raise InputError(f"cannot read --brief {path}: {exc}") from exc
371
555
  except yaml.YAMLError as exc:
@@ -373,8 +557,17 @@ def read_brief(path: str) -> dict:
373
557
  if not isinstance(data, dict):
374
558
  raise InputError(f"--brief {path} is not a YAML mapping")
375
559
  scope = data.get("scope") if isinstance(data.get("scope"), dict) else {}
560
+ amendments: dict[str, str] = {}
561
+ for amend in scope.get("amendments") if isinstance(scope.get("amendments"), list) else []:
562
+ if isinstance(amend, dict) and amend.get("category") == "scope-expansion":
563
+ amended = _norm(amend["path"]) if isinstance(amend.get("path"), str) else ""
564
+ action = resolver._amendment_action(amend)
565
+ if amended and action is not None:
566
+ amendments[amended] = action
376
567
  return {"scope": _scope(scope.get("include"), scope.get("exclude"), scope.get("tier_a_include")),
377
- "language": data.get("language") if isinstance(data.get("language"), str) else None}
568
+ "type": scope.get("type") if isinstance(scope.get("type"), str) else None,
569
+ "language": data.get("language") if isinstance(data.get("language"), str) else None,
570
+ "amendments": amendments}
378
571
 
379
572
 
380
573
  def matching(records: list[dict], globs: list[str], excludes: list[str]) -> list[str]:
@@ -384,70 +577,246 @@ def matching(records: list[dict], globs: list[str], excludes: list[str]) -> list
384
577
  and not any(glob_match(_norm(r["file"]), g) for g in excludes)})
385
578
 
386
579
 
387
- def from_extraction(data: dict, language: str | None = None) -> dict:
388
- """The public surface of an extraction, less what lies outside scope."""
580
+ def _apart(a: str, b: str) -> bool:
581
+ """True when, before either has a `*` or `?`, the two globs spell a different segment at the same
582
+ place, so no file can match both. Any other pair counts as overlapping, even one no file can match
583
+ both of (`src` beside `src/index.ts`), so this never parts two that can share a file."""
584
+ for x, y in zip(a.split("/"), b.split("/")):
585
+ if any(c in x or c in y for c in "*?"):
586
+ return False
587
+ if x != y:
588
+ return True
589
+ return False
590
+
591
+
592
+ def _inside(a: str, b: str) -> bool:
593
+ """True when glob `b` matches every path glob `a` can match: segment by segment each of `b`'s is
594
+ `**`, the same as `a`'s, or `*` beside one of `a`'s that is not `**`, and `a` has as many segments
595
+ as `b`, or more when `b` ends in `**`. Any other pair counts as not inside, even one where `b`
596
+ does cover `a` (`gen/**` in `**/gen/**`), so this never drops a glob that can count a name."""
597
+ xs, ys = a.split("/"), b.split("/")
598
+ if len(xs) < len(ys) or (len(xs) > len(ys) and ys[-1] != "**"):
599
+ return False
600
+ return all(y == "**" or x == y or (y == "*" and x != "**") for x, y in zip(xs, ys))
601
+
602
+
603
+ def _nested_tops(entry_files: list[dict], named: set[str], exporting: set[str]) -> dict[str, str]:
604
+ """{top: the outermost top whose folder holds it} for each Python top
605
+ __init__.py inside the folder of another top that `exporting` lists (one
606
+ that exports at least one public name), a sub-package behind a folder
607
+ with no __init__.py, unless `named` lists its file. A top that exports
608
+ nothing (a stray __init__.py at the source root, say) nests no other."""
609
+ tops = sorted({e["file"] for e in entry_files
610
+ if e.get("language") == "python" and isinstance(e.get("file"), str) and e["file"]})
611
+ nested = {}
612
+ for top in tops:
613
+ folder = posixpath.dirname(top)
614
+ outer = [o for o in tops if o != top and o in exporting
615
+ and (not posixpath.dirname(o) or folder.startswith(posixpath.dirname(o) + "/"))]
616
+ if outer and _norm(top) not in named:
617
+ nested[top] = min(outer, key=lambda o: (o.count("/"), o))
618
+ return nested
619
+
620
+
621
+ def _members_in_place(public: list[dict]) -> list[dict]:
622
+ """The public names, a namespace item with a `members` list (the names
623
+ its module passes on, `pkg.sub.name`) given as its members, each with
624
+ the namespace's entry and language and `via: namespace`. A member whose
625
+ name and file another public name, or an earlier member, has is left
626
+ out; a namespace whose members the runner could not read (null), or one
627
+ from a runner that lists none, stays itself."""
628
+ expanded = [p for p in public if not (p.get("via") == "namespace" and isinstance(p.get("members"), list))]
629
+ seen = {(p["name"], p.get("file")) for p in expanded}
630
+ for item in public:
631
+ if item.get("via") != "namespace" or not isinstance(item.get("members"), list):
632
+ continue
633
+ for member in item["members"]:
634
+ if not isinstance(member, dict) or not isinstance(member.get("name"), str) or not member["name"]:
635
+ continue
636
+ key = (member["name"], member.get("file"))
637
+ if key in seen:
638
+ continue
639
+ seen.add(key)
640
+ expanded.append({"name": member["name"], "language": item.get("language"), "entry": item.get("entry"),
641
+ "via": "namespace", "local": member.get("local"), "file": member.get("file"),
642
+ "line": member.get("line")})
643
+ return expanded
644
+
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
+
650
+ def _extraction_summary(data: dict, language: str | None) -> dict:
651
+ """The surface's `extraction` object: the runner's status and counts,
652
+ the files to read by eye, the files no one can read and the fallback
653
+ verdict."""
654
+ counts = data.get("counts") if isinstance(data.get("counts"), dict) else {}
655
+ arms = data.get("arms") if isinstance(data.get("arms"), dict) else {}
656
+ entry_points = data.get("entry_points") if isinstance(data.get("entry_points"), dict) else {}
657
+ without = data.get("files_without_recipes") if isinstance(data.get("files_without_recipes"), dict) else {}
658
+ status = data.get("status")
659
+ reason = None
660
+ if status == "no-ast-grep":
661
+ reason = "no ast-grep the runner can run"
662
+ elif not data.get("files_in_scope"):
663
+ reason = "no file in scope"
664
+ elif without and not data.get("exports"):
665
+ reason = f"no recipe reads the files in scope ({', '.join(sorted(without))})"
666
+ elif without and language and language.strip().lower() not in RECIPE_LANGUAGES:
667
+ reason = (f"no recipe reads the skill's language, {language} "
668
+ f"({', '.join(sorted(without))} in scope)")
669
+ errors = [e for e in data.get("errors") or [] if isinstance(e, dict)]
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}
675
+ | {str(e["first_file"]) for e in errors if e.get("first_file")}
676
+ | {str(f) for e in errors if isinstance(e.get("unread"), list) for f in e["unread"] if f})
677
+ return {
678
+ "status": status,
679
+ "filesInScope": data.get("files_in_scope"),
680
+ "truncated": data.get("truncated"),
681
+ "effectiveDenominator": _count(counts.get("effective_denominator")),
682
+ "effectiveDenominatorBasis": counts.get("effective_denominator_basis"),
683
+ "exportsPublicApi": _count(counts.get("exports_public_api")),
684
+ "arms": arms,
685
+ "curatedSubset": counts.get("curated_subset") if isinstance(counts.get("curated_subset"), bool) else None,
686
+ "entryPointStatus": entry_points.get("status"),
687
+ # A run that read a file in scope and found no entry point says so whether or not ast-grep ran.
688
+ "entryPointsFound": (bool(entry_points["files"]) if isinstance(entry_points.get("files"), list)
689
+ and data.get("files_in_scope") else None),
690
+ "readByEye": read_by_eye,
691
+ "unread": unread,
692
+ "fallback": {"needed": reason is not None, "reason": reason},
693
+ }
694
+
695
+
696
+ NO_UMBRELLA = {"entries": [], "names": 0, "reexported": 0, "ratio": None, "umbrella": False}
697
+
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
+
714
+ def from_extraction(data: dict, language: str | None = None, brief: dict | None = None) -> dict:
715
+ """The public surface of an extraction, less what lies outside scope,
716
+ plus the root exports a stale scope.include glob left out. A nested
717
+ Python top's records come apart (`nestedRecords`): they count in the
718
+ scope sets, not in `all`. An extraction the per-file scan replaces
719
+ (`extraction.fallback.needed`) gives only its scope, its stale globs and
720
+ its `extraction` object: no name and no set."""
389
721
  if data.get("mode") != "full":
390
722
  raise InputError("--extraction is not skf-extract-public-api.py --mode full output")
723
+
724
+ # The scope: the one the extractor recorded, else the brief's.
725
+ recorded = data.get("scope") if isinstance(data.get("scope"), dict) else {}
726
+ scope = _scope(recorded.get("include"), recorded.get("exclude"), recorded.get("tier_a_include"))
727
+ if not any(scope.values()) and brief is not None:
728
+ scope = brief["scope"]
729
+ unmatched = _globs(recorded.get("unmatched_include"))
730
+ # A tier A glob that matches no file: the runner's list, never re-matched here.
731
+ unmatched_tier_a = _globs(recorded.get("unmatched_tier_a_include"))
732
+ extraction = _extraction_summary(data, language)
733
+ if extraction["fallback"]["needed"]:
734
+ return {"records": [], "nestedRecords": [], "sets": {}, "outside": [], "nested": [],
735
+ "staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
736
+ "restored": []},
737
+ "gaps": [], "scope": scope, "scopeType": recorded.get("type"), "umbrella": dict(NO_UMBRELLA),
738
+ "extraction": extraction}
739
+
391
740
  diff = data.get("entry_point_diff") if isinstance(data.get("entry_point_diff"), dict) else {}
392
- public = [p for p in diff.get("public") or [] 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"])
393
747
  outside = [o for o in diff.get("outside_scope") or [] if isinstance(o, dict)]
394
748
  gaps = [g for g in diff.get("extraction_gaps") or [] if isinstance(g, dict)]
749
+
750
+ entry_points = data.get("entry_points") if isinstance(data.get("entry_points"), dict) else {}
751
+ entry_files = [e for e in entry_points.get("files") or [] if isinstance(e, dict)]
752
+ # A Python top nested in another that exports a name is no barrel: its
753
+ # names leave `all`, `root` and the umbrella ratio, and keep their
754
+ # records (so the scope sets a specific-modules or stratified brief
755
+ # counts) and their outside_scope and extraction_gaps rows.
756
+ nested = _nested_tops(entry_files, set(scope["include"]) | set(scope["tier_a_include"]),
757
+ {p.get("entry") for p in rows["public"]})
758
+ nested_names = {top: sorted({p["name"] for p in public if p.get("entry") == top}) for top in nested}
759
+
760
+ subpath_files = {e.get("file") for e in entry_files
761
+ if e.get("subpath") not in ROOT_SUBPATHS and "*" not in str(e.get("subpath"))}
762
+ root_files = {e.get("file") for e in entry_files if e.get("subpath") in ROOT_SUBPATHS} - set(nested)
763
+
764
+ # A scope.include glob that matches no file: the brief predates the
765
+ # source, so a root export defined in a file no glob covers comes back,
766
+ # unless the brief excludes or declined that file.
767
+ declined = [path for path, action in ((brief or {}).get("amendments") or {}).items()
768
+ if action in DECLINED_ACTIONS]
769
+ restored = [o for o in outside if unmatched and o.get("entry") in root_files
770
+ and isinstance(o.get("name"), str) and isinstance(o.get("file"), str) and o["file"]
771
+ and not any(glob_match(_norm(o["file"]), g)
772
+ for g in scope["include"] + scope["exclude"] + declined)]
773
+ restored_at = {(o["name"], o["file"]): o.get("line") for o in restored}
774
+ outside = [o for o in outside if (o.get("name"), o.get("file")) not in restored_at]
775
+
395
776
  outside_keys = {(o.get("name"), o.get("file")) for o in outside}
396
777
  index = {(e.get("export_name"), e.get("source_file")): e
397
778
  for e in data.get("exports") or [] if isinstance(e, dict)}
398
- records = []
779
+ records, nested_records = [], []
399
780
  for info in public:
400
781
  name, file = info["name"], info.get("file")
401
782
  if (name, file) in outside_keys:
402
783
  continue
784
+ if (name, file) in restored_at:
785
+ # No recipe reads a file out of scope, so its kind is unknown: the
786
+ # name carries the file and line of its outside_scope row.
787
+ records.append(_record(name, None, file, restored_at[(name, file)], None, "restored"))
788
+ continue
403
789
  # An aliased re-export (`export { Options as Config }`) is recorded
404
790
  # under the name its defining file gives it, `local`.
405
791
  found = index.get((info.get("local") or name, file)) or index.get((name, file))
406
792
  kind = found.get("export_type") if found else None
407
- records.append(_record(name, None if kind == "re-export" else kind, file,
408
- found.get("source_line") if found else info.get("line"),
409
- found.get("signature_line") if found else None, "extraction"))
793
+ (nested_records if info.get("entry") in nested else records).append(
794
+ _record(name, None if kind == "re-export" else kind, file,
795
+ found.get("source_line") if found else info.get("line"),
796
+ found.get("signature_line") if found else None, "extraction"))
410
797
 
411
- scope = data.get("scope") if isinstance(data.get("scope"), dict) else {}
412
-
413
- entry_points = data.get("entry_points") if isinstance(data.get("entry_points"), dict) else {}
414
- entry_files = [e for e in entry_points.get("files") or [] if isinstance(e, dict)]
415
- subpath_files = {e.get("file") for e in entry_files
416
- if e.get("subpath") not in ROOT_SUBPATHS and "*" not in str(e.get("subpath"))}
417
- root_files = {e.get("file") for e in entry_files if e.get("subpath") in ROOT_SUBPATHS}
418
- 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]
419
800
  reexported = [p for p in root_names if p.get("via") not in (None, "declaration")]
420
801
 
421
802
  sets = {}
422
803
  if subpath_files:
423
804
  sets["subpaths"] = sorted({p["name"] for p in public if p.get("entry") in subpath_files})
424
805
  if root_files:
425
- 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})
426
807
 
427
- counts = data.get("counts") if isinstance(data.get("counts"), dict) else {}
428
- arms = data.get("arms") if isinstance(data.get("arms"), dict) else {}
429
- without = data.get("files_without_recipes") if isinstance(data.get("files_without_recipes"), dict) else {}
430
- status = data.get("status")
431
- reason = None
432
- if status == "no-ast-grep":
433
- reason = "no ast-grep the runner can run"
434
- elif not data.get("files_in_scope"):
435
- reason = "no file in scope"
436
- elif without and not data.get("exports"):
437
- reason = f"no recipe reads the files in scope ({', '.join(sorted(without))})"
438
- elif without and language and language.strip().lower() not in RECIPE_LANGUAGES:
439
- reason = (f"no recipe reads the skill's language, {language} "
440
- f"({', '.join(sorted(without))} in scope)")
441
- read_by_eye = sorted({str(i["file"]) for i in data.get("file_issues") or []
442
- if isinstance(i, dict) and i.get("file") and i.get("issue") != "no-recipes"}
443
- | {str(e["first_file"]) for e in data.get("errors") or []
444
- if isinstance(e, dict) and e.get("first_file")})
445
808
  return {
446
809
  "records": records,
810
+ "nestedRecords": nested_records,
447
811
  "sets": sets,
448
- "outside": [{"name": o.get("name"), "file": o.get("file")} for o in outside],
812
+ "outside": [_outside_row(o, scope) for o in outside],
813
+ "nested": [{"entry": top, "within": nested[top], "names": nested_names[top]} for top in sorted(nested)],
814
+ "staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
815
+ "restored": [{"name": o["name"], "file": o["file"], "line": o.get("line")}
816
+ for o in sorted(restored, key=lambda o: (o["name"], o["file"]))]},
449
817
  "gaps": [{"name": g.get("name"), "file": g.get("file"), "line": g.get("line")} for g in gaps],
450
- "scope": _scope(scope.get("include"), scope.get("exclude"), scope.get("tier_a_include")),
818
+ "scope": scope,
819
+ "scopeType": recorded.get("type") if isinstance(recorded.get("type"), str) else None,
451
820
  "umbrella": {
452
821
  "entries": sorted(f for f in root_files if f),
453
822
  "names": len(root_names),
@@ -455,17 +824,7 @@ def from_extraction(data: dict, language: str | None = None) -> dict:
455
824
  "ratio": round2(len(reexported) / len(root_names)) if root_names else None,
456
825
  "umbrella": bool(root_names) and len(reexported) / len(root_names) > UMBRELLA_RATIO,
457
826
  },
458
- "extraction": {
459
- "status": status,
460
- "filesInScope": data.get("files_in_scope"),
461
- "truncated": data.get("truncated"),
462
- "effectiveDenominator": _count(counts.get("effective_denominator")),
463
- "effectiveDenominatorBasis": counts.get("effective_denominator_basis"),
464
- "exportsPublicApi": _count(counts.get("exports_public_api")),
465
- "arms": arms,
466
- "readByEye": read_by_eye,
467
- "fallback": {"needed": reason is not None, "reason": reason},
468
- },
827
+ "extraction": extraction,
469
828
  }
470
829
 
471
830
 
@@ -473,7 +832,7 @@ def from_quick(data: dict) -> list[dict]:
473
832
  exports = data.get("exports")
474
833
  if not isinstance(exports, list):
475
834
  raise InputError("--quick is not skf-extract-public-api.py --mode quick output (no exports list)")
476
- return [_record(e["name"], e.get("type"), e.get("source_file"), None, None, "quick")
835
+ return [_record(e["name"], e.get("type"), e.get("defined_in") or e.get("source_file"), None, None, "quick")
477
836
  for e in exports if isinstance(e, dict) and isinstance(e.get("name"), str) and e["name"]]
478
837
 
479
838
 
@@ -525,15 +884,74 @@ def fold_names(names: list[str], prefixes: list[str], keep: set[str],
525
884
  return variants, tally
526
885
 
527
886
 
528
- NO_UMBRELLA = {"entries": [], "names": 0, "reexported": 0, "ratio": None, "umbrella": False}
887
+ def _over(clause: str, sets: dict, name: str, step: int | None, rebuild: str | None = None) -> dict:
888
+ """The barrel branch over set `name`, the `all` set when the surface lacks it."""
889
+ present = name in sets
890
+ return {"clause": clause, "branch": "barrel", "surfaceSet": name if present else "all", "ladderStep": step,
891
+ "absentSet": None if present else name, "rebuild": rebuild}
892
+
893
+
894
+ def pick_denominator(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str | None, arms: dict,
895
+ scope_type: str | None, skill_type: str | None, monorepo: bool, pattern_reference: bool,
896
+ curated: bool, source_read: bool, set_trusted: bool = False) -> dict:
897
+ """The Source API Surface Definition's clause for a skill, with the branch
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}
906
+
907
+
908
+ def _pick(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str | None, arms: dict,
909
+ scope_type: str | None, skill_type: str | None, monorepo: bool, pattern_reference: bool,
910
+ curated: bool, source_read: bool) -> dict:
911
+ if skill_type == "stack":
912
+ return {"clause": "stack", "branch": "stack", "surfaceSet": None, "ladderStep": None, "absentSet": None,
913
+ "rebuild": None}
914
+ multi = bool(arms.get("multi_subpath_exports"))
915
+ if (not monorepo and barrel != "barrel" and scope_type not in NOT_PATTERN_REFERENCE
916
+ and not (multi and scope_type == "public-api")
917
+ and (pattern_reference or (barrel == "no-entry-point" and not multi))):
918
+ if "tier_a_include" in sets:
919
+ return _over("pattern-reference", sets, "tier_a_include", None)
920
+ # The canonicalized provenance map: a surface read from the source is built again from the map alone.
921
+ return _over("pattern-reference", sets, "all", None, "provenance-fold" if source_read else None)
922
+ if barrel == "empty-barrel":
923
+ return _over("empty-barrel", sets, "scope.include", None)
924
+ if monorepo and curated and scope_type != "full-library":
925
+ clause = "stratified-scope"
926
+ elif scope_type == "specific-modules" and not monorepo:
927
+ clause = "specific-modules"
928
+ elif multi and not monorepo and scope_type in ("full-library", "public-api"):
929
+ clause = "multi-entry"
930
+ else:
931
+ return _over("standard-barrel", sets, "all", None)
932
+ ladder = CLAUSE_LADDERS[clause]
933
+ for step, rung in enumerate(ladder, 1):
934
+ if rung != "scalar":
935
+ if rung in sets or step == len(ladder):
936
+ return _over(clause, sets, rung, step)
937
+ elif stats_eff:
938
+ # A stored 0 is no denominator: the next rung.
939
+ if deflation_fires:
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)
943
+ scored = next((name for name in ("tier_a_include", "scope.include") if name in sets), "all")
944
+ return {"clause": clause, "branch": "scalar", "surfaceSet": scored, "ladderStep": step,
945
+ "absentSet": None, "rebuild": None}
946
+ raise AssertionError(f"the {clause} ladder ends on a set")
529
947
 
530
948
 
531
949
  def surface(args: argparse.Namespace) -> dict:
532
950
  records: list[dict] = []
533
951
  sets: dict[str, list[str]] = {}
534
952
  warnings: list[str] = []
535
- out: dict = {"candidates": None, "guards": None, "state2": None, "canonical": None,
536
- "extraction": None, "extractionGaps": [], "excluded": {"outsideScope": []}}
953
+ out: dict = {"candidates": None, "guards": None, "denominator": None, "state2": None, "canonical": None,
954
+ "extraction": None, "extractionGaps": [], "excluded": {"outsideScope": [], "nestedEntries": []}}
537
955
 
538
956
  meta = _object(args.metadata, "--metadata") if args.metadata else None
539
957
  provenance = _object(args.provenance, "--provenance") if args.provenance else None
@@ -541,17 +959,23 @@ def surface(args: argparse.Namespace) -> dict:
541
959
  language = (brief or {}).get("language")
542
960
  if not language and meta is not None and isinstance(meta.get("language"), str):
543
961
  language = meta["language"]
962
+ stats = meta.get("stats") if isinstance(meta, dict) and isinstance(meta.get("stats"), dict) else {}
963
+ stats_eff = _count(stats.get("effective_denominator"))
544
964
 
545
965
  extracted = None
546
966
  if args.extraction:
547
- extracted = from_extraction(_object(args.extraction, "--extraction"), language)
967
+ data = _object(args.extraction, "--extraction")
968
+ extracted = from_extraction(data, language, brief)
548
969
  records += extracted["records"]
549
970
  sets.update(extracted["sets"])
550
971
  out["extraction"] = extracted["extraction"]
551
972
  out["extractionGaps"] = extracted["gaps"]
552
- out["excluded"]["outsideScope"] = extracted["outside"]
973
+ out["excluded"] = {"outsideScope": extracted["outside"], "nestedEntries": extracted["nested"]}
974
+ warnings += [f"{Path(args.extraction).name}: {w}" for w in data.get("warnings") or [] if isinstance(w, str)]
975
+ quick_outputs = []
553
976
  for path in args.quick or []:
554
977
  data = _object(path, "--quick")
978
+ quick_outputs.append(data)
555
979
  records += from_quick(data)
556
980
  warnings += [f"{Path(path).name}: {w}" for w in data.get("warnings") or [] if isinstance(w, str)]
557
981
  violations: list[str] = []
@@ -574,11 +998,15 @@ def surface(args: argparse.Namespace) -> dict:
574
998
  exports = meta.get("exports") if meta is not None else None
575
999
  meta_names = sorted({n for n in (_export_name(e) for e in exports) if n}) if isinstance(exports, list) else []
576
1000
  base_of: dict[str, str] = {}
1001
+ # A map whose raw count is the stored effective_denominator is already canonical: no fold.
1002
+ skipped = bool(args.fold) and provenance is not None and stats_eff == len(prov_names)
577
1003
  if args.fold:
578
1004
  # One fold over both lists, so a variant folded out of the map
579
1005
  # never comes back through metadata.json.
580
1006
  base_of, tally = fold_names(sorted(set(prov_names) | set(meta_names)), args.fold_prefix or [],
581
1007
  set(args.keep or []), tallied=set(prov_names))
1008
+ if skipped:
1009
+ base_of, tally = {}, dict.fromkeys(tally, 0)
582
1010
  if provenance is not None:
583
1011
  canonical = sorted({base_of.get(n, n) for n in prov_names})
584
1012
  out["canonical"] = {
@@ -586,10 +1014,14 @@ def surface(args: argparse.Namespace) -> dict:
586
1014
  "canonical": len(canonical),
587
1015
  "folded": tally,
588
1016
  "variants": {n: base_of[n] for n in prov_names if n in base_of},
1017
+ "skipped": skipped,
1018
+ "residual": None if stats_eff is None else len(canonical) - stats_eff,
589
1019
  "summary": (f"Provenance-map canonicalization: {len(prov_names)} raw entries → "
590
1020
  f"{len(canonical)} canonical bases ({len(prov_names) - len(canonical)} "
591
1021
  f"bookkeeping variants folded: _def×{tally['_def']}, _exact×{tally['_exact']}, "
592
- f"a11y_×{tally[FOLD_PREFIX]}, other×{tally['other']})"),
1022
+ f"a11y_×{tally[FOLD_PREFIX]}, other×{tally['other']})"
1023
+ + (": the raw count equals `stats.effective_denominator`, so the map is already "
1024
+ "canonical" if skipped else "")),
593
1025
  }
594
1026
  seen = set()
595
1027
  for e in named:
@@ -609,22 +1041,78 @@ def surface(args: argparse.Namespace) -> dict:
609
1041
  "metadataOnly": sorted(set(meta_bases) - seen),
610
1042
  }
611
1043
 
612
- records.sort(key=lambda r: (r["name"], str(r["file"] or ""), r["origin"]))
613
1044
  sets["all"] = sorted({r["name"] for r in records})
1045
+ # A nested Python top's records count in the scope sets, never in `all`.
1046
+ records += extracted["nestedRecords"] if extracted is not None else []
1047
+ records.sort(key=lambda r: (r["name"], str(r["file"] or ""), r["origin"]))
614
1048
 
615
1049
  # The scope: the one the extractor recorded, else the brief's.
616
1050
  scope = extracted["scope"] if extracted is not None and any(extracted["scope"].values()) else None
617
1051
  if scope is None:
618
1052
  scope = brief["scope"] if brief is not None else _scope(None, None, None)
1053
+ # A tier A glob the runner lists as matching no file counts no name, and
1054
+ # so does one whose folder or file differs from every scope.include
1055
+ # glob's before any `*` or `?` (`gen/**` beside `src/**`) or one a
1056
+ # scope.exclude glob covers segment by segment (`src/gen/**` beside an
1057
+ # exclude of `src/gen/**`), whatever the tree holds: the set comes from
1058
+ # the other globs, over the names whose file a scope.include glob matches
1059
+ # too (any file with none), so a restored name is in no set. A tier A
1060
+ # list that counts no name (every glob stale, or matching only excluded
1061
+ # files, files scope.include leaves out or files that define no name)
1062
+ # narrows nothing: there is no set.
1063
+ stale_tier_a = set(extracted["staleScope"]["unmatchedTierAInclude"]) if extracted is not None else set()
1064
+ tier_a = [g for g in scope["tier_a_include"] if g not in stale_tier_a
1065
+ and not (scope["include"] and all(_apart(g, i) for i in scope["include"]))
1066
+ and not any(_inside(g, e) for e in scope["exclude"])]
619
1067
  filed = [r for r in records if r["file"]]
620
1068
  if filed and scope["include"]:
621
1069
  sets["scope.include"] = matching(filed, scope["include"], scope["exclude"])
622
- if filed and scope["tier_a_include"]:
623
- sets["tier_a_include"] = matching(filed, scope["tier_a_include"], scope["exclude"])
1070
+ in_include = [r for r in filed if not scope["include"]
1071
+ or any(glob_match(_norm(r["file"]), g) for g in scope["include"])]
1072
+ tier_a_names = matching(in_include, tier_a, scope["exclude"]) if tier_a else []
1073
+ if tier_a_names:
1074
+ sets["tier_a_include"] = tier_a_names
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"])
624
1114
 
625
1115
  if extracted is not None or brief is not None or meta is not None:
626
- stats = meta.get("stats") if isinstance(meta, dict) and isinstance(meta.get("stats"), dict) else {}
627
- stats_eff = _count(stats.get("effective_denominator"))
628
1116
  include_union = len(sets["scope.include"]) if "scope.include" in sets else len(sets["all"])
629
1117
  extraction = extracted["extraction"] if extracted is not None else {}
630
1118
  out["candidates"] = {
@@ -636,12 +1124,34 @@ def surface(args: argparse.Namespace) -> dict:
636
1124
  "extractorEffectiveDenominator": extraction.get("effectiveDenominator"),
637
1125
  "extractorBasis": extraction.get("effectiveDenominatorBasis"),
638
1126
  }
639
- no_tier_a = not scope["tier_a_include"]
640
- deflation = {"applicable": source_read and bool(stats_eff), "effectiveDenominator": stats_eff,
641
- "rederived": include_union if source_read else None, "pct": None, "fires": False}
642
- if deflation["applicable"]:
643
- deflation["pct"] = round2((include_union - stats_eff) / stats_eff * 100)
644
- deflation["fires"] = no_tier_a and deflation["pct"] > DEFLATION_PCT
1127
+ # With an extraction or the fallback scan's results (each at the file
1128
+ # that defines its names), no tier A set is no tier A list; otherwise
1129
+ # the globs left in the list decide (a name read by eye at Quick tier
1130
+ # or State 4, and a quick name with no defined_in, carries its entry
1131
+ # file, not the file that defines it).
1132
+ no_tier_a = ("tier_a_include" not in sets if extracted is not None or args.fallback_scan
1133
+ else not tier_a)
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}
1144
+ if deflation["applicable"] and stats_eff:
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)
1148
+ elif deflation["applicable"]:
1149
+ # A stored 0 is deflation, not a denominator, once a name is re-derived.
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)
645
1155
  entry_count = len(_entries(provenance)) if provenance is not None else 0
646
1156
  inflation = {"applicable": source_read and no_tier_a and bool(scope["include"]) and entry_count > 0,
647
1157
  "scopeIncludeUnion": include_union if scope["include"] else None,
@@ -650,14 +1160,25 @@ def surface(args: argparse.Namespace) -> dict:
650
1160
  if inflation["applicable"]:
651
1161
  inflation["pct"] = round2((include_union - entry_count) / entry_count * 100)
652
1162
  inflation["fires"] = inflation["pct"] > INFLATION_PCT
1163
+ stale_scope = {"applicable": extracted is not None and bool(scope["include"] or scope["tier_a_include"]),
1164
+ "fires": False, "unmatchedInclude": [], "unmatchedTierAInclude": [], "restored": []}
1165
+ if stale_scope["applicable"]:
1166
+ stale_scope.update(extracted["staleScope"])
1167
+ stale_scope["fires"] = bool(stale_scope["unmatchedInclude"] or stale_scope["unmatchedTierAInclude"])
653
1168
  out["guards"] = {
654
1169
  "deflation": deflation,
655
1170
  "inflation": inflation,
1171
+ "staleScope": stale_scope,
1172
+ "tierAInclude": list(scope["tier_a_include"]),
656
1173
  "umbrella": extracted["umbrella"] if extracted is not None else dict(NO_UMBRELLA),
657
1174
  "thresholds": {"deflationPct": DEFLATION_PCT, "inflationPct": INFLATION_PCT,
658
1175
  "umbrellaRatio": UMBRELLA_RATIO},
659
1176
  }
660
1177
 
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)
1181
+
661
1182
  return {
662
1183
  "inputs": {
663
1184
  "extraction": args.extraction, "quick": args.quick or [], "perFile": args.per_file or [],
@@ -708,6 +1229,13 @@ def _build_parser() -> argparse.ArgumentParser:
708
1229
  p.add_argument("--extraction", help="skf-extract-public-api.py --mode full output")
709
1230
  p.add_argument("--quick", action="append", help="skf-extract-public-api.py --mode quick output (repeatable)")
710
1231
  p.add_argument("--per-file", action="append", help="a subagent per-file result (repeatable)")
1232
+ p.add_argument("--fallback-scan", action="store_true",
1233
+ help="the --per-file results are the fallback scan's, each at the file that defines its "
1234
+ "names: as with --extraction, no tier_a_include set is no tier A list, so the deflation "
1235
+ "and inflation guards run; without either, a tier_a_include glob keeps them off unless "
1236
+ "its folder or file differs from every scope.include glob's before any wildcard "
1237
+ "(gen/** beside src/**) or a scope.exclude glob covers it segment by segment "
1238
+ "(src/gen/** beside an exclude of src/gen/**)")
711
1239
  p.add_argument("--name", action="append", help="a name read by eye whose file is not known (repeatable)")
712
1240
  p.add_argument("--provenance", help="the provenance map: its named exports")
713
1241
  p.add_argument("--metadata", help="metadata.json: its exports[] names, stats and language")
@@ -715,6 +1243,17 @@ def _build_parser() -> argparse.ArgumentParser:
715
1243
  p.add_argument("--fold", action="store_true", help="fold the provenance and metadata bookkeeping variants")
716
1244
  p.add_argument("--fold-prefix", action="append", help="a renderer prefix to fold (repeatable)")
717
1245
  p.add_argument("--keep", action="append", help="a variant that is a real export (repeatable)")
1246
+ p.add_argument("--monorepo", action="store_true",
1247
+ help="the source is a monorepo (skf-detect-workspaces.py's is_monorepo)")
1248
+ p.add_argument("--pattern-reference-intent", action="store_true",
1249
+ help="the brief's scope.notes flags pattern-reference intent (your judgment)")
1250
+ p.add_argument("--curated-subset", action="store_true",
1251
+ help="with --monorepo: the brief's scope.include lists a curated subset rather than the full "
1252
+ "workspace (your judgment; an extraction's counts.curated_subset wins)")
1253
+ p.add_argument("--barrel", choices=tuple(BARREL_FLAG),
1254
+ help="the entry points as you read them, where no extraction or quick output tells: none (no "
1255
+ "barrel file at any recognized entry-point path), empty (the primary one exports nothing) "
1256
+ "or barrel")
718
1257
  p.add_argument("--output", help="also write the result to this file")
719
1258
  p.set_defaults(func=surface)
720
1259
  return parser