bmad-module-skill-forge 3.0.0 → 4.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 (93) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/examples.md +1 -1
  4. package/docs/troubleshooting.md +8 -0
  5. package/docs/verifying-a-skill.md +7 -3
  6. package/docs/workflows.md +3 -3
  7. package/package.json +1 -1
  8. package/src/knowledge/qmd-registry.md +1 -1
  9. package/src/knowledge/version-paths.md +1 -1
  10. package/src/shared/health-check.md +2 -50
  11. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
  12. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +2 -2
  13. package/src/shared/scripts/skf-atomic-write.py +212 -59
  14. package/src/shared/scripts/skf-build-change-manifest.py +593 -47
  15. package/src/shared/scripts/skf-classify-changed-files.py +78 -0
  16. package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
  17. package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
  18. package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
  19. package/src/shared/scripts/skf-extract-public-api.py +1309 -157
  20. package/src/shared/scripts/skf-extraction-inventory.py +580 -46
  21. package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
  22. package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
  23. package/src/shared/scripts/skf-github-probe.py +47 -8
  24. package/src/shared/scripts/skf-hash-content.py +14 -8
  25. package/src/shared/scripts/skf-load-provenance.py +3 -1
  26. package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
  27. package/src/shared/scripts/skf-preflight.py +15 -1
  28. package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
  29. package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
  30. package/src/shared/scripts/skf-structural-diff.py +133 -18
  31. package/src/shared/scripts/skf-update-active-symlink.py +115 -62
  32. package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
  33. package/src/shared/scripts/skf-verify-provenance-completeness.py +977 -49
  34. package/src/shared/scripts/skf-write-skill-brief.py +41 -8
  35. package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
  36. package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
  37. package/src/skf-audit-skill/references/headless-contract.md +1 -1
  38. package/src/skf-audit-skill/references/health-check.md +2 -0
  39. package/src/skf-audit-skill/references/init.md +2 -1
  40. package/src/skf-audit-skill/references/re-index.md +14 -9
  41. package/src/skf-audit-skill/references/report.md +2 -2
  42. package/src/skf-audit-skill/references/semantic-diff.md +15 -15
  43. package/src/skf-audit-skill/references/structural-diff.md +10 -5
  44. package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
  45. package/src/skf-brief-skill/SKILL.md +1 -1
  46. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  47. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -9
  48. package/src/skf-brief-skill/references/analyze-target.md +6 -6
  49. package/src/skf-brief-skill/references/confirm-brief.md +1 -1
  50. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  51. package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
  52. package/src/skf-brief-skill/references/version-resolution.md +5 -9
  53. package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
  54. package/src/skf-create-skill/assets/skill-sections.md +6 -6
  55. package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
  56. package/src/skf-create-skill/references/compile.md +31 -60
  57. package/src/skf-create-skill/references/component-extraction.md +3 -3
  58. package/src/skf-create-skill/references/enrich.md +2 -2
  59. package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
  60. package/src/skf-create-skill/references/extract.md +22 -19
  61. package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
  62. package/src/skf-create-skill/references/extraction-patterns.md +8 -9
  63. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  64. package/src/skf-create-skill/references/report.md +16 -5
  65. package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
  66. package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
  67. package/src/skf-drop-skill/references/execute.md +5 -1
  68. package/src/skf-drop-skill/references/health-check.md +2 -0
  69. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  70. package/src/skf-drop-skill/references/report.md +2 -2
  71. package/src/skf-drop-skill/references/select.md +7 -3
  72. package/src/skf-test-skill/references/coherence-check.md +1 -1
  73. package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
  74. package/src/skf-test-skill/references/coverage-check.md +39 -57
  75. package/src/skf-test-skill/references/init.md +3 -5
  76. package/src/skf-test-skill/references/report.md +10 -8
  77. package/src/skf-test-skill/references/scoring-rules.md +6 -5
  78. package/src/skf-test-skill/references/source-access-protocol.md +14 -17
  79. package/src/skf-test-skill/scripts/build-result-context.py +114 -21
  80. package/src/skf-test-skill/scripts/gap-ledger.py +315 -30
  81. package/src/skf-test-skill/scripts/load-coverage-inputs.py +534 -96
  82. package/src/skf-test-skill/scripts/reconcile-coverage.py +276 -2
  83. package/src/skf-test-skill/scripts/score-signatures.py +36 -16
  84. package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
  85. package/src/skf-update-skill/references/detect-changes.md +32 -13
  86. package/src/skf-update-skill/references/gap-driven.md +3 -3
  87. package/src/skf-update-skill/references/init.md +6 -2
  88. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  89. package/src/skf-update-skill/references/merge.md +2 -2
  90. package/src/skf-update-skill/references/re-extract.md +2 -2
  91. package/src/skf-update-skill/references/report.md +3 -1
  92. package/src/skf-update-skill/references/write.md +43 -23
  93. package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
@@ -55,16 +55,28 @@ Subcommands:
55
55
  apply --update-type incremental|gap-driven|full --skill-name <name>
56
56
  --generation-date <iso> --confidence-tier <tier>
57
57
  --manual-sections-preserved <n> -o <file> [inputs...]
58
+ [--not-public-out <file>] [--metadata-in <file> --lists-out <file>]
58
59
  The provenance map an update writes (see "Apply" below): the old
59
60
  map with this run's changes, written to -o through a temporary file
60
- and a rename. Prints a summary with the warnings for a person.
61
+ and a rename, the summary's not_public list to --not-public-out and
62
+ metadata.json's exports[], scripts[] and assets[] to --lists-out
63
+ when given. Prints a summary with the warnings for a person.
64
+
65
+ metadata --metadata <metadata.json> --lists <file> --stats <file>
66
+ --version <version> --generation-date <iso>
67
+ [--source-commit <sha>] [--source-ref <ref>]
68
+ The skill's metadata.json an update leaves (see "Metadata" below),
69
+ written over --metadata through a temporary file and a rename.
70
+ Prints {"status": "written", "metadata": <path>, "version",
71
+ "exports": N, "scripts": N, "assets": N}.
61
72
 
62
73
  records [--extraction <file>] [--export-details <file>]
63
74
  [--files-from <file>] [--patches <dir>] -o <file>
64
75
  Step 3's re-extraction records (see "Records" below), written to -o
65
76
  through a temporary file and a rename. Prints {"status": "written",
66
77
  "output", "files_extracted", "exports_extracted",
67
- "confidence_breakdown": {"T1", "T1-low", "T2"}, "warnings"}.
78
+ "confidence_breakdown": {"T1", "T1-low", "T2"}, "marked_not_public"
79
+ (the exports marked public: false), "warnings"}.
68
80
 
69
81
  gap-records --manifest <file> --provenance-map <file> [--source-root <dir>]
70
82
  [--drift-status ok|skipped|overridden] [--judgments <file>]
@@ -75,6 +87,14 @@ Subcommands:
75
87
  files targeted re-extraction scans; without it, the records are
76
88
  written to -o through a temporary file and a rename.
77
89
 
90
+ baseline-gaps --provenance-map <file> --extraction <file> --files <file>
91
+ --source-root <dir> -o <file>
92
+ Category B's baseline gaps (see "Baseline gaps" below): each map
93
+ entry of the files --files lists that the runner did not report and
94
+ its file still declares, written to -o through a temporary file and
95
+ a rename. Prints {"status": "written", "output": <-o>, "gaps": N,
96
+ "unchecked": N}.
97
+
78
98
  Helper files in place of typed slices (update-skill detect-changes §2.1):
79
99
 
80
100
  --category-a <file> the output of skf-classify-changed-files.py
@@ -94,9 +114,13 @@ Helper files in place of typed slices (update-skill detect-changes §2.1):
94
114
  changed); moved_exports for an
95
115
  export whose only field is `line`
96
116
  moved[] -> moved_exports {name, file:
97
- current_file, old_line:
117
+ current_file, old_file:
118
+ previous_file (only when it
119
+ differs), old_line:
98
120
  previous_line, new_line: line},
99
- unless it is a modified export
121
+ unless it is a modified export,
122
+ which then takes old_file and
123
+ old_line: previous_line
100
124
  label_changes[] is not drift, and a name
101
125
  ambiguous_names[] lists stays removed and added
102
126
  --category-c <file> the output of rename-candidates: its category_c
@@ -136,10 +160,10 @@ Input JSON shape (object on stdin or in --input file):
136
160
  "deleted": ["path3", ...]
137
161
  },
138
162
  "category_b": {
139
- "modified_exports": [{name, file, old_line, new_line}, ...],
163
+ "modified_exports": [{name, file, old_line, new_line, old_file?}, ...],
140
164
  "new_exports": [{name, file, line}, ...],
141
165
  "deleted_exports": [{name, file, old_line}, ...],
142
- "moved_exports": [{name, file, old_line, new_line}, ...]
166
+ "moved_exports": [{name, file, old_line, new_line, old_file?}, ...]
143
167
  },
144
168
  "category_c": {
145
169
  "renamed_files": [{old_path, new_path}, ...],
@@ -164,6 +188,10 @@ Manifest (build's output):
164
188
  "total_export_changes": N, "per_file": [...],
165
189
  "category_d": {the eight category_d lists, each path once}}
166
190
 
191
+ Each per_file item's exports_affected holds {name, change_type,
192
+ old_line, new_line}, and old_file for an export that moved across files
193
+ (a MOVED_EXPORT, or a MODIFIED_EXPORT that also changed).
194
+
167
195
  A tracked document (a doc row of file_entries[]) has no other detector:
168
196
  update-skill's Category A never lists it, so a document-only change still
169
197
  counts, and the update records its new hash.
@@ -233,14 +261,36 @@ Apply (update-skill write.md §3):
233
261
  details, then step 3's record (the runner's in the map's typed form).
234
262
  Per file of the manifest:
235
263
  MODIFIED NEW_EXPORT adds an entry, MODIFIED_EXPORT rewrites the
236
- export's fields from its fresh record, MOVED_EXPORT sets
237
- its line (and file), DELETED_EXPORT removes it
264
+ export's fields (file included) from its fresh record,
265
+ MOVED_EXPORT moves the entry to the file and the fresh
266
+ record's line, else new_line, DELETED_EXPORT removes it;
267
+ a change with an old_file finds its entry in that file
238
268
  ADDED every fresh record of the file becomes an entry
239
269
  DELETED every entry of the file is removed
240
270
  MOVED the old path's entries are replaced by the new path's
241
271
  fresh records, each keeping the keys of the entry of its
242
272
  name it replaces
243
- and each renamed export takes its new name and fresh fields. A docs-only
273
+ and each renamed export takes its new name and fresh fields. A
274
+ MOVED_EXPORT whose fresh record is the runner's (ast-grep) also takes
275
+ its confidence, extraction_method and ast_node_type (the entry's when
276
+ the record's is null), and its signature_source when the record holds
277
+ each of params and return_type the entry holds (else the entry's),
278
+ never its export_type, params or return_type; one read by eye moves
279
+ the line and file only.
280
+
281
+ Public-api skills (incremental only): a NEW_EXPORT or MODIFIED_EXPORT
282
+ the map does not hold, an ADDED file's record and a MOVED file's record
283
+ whose name no old entry carries become entries only when on the public
284
+ surface: the record's `public` mark (see "Records"), else, for a record
285
+ --reextract-records does not mark, the same rule over --extraction (its
286
+ scope.type). A name whose entry this run removes (a DELETED_EXPORT, a
287
+ DELETED file's or a MOVED file's old path's entry) is added whatever
288
+ its mark. Each one left out is listed in the summary's not_public,
289
+ {name, file}, with one warning for them all; an entry the map holds is
290
+ updated whatever its mark, and no entry is removed for it. A public-api
291
+ --extraction the rule cannot read (no entry_point_diff, an incomplete
292
+ run or one with errors, an unresolved entry point) adds every name, with
293
+ one warning. A docs-only
244
294
  run's records ({"mode": "docs-only", "changed_urls": [...], "exports":
245
295
  [{name, type, params, return_type, url}]}) replace the entries of each
246
296
  changed URL (source_file is the URL; confidence and signature_source
@@ -295,9 +345,50 @@ Apply (update-skill write.md §3):
295
345
  manual_sections_preserved. An older update-history key
296
346
  (update_operations, update_metadata) stays as it is.
297
347
 
348
+ Lists. With --metadata-in (the skill's metadata.json as the update
349
+ found it) and --lists-out, it also writes the lists metadata.json takes,
350
+ {"exports": [...], "scripts": [...], "assets": [...]}. exports: the
351
+ --metadata-in exports[] less each name whose entry this run removed (a
352
+ DELETED_EXPORT, a DELETED file's or a MOVED file's old path's, a
353
+ rescoped one, a changed URL's) or renamed, unless an entry of the new
354
+ map still holds it, then each name it added an entry for or renamed an
355
+ entry to, each once; a renamed name takes its old name's place. A name
356
+ left out as off the public surface (the summary's not_public) has no
357
+ entry, so it is not added. A full update, which rebuilds the map, also
358
+ drops each name the new map does not hold, and a reference app
359
+ (scope_type reference-app) gets []. scripts and assets: one row per
360
+ file_entries[] row of the new map whose file_type is script or asset
361
+ (a row with none takes the type its file_name's folder names), in the
362
+ map's order: the --metadata-in scripts[] or assets[] row of its
363
+ source_file (else of its file_name) as it stands, else a new row {file:
364
+ its file_name, purpose, source_file, confidence (default T1-low)},
365
+ whose purpose skf-detect-scripts-assets.py reads from the file under
366
+ --source-root (its file name when no --source-root is given or the
367
+ file is not there).
368
+
369
+ Metadata (update-skill write.md §2):
370
+
371
+ metadata.json as --metadata holds it, with what the update sets: version
372
+ (--version) and generation_date (--generation-date, YYYY-MM-DDTHH:MM:SSZ);
373
+ source_commit and source_ref when --source-commit or --source-ref is
374
+ given (an empty one sets null), else as they stand; exports, scripts and
375
+ assets from --lists (apply's --lists-out), scripts or assets left out
376
+ when its list is empty; confidence_distribution from --stats (the JSON
377
+ skf-render-metadata-stats.py writes with -o), and stats: the stats
378
+ helper's fields, then each other field --metadata's stats holds (a
379
+ component library's own counts, a reference app's notes, a field a
380
+ gap-driven patch added), the stats helper's fields an earlier run wrote
381
+ that this run's does not (effective_denominator,
382
+ pattern_surfaces_documented) dropped. It removes last_update and
383
+ update_type, which the provenance map's update block records, and keeps
384
+ every other field. It refuses (exit 1, nothing written) a --stats whose
385
+ scripts_count or assets_count is not the length of --lists' scripts or
386
+ assets, a --generation-date of another form, and a file it cannot read.
387
+
298
388
  Summary on stdout: {"status": "written", "map": <-o>, "entries":
299
- {"added", "updated", "removed"}, "file_entries": {"added", "updated",
300
- "removed"}, "warnings": [...]}, or {"status": "refused",
389
+ {"added", "updated", "removed"}, "not_public": [{name, file}],
390
+ "file_entries": {"added", "updated", "removed"}, "warnings": [...]},
391
+ or {"status": "refused",
301
392
  "blocking_unresolved": [{export_name, severity}]} with exit 3.
302
393
 
303
394
  Records (update-skill re-extract.md §4, and gap-driven.md §4a for the files
@@ -324,6 +415,29 @@ it scans):
324
415
  no seed holds is not added, with a warning. confidence_breakdown counts
325
416
  the exports by confidence, and T2 the exports with a qmd_evidence.
326
417
 
418
+ Public surface. When --extraction's scope.type is public-api (in any
419
+ case) and it has an entry_point_diff, each export of a language family
420
+ whose entry_points.by_language status is `barrel` is marked `public`:
421
+ true when its name and file are a pair of the runner's entry_point_diff
422
+ `public` (its name, or the name `local` gives it in that file) or
423
+ `extraction_gaps`, or when a `public` item of its name and family has
424
+ no file; false otherwise. A `public` item with `via` namespace and a
425
+ `members` list (what the submodule passes on, which users reach as
426
+ `pkg.sub.name`, and which counts.exports_public_api counts in its place)
427
+ gives its members' pairs (each member's name, or its `local` one, and
428
+ its file; a member with no file its name) in place of its own; one
429
+ whose `members` is null (a module the runner could not read), or that
430
+ has no `members` key (an older runner's), gives its own pair, as any
431
+ item does.
432
+ The rule is skf-extraction-inventory.py's public_surface, loaded from
433
+ this folder, by which create-skill marks its extraction inventory too.
434
+ In a normal update merge documents no export marked false that the map
435
+ does not hold, and apply adds none; gap-records drops the mark, and a
436
+ full (degraded) apply adds every record. Any other scope type, a family
437
+ with no barrel, and a run with no entry_point_diff, an incomplete one,
438
+ one with errors or one whose entry_points.unresolved is not empty mark
439
+ nothing.
440
+
327
441
  Gap records (update-skill gap-driven.md §3, §4 and §4a):
328
442
 
329
443
  Reads the change manifest skf-parse-gaps.py translate wrote. Its
@@ -383,7 +497,8 @@ Gap records (update-skill gap-driven.md §3, §4 and §4a):
383
497
  first export of its name in the blocks of those files, in their
384
498
  order: `re-extracted`, new_location its record's location,
385
499
  resolution_source remediation-paths, and the record copied into
386
- `files` as `records` wrote it, with the answered `docstring` added.
500
+ `files` as `records` wrote it, less its `public` mark (a normal
501
+ update's), with the answered `docstring` added.
387
502
  An entry with no match, or no path to scan, is unresolved, except a
388
503
  missing-export or missing-type one that is not blocking, which is
389
504
  unknown. Any unresolved entry: {"status": "unresolved",
@@ -420,12 +535,37 @@ Gap records (update-skill gap-driven.md §3, §4 and §4a):
420
535
  is {resolved_count, files_scanned, exports_matched, tier}, also appended
421
536
  to --evidence as one JSON line keyed `targeted_reextraction`.
422
537
 
538
+ Baseline gaps (update-skill detect-changes Category B, step 1):
539
+
540
+ The recipe runner never reports a module, an alias that renames, a
541
+ dunder such as `__version__` or another underscore name, so the diff
542
+ would read each such map entry as a deleted export. baseline-gaps lists,
543
+ for step 2 to read by eye, each entry of a file --files lists (the
544
+ modified files, Category A's modified-files.json) whose name and file
545
+ no export of --extraction (the runner's JSON, or {"exports": []} when
546
+ it could not run) holds, under its name or its re-export target, and
547
+ whose file still declares it. The selection and the declaration rule
548
+ are skf-verify-provenance-completeness.py's `baseline_gaps` (the rule
549
+ audit-skill's extraction snapshot applies), loaded from this folder: a
550
+ verifier that cannot be loaded is an error, never an empty list. A
551
+ name the runner's own `entry_point_diff.extraction_gaps` lists at that
552
+ file is left to that list. A map whose `entries` is missing or null
553
+ has none to check. Writes {"gaps": [{name, file, line, entry: null,
554
+ export_type?}], "unchecked": [{name, file, export_type}]}: a gap's line
555
+ declares the name (line 1 of the module or package so named for a
556
+ `module` or `package` entry), and `unchecked` holds each entry no rule
557
+ can check, to read by eye: its file neither Python nor TS/JS, a file
558
+ the lookup will not read (outside the source root, a test file or a
559
+ minified bundle, a symlink on its path), or a dotted `module` or
560
+ `package` name.
561
+
423
562
  Exit codes:
424
563
  0 operation succeeded (for gap-records, whatever its `status`)
425
564
  1 user error (malformed JSON, bad path, malformed provenance file, a
426
565
  helper file that is not the output it names, a gap-records answer of
427
- the wrong type, or targeted re-extraction to match with no
428
- --remediation-records)
566
+ the wrong type, targeted re-extraction to match with no
567
+ --remediation-records, or, for baseline-gaps, a source root that is
568
+ no folder or a verifier that cannot be loaded)
429
569
  3 apply refused: a blocking gap reached the provenance write with no
430
570
  source line (nothing written)
431
571
  """
@@ -538,11 +678,20 @@ def category_b_from_diff(diff: dict) -> dict:
538
678
  lists["modified_exports"].append({"name": key[0], "file": key[1], "old_line": None, "new_line": None})
539
679
  modified.add(key)
540
680
  for item in _get_list(diff, "moved"):
541
- if isinstance(item, dict) and (item.get("name"), item.get("current_file")) not in modified:
542
- lists["moved_exports"].append({
543
- "name": item.get("name"), "file": item.get("current_file"),
544
- "old_line": item.get("previous_line"), "new_line": item.get("line"),
545
- })
681
+ if not isinstance(item, dict):
682
+ continue
683
+ key, old_file = (item.get("name"), item.get("current_file")), _norm_path(item.get("previous_file"))
684
+ across = old_file not in (None, _norm_path(item.get("current_file"))) # apply finds the entry at old_file
685
+ if key in modified:
686
+ if across: # moved across files and changed: a MODIFIED_EXPORT that names the file it left
687
+ for entry in lists["modified_exports"]:
688
+ if (entry["name"], entry["file"]) == key:
689
+ entry.update(old_line=item.get("previous_line"), old_file=old_file)
690
+ continue
691
+ move = {"name": item.get("name"), "file": item.get("current_file")}
692
+ if across:
693
+ move["old_file"] = old_file
694
+ lists["moved_exports"].append({**move, "old_line": item.get("previous_line"), "new_line": item.get("line")})
546
695
  return lists
547
696
 
548
697
 
@@ -738,12 +887,15 @@ def _build_per_file(
738
887
  path = entry.get("file")
739
888
  if not path:
740
889
  return
741
- exports_by_file.setdefault(path, []).append({
890
+ change = {
742
891
  "name": entry.get("name"),
743
892
  "change_type": change_type,
744
893
  "old_line": entry.get("old_line"),
745
894
  "new_line": entry.get("new_line") or entry.get("line"),
746
- })
895
+ }
896
+ if entry.get("old_file"):
897
+ change["old_file"] = entry["old_file"] # a move across files: a MOVED_EXPORT, or a MODIFIED_EXPORT
898
+ exports_by_file.setdefault(path, []).append(change)
747
899
 
748
900
  for e in b_modified:
749
901
  _record(e, "MODIFIED_EXPORT")
@@ -943,6 +1095,34 @@ def _file_of(record: dict) -> str | None:
943
1095
  return _norm_path(record.get("source_file", record.get("file", record.get("file_path"))))
944
1096
 
945
1097
 
1098
+ _INVENTORY = None
1099
+
1100
+
1101
+ def _inventory():
1102
+ """skf-extraction-inventory.py, loaded once from this folder: its typed_param and its public-surface rule, so
1103
+ create-skill and update-skill write one parameter form and keep one public surface."""
1104
+ global _INVENTORY
1105
+ if _INVENTORY is None:
1106
+ path = Path(__file__).resolve().parent / "skf-extraction-inventory.py"
1107
+ spec = importlib.util.spec_from_file_location("skf_extraction_inventory", path)
1108
+ if spec is None or spec.loader is None:
1109
+ raise ImportError(f"cannot load {path}")
1110
+ module = importlib.util.module_from_spec(spec)
1111
+ spec.loader.exec_module(module)
1112
+ _INVENTORY = module
1113
+ return _INVENTORY
1114
+
1115
+
1116
+ def public_surface(extraction: object) -> tuple[object | None, str | None]:
1117
+ """skf-extraction-inventory.py's public_surface over the recipe runner's output (see "Records" in the module
1118
+ docstring): (the public surface, None) for a public-api run, (None, why) when its surface cannot be read, and
1119
+ (None, None) for any other scope type. An input that is no object (a gap-driven or docs-only run gives none)
1120
+ is (None, None) without loading the inventory helper."""
1121
+ if not isinstance(extraction, dict):
1122
+ return None, None
1123
+ return _inventory().public_surface(extraction)
1124
+
1125
+
946
1126
  # --------------------------------------------------------------------------
947
1127
  # Rename candidates (update-skill detect-changes Category C)
948
1128
  # --------------------------------------------------------------------------
@@ -1192,6 +1372,7 @@ class _Fresh:
1192
1372
  self.runner = {}
1193
1373
  self.details = {}
1194
1374
  self.worker = {}
1375
+ self.surface, self.surface_problem = public_surface(extraction)
1195
1376
  for record in _exports_of(extraction, "--extraction file"):
1196
1377
  key = (_name_of(record), _file_of(record))
1197
1378
  if all(key):
@@ -1215,6 +1396,17 @@ class _Fresh:
1215
1396
  def all_keys(self) -> list[tuple]:
1216
1397
  return sorted({k for source in (self.runner, self.details, self.worker) for k in source})
1217
1398
 
1399
+ def public(self, name: str, path: str) -> bool | None:
1400
+ """The `public` mark `records` gave the export, else the public-surface rule over --extraction; None when
1401
+ neither marks it."""
1402
+ mark = (self.worker.get((name, path)) or {}).get("public")
1403
+ if isinstance(mark, bool):
1404
+ return mark
1405
+ if self.surface is None:
1406
+ return None
1407
+ record = self.runner.get((name, path)) or self.details.get((name, path)) or {}
1408
+ return self.surface.public(name, path, record.get("language"))
1409
+
1218
1410
  def record(self, name: str, path: str) -> dict | None:
1219
1411
  """The entry fields of an export's fresh record, or None when no source holds it."""
1220
1412
  key = (name, path)
@@ -1304,6 +1496,7 @@ class _Map:
1304
1496
  self.added: list[str] = []
1305
1497
  self.updated: list[str] = []
1306
1498
  self.removed: list[str] = []
1499
+ self.renamed: list[tuple[str, str]] = [] # (old name, new name)
1307
1500
  self.touched_files: set[str] = set()
1308
1501
 
1309
1502
  def find(self, name: str, path: str | None, line: int | None = None) -> int | None:
@@ -1329,6 +1522,8 @@ class _Map:
1329
1522
  if after != before:
1330
1523
  self.doc["entries"][index] = after
1331
1524
  self.updated.append(_name_of(after))
1525
+ if _name_of(before) and _name_of(after) and _name_of(before) != _name_of(after):
1526
+ self.renamed.append((_name_of(before), _name_of(after)))
1332
1527
  for path in (_file_of(before), _file_of(after)):
1333
1528
  if path:
1334
1529
  self.touched_files.add(path)
@@ -1342,9 +1537,29 @@ class _Map:
1342
1537
 
1343
1538
 
1344
1539
  def _apply_normal(pmap: _Map, manifest: dict | None, categories: dict | None, fresh: _Fresh,
1345
- warnings: list[str]) -> tuple[int, int]:
1346
- """Normal mode: the manifest's file and export changes. Returns (files, exports) for the update block."""
1540
+ warnings: list[str], not_public: list[dict]) -> tuple[int, int]:
1541
+ """Normal mode: the manifest's file and export changes. Returns (files, exports) for the update block; each
1542
+ name it leaves out as off a public-api skill's public surface goes to `not_public`."""
1347
1543
  per_file = [f for f in _get_list(manifest or {}, "per_file") if isinstance(f, dict)]
1544
+ # the names of the entries this run removes (a DELETED_EXPORT, a DELETED file's, a MOVED file's old path's):
1545
+ # one that comes back in another file is re-added whatever its mark, so no held export is lost
1546
+ removed: set[str] = set()
1547
+ for item in per_file:
1548
+ path = _norm_path(item.get("old_path") if item.get("status") == "MOVED" else item.get("file_path"))
1549
+ if item.get("status") in ("DELETED", "MOVED") and path:
1550
+ removed.update(_name_of(pmap.doc["entries"][i]) for i in pmap.indexes_of(path))
1551
+ removed.update(_name_of(c) for c in item.get("exports_affected") or []
1552
+ if isinstance(c, dict) and c.get("change_type") == "DELETED_EXPORT")
1553
+ listed: set[tuple[str, str]] = set()
1554
+
1555
+ def off_surface(name: str, path: str) -> bool:
1556
+ """True for a name the run would add that is off the public surface, which it lists."""
1557
+ if name in removed or fresh.public(name, path) is not False:
1558
+ return False
1559
+ if (name, path) not in listed:
1560
+ listed.add((name, path))
1561
+ not_public.append({"name": name, "file": path})
1562
+ return True
1348
1563
 
1349
1564
  def refresh(index: int | None, name: str, path: str, *, add: bool) -> None:
1350
1565
  record = fresh.record(name, path)
@@ -1353,7 +1568,7 @@ def _apply_normal(pmap: _Map, manifest: dict | None, categories: dict | None, fr
1353
1568
  f"{'kept as it was' if index is not None else 'not added'}")
1354
1569
  return
1355
1570
  if index is None:
1356
- if add:
1571
+ if add and not off_surface(name, path):
1357
1572
  pmap.add(_new_entry(record, pmap.library))
1358
1573
  return
1359
1574
  pmap.update(index, _without_none(record, keep=("ast_node_type",)))
@@ -1373,6 +1588,8 @@ def _apply_normal(pmap: _Map, manifest: dict | None, categories: dict | None, fr
1373
1588
  for name, key_path in fresh.keys_of(path):
1374
1589
  record = fresh.record(name, key_path)
1375
1590
  carried = old_entries.get(name)
1591
+ if carried is None and off_surface(name, key_path):
1592
+ continue
1376
1593
  entry = _rewrite(carried, _without_none(record, keep=("ast_node_type",))) if carried \
1377
1594
  else _new_entry(record, pmap.library)
1378
1595
  pmap.add(entry)
@@ -1386,7 +1603,8 @@ def _apply_normal(pmap: _Map, manifest: dict | None, categories: dict | None, fr
1386
1603
  if not isinstance(change, dict) or not _name_of(change):
1387
1604
  continue
1388
1605
  name, kind = _name_of(change), change.get("change_type")
1389
- index = pmap.find(name, path, _line_of(change.get("old_line")))
1606
+ # a move across files (a MOVED_EXPORT, or a MODIFIED_EXPORT that also moved) names the file its entry is at
1607
+ index = pmap.find(name, _norm_path(change.get("old_file")) or path, _line_of(change.get("old_line")))
1390
1608
  if kind == "DELETED_EXPORT":
1391
1609
  if index is not None:
1392
1610
  pmap.remove([index])
@@ -1395,7 +1613,18 @@ def _apply_normal(pmap: _Map, manifest: dict | None, categories: dict | None, fr
1395
1613
  line = record["source_line"] if record and record.get("source_line") is not None \
1396
1614
  else _line_of(change.get("new_line"))
1397
1615
  if index is not None and line is not None:
1398
- pmap.update(index, {"source_file": path, "source_line": line})
1616
+ fields = {"source_file": path, "source_line": line}
1617
+ if record is not None and record["extraction_method"] == "ast-grep":
1618
+ # the runner matched it: its labels, never its export_type, params or return_type
1619
+ fields.update(confidence=record["confidence"], extraction_method=record["extraction_method"])
1620
+ if record["ast_node_type"] is not None:
1621
+ fields["ast_node_type"] = record["ast_node_type"]
1622
+ entry = pmap.doc["entries"][index]
1623
+ # its signature_source only when the record holds each signature part the entry does
1624
+ if all(record.get(part) is not None for part in ("params", "return_type")
1625
+ if entry.get(part) is not None):
1626
+ fields["signature_source"] = record["signature_source"]
1627
+ pmap.update(index, fields)
1399
1628
  elif kind in ("NEW_EXPORT", "MODIFIED_EXPORT"):
1400
1629
  refresh(index, name, path, add=True)
1401
1630
  for rename in _get_list(categories or {}, "category_c", "renamed_exports"):
@@ -1583,14 +1812,118 @@ def _apply_files(pmap: _Map, compare: dict | None, new_files: dict | None, promo
1583
1812
  return done
1584
1813
 
1585
1814
 
1815
+ REFERENCE_APP = "reference-app"
1816
+
1817
+
1818
+ def _export_name(item: object) -> str | None:
1819
+ """The name of a metadata.json exports[] item: a string, or an object's name or export_name."""
1820
+ if isinstance(item, str):
1821
+ return item or None
1822
+ return _name_of(item) if isinstance(item, dict) else None
1823
+
1824
+
1825
+ def exports_after(metadata: object, pmap: _Map, update_type: str) -> list:
1826
+ """metadata.json's exports[] after the update (see "Exports" in the module docstring)."""
1827
+ if not isinstance(metadata, dict):
1828
+ raise ValueError("--metadata-in file must hold a JSON object (the skill's metadata.json)")
1829
+ if metadata.get("scope_type") == REFERENCE_APP:
1830
+ return []
1831
+ current = metadata.get("exports") if isinstance(metadata.get("exports"), list) else []
1832
+ held = {_name_of(e) for e in pmap.doc["entries"] if isinstance(e, dict)} - {None}
1833
+ renamed = dict(pmap.renamed)
1834
+ gone = ({n for n in pmap.removed if n} | set(renamed)) - held
1835
+ if update_type == "full":
1836
+ gone |= {n for n in map(_export_name, current) if n and n not in held}
1837
+ out: list = []
1838
+ seen: set[str] = set()
1839
+
1840
+ def put(item: object, name: str | None) -> None:
1841
+ if name and name not in seen:
1842
+ seen.add(name)
1843
+ out.append(item)
1844
+
1845
+ for item in current:
1846
+ name = _export_name(item)
1847
+ if name in gone:
1848
+ new = renamed.get(name)
1849
+ put(new, new if new in held else None)
1850
+ else:
1851
+ put(item, name)
1852
+ for name in pmap.added + [new for _old, new in pmap.renamed]:
1853
+ put(name, name if name in held else None)
1854
+ return out
1855
+
1856
+
1857
+ _DETECTOR = None
1858
+
1859
+
1860
+ def _detector():
1861
+ """skf-detect-scripts-assets.py, loaded once from this folder: the purpose a new script's or asset's row
1862
+ takes, read from the file as create-skill's detector reads it."""
1863
+ global _DETECTOR
1864
+ if _DETECTOR is None:
1865
+ path = Path(__file__).resolve().parent / "skf-detect-scripts-assets.py"
1866
+ spec = importlib.util.spec_from_file_location("skf_detect_scripts_assets", path)
1867
+ if spec is None or spec.loader is None or not path.is_file():
1868
+ raise ValueError(f"cannot load {path.name} beside {Path(__file__).name}: re-install SKF")
1869
+ module = importlib.util.module_from_spec(spec)
1870
+ spec.loader.exec_module(module)
1871
+ _DETECTOR = module
1872
+ return _DETECTOR
1873
+
1874
+
1875
+ def _purpose(source_file: str, kind: str, source_root: Path | None) -> str:
1876
+ """A new script's or asset's purpose: the detector's, read from the file under `source_root`, else its name."""
1877
+ path = source_root / source_file if source_root is not None else None
1878
+ if path is None or not path.is_file():
1879
+ return Path(source_file).name
1880
+ detector = _detector()
1881
+ hint = detector.asset_type(path, source_root) if kind == "asset" else None
1882
+ return detector.extract_purpose(path, asset_type_hint=hint)
1883
+
1884
+
1885
+ def file_rows_after(metadata: dict, pmap: _Map, source_root: Path | None) -> dict[str, list]:
1886
+ """metadata.json's scripts[] and assets[] after the update (see "Lists" in the module docstring)."""
1887
+ by_source: dict[str, dict] = {}
1888
+ by_file: dict[str, dict] = {}
1889
+ for key in ("scripts", "assets"):
1890
+ rows = metadata.get(key)
1891
+ for row in rows if isinstance(rows, list) else []:
1892
+ if not isinstance(row, dict):
1893
+ continue
1894
+ source = _norm_path(row.get("source_file"))
1895
+ if source:
1896
+ by_source.setdefault(source, row)
1897
+ if isinstance(row.get("file"), str):
1898
+ by_file.setdefault(row["file"], row)
1899
+ lists: dict[str, list] = {"scripts": [], "assets": []}
1900
+ entries = pmap.doc.get("file_entries")
1901
+ for entry in entries if isinstance(entries, list) else []:
1902
+ if not isinstance(entry, dict) or _file_type(entry) not in ("script", "asset"):
1903
+ continue
1904
+ kind = _file_type(entry)
1905
+ source = _norm_path(entry.get("source_file"))
1906
+ held = by_source.get(source) if source else None
1907
+ if held is None and isinstance(entry.get("file_name"), str):
1908
+ held = by_file.get(entry["file_name"])
1909
+ if held is None:
1910
+ held = {"file": entry.get("file_name") or f"{kind}s/{Path(source or '').name}",
1911
+ "purpose": _purpose(source, kind, source_root) if source else "",
1912
+ "source_file": source, "confidence": entry.get("confidence") or "T1-low"}
1913
+ lists[f"{kind}s"].append(held)
1914
+ return lists
1915
+
1916
+
1586
1917
  def apply_update(*, update_type: str, provenance: dict | None, skill_name: str, manifest: dict | None = None,
1587
1918
  categories: dict | None = None, extraction: dict | None = None, details: dict | None = None,
1588
1919
  records: dict | None = None, merge_records: dict | None = None, compare: dict | None = None,
1589
1920
  new_files: dict | None = None, promoted: list | None = None, source_root: Path | None = None,
1590
1921
  generation_date: str, test_report_run_id: str | None, confidence_tier: str,
1591
1922
  manual_sections_preserved: int, source_commit: str | None = None,
1592
- source_ref: str | None = None, drift: tuple | None = None) -> tuple[dict, dict]:
1593
- """(new map, summary) of one update (see "Apply" in the module docstring).
1923
+ source_ref: str | None = None, drift: tuple | None = None,
1924
+ metadata: dict | None = None) -> tuple[dict, dict]:
1925
+ """(new map, summary) of one update (see "Apply" in the module docstring). With `metadata` (the skill's
1926
+ metadata.json), the summary's `lists` holds the exports[], scripts[] and assets[] it takes after the update.
1594
1927
 
1595
1928
  A refusal returns ({}, summary) with status "refused" and no map.
1596
1929
  Raises ValueError for inputs it cannot read.
@@ -1602,6 +1935,7 @@ def apply_update(*, update_type: str, provenance: dict | None, skill_name: str,
1602
1935
  records = records if isinstance(records, dict) else {}
1603
1936
  pmap = _Map(provenance, skill_name)
1604
1937
  warnings: list[str] = []
1938
+ not_public: list[dict] = []
1605
1939
  mode = records.get("mode")
1606
1940
  if update_type == "gap-driven":
1607
1941
  outcome = _apply_gap_driven(pmap, records, merge_records, drift, warnings)
@@ -1623,7 +1957,12 @@ def apply_update(*, update_type: str, provenance: dict | None, skill_name: str,
1623
1957
  files = len({path for _name, path in fresh.all_keys()})
1624
1958
  exports = len(fresh.all_keys())
1625
1959
  else:
1626
- files, exports = _apply_normal(pmap, manifest, categories, fresh, warnings)
1960
+ files, exports = _apply_normal(pmap, manifest, categories, fresh, warnings, not_public)
1961
+ if fresh.surface_problem:
1962
+ warnings.append(f"provenance: the public surface of this public-api skill could not be applied "
1963
+ f"({fresh.surface_problem}): every new export was added")
1964
+ if not_public:
1965
+ warnings.append(_not_public_warning(not_public))
1627
1966
  file_changes = _apply_files(pmap, compare, new_files, promoted, source_root)
1628
1967
  doc = pmap.doc
1629
1968
  for key, value in (("source_commit", source_commit), ("source_ref", source_ref)):
@@ -1643,12 +1982,76 @@ def apply_update(*, update_type: str, provenance: dict | None, skill_name: str,
1643
1982
  summary = {
1644
1983
  "status": "written",
1645
1984
  "entries": {"added": pmap.added, "updated": pmap.updated, "removed": pmap.removed},
1985
+ "not_public": not_public,
1646
1986
  "file_entries": file_changes,
1647
1987
  "warnings": warnings,
1648
1988
  }
1989
+ if metadata is not None:
1990
+ summary["lists"] = {"exports": exports_after(metadata, pmap, update_type),
1991
+ **file_rows_after(metadata, pmap, source_root)}
1649
1992
  return doc, summary
1650
1993
 
1651
1994
 
1995
+ GENERATION_DATE_RE = re.compile(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ")
1996
+ # the stats fields skf-render-metadata-stats.py writes, some only for a shape or a judgment
1997
+ STATS_HELPER_FIELDS = ("exports_documented", "exports_public_api", "exports_internal", "exports_total",
1998
+ "public_api_coverage", "total_coverage", "scripts_count", "assets_count",
1999
+ "pattern_surfaces_documented", "effective_denominator")
2000
+
2001
+
2002
+ def update_metadata(metadata: object, lists: object, stats: object, *, version: str, generation_date: str,
2003
+ source_commit: str | None = None, source_ref: str | None = None) -> dict:
2004
+ """The metadata.json an update leaves (see "Metadata" in the module docstring). Raises ValueError."""
2005
+ if not isinstance(metadata, dict):
2006
+ raise ValueError("--metadata file must hold a JSON object (the skill's metadata.json)")
2007
+ if not isinstance(lists, dict) or not all(isinstance(lists.get(k), list) for k in ("exports", "scripts",
2008
+ "assets")):
2009
+ raise ValueError("--lists file must hold the exports, scripts and assets lists apply wrote (--lists-out)")
2010
+ if not isinstance(stats, dict) or not isinstance(stats.get("stats"), dict) \
2011
+ or not isinstance(stats.get("confidence_distribution"), dict):
2012
+ raise ValueError("--stats file must hold the stats helper's JSON: a stats and a confidence_distribution "
2013
+ "object")
2014
+ if not version.strip():
2015
+ raise ValueError("--version must not be empty")
2016
+ if not GENERATION_DATE_RE.fullmatch(generation_date):
2017
+ raise ValueError(f"--generation-date must be YYYY-MM-DDTHH:MM:SSZ, read from the clock; got "
2018
+ f"{generation_date!r}")
2019
+ for kind in ("scripts", "assets"):
2020
+ counted = stats["stats"].get(f"{kind}_count")
2021
+ if counted != len(lists[kind]):
2022
+ raise ValueError(f"the stats count {counted} {kind} but apply's lists hold {len(lists[kind])}: run the "
2023
+ f"stats helper again with the lists' {kind}")
2024
+ out = {k: v for k, v in metadata.items() if k not in ("last_update", "update_type")}
2025
+ out["version"] = version
2026
+ out["generation_date"] = generation_date
2027
+ for key, value in (("source_commit", source_commit), ("source_ref", source_ref)):
2028
+ if value is not None:
2029
+ out[key] = value or None
2030
+ out["exports"] = lists["exports"]
2031
+ for kind in ("scripts", "assets"):
2032
+ if lists[kind]:
2033
+ out[kind] = lists[kind]
2034
+ else:
2035
+ out.pop(kind, None)
2036
+ old_stats = metadata.get("stats") if isinstance(metadata.get("stats"), dict) else {}
2037
+ kept = {k: v for k, v in old_stats.items() if k not in STATS_HELPER_FIELDS and k not in stats["stats"]}
2038
+ out["stats"] = {**stats["stats"], **kept}
2039
+ out["confidence_distribution"] = stats["confidence_distribution"]
2040
+ return out
2041
+
2042
+
2043
+ NOT_PUBLIC_NAMED = 10 # the names the not_public warning spells out; the summary lists them all
2044
+
2045
+
2046
+ def _not_public_warning(not_public: list[dict]) -> str:
2047
+ """The one warning for every name apply left out as off a public-api skill's public surface."""
2048
+ names = [item["name"] for item in not_public]
2049
+ shown = ", ".join(names[:NOT_PUBLIC_NAMED])
2050
+ more = f" and {len(names) - NOT_PUBLIC_NAMED} more" if len(names) > NOT_PUBLIC_NAMED else ""
2051
+ return (f"provenance: {len(names)} new export(s) off the public surface of this public-api skill not added "
2052
+ f"(the summary's not_public lists each with its file): {shown}{more}")
2053
+
2054
+
1652
2055
  def _write_json_atomic(path: Path, value) -> None:
1653
2056
  """Write `value` as indented JSON through a temporary file beside `path` and one rename, retried on Windows,
1654
2057
  where a scanner may hold the target open for a moment."""
@@ -1681,22 +2084,12 @@ def _write_json_atomic(path: Path, value) -> None:
1681
2084
  # a patch sets only where the seed has none.
1682
2085
  PATCH_FIELDS = ("signature", "members", "docstring", "qmd_evidence")
1683
2086
  PATCH_IF_NULL = ("params", "return_type")
1684
- _INVENTORY = None
1685
2087
 
1686
2088
 
1687
2089
  def _typed_param(param, language: object) -> str | None:
1688
- """One parameter as the provenance map writes it: skf-extraction-inventory.py's typed_param, loaded once from
1689
- this folder, so create-skill and update-skill write one form."""
1690
- global _INVENTORY
1691
- if _INVENTORY is None:
1692
- path = Path(__file__).resolve().parent / "skf-extraction-inventory.py"
1693
- spec = importlib.util.spec_from_file_location("skf_extraction_inventory", path)
1694
- if spec is None or spec.loader is None:
1695
- raise ImportError(f"cannot load {path}")
1696
- module = importlib.util.module_from_spec(spec)
1697
- spec.loader.exec_module(module)
1698
- _INVENTORY = module
1699
- return _INVENTORY.typed_param(param, language if isinstance(language, str) else None)
2090
+ """One parameter as the provenance map writes it: skf-extraction-inventory.py's typed_param, so create-skill
2091
+ and update-skill write one form."""
2092
+ return _inventory().typed_param(param, language if isinstance(language, str) else None)
1700
2093
 
1701
2094
 
1702
2095
  def _typed_params(value, language: object) -> list | None:
@@ -1784,6 +2177,11 @@ def build_records(extraction: dict | None, details: dict | None, files: list | N
1784
2177
  if patch.get(field) is not None:
1785
2178
  seeded[key][field] = patch[field]
1786
2179
  fill_if_null(key, patch)
2180
+ surface, _problem = public_surface(extraction) # apply warns when a public-api run gives no surface
2181
+ for key in seeded if surface is not None else ():
2182
+ mark = surface.public(key[0], key[1], languages.get(key))
2183
+ if mark is not None:
2184
+ seeded[key]["public"] = mark
1787
2185
  paths = list(dict.fromkeys([p for p in (_norm_path(f) for f in files or []) if p]
1788
2186
  + sorted({key[1] for key in seeded})))
1789
2187
  blocks = [{"file_path": path, "exports": [seeded[key] for key in sorted(seeded) if key[1] == path]}
@@ -1797,6 +2195,7 @@ def build_records(extraction: dict | None, details: dict | None, files: list | N
1797
2195
  "T1-low": sum(1 for r in exports if r["confidence"] == "T1-low"),
1798
2196
  "T2": sum(1 for r in exports if r.get("qmd_evidence") is not None),
1799
2197
  },
2198
+ "marked_not_public": sum(1 for r in exports if r.get("public") is False),
1800
2199
  "warnings": warnings,
1801
2200
  }
1802
2201
  return {"mode": "normal", "files": blocks}, summary
@@ -2156,6 +2555,7 @@ def gap_records(manifest: dict, provenance: dict | None, *, source_root: Path |
2156
2555
  for g in routed:
2157
2556
  if g.record is not None and not g.internal:
2158
2557
  record = dict(g.record)
2558
+ record.pop("public", None) # a repair documents and maps every record: the mark is a normal update's
2159
2559
  if g.answers.get("docstring") is not None:
2160
2560
  record["docstring"] = g.answers["docstring"]
2161
2561
  if record not in kept.setdefault(g.record_path, []):
@@ -2356,10 +2756,43 @@ def _apply_categories(args: argparse.Namespace) -> dict | None:
2356
2756
  return {**(categories or {}), "category_c": cat_c}
2357
2757
 
2358
2758
 
2759
+ def _metadata_in(path: str | None) -> dict | None:
2760
+ """apply's --metadata-in: the skill's metadata.json, an object."""
2761
+ if not path:
2762
+ return None
2763
+ metadata = _load_json_file(Path(path), "--metadata-in file")
2764
+ if not isinstance(metadata, dict):
2765
+ raise ValueError("--metadata-in file must hold a JSON object (the skill's metadata.json)")
2766
+ return metadata
2767
+
2768
+
2769
+ def _cmd_metadata(args: argparse.Namespace) -> int:
2770
+ target = Path(args.metadata)
2771
+ try:
2772
+ metadata = update_metadata(
2773
+ _load_json_file(target, "--metadata file"), _load_json_file(Path(args.lists), "--lists file"),
2774
+ _load_json_file(Path(args.stats), "--stats file"), version=args.version,
2775
+ generation_date=args.generation_date, source_commit=args.source_commit, source_ref=args.source_ref)
2776
+ _write_json_atomic(target, metadata)
2777
+ except ValueError as exc:
2778
+ print(f"error: {exc}", file=sys.stderr)
2779
+ return 1
2780
+ except OSError as exc:
2781
+ print(f"error: cannot write {args.metadata}: {exc}", file=sys.stderr)
2782
+ return 1
2783
+ print(json.dumps({"status": "written", "metadata": target.as_posix(), "version": metadata["version"],
2784
+ "exports": len(metadata["exports"]), "scripts": len(metadata.get("scripts") or []),
2785
+ "assets": len(metadata.get("assets") or [])}))
2786
+ return 0
2787
+
2788
+
2359
2789
  def _cmd_apply(args: argparse.Namespace) -> int:
2360
2790
  if bool(args.drift_head) != bool(args.drift_pinned):
2361
2791
  print("error: --drift-head and --drift-pinned go together", file=sys.stderr)
2362
2792
  return 1
2793
+ if bool(args.metadata_in) != bool(args.lists_out):
2794
+ print("error: --metadata-in and --lists-out go together", file=sys.stderr)
2795
+ return 1
2363
2796
  try:
2364
2797
  promoted = _optional_json(args.promoted_docs, "--promoted-docs file")
2365
2798
  if promoted is not None and not isinstance(promoted, list):
@@ -2385,6 +2818,7 @@ def _cmd_apply(args: argparse.Namespace) -> int:
2385
2818
  source_commit=args.source_commit,
2386
2819
  source_ref=args.source_ref,
2387
2820
  drift=(args.drift_head, args.drift_pinned) if args.drift_head else None,
2821
+ metadata=_metadata_in(args.metadata_in),
2388
2822
  )
2389
2823
  except ValueError as exc:
2390
2824
  print(f"error: {exc}", file=sys.stderr)
@@ -2392,11 +2826,15 @@ def _cmd_apply(args: argparse.Namespace) -> int:
2392
2826
  if summary["status"] == "refused":
2393
2827
  print(json.dumps(summary))
2394
2828
  return EXIT_REFUSED
2395
- try:
2396
- _write_json_atomic(Path(args.output), doc)
2397
- except OSError as exc:
2398
- print(f"error: cannot write {args.output}: {exc}", file=sys.stderr)
2399
- return 1
2829
+ lists = summary.pop("lists", None)
2830
+ for path, value in ((args.not_public_out, summary["not_public"]), (args.lists_out, lists),
2831
+ (args.output, doc)):
2832
+ try:
2833
+ if path:
2834
+ _write_json_atomic(Path(path), value)
2835
+ except OSError as exc:
2836
+ print(f"error: cannot write {path}: {exc}", file=sys.stderr)
2837
+ return 1
2400
2838
  print(json.dumps({**summary, "map": args.output}))
2401
2839
  return 0
2402
2840
 
@@ -2464,6 +2902,76 @@ def _cmd_gap_records(args: argparse.Namespace) -> int:
2464
2902
  return 0
2465
2903
 
2466
2904
 
2905
+ # --------------------------------------------------------------------------
2906
+ # Baseline gaps (update-skill detect-changes Category B)
2907
+ # --------------------------------------------------------------------------
2908
+
2909
+
2910
+ VERIFIER_FILE = "skf-verify-provenance-completeness.py"
2911
+
2912
+
2913
+ def _baseline_verifier():
2914
+ """The verifier beside this script, for its `baseline_gaps`. Unlike `_verifier()`, which lets a gap-driven
2915
+ spot-check go on as unknown, one that cannot be loaded (or has no `baseline_gaps`) is a ValueError: with no
2916
+ verifier no gap is listed, and every name the runner leaves out would read as a deleted export."""
2917
+ path = Path(__file__).resolve().parent / VERIFIER_FILE
2918
+ where = f"cannot load {VERIFIER_FILE} beside {Path(__file__).name}"
2919
+ try:
2920
+ spec = importlib.util.spec_from_file_location("skf_verify_provenance_completeness_baseline", path)
2921
+ if spec is None or spec.loader is None or not path.is_file():
2922
+ raise ImportError(f"no file at {path}")
2923
+ module = importlib.util.module_from_spec(spec)
2924
+ spec.loader.exec_module(module)
2925
+ except Exception as exc: # whatever the script raises, the call reports it
2926
+ raise ValueError(f"{where}: {exc}; re-install SKF") from exc
2927
+ if not callable(getattr(module, "baseline_gaps", None)):
2928
+ raise ValueError(f"{where}: it has no baseline_gaps; re-install SKF")
2929
+ return module
2930
+
2931
+
2932
+ def baseline_gaps(provenance: dict, extraction: dict, files: list, source_root: Path, verifier) -> dict:
2933
+ """{"gaps", "unchecked"}: the map entries of `files` the runner's `extraction` lacks whose file still declares
2934
+ them (see "Baseline gaps" in the module docstring)."""
2935
+ if not isinstance(extraction, dict):
2936
+ raise ValueError("--extraction file has no `exports` array: is it the helper's output?")
2937
+ held = {(name, file) for e in _exports_of(extraction, "--extraction file")
2938
+ if (name := _name_of(e)) is not None and (file := _file_of(e)) is not None}
2939
+ diff = extraction.get("entry_point_diff")
2940
+ listed = {(item["name"], _norm_path(item["file"]))
2941
+ for item in (diff.get("extraction_gaps") or [] if isinstance(diff, dict) else [])
2942
+ if isinstance(item, dict) and isinstance(item.get("name"), str) and _norm_path(item.get("file"))}
2943
+ if provenance.get("entries") is not None and not isinstance(provenance["entries"], list):
2944
+ raise ValueError("provenance `entries` must be an array") # missing or null: no entry (#684)
2945
+ wanted = {f for f in (_norm_path(p) for p in files) if f is not None}
2946
+ gaps, unchecked = verifier.baseline_gaps(provenance, source_root, held, wanted, listed)
2947
+ return {"gaps": gaps, "unchecked": unchecked}
2948
+
2949
+
2950
+ def _cmd_baseline_gaps(args: argparse.Namespace) -> int:
2951
+ try:
2952
+ source_root = Path(args.source_root)
2953
+ if not source_root.is_dir():
2954
+ raise ValueError(f"--source-root {args.source_root} is not a folder")
2955
+ files = _load_json_file(Path(args.files), "--files file")
2956
+ if not (isinstance(files, list) and all(isinstance(f, str) for f in files)):
2957
+ raise ValueError(f"--files file {args.files} must hold a JSON array of paths")
2958
+ provenance = _load_json_file(Path(args.provenance_map), "--provenance-map file")
2959
+ if not isinstance(provenance, dict):
2960
+ raise ValueError(f"--provenance-map file {args.provenance_map} must hold a JSON object")
2961
+ extraction = _load_json_file(Path(args.extraction), "--extraction file")
2962
+ result = baseline_gaps(provenance, extraction, files, source_root, _baseline_verifier())
2963
+ _write_json_atomic(Path(args.output), result)
2964
+ except ValueError as exc:
2965
+ print(f"error: {exc}", file=sys.stderr)
2966
+ return 1
2967
+ except OSError as exc:
2968
+ print(f"error: cannot write {args.output}: {exc}", file=sys.stderr)
2969
+ return 1
2970
+ print(json.dumps({"status": "written", "output": args.output, "gaps": len(result["gaps"]),
2971
+ "unchecked": len(result["unchecked"])}))
2972
+ return 0
2973
+
2974
+
2467
2975
  def _build_parser() -> argparse.ArgumentParser:
2468
2976
  parser = argparse.ArgumentParser(
2469
2977
  prog="skf-build-change-manifest",
@@ -2537,9 +3045,36 @@ def _build_parser() -> argparse.ArgumentParser:
2537
3045
  p_apply.add_argument("--source-ref", default=None, help="the map's source_ref (empty: null)")
2538
3046
  p_apply.add_argument("--drift-head", default=None, help="under the drift override: HEAD's short SHA")
2539
3047
  p_apply.add_argument("--drift-pinned", default=None, help="under the drift override: the pinned short SHA")
3048
+ p_apply.add_argument("--not-public-out", metavar="FILE",
3049
+ help="also write the summary's not_public list here (a JSON array)")
3050
+ p_apply.add_argument("--metadata-in", metavar="FILE",
3051
+ help="the skill's metadata.json, whose exports[], scripts[] and assets[] the update "
3052
+ "starts from")
3053
+ p_apply.add_argument("--lists-out", metavar="FILE",
3054
+ help="write the exports[], scripts[] and assets[] metadata.json takes after the update "
3055
+ "here (a JSON object)")
2540
3056
  p_apply.add_argument("-o", "--output", required=True, metavar="FILE", help="where to write the new map")
2541
3057
  p_apply.set_defaults(func=_cmd_apply)
2542
3058
 
3059
+ p_meta = sub.add_parser(
3060
+ "metadata", help="write the metadata.json an update leaves",
3061
+ description="Rewrite the skill's metadata.json in place. It sets version and generation_date, "
3062
+ "source_commit and source_ref when given, exports, scripts and assets from --lists (a script "
3063
+ "or asset list left out when empty), confidence_distribution from --stats and the stats "
3064
+ "helper's stats fields, dropping one an earlier run wrote that --stats lacks; it removes "
3065
+ "last_update and update_type and keeps every other field, a patched one included. It refuses "
3066
+ "a --stats whose scripts_count or assets_count is not the length of --lists' scripts or assets.")
3067
+ p_meta.add_argument("--metadata", required=True, metavar="FILE",
3068
+ help="the skill's metadata.json, rewritten in place")
3069
+ p_meta.add_argument("--lists", required=True, metavar="FILE", help="the lists apply wrote (--lists-out)")
3070
+ p_meta.add_argument("--stats", required=True, metavar="FILE",
3071
+ help="the JSON skf-render-metadata-stats.py wrote (its -o file)")
3072
+ p_meta.add_argument("--version", required=True, help="the version this update writes")
3073
+ p_meta.add_argument("--generation-date", required=True, help="the clock's UTC time, YYYY-MM-DDTHH:MM:SSZ")
3074
+ p_meta.add_argument("--source-commit", default=None, help="the commit this update read (an empty one: null)")
3075
+ p_meta.add_argument("--source-ref", default=None, help="the ref this update read (an empty one: null)")
3076
+ p_meta.set_defaults(func=_cmd_metadata)
3077
+
2543
3078
  p_records = sub.add_parser("records", help="write step 3's re-extraction records from the runner's and the "
2544
3079
  "workers' files")
2545
3080
  p_records.add_argument("--extraction", metavar="FILE", help="the recipe runner's output")
@@ -2569,6 +3104,17 @@ def _build_parser() -> argparse.ArgumentParser:
2569
3104
  p_gap.add_argument("-o", "--output", metavar="FILE", help="where to write the records (without --plan)")
2570
3105
  p_gap.set_defaults(func=_cmd_gap_records)
2571
3106
 
3107
+ p_base = sub.add_parser("baseline-gaps", help="list the map entries the runner left out that their modified "
3108
+ "file still declares, for Category B's step 2 to read")
3109
+ p_base.add_argument("--provenance-map", required=True, metavar="FILE", help="the map the update starts from")
3110
+ p_base.add_argument("--extraction", required=True, metavar="FILE",
3111
+ help="the recipe runner's output ({\"exports\": []} when it could not run)")
3112
+ p_base.add_argument("--files", required=True, metavar="FILE",
3113
+ help="the files whose entries are checked: Category A's modified-files.json")
3114
+ p_base.add_argument("--source-root", required=True, metavar="DIR", help="the source tree the files are read in")
3115
+ p_base.add_argument("-o", "--output", required=True, metavar="FILE", help="where to write the gaps")
3116
+ p_base.set_defaults(func=_cmd_baseline_gaps)
3117
+
2572
3118
  for p in (p_build, p_ratio):
2573
3119
  p.add_argument(
2574
3120
  "--input", default=None, help="path to JSON input (default: stdin)"