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.
- package/.claude-plugin/marketplace.json +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/docs/examples.md +1 -1
- package/docs/troubleshooting.md +8 -0
- package/docs/verifying-a-skill.md +7 -3
- package/docs/workflows.md +3 -3
- package/package.json +1 -1
- package/src/knowledge/qmd-registry.md +1 -1
- package/src/knowledge/version-paths.md +1 -1
- package/src/shared/health-check.md +2 -50
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +2 -2
- package/src/shared/scripts/skf-atomic-write.py +212 -59
- package/src/shared/scripts/skf-build-change-manifest.py +593 -47
- package/src/shared/scripts/skf-classify-changed-files.py +78 -0
- package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
- package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
- package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
- package/src/shared/scripts/skf-extract-public-api.py +1309 -157
- package/src/shared/scripts/skf-extraction-inventory.py +580 -46
- package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
- package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
- package/src/shared/scripts/skf-github-probe.py +47 -8
- package/src/shared/scripts/skf-hash-content.py +14 -8
- package/src/shared/scripts/skf-load-provenance.py +3 -1
- package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
- package/src/shared/scripts/skf-preflight.py +15 -1
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
- package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
- package/src/shared/scripts/skf-structural-diff.py +133 -18
- package/src/shared/scripts/skf-update-active-symlink.py +115 -62
- package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
- package/src/shared/scripts/skf-verify-provenance-completeness.py +977 -49
- package/src/shared/scripts/skf-write-skill-brief.py +41 -8
- package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
- package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
- package/src/skf-audit-skill/references/headless-contract.md +1 -1
- package/src/skf-audit-skill/references/health-check.md +2 -0
- package/src/skf-audit-skill/references/init.md +2 -1
- package/src/skf-audit-skill/references/re-index.md +14 -9
- package/src/skf-audit-skill/references/report.md +2 -2
- package/src/skf-audit-skill/references/semantic-diff.md +15 -15
- package/src/skf-audit-skill/references/structural-diff.md +10 -5
- package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
- package/src/skf-brief-skill/SKILL.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -9
- package/src/skf-brief-skill/references/analyze-target.md +6 -6
- package/src/skf-brief-skill/references/confirm-brief.md +1 -1
- package/src/skf-brief-skill/references/scope-definition.md +4 -4
- package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
- package/src/skf-brief-skill/references/version-resolution.md +5 -9
- package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
- package/src/skf-create-skill/assets/skill-sections.md +6 -6
- package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
- package/src/skf-create-skill/references/compile.md +31 -60
- package/src/skf-create-skill/references/component-extraction.md +3 -3
- package/src/skf-create-skill/references/enrich.md +2 -2
- package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
- package/src/skf-create-skill/references/extract.md +22 -19
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
- package/src/skf-create-skill/references/extraction-patterns.md +8 -9
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +16 -5
- package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
- package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
- package/src/skf-drop-skill/references/execute.md +5 -1
- package/src/skf-drop-skill/references/health-check.md +2 -0
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-drop-skill/references/report.md +2 -2
- package/src/skf-drop-skill/references/select.md +7 -3
- package/src/skf-test-skill/references/coherence-check.md +1 -1
- package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
- package/src/skf-test-skill/references/coverage-check.md +39 -57
- package/src/skf-test-skill/references/init.md +3 -5
- package/src/skf-test-skill/references/report.md +10 -8
- package/src/skf-test-skill/references/scoring-rules.md +6 -5
- package/src/skf-test-skill/references/source-access-protocol.md +14 -17
- package/src/skf-test-skill/scripts/build-result-context.py +114 -21
- package/src/skf-test-skill/scripts/gap-ledger.py +315 -30
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +534 -96
- package/src/skf-test-skill/scripts/reconcile-coverage.py +276 -2
- package/src/skf-test-skill/scripts/score-signatures.py +36 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
- package/src/skf-update-skill/references/detect-changes.md +32 -13
- package/src/skf-update-skill/references/gap-driven.md +3 -3
- package/src/skf-update-skill/references/init.md +6 -2
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +2 -2
- package/src/skf-update-skill/references/re-extract.md +2 -2
- package/src/skf-update-skill/references/report.md +3 -1
- package/src/skf-update-skill/references/write.md +43 -23
- 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
|
|
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"}, "
|
|
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,
|
|
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,
|
|
237
|
-
|
|
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
|
|
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"}, "
|
|
300
|
-
"removed"}, "warnings": [...]},
|
|
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,
|
|
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,
|
|
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)
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1593
|
-
|
|
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,
|
|
1689
|
-
|
|
1690
|
-
|
|
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
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
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)"
|