bmad-module-skill-forge 5.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/docs/agents.md +1 -1
- package/docs/architecture.md +3 -3
- package/docs/campaign.md +1 -1
- package/docs/troubleshooting.md +9 -3
- package/docs/verifying-a-skill.md +9 -9
- package/docs/workflows.md +4 -4
- package/package.json +1 -1
- package/src/knowledge/tool-resolution.md +6 -6
- package/src/shared/references/headless-gate-convention.md +4 -1
- package/src/shared/references/output-contract-schema.md +3 -3
- package/src/shared/references/pipeline-contracts.md +2 -1
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
- package/src/shared/scripts/skf-build-change-manifest.py +332 -54
- package/src/shared/scripts/skf-classify-changed-files.py +15 -10
- package/src/shared/scripts/skf-emit-result-envelope.py +58 -15
- package/src/shared/scripts/skf-extract-public-api.py +33 -13
- package/src/shared/scripts/skf-parse-gaps.py +3 -2
- package/src/shared/scripts/skf-verify-provenance-completeness.py +108 -11
- package/src/skf-analyze-source/SKILL.md +1 -0
- package/src/skf-analyze-source/references/generate-briefs.md +1 -1
- package/src/skf-analyze-source/references/headless-contract.md +1 -1
- package/src/skf-audit-skill/SKILL.md +2 -1
- package/src/skf-audit-skill/references/report.md +1 -1
- package/src/skf-brief-skill/SKILL.md +1 -0
- package/src/skf-brief-skill/references/invocation-contract.md +2 -2
- package/src/skf-campaign/SKILL.md +1 -0
- package/src/skf-campaign/references/campaign-contracts.md +2 -0
- package/src/skf-campaign/references/step-05-skill-loop.md +2 -2
- package/src/skf-campaign/references/step-10-export.md +3 -2
- package/src/skf-create-skill/SKILL.md +2 -1
- package/src/skf-create-skill/references/ccc-index-check.md +1 -1
- package/src/skf-create-skill/references/component-extraction.md +1 -1
- package/src/skf-create-skill/references/extract.md +1 -1
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +2 -2
- package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
- package/src/skf-create-skill/references/validate.md +1 -1
- package/src/skf-create-stack-skill/SKILL.md +1 -0
- package/src/skf-create-stack-skill/references/generate-output.md +1 -1
- package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
- package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
- package/src/skf-create-stack-skill/references/report.md +1 -1
- package/src/skf-drop-skill/SKILL.md +1 -0
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-drop-skill/references/report.md +1 -1
- package/src/skf-export-skill/SKILL.md +1 -0
- package/src/skf-export-skill/references/invocation-contract.md +1 -1
- package/src/skf-export-skill/references/summary.md +1 -1
- package/src/skf-forger/SKILL.md +1 -0
- package/src/skf-forger/references/pipeline-mode.md +2 -2
- package/src/skf-quick-skill/SKILL.md +2 -1
- package/src/skf-quick-skill/references/finalize.md +1 -1
- package/src/skf-quick-skill/references/halt-contract.md +2 -2
- package/src/skf-quick-skill/references/write-and-validate.md +1 -1
- package/src/skf-refine-architecture/SKILL.md +2 -1
- package/src/skf-refine-architecture/customize.toml +1 -1
- package/src/skf-refine-architecture/references/report.md +1 -1
- package/src/skf-rename-skill/SKILL.md +1 -0
- package/src/skf-rename-skill/references/invocation-contract.md +2 -2
- package/src/skf-setup/SKILL.md +1 -0
- package/src/skf-setup/references/ccc-index.md +1 -1
- package/src/skf-setup/references/invocation-contract.md +1 -1
- package/src/skf-test-skill/SKILL.md +1 -0
- package/src/skf-test-skill/assets/output-section-formats.md +4 -4
- package/src/skf-test-skill/references/coherence-check.md +8 -6
- package/src/skf-test-skill/references/coverage-check-branches.md +23 -3
- package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
- package/src/skf-test-skill/references/coverage-check.md +30 -43
- package/src/skf-test-skill/references/init.md +1 -1
- package/src/skf-test-skill/references/inventory-contract.md +27 -0
- package/src/skf-test-skill/references/invocation-contract.md +3 -3
- package/src/skf-test-skill/references/report.md +3 -3
- package/src/skf-test-skill/references/scoring-rules.md +6 -6
- package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
- package/src/skf-test-skill/references/source-access-protocol.md +12 -8
- package/src/skf-test-skill/references/step-hard-gate.md +1 -0
- package/src/skf-test-skill/scripts/build-result-context.py +20 -3
- package/src/skf-test-skill/scripts/gap-ledger.py +78 -13
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +345 -9
- package/src/skf-test-skill/scripts/locate-export-segments.py +591 -63
- package/src/skf-test-skill/scripts/score-signatures.py +939 -74
- package/src/skf-test-skill/scripts/validate-inventory.py +245 -29
- package/src/skf-update-skill/SKILL.md +1 -0
- package/src/skf-update-skill/references/gap-driven.md +28 -22
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +4 -4
- package/src/skf-update-skill/references/report.md +1 -1
- package/src/skf-update-skill/references/write.md +5 -4
- package/src/skf-verify-stack/SKILL.md +2 -1
|
@@ -9,7 +9,7 @@ coverage-check.md decides whether a bookkeeping variant is a real export,
|
|
|
9
9
|
and makes the judgments the denominator clause needs where no file holds
|
|
10
10
|
them (--pattern-reference-intent, and --curated-subset and --barrel where
|
|
11
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.
|
|
12
|
+
unions on the way runs here, so no count is made by hand. Four subcommands, each
|
|
13
13
|
reading files and writing one JSON object:
|
|
14
14
|
|
|
15
15
|
census docs-only detection. Counts the citations SKILL.md and each .md
|
|
@@ -18,6 +18,65 @@ reading files and writing one JSON object:
|
|
|
18
18
|
(`[QMD:` and `[DOC:` are counted and decide nothing). A skill is
|
|
19
19
|
docs-only when it cites `[EXT:` and no local form.
|
|
20
20
|
|
|
21
|
+
floor the documented inventory's floor (#790): every name SKILL.md
|
|
22
|
+
and each .md file directly in references/ (a subfolder is not
|
|
23
|
+
read) write in a fixed form, which validate-inventory.py --floor
|
|
24
|
+
refuses an inventory for leaving out. It owns names only: no
|
|
25
|
+
kind is mapped and no signature read. A name is a plain
|
|
26
|
+
identifier (`[A-Za-z_$][\\w$]*`) once a leading `new ` and
|
|
27
|
+
everything from the first `(` or `<` are cut, so `Meta<T>`,
|
|
28
|
+
`fetch(url): Promise<T>` and `new Client(opts)` give `Meta`,
|
|
29
|
+
`fetch` and `Client`. A dotted or `::` name gives none, so a
|
|
30
|
+
namespace-style skill such as zod (`z.object`) gets no table
|
|
31
|
+
floor. The forms, read outside the frontmatter and HTML
|
|
32
|
+
comments (a table row or heading inside fenced code gives none):
|
|
33
|
+
table-row each inline code span in the first cell of a
|
|
34
|
+
table row (cells split on an unescaped `|`)
|
|
35
|
+
whose header row's first cell is, in any case,
|
|
36
|
+
singular or plural and bare or wrapped in `**`
|
|
37
|
+
or `__`, Export, Symbol, Function, Class,
|
|
38
|
+
Interface, Type, Hook, Component, Helper,
|
|
39
|
+
Constant, Enum or API (a Name, Method, Prop,
|
|
40
|
+
Param, Import or Subpath table gives none). The
|
|
41
|
+
row's Kind cell, as written, is the name's
|
|
42
|
+
`kindLabel`: the skill's own label, never mapped
|
|
43
|
+
to a kind (the first in origin order when two
|
|
44
|
+
rows label the name differently; null with no
|
|
45
|
+
Kind column)
|
|
46
|
+
heading an ATX heading whose whole text is one code
|
|
47
|
+
span, as in ``### `Meta<T>` `` (a heading with
|
|
48
|
+
other words, ``## Testing with `mount` ``, gives
|
|
49
|
+
none)
|
|
50
|
+
declaration a column-0 public declaration in a fenced block
|
|
51
|
+
whose first non-blank line is a comment (`//`,
|
|
52
|
+
`#`, `/* */`, `--`, `<!-- -->` or `;`) holding an
|
|
53
|
+
`[AST:` or `[SRC:` citation, or naming a path one
|
|
54
|
+
such citation of the skill names (its `:12`,
|
|
55
|
+
`:L12` or `:12-30` left out). A citation counts
|
|
56
|
+
only with its `:L<n>` (`[AST:src/a.ts:L12]`, not
|
|
57
|
+
`[AST:src/a.ts]`), and one on the line above the
|
|
58
|
+
fence heads nothing. Comments and strings are
|
|
59
|
+
masked first. TS/JS (not Svelte, whose `export
|
|
60
|
+
let` is a prop): `export` then `function`,
|
|
61
|
+
`const`, `let`, `var`, `class`, `interface`,
|
|
62
|
+
`type` or `enum` (`declare`, `abstract` and
|
|
63
|
+
`async` may come between), or `export default
|
|
64
|
+
function` or `class` with a name; Python: `def`,
|
|
65
|
+
`async def` or `class`, a name not starting with
|
|
66
|
+
`_`; Rust: a `pub` (not `pub(crate)`) `fn`,
|
|
67
|
+
`struct`, `enum`, `trait`, `type`, `const` or
|
|
68
|
+
`static`, and a `macro_rules!` under
|
|
69
|
+
`#[macro_export]`; Go: `func`, `type`, `var` or
|
|
70
|
+
`const` with a capitalized name, each name of a
|
|
71
|
+
`type (`, `var (` or `const (` group too. A
|
|
72
|
+
block in another language, an untagged one or a
|
|
73
|
+
data block (json, yaml, text...) gives none.
|
|
74
|
+
Output: {"files": [...], "names": [{"name", "kindLabel",
|
|
75
|
+
"origins": [{"file", "line", "form"}]}], "summary": {"names",
|
|
76
|
+
"byForm"}}, names sorted, each one's origins in file order then
|
|
77
|
+
line, `byForm` counting the origins of each form; the same skill
|
|
78
|
+
gives the same bytes.
|
|
79
|
+
|
|
21
80
|
metadata the metadata.json and provenance-map counts. Cluster A
|
|
22
81
|
(`stats.exports_public_api`, the `exports[]` length) and Cluster
|
|
23
82
|
B (`stats.exports_documented`, the provenance entry names, the
|
|
@@ -54,7 +113,10 @@ reading files and writing one JSON object:
|
|
|
54
113
|
glob that matches it (`excludeGlob`) or no
|
|
55
114
|
scope.include glob covering it
|
|
56
115
|
(`outsideInclude`); exports[] only gives each name's
|
|
57
|
-
kind, line and signature line
|
|
116
|
+
kind, line and signature line (a local export
|
|
117
|
+
list's item: its binding's declaration_line and
|
|
118
|
+
declaration_signature_line, when the extractor
|
|
119
|
+
records them), looked up by the
|
|
58
120
|
name its defining file gives it (an aliased
|
|
59
121
|
re-export's `local`) and that file.
|
|
60
122
|
extraction_gaps are listed to read by eye, and
|
|
@@ -142,7 +204,13 @@ reconcile-coverage.py and score-signatures.py --surface-set pick one.
|
|
|
142
204
|
|
|
143
205
|
Candidates and guards (with an extraction, --brief or --metadata): each
|
|
144
206
|
set's count, the metadata `stats.effective_denominator` (--metadata), and
|
|
145
|
-
the extractor's own effective_denominator and its basis
|
|
207
|
+
the extractor's own effective_denominator and its basis; for the
|
|
208
|
+
Denominator annotations, `tierAIncludeFiles` and `scopeIncludeFiles` (the
|
|
209
|
+
distinct files the set's names sit at: the files that define them when
|
|
210
|
+
each name sits at its defining file) and `subpathEntries` (an extraction's
|
|
211
|
+
distinct in-scope non-root, non-wildcard subpaths, each package's subpath
|
|
212
|
+
once however many files it names), each null when its set is absent.
|
|
213
|
+
Both guards run
|
|
146
214
|
only on a surface read from the source. The deflation guard compares a
|
|
147
215
|
re-derived set with `stats.effective_denominator` and names it in `basis`.
|
|
148
216
|
It is the tier_a_include set (`tier_a_include`) when the clause's ladder
|
|
@@ -253,6 +321,7 @@ a stylesheet, holds nothing to read and is in neither list.
|
|
|
253
321
|
|
|
254
322
|
Usage:
|
|
255
323
|
uv run load-coverage-inputs.py census --skill-dir <dir> [--output <file>]
|
|
324
|
+
uv run load-coverage-inputs.py floor --skill-dir <dir> [--output <file>]
|
|
256
325
|
uv run load-coverage-inputs.py metadata --metadata <metadata.json> \\
|
|
257
326
|
[--provenance <provenance-map.json>] [--output <file>]
|
|
258
327
|
uv run load-coverage-inputs.py surface [--extraction <file>] [--quick <file>]... \\
|
|
@@ -264,7 +333,8 @@ Usage:
|
|
|
264
333
|
Exit codes:
|
|
265
334
|
0 the result was printed (and written to --output)
|
|
266
335
|
1 an input file is missing, unreadable or not the JSON (or the YAML
|
|
267
|
-
brief) it should be
|
|
336
|
+
brief) it should be, or the --skill-dir holds no SKILL.md or a skill
|
|
337
|
+
file that is not UTF-8 (one line on stderr)
|
|
268
338
|
2 a per-file result breaks the schema: {"valid": false, "violations":
|
|
269
339
|
[...]} names each breach and nothing is written to --output; or a
|
|
270
340
|
usage error (argparse)
|
|
@@ -294,6 +364,40 @@ CITATION_FORMS = {
|
|
|
294
364
|
"DOC": "[DOC:",
|
|
295
365
|
}
|
|
296
366
|
LOCAL_FORMS = ("AST", "SRC", "from skill")
|
|
367
|
+
# floor: the forms a documented name takes, in the order an origin on one line sorts by.
|
|
368
|
+
FLOOR_FORMS = ("table-row", "heading", "declaration")
|
|
369
|
+
# The first header cells (lower case, singular or plural) of a table whose first column names exports.
|
|
370
|
+
FLOOR_TABLE_HEADERS = frozenset({
|
|
371
|
+
"export", "exports", "symbol", "symbols", "function", "functions", "class", "classes", "interface", "interfaces",
|
|
372
|
+
"type", "types", "hook", "hooks", "component", "components", "helper", "helpers", "constant", "constants",
|
|
373
|
+
"enum", "enums", "api", "apis"})
|
|
374
|
+
# A header cell wrapped in `**` or `__` (group 2 its text).
|
|
375
|
+
EMPHASIS_RE = re.compile(r"(\*\*|__)(.+)\1")
|
|
376
|
+
PLAIN_IDENTIFIER_RE = re.compile(r"[A-Za-z_$][\w$]*")
|
|
377
|
+
TABLE_CELL_RE = re.compile(r"(?<!\\)\|")
|
|
378
|
+
# A comment's opening marker: `//`, `#`, `/*`, `--`, `<!--` or `;`.
|
|
379
|
+
COMMENT_MARKER_RE = re.compile(r"[ \t]*(?://+|#+|/\*+|--|<!--|;+)")
|
|
380
|
+
# A cited path's line suffix: `:12`, `:L12`, `:12-30` or `:L12-L30`.
|
|
381
|
+
LINE_SUFFIX_RE = re.compile(r":L?\d+(?:-L?\d+)?$")
|
|
382
|
+
PYTHON_LANGUAGES = frozenset({"python", "py", "python3"})
|
|
383
|
+
RUST_LANGUAGES = frozenset({"rust", "rs"})
|
|
384
|
+
GO_LANGUAGES = frozenset({"go", "golang"})
|
|
385
|
+
# Column-0 public declarations (group 1 the name), matched on the block with comments and strings masked.
|
|
386
|
+
JS_DECLARATION_RE = re.compile(
|
|
387
|
+
r"export\s+(?:default\s+(?:abstract\s+)?(?:async\s+)?(?:function(?:\s*\*\s*|\s+)|class\s+)"
|
|
388
|
+
r"|(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?"
|
|
389
|
+
r"(?:function(?:\s*\*\s*|\s+)|const\s+enum\s+|const\s+|let\s+|var\s+|class\s+|interface\s+|type\s+|enum\s+))"
|
|
390
|
+
r"(?!(?:extends|implements)(?![\w$]))([A-Za-z_$][\w$]*)")
|
|
391
|
+
PYTHON_DECLARATION_RE = re.compile(r"(?:async\s+def|def|class)\s+([A-Za-z_]\w*)")
|
|
392
|
+
RUST_DECLARATION_RE = re.compile(
|
|
393
|
+
r"pub\s+(?:(?:async|unsafe|const|extern)\s+)*(?:fn|struct|enum|trait|type|const|static)\s+(?:mut\s+)?"
|
|
394
|
+
r"([A-Za-z_]\w*)")
|
|
395
|
+
RUST_MACRO_EXPORT_RE = re.compile(r"#\[macro_export\]")
|
|
396
|
+
RUST_MACRO_RE = re.compile(r"(?:#\[[^\]]*\][ \t]*)*macro_rules![ \t]*([A-Za-z_]\w*)")
|
|
397
|
+
# Go: a single declaration (group 1 its keyword, group 2 its names) or a group's opening line (group 1).
|
|
398
|
+
GO_DECLARATION_RE = re.compile(r"(func|type|var|const)[ \t]+([A-Za-z_]\w*(?:[ \t]*,[ \t]*[A-Za-z_]\w*)*)")
|
|
399
|
+
GO_GROUP_RE = re.compile(r"(type|var|const)[ \t]*\([ \t]*$")
|
|
400
|
+
GO_GROUP_NAMES_RE = re.compile(r"[ \t]+([A-Za-z_]\w*(?:[ \t]*,[ \t]*[A-Za-z_]\w*)*)")
|
|
297
401
|
# Bookkeeping variants folded to their base name, in rule order.
|
|
298
402
|
FOLD_SUFFIXES = ("_def", "_exact")
|
|
299
403
|
FOLD_PREFIX = "a11y_"
|
|
@@ -334,7 +438,9 @@ SHARED_SCRIPTS = Path(__file__).resolve().parent.parent.parent / "shared" / "scr
|
|
|
334
438
|
|
|
335
439
|
def _sibling(filename: str, folder: Path | None = None):
|
|
336
440
|
"""A script beside this one, loaded once: validate-inventory.py (the doc
|
|
337
|
-
text
|
|
441
|
+
text, and the skill citations floor reads), locate-export-segments.py
|
|
442
|
+
(the line model floor reads the skill with) and score-signatures.py (the
|
|
443
|
+
per-file result schema); or, with
|
|
338
444
|
`folder`, one in that folder: SHARED_SCRIPTS'
|
|
339
445
|
skf-resolve-authoritative-files.py (a skill brief's YAML)."""
|
|
340
446
|
module = _SIBLINGS.get(filename)
|
|
@@ -421,6 +527,199 @@ def census(skill_dir: str) -> dict:
|
|
|
421
527
|
}
|
|
422
528
|
|
|
423
529
|
|
|
530
|
+
# --------------------------------------------------------------------------
|
|
531
|
+
# floor
|
|
532
|
+
# --------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _floor_name(text: str) -> str | None:
|
|
536
|
+
"""The plain identifier `text` writes once a leading `new ` and everything from the first `(` or `<` are
|
|
537
|
+
cut; None for any other text, a dotted or `::` name among them."""
|
|
538
|
+
text = text.strip()
|
|
539
|
+
if text.startswith("new "):
|
|
540
|
+
text = text[4:]
|
|
541
|
+
text = re.split(r"[(<]", text, maxsplit=1)[0].strip()
|
|
542
|
+
return text if PLAIN_IDENTIFIER_RE.fullmatch(text) else None
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _header_word(cell: str) -> str:
|
|
546
|
+
"""A header cell's word, lower case, unwrapped from `**` or `__`."""
|
|
547
|
+
m = EMPHASIS_RE.fullmatch(cell)
|
|
548
|
+
return (m.group(2) if m else cell).strip().lower()
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _cells(row: str) -> list[str]:
|
|
552
|
+
"""A table row's cells, split on each unescaped `|`, each stripped."""
|
|
553
|
+
row = row.strip()
|
|
554
|
+
if row.startswith("|"):
|
|
555
|
+
row = row[1:]
|
|
556
|
+
if row.endswith("|") and not row.endswith("\\|"):
|
|
557
|
+
row = row[:-1]
|
|
558
|
+
return [cell.strip() for cell in TABLE_CELL_RE.split(row)]
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _whole_span(text: str, segments) -> str | None:
|
|
562
|
+
"""The code span `text` is, when the whole of it is one; else None."""
|
|
563
|
+
spans = segments._inline_code(text)
|
|
564
|
+
if len(spans) != 1:
|
|
565
|
+
return None
|
|
566
|
+
run = len(text) - len(text.lstrip("`"))
|
|
567
|
+
return spans[0] if text == "`" * run + spans[0] + "`" * run else None
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _comment_text(line: str) -> str | None:
|
|
571
|
+
"""A comment line's text, its markers stripped (`// x`, `# x`, `/* x */`, `-- x`, `<!-- x -->`, `; x`);
|
|
572
|
+
None for a line that is no comment. Linear in the line's length."""
|
|
573
|
+
m = COMMENT_MARKER_RE.match(line)
|
|
574
|
+
if not m:
|
|
575
|
+
return None
|
|
576
|
+
text = line[m.end():].strip()
|
|
577
|
+
if text.endswith("-->"):
|
|
578
|
+
text = text[:-3]
|
|
579
|
+
elif text.endswith("*/"):
|
|
580
|
+
text = text[:-2].rstrip("*")
|
|
581
|
+
return text.strip()
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _headed(lines: list[str], opening: int, closing: int, cited: set[str], inventory) -> bool:
|
|
585
|
+
"""Whether a source citation heads the fenced block opening at `opening`: its first non-blank line is a
|
|
586
|
+
comment holding a citation, or naming a path the skill cites (a `:12`, `:L12` or `:12-30` suffix left
|
|
587
|
+
out). A citation on the line above the fence heads nothing."""
|
|
588
|
+
first = next((lines[i] for i in range(opening + 1, closing) if lines[i].strip()), None)
|
|
589
|
+
text = _comment_text(first) if first is not None else None
|
|
590
|
+
if not text:
|
|
591
|
+
return False
|
|
592
|
+
if inventory._SKILL_CITATION_RE.search(text):
|
|
593
|
+
return True
|
|
594
|
+
suffix = LINE_SUFFIX_RE.search(text)
|
|
595
|
+
path = text[:suffix.start()] if suffix else text
|
|
596
|
+
return bool(path) and not any(c.isspace() for c in path) and inventory._cited_path(path) in cited
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _html_comment_lines(lines: list[str], start: int, code: list[bool]) -> set[int]:
|
|
600
|
+
"""The lines inside an HTML comment, the fenced lines it holds included, read as _prose reads one: a line
|
|
601
|
+
outside fenced code that starts `<!--` opens it, the next such line holding `-->` closes it."""
|
|
602
|
+
inside, open_ = set(), False
|
|
603
|
+
for i in range(start, len(lines)):
|
|
604
|
+
if open_:
|
|
605
|
+
inside.add(i)
|
|
606
|
+
open_ = code[i] or "-->" not in lines[i]
|
|
607
|
+
elif not code[i] and lines[i].strip().startswith("<!--"):
|
|
608
|
+
inside.add(i)
|
|
609
|
+
open_ = "-->" not in lines[i].strip()[4:]
|
|
610
|
+
return inside
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _names(text: str) -> list[str]:
|
|
614
|
+
return [name.strip() for name in text.split(",")]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _declared_names(lines: list[str], language: str, js_family) -> list[tuple[int, str]]:
|
|
618
|
+
"""(offset, name) of each column-0 public declaration in a block's masked lines, by its language."""
|
|
619
|
+
found: list[tuple[int, str]] = []
|
|
620
|
+
if language in js_family:
|
|
621
|
+
found = [(i, m.group(1)) for i, line in enumerate(lines) if (m := JS_DECLARATION_RE.match(line))]
|
|
622
|
+
elif language in PYTHON_LANGUAGES:
|
|
623
|
+
found = [(i, m.group(1)) for i, line in enumerate(lines)
|
|
624
|
+
if (m := PYTHON_DECLARATION_RE.match(line)) and not m.group(1).startswith("_")]
|
|
625
|
+
elif language in RUST_LANGUAGES:
|
|
626
|
+
exported = False # a `#[macro_export]` above, with only blank or attribute lines between
|
|
627
|
+
for i, line in enumerate(lines):
|
|
628
|
+
if m := RUST_DECLARATION_RE.match(line):
|
|
629
|
+
found.append((i, m.group(1)))
|
|
630
|
+
exported = exported or bool(RUST_MACRO_EXPORT_RE.match(line))
|
|
631
|
+
macro = RUST_MACRO_RE.match(line)
|
|
632
|
+
if macro and exported:
|
|
633
|
+
found.append((i, macro.group(1)))
|
|
634
|
+
if macro or (line.strip() and not line.startswith("#[")):
|
|
635
|
+
exported = False
|
|
636
|
+
elif language in GO_LANGUAGES:
|
|
637
|
+
group, depth = False, 0 # inside a `type (`, `var (` or `const (` group, and its bracket depth there
|
|
638
|
+
for i, line in enumerate(lines):
|
|
639
|
+
if group:
|
|
640
|
+
if depth == 0 and line.lstrip().startswith(")"):
|
|
641
|
+
group = False
|
|
642
|
+
continue
|
|
643
|
+
if depth == 0 and (m := GO_GROUP_NAMES_RE.match(line)):
|
|
644
|
+
found += [(i, name) for name in _names(m.group(1)) if name[0].isupper()]
|
|
645
|
+
depth = max(0, depth + sum(map(line.count, "{([")) - sum(map(line.count, "})]")))
|
|
646
|
+
elif GO_GROUP_RE.match(line):
|
|
647
|
+
group, depth = True, 0
|
|
648
|
+
elif m := GO_DECLARATION_RE.match(line):
|
|
649
|
+
found += [(i, name) for name in _names(m.group(2)) if name[0].isupper()]
|
|
650
|
+
return found
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def floor(skill_dir: str) -> dict:
|
|
654
|
+
"""The names the skill documents in a fixed form, each with its origins (see the module docstring)."""
|
|
655
|
+
root = Path(skill_dir)
|
|
656
|
+
if not (root / "SKILL.md").is_file():
|
|
657
|
+
raise InputError(f"--skill-dir {skill_dir} holds no SKILL.md")
|
|
658
|
+
segments = _sibling("locate-export-segments.py")
|
|
659
|
+
inventory = _sibling("validate-inventory.py")
|
|
660
|
+
refs = root / "references"
|
|
661
|
+
files = ["SKILL.md"] + ([f"references/{p.name}" for p in sorted(refs.glob("*.md")) if p.is_file()]
|
|
662
|
+
if refs.is_dir() else [])
|
|
663
|
+
texts = []
|
|
664
|
+
for rel in files:
|
|
665
|
+
try:
|
|
666
|
+
texts.append(segments._split_lines((root / rel).read_bytes().decode("utf-8-sig")))
|
|
667
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
668
|
+
raise InputError(f"cannot read {rel} under {skill_dir}: {exc}") from exc
|
|
669
|
+
cited = {inventory._cited_path(m.group(2))
|
|
670
|
+
for lines in texts for line in lines for m in inventory._SKILL_CITATION_RE.finditer(line)}
|
|
671
|
+
|
|
672
|
+
found: dict[str, set[tuple[int, int, str, str | None]]] = {}
|
|
673
|
+
|
|
674
|
+
def add(name: str | None, file_index: int, index: int, form: str, label: str | None = None) -> None:
|
|
675
|
+
if name:
|
|
676
|
+
found.setdefault(name, set()).add((file_index, index + 1, form, label))
|
|
677
|
+
|
|
678
|
+
js_family = segments.JS_FAMILY - {"svelte"} # a Svelte block's `export let` is a component prop
|
|
679
|
+
for file_index, lines in enumerate(texts):
|
|
680
|
+
start = segments._frontmatter_end(lines)
|
|
681
|
+
blocks, code = segments._fenced_blocks(lines, start)
|
|
682
|
+
headings, prose = segments._prose(lines, start, code)
|
|
683
|
+
for block in prose:
|
|
684
|
+
if block["kind"] != "table-row" or block["header"] is None:
|
|
685
|
+
continue
|
|
686
|
+
header = _cells(block["header"])
|
|
687
|
+
if _header_word(header[0]) not in FLOOR_TABLE_HEADERS:
|
|
688
|
+
continue
|
|
689
|
+
row = _cells(lines[block["start"]])
|
|
690
|
+
kind_at = next((i for i, cell in enumerate(header) if _header_word(cell) == "kind"), None)
|
|
691
|
+
label = row[kind_at] if kind_at is not None and kind_at < len(row) and row[kind_at] else None
|
|
692
|
+
for span in segments._inline_code(row[0]):
|
|
693
|
+
add(_floor_name(span), file_index, block["start"], "table-row", label)
|
|
694
|
+
for index, _level, text, _above in headings:
|
|
695
|
+
span = _whole_span(text, segments)
|
|
696
|
+
if span is not None:
|
|
697
|
+
add(_floor_name(span), file_index, index, "heading")
|
|
698
|
+
comments = _html_comment_lines(lines, start, code)
|
|
699
|
+
for opening, closing, info in blocks:
|
|
700
|
+
language = segments._language(info)
|
|
701
|
+
if (opening in comments or language in segments.DATA_LANGUAGES
|
|
702
|
+
or not _headed(lines, opening, closing, cited, inventory)):
|
|
703
|
+
continue
|
|
704
|
+
masked = segments.mask_code("\n".join(lines[opening + 1:closing]), language).split("\n")
|
|
705
|
+
for offset, name in _declared_names(masked, language, js_family):
|
|
706
|
+
add(name, file_index, opening + 1 + offset, "declaration")
|
|
707
|
+
|
|
708
|
+
names = []
|
|
709
|
+
by_form = dict.fromkeys(FLOOR_FORMS, 0)
|
|
710
|
+
for name in sorted(found):
|
|
711
|
+
origins = sorted(found[name], key=lambda o: (o[0], o[1], FLOOR_FORMS.index(o[2]), o[3] or ""))
|
|
712
|
+
seen, kept = set(), []
|
|
713
|
+
for file_index, line, form, _label in origins:
|
|
714
|
+
if (file_index, line, form) not in seen:
|
|
715
|
+
seen.add((file_index, line, form))
|
|
716
|
+
kept.append({"file": files[file_index], "line": line, "form": form})
|
|
717
|
+
by_form[form] += 1
|
|
718
|
+
label = next((o[3] for o in origins if o[2] == "table-row" and o[3]), None)
|
|
719
|
+
names.append({"name": name, "kindLabel": label, "origins": kept})
|
|
720
|
+
return {"files": files, "names": names, "summary": {"names": len(names), "byForm": by_form}}
|
|
721
|
+
|
|
722
|
+
|
|
424
723
|
# --------------------------------------------------------------------------
|
|
425
724
|
# metadata
|
|
426
725
|
# --------------------------------------------------------------------------
|
|
@@ -577,6 +876,14 @@ def matching(records: list[dict], globs: list[str], excludes: list[str]) -> list
|
|
|
577
876
|
and not any(glob_match(_norm(r["file"]), g) for g in excludes)})
|
|
578
877
|
|
|
579
878
|
|
|
879
|
+
def matching_files(records: list[dict], globs: list[str], excludes: list[str]) -> list[str]:
|
|
880
|
+
"""The distinct files `matching` takes its names from: each record's file one of `globs` matches and no
|
|
881
|
+
exclude glob does."""
|
|
882
|
+
return sorted({r["file"] for r in records if r.get("file")
|
|
883
|
+
and any(glob_match(_norm(r["file"]), g) for g in globs)
|
|
884
|
+
and not any(glob_match(_norm(r["file"]), g) for g in excludes)})
|
|
885
|
+
|
|
886
|
+
|
|
580
887
|
def _apart(a: str, b: str) -> bool:
|
|
581
888
|
"""True when, before either has a `*` or `?`, the two globs spell a different segment at the same
|
|
582
889
|
place, so no file can match both. Any other pair counts as overlapping, even one no file can match
|
|
@@ -735,7 +1042,7 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
|
|
|
735
1042
|
"staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
|
|
736
1043
|
"restored": []},
|
|
737
1044
|
"gaps": [], "scope": scope, "scopeType": recorded.get("type"), "umbrella": dict(NO_UMBRELLA),
|
|
738
|
-
"extraction": extraction}
|
|
1045
|
+
"subpathEntries": None, "extraction": extraction}
|
|
739
1046
|
|
|
740
1047
|
diff = data.get("entry_point_diff") if isinstance(data.get("entry_point_diff"), dict) else {}
|
|
741
1048
|
# Each in-scope definition of a public name is a record of its own (#830): the runner's other
|
|
@@ -760,6 +1067,11 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
|
|
|
760
1067
|
subpath_files = {e.get("file") for e in entry_files
|
|
761
1068
|
if e.get("subpath") not in ROOT_SUBPATHS and "*" not in str(e.get("subpath"))}
|
|
762
1069
|
root_files = {e.get("file") for e in entry_files if e.get("subpath") in ROOT_SUBPATHS} - set(nested)
|
|
1070
|
+
# The distinct in-scope subpaths of those (a package's subpath once, however many files its conditions
|
|
1071
|
+
# name), for the multi-entry annotation.
|
|
1072
|
+
subpath_entries = {(e.get("package"), e.get("subpath")) for e in entry_files
|
|
1073
|
+
if e.get("subpath") not in ROOT_SUBPATHS and "*" not in str(e.get("subpath"))
|
|
1074
|
+
and e.get("in_scope") is not False}
|
|
763
1075
|
|
|
764
1076
|
# A scope.include glob that matches no file: the brief predates the
|
|
765
1077
|
# source, so a root export defined in a file no glob covers comes back,
|
|
@@ -790,10 +1102,16 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
|
|
|
790
1102
|
# under the name its defining file gives it, `local`.
|
|
791
1103
|
found = index.get((info.get("local") or name, file)) or index.get((name, file))
|
|
792
1104
|
kind = found.get("export_type") if found else None
|
|
1105
|
+
# A local `export { ... }` list's item is planned at the declaration it
|
|
1106
|
+
# names (#836): the line and the signature line both come from that pair.
|
|
1107
|
+
if found and isinstance(found.get("declaration_line"), int):
|
|
1108
|
+
line, signature_line = found["declaration_line"], found.get("declaration_signature_line")
|
|
1109
|
+
elif found:
|
|
1110
|
+
line, signature_line = found.get("source_line"), found.get("signature_line")
|
|
1111
|
+
else:
|
|
1112
|
+
line, signature_line = info.get("line"), None
|
|
793
1113
|
(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"))
|
|
1114
|
+
_record(name, None if kind == "re-export" else kind, file, line, signature_line, "extraction"))
|
|
797
1115
|
|
|
798
1116
|
# The umbrella ratio reads the public rows alone: an other definition is no root barrel name there.
|
|
799
1117
|
root_names = [p for p in _members_in_place(rows["public"]) if p.get("entry") in root_files]
|
|
@@ -824,6 +1142,8 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
|
|
|
824
1142
|
"ratio": round2(len(reexported) / len(root_names)) if root_names else None,
|
|
825
1143
|
"umbrella": bool(root_names) and len(reexported) / len(root_names) > UMBRELLA_RATIO,
|
|
826
1144
|
},
|
|
1145
|
+
# The in-scope subpaths the `subpaths` set reads (None when there is no such set).
|
|
1146
|
+
"subpathEntries": len(subpath_entries) if subpath_files else None,
|
|
827
1147
|
"extraction": extraction,
|
|
828
1148
|
}
|
|
829
1149
|
|
|
@@ -1065,13 +1385,17 @@ def surface(args: argparse.Namespace) -> dict:
|
|
|
1065
1385
|
and not (scope["include"] and all(_apart(g, i) for i in scope["include"]))
|
|
1066
1386
|
and not any(_inside(g, e) for e in scope["exclude"])]
|
|
1067
1387
|
filed = [r for r in records if r["file"]]
|
|
1388
|
+
# The files each scope set's names sit at, for the Denominator annotations' file counts.
|
|
1389
|
+
set_files: dict[str, list[str]] = {}
|
|
1068
1390
|
if filed and scope["include"]:
|
|
1069
1391
|
sets["scope.include"] = matching(filed, scope["include"], scope["exclude"])
|
|
1392
|
+
set_files["scope.include"] = matching_files(filed, scope["include"], scope["exclude"])
|
|
1070
1393
|
in_include = [r for r in filed if not scope["include"]
|
|
1071
1394
|
or any(glob_match(_norm(r["file"]), g) for g in scope["include"])]
|
|
1072
1395
|
tier_a_names = matching(in_include, tier_a, scope["exclude"]) if tier_a else []
|
|
1073
1396
|
if tier_a_names:
|
|
1074
1397
|
sets["tier_a_include"] = tier_a_names
|
|
1398
|
+
set_files["tier_a_include"] = matching_files(in_include, tier_a, scope["exclude"])
|
|
1075
1399
|
|
|
1076
1400
|
# The clause: an extraction's entry-point status (one the per-file scan replaces says only that it found no
|
|
1077
1401
|
# entry point), else a quick output's (an export is a barrel; none, and no warning, an empty one), else what
|
|
@@ -1119,7 +1443,10 @@ def surface(args: argparse.Namespace) -> dict:
|
|
|
1119
1443
|
"statsEffectiveDenominator": stats_eff,
|
|
1120
1444
|
"tierAIncludeUnion": len(sets["tier_a_include"]) if "tier_a_include" in sets else None,
|
|
1121
1445
|
"scopeIncludeUnion": len(sets["scope.include"]) if "scope.include" in sets else None,
|
|
1446
|
+
"tierAIncludeFiles": len(set_files["tier_a_include"]) if "tier_a_include" in sets else None,
|
|
1447
|
+
"scopeIncludeFiles": len(set_files["scope.include"]) if "scope.include" in sets else None,
|
|
1122
1448
|
"subpathUnion": len(sets["subpaths"]) if "subpaths" in sets else None,
|
|
1449
|
+
"subpathEntries": extracted["subpathEntries"] if extracted is not None else None,
|
|
1123
1450
|
"rootBarrel": len(sets["root"]) if "root" in sets else None,
|
|
1124
1451
|
"extractorEffectiveDenominator": extraction.get("effectiveDenominator"),
|
|
1125
1452
|
"extractorBasis": extraction.get("effectiveDenominatorBasis"),
|
|
@@ -1201,6 +1528,10 @@ def _cmd_census(args: argparse.Namespace) -> dict:
|
|
|
1201
1528
|
return census(args.skill_dir)
|
|
1202
1529
|
|
|
1203
1530
|
|
|
1531
|
+
def _cmd_floor(args: argparse.Namespace) -> dict:
|
|
1532
|
+
return floor(args.skill_dir)
|
|
1533
|
+
|
|
1534
|
+
|
|
1204
1535
|
def _cmd_metadata(args: argparse.Namespace) -> dict:
|
|
1205
1536
|
meta = _object(args.metadata, "--metadata")
|
|
1206
1537
|
provenance = _object(args.provenance, "--provenance") if args.provenance else None
|
|
@@ -1219,6 +1550,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
1219
1550
|
p.add_argument("--output", help="also write the result to this file")
|
|
1220
1551
|
p.set_defaults(func=_cmd_census)
|
|
1221
1552
|
|
|
1553
|
+
p = sub.add_parser("floor", help="the names the skill documents in a fixed form, which the inventory keeps")
|
|
1554
|
+
p.add_argument("--skill-dir", required=True, help="the skill package: SKILL.md and references/")
|
|
1555
|
+
p.add_argument("--output", help="also write the result to this file")
|
|
1556
|
+
p.set_defaults(func=_cmd_floor)
|
|
1557
|
+
|
|
1222
1558
|
p = sub.add_parser("metadata", help="the metadata.json and provenance-map counts")
|
|
1223
1559
|
p.add_argument("--metadata", required=True, help="the skill's metadata.json")
|
|
1224
1560
|
p.add_argument("--provenance", help="the provenance map, when there is one")
|