skilldrop-cli 0.16.34 → 0.16.36
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/README.md +1 -0
- package/agents/README.md +4 -1
- package/agents/docs-reviewer.md +47 -0
- package/agents/finding-adjudicator.md +49 -0
- package/agents/test-reviewer.md +52 -0
- package/catalogue.json +17 -7
- package/model-routing.json +40 -0
- package/package.json +1 -1
- package/packs/ai-engineering/skills/agent-config-review/SKILL.md +116 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/eval_queries.json +12 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/evals.json +75 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/agents/db-migrator.md +8 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/agents/reviewer.md +9 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/settings.json +48 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/settings.local.json +5 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.mcp.json +22 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/package.json +12 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/2/.cursor/mcp.json +17 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/2/config.toml +21 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/4/my-user-settings.json +11 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.claude/agents/release-notes.md +13 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.claude/settings.json +39 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.mcp.json +18 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/package.json +14 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/5/.github/workflows/codex-triage.yml +29 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/5/AGENTS.md +16 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/5/codex/config.toml +34 -0
- package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/.claude/settings.json +22 -0
- package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/.mcp.json +9 -0
- package/packs/ai-engineering/skills/agent-config-review/examples/python-repo-tighten.md +81 -0
- package/packs/ai-engineering/skills/agent-config-review/manifest.json +60 -0
- package/packs/ai-engineering/skills/agent-config-review/reference.md +205 -0
- package/packs/ai-engineering/skills/agent-config-review/scripts/agent_config_scan.py +989 -0
- package/packs/ai-engineering/skills/agent-incident-response/SKILL.md +175 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/eval_queries.json +14 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/evals.json +107 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/agent-session-excerpt.txt +11 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/ci-build-log.txt +24 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/incident-notes.md +19 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/2/mcp-config.json +20 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/2/session-transcript.txt +8 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/4/agent-session.log +70 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/4/infra/staging-db.tf +25 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/jobs/nightly-triage/job.yaml +19 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/jobs/nightly-triage/run.log +13 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/usage-export-2026-10-20.csv +13 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/.mcp.json +20 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/git-log.txt +12 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/notesync-excerpt.txt +30 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/notesync.log +15 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/ssh-config.txt +16 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/.github/workflows/ci.yml +28 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/SECURITY.md +8 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/issue-479.md +35 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/package.json +15 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/pr-482.diff +20 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/run-log.txt +30 -0
- package/packs/ai-engineering/skills/agent-incident-response/examples/injected-issue-comment.md +57 -0
- package/packs/ai-engineering/skills/agent-incident-response/manifest.json +55 -0
- package/packs/ai-engineering/skills/agent-incident-response/reference.md +152 -0
- package/packs/ai-engineering/skills/agent-incident-response/scripts/find_secret_exposure.py +239 -0
- package/packs/ai-engineering/skills/agent-incident-response/scripts/usage_split.py +194 -0
- package/packs/converters/skills/file-to-markdown/SKILL.md +3 -2
- package/packs/converters/skills/file-to-markdown/evals/eval_queries.json +4 -0
- package/packs/converters/skills/file-to-markdown/manifest.json +2 -2
- package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +340 -7
- package/packs/converters/skills/md-to-confluence/SKILL.md +139 -0
- package/packs/converters/skills/md-to-confluence/evals/eval_queries.json +42 -0
- package/packs/converters/skills/md-to-confluence/evals/evals.json +89 -0
- package/packs/converters/skills/md-to-confluence/evals/files/1/img/latency-dashboard.png +0 -0
- package/packs/converters/skills/md-to-confluence/evals/files/1/payments-runbook.md +60 -0
- package/packs/converters/skills/md-to-confluence/evals/files/2/deploy-checklist.md +33 -0
- package/packs/converters/skills/md-to-confluence/evals/files/3/img/after/screen.png +0 -0
- package/packs/converters/skills/md-to-confluence/evals/files/3/img/before/screen.png +0 -0
- package/packs/converters/skills/md-to-confluence/evals/files/3/release-notes-4.2.md +49 -0
- package/packs/converters/skills/md-to-confluence/evals/files/4/docs/img/topology.png +0 -0
- package/packs/converters/skills/md-to-confluence/evals/files/4/docs/ingest-pipeline-design.md +65 -0
- package/packs/converters/skills/md-to-confluence/evals/files/5/support/troubleshooting-sso.md +57 -0
- package/packs/converters/skills/md-to-confluence/examples/inputs/img/queue-depth.png +0 -0
- package/packs/converters/skills/md-to-confluence/examples/inputs/queue-runbook.fixed.md +21 -0
- package/packs/converters/skills/md-to-confluence/examples/inputs/queue-runbook.md +21 -0
- package/packs/converters/skills/md-to-confluence/examples/queue-runbook.md +57 -0
- package/packs/converters/skills/md-to-confluence/manifest.json +52 -0
- package/packs/converters/skills/md-to-confluence/reference.md +92 -0
- package/packs/converters/skills/md-to-confluence/scripts/md_to_confluence.py +821 -0
- package/packs/converters/skills/md-to-pptx/SKILL.md +131 -0
- package/packs/converters/skills/md-to-pptx/evals/eval_queries.json +12 -0
- package/packs/converters/skills/md-to-pptx/evals/evals.json +84 -0
- package/packs/converters/skills/md-to-pptx/evals/files/1/img/defect-burndown.png +0 -0
- package/packs/converters/skills/md-to-pptx/evals/files/1/status-report.md +75 -0
- package/packs/converters/skills/md-to-pptx/evals/files/3/brand.json +12 -0
- package/packs/converters/skills/md-to-pptx/evals/files/3/night-shift-induction.md +53 -0
- package/packs/converters/skills/md-to-pptx/evals/files/4/charts/ontime.png +0 -0
- package/packs/converters/skills/md-to-pptx/evals/files/4/qbr-q3.md +85 -0
- package/packs/converters/skills/md-to-pptx/evals/files/5/vendor-sync-notes.md +28 -0
- package/packs/converters/skills/md-to-pptx/examples/support-weekly-input.md +48 -0
- package/packs/converters/skills/md-to-pptx/examples/support-weekly.md +75 -0
- package/packs/converters/skills/md-to-pptx/examples/tickets-by-week.png +0 -0
- package/packs/converters/skills/md-to-pptx/manifest.json +49 -0
- package/packs/converters/skills/md-to-pptx/reference.md +139 -0
- package/packs/converters/skills/md-to-pptx/scripts/md_to_pptx.py +1841 -0
- package/packs/dev-team/loops/modernize/LOOP.md +124 -0
- package/packs/dev-team/loops/modernize/evals/eval_queries.json +9 -0
- package/packs/dev-team/loops/modernize/evals/evals.json +27 -0
- package/packs/dev-team/loops/modernize/loop.json +60 -0
- package/packs/dev-team/skills/behaviour-equivalence/SKILL.md +155 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/eval_queries.json +11 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/evals.json +76 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/cases.jsonl +12 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/new.jsonl +12 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/notes.md +15 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/old.jsonl +12 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/old_rerun.jsonl +12 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/pricing_v1.py +45 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/pricing_v2.py +65 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/rules.json +10 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/3/diff-summary.txt +16 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/diff.txt +13 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/fee_calc_py2.py +16 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/fee_calc_py3.py +24 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/input.csv +13 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/new_output.csv +13 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/old_output.csv +13 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/TEAM_NOTES.md +19 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/normalisation.yaml +12 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/new_run.jsonl +8 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/old_run1.jsonl +8 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/old_run2.jsonl +8 -0
- package/packs/dev-team/skills/behaviour-equivalence/examples/delivery-date.md +78 -0
- package/packs/dev-team/skills/behaviour-equivalence/manifest.json +33 -0
- package/packs/dev-team/skills/behaviour-equivalence/reference.md +97 -0
- package/packs/dev-team/skills/behaviour-equivalence/scripts/golden_diff.py +524 -0
- package/packs/dev-team/skills/behaviour-equivalence/templates/accepted.json +10 -0
- package/packs/dev-team/skills/behaviour-equivalence/templates/rules.json +7 -0
- package/packs/frontend/pack.json +55 -0
- package/packs/frontend/skills/rendering-strategy/SKILL.md +149 -0
- package/packs/frontend/skills/rendering-strategy/evals/eval_queries.json +38 -0
- package/packs/frontend/skills/rendering-strategy/evals/evals.json +77 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/1/fernhill-app.md +40 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/3/routes.md +33 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/4/build-notes.txt +5 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/4/nuxt.config.ts +19 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/4/pages/recipes/[slug].vue +37 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/4/routes.md +15 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/routes.txt +18 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/src/hooks.server.ts +14 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/src/routes/(marketing)/pricing/+page.server.ts +10 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/src/routes/+layout.server.ts +10 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/svelte.config.js +10 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/traffic.md +18 -0
- package/packs/frontend/skills/rendering-strategy/examples/saas-site.md +42 -0
- package/packs/frontend/skills/rendering-strategy/manifest.json +50 -0
- package/packs/frontend/skills/rendering-strategy/reference.md +116 -0
- package/packs/frontend/skills/rendering-strategy/scripts/render_load.py +181 -0
- package/packs/frontend/skills/web-performance-review/SKILL.md +106 -0
- package/packs/frontend/skills/web-performance-review/evals/eval_queries.json +38 -0
- package/packs/frontend/skills/web-performance-review/evals/evals.json +76 -0
- package/packs/frontend/skills/web-performance-review/evals/files/1/psi.json +1065 -0
- package/packs/frontend/skills/web-performance-review/evals/files/2/after.json +359 -0
- package/packs/frontend/skills/web-performance-review/evals/files/2/before.json +375 -0
- package/packs/frontend/skills/web-performance-review/evals/files/4/psi-pdp-mobile-2026-10-19.json +171 -0
- package/packs/frontend/skills/web-performance-review/evals/files/4/rum-inp-attribution-oct14-19.csv +6 -0
- package/packs/frontend/skills/web-performance-review/evals/files/4/rum-inp-daily.csv +15 -0
- package/packs/frontend/skills/web-performance-review/evals/files/5/crux-home-phone.json +35 -0
- package/packs/frontend/skills/web-performance-review/evals/files/5/lighthouse-home-mobile.json +173 -0
- package/packs/frontend/skills/web-performance-review/examples/quayside-article-lhr.json +480 -0
- package/packs/frontend/skills/web-performance-review/examples/quayside-article.md +103 -0
- package/packs/frontend/skills/web-performance-review/manifest.json +48 -0
- package/packs/frontend/skills/web-performance-review/reference.md +105 -0
- package/packs/frontend/skills/web-performance-review/scripts/lh_summary.py +743 -0
- package/packs/product-manager/skills/lean-canvas/SKILL.md +162 -0
- package/packs/product-manager/skills/lean-canvas/evals/eval_queries.json +42 -0
- package/packs/product-manager/skills/lean-canvas/evals/evals.json +82 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/1/pawpass-notes.md +54 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/3/lunchloop-pitch.md +30 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/4/clubkit-notes.md +44 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/5/cost-model.csv +5 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/5/proposal.md +40 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/5/stakeholder-emails.txt +18 -0
- package/packs/product-manager/skills/lean-canvas/examples/bike-repair.md +72 -0
- package/packs/product-manager/skills/lean-canvas/manifest.json +34 -0
- package/packs/product-manager/skills/lean-canvas/reference.md +102 -0
- package/packs/product-manager/skills/lean-canvas/scripts/unit_economics.py +211 -0
- package/packs/solution-architect/skills/api-contract-draft/SKILL.md +2 -2
- package/packs/solution-architect/skills/api-contract-draft/manifest.json +28 -6
- package/packs/solution-architect/skills/event-contract-draft/SKILL.md +167 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/eval_queries.json +38 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/evals.json +76 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/files/1/order-events-brief.md +45 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/files/4/NOTES.md +23 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/files/4/schemas/shipment-update.json +40 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/files/5/invoice.example.json +25 -0
- package/packs/solution-architect/skills/event-contract-draft/examples/order-service.asyncapi.json +246 -0
- package/packs/solution-architect/skills/event-contract-draft/examples/order-service.md +92 -0
- package/packs/solution-architect/skills/event-contract-draft/manifest.json +25 -0
- package/packs/solution-architect/skills/event-contract-draft/reference.md +113 -0
- package/packs/solution-architect/skills/event-contract-draft/scripts/check_asyncapi.py +502 -0
- package/packs/solution-architect/skills/event-contract-draft/templates/asyncapi-skeleton.json +144 -0
- package/packs/sre-oncall/skills/slo-spec/SKILL.md +161 -0
- package/packs/sre-oncall/skills/slo-spec/evals/eval_queries.json +38 -0
- package/packs/sre-oncall/skills/slo-spec/evals/evals.json +75 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/1/order-service.md +51 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/3/search-slo-status.md +30 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/4/alerts/payments-api.rules.yaml +16 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/4/docs/dependencies.md +8 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/4/metrics/summary-90d.md +51 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/5/ops-notes.md +34 -0
- package/packs/sre-oncall/skills/slo-spec/examples/ticket-booking.md +88 -0
- package/packs/sre-oncall/skills/slo-spec/manifest.json +34 -0
- package/packs/sre-oncall/skills/slo-spec/reference.md +113 -0
- package/packs/sre-oncall/skills/slo-spec/scripts/slo_math.py +449 -0
- package/packs/sre-oncall/skills/slo-spec/templates/slo-spec.md +35 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Compare recorded outputs of an old and a new system, case by case (golden master).
|
|
3
|
+
|
|
4
|
+
Each side is either
|
|
5
|
+
- a directory: one file per case, the case id is the file name without its
|
|
6
|
+
extension; *.json files are parsed as JSON, any other file is compared as
|
|
7
|
+
text; or
|
|
8
|
+
- a JSONL file: one JSON object per line, the case id is the value of --key
|
|
9
|
+
(default "case_id").
|
|
10
|
+
|
|
11
|
+
A rules file normalises known noise before comparing. Each rule names one
|
|
12
|
+
field path, one rule and a reason:
|
|
13
|
+
|
|
14
|
+
{"rules": [
|
|
15
|
+
{"path": "body.quoted_at", "rule": "ignore", "reason": "wall-clock time"},
|
|
16
|
+
{"path": "body.quote_id", "rule": "regex", "pattern": "^Q-[0-9a-f]{8}$",
|
|
17
|
+
"replace": "Q-<id>", "reason": "random per call"},
|
|
18
|
+
{"path": "body.lines", "rule": "sort", "by": "sku", "reason": "set order"},
|
|
19
|
+
{"path": "body.fx_rate", "rule": "tolerance", "abs": "0.000001",
|
|
20
|
+
"reason": "float printed by a different library"},
|
|
21
|
+
{"path": "body.score", "rule": "round", "digits": 3, "reason": "..."}
|
|
22
|
+
]}
|
|
23
|
+
|
|
24
|
+
A plain object {"<path>": "ignore" | {"rule": ...}} is accepted too.
|
|
25
|
+
|
|
26
|
+
Paths: dotted keys; `[]` is every element of a list, `[2]` one element, `*` any
|
|
27
|
+
key at that level, `$` the whole record (for text files). There is no
|
|
28
|
+
any-depth wildcard on purpose: every rule names where it applies.
|
|
29
|
+
|
|
30
|
+
Rules:
|
|
31
|
+
ignore the value becomes "<ignored>"; a field present on one side only
|
|
32
|
+
still counts as a difference
|
|
33
|
+
regex re.sub(pattern, replace) on string values (replace defaults to "<norm>")
|
|
34
|
+
sort sorts the list, by the element key "by" or by the whole element
|
|
35
|
+
tolerance numbers within "abs" and/or "rel" compare equal (applied while comparing)
|
|
36
|
+
round rounds numbers to "digits" places, "mode" half_even (default) or half_up
|
|
37
|
+
Order: ignore, regex and round in file order, then sort, then compare.
|
|
38
|
+
|
|
39
|
+
--accepted FILE lists intended changes, each with an approver:
|
|
40
|
+
{"accepted": [{"path": "body.message", "reason": "...", "approver": "...",
|
|
41
|
+
"cases": ["c07"]}]}
|
|
42
|
+
A difference at that path (in those cases, or every case when "cases" is
|
|
43
|
+
absent) is reported as accepted instead of failing the comparison.
|
|
44
|
+
|
|
45
|
+
Usage:
|
|
46
|
+
python3 golden_diff.py OLD NEW [--rules rules.json] [--accepted accepted.json]
|
|
47
|
+
[--key case_id] [--max-diffs 20] [--json] [-o report]
|
|
48
|
+
|
|
49
|
+
Exit codes: 0 equivalent after rules (accepted changes allowed),
|
|
50
|
+
1 not equivalent (a difference, or a case missing on one side),
|
|
51
|
+
2 bad input.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
import argparse
|
|
55
|
+
import json
|
|
56
|
+
import re
|
|
57
|
+
import sys
|
|
58
|
+
from collections import OrderedDict
|
|
59
|
+
from decimal import ROUND_HALF_EVEN, ROUND_HALF_UP, Decimal, InvalidOperation
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
|
|
62
|
+
RULES = ("ignore", "regex", "sort", "tolerance", "round")
|
|
63
|
+
MONEY_WORDS = ("price", "amount", "total", "cost", "fee", "tax", "vat", "minor",
|
|
64
|
+
"cents", "pence", "balance", "charge", "subtotal", "money")
|
|
65
|
+
IGNORED = "<ignored>"
|
|
66
|
+
ABSENT = object()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BadInput(Exception):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------- paths
|
|
74
|
+
|
|
75
|
+
TOKEN = re.compile(r"\[(\d*)\]|([^.\[\]]+)")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_path(text):
|
|
79
|
+
text = text.strip()
|
|
80
|
+
if text in ("$", ""):
|
|
81
|
+
return []
|
|
82
|
+
if text.startswith("$."):
|
|
83
|
+
text = text[2:]
|
|
84
|
+
out, pos = [], 0
|
|
85
|
+
while pos < len(text):
|
|
86
|
+
if text[pos] == ".":
|
|
87
|
+
pos += 1
|
|
88
|
+
continue
|
|
89
|
+
m = TOKEN.match(text, pos)
|
|
90
|
+
if not m:
|
|
91
|
+
raise BadInput("cannot read path: " + text)
|
|
92
|
+
if m.group(0).startswith("["):
|
|
93
|
+
out.append(("idx", int(m.group(1))) if m.group(1) else ("any_idx", None))
|
|
94
|
+
elif m.group(2) == "*":
|
|
95
|
+
out.append(("any_key", None))
|
|
96
|
+
else:
|
|
97
|
+
out.append(("key", m.group(2)))
|
|
98
|
+
pos = m.end()
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def matches(pattern, concrete):
|
|
103
|
+
if len(pattern) != len(concrete):
|
|
104
|
+
return False
|
|
105
|
+
for (kind, val), step in zip(pattern, concrete):
|
|
106
|
+
if kind == "key" and not (isinstance(step, str) and step == val):
|
|
107
|
+
return False
|
|
108
|
+
if kind == "any_key" and not isinstance(step, str):
|
|
109
|
+
return False
|
|
110
|
+
if kind == "idx" and not (isinstance(step, int) and step == val):
|
|
111
|
+
return False
|
|
112
|
+
if kind == "any_idx" and not isinstance(step, int):
|
|
113
|
+
return False
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def show(concrete):
|
|
118
|
+
out = ""
|
|
119
|
+
for step in concrete:
|
|
120
|
+
out += "[{}]".format(step) if isinstance(step, int) else ("." if out else "") + step
|
|
121
|
+
return out or "$"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def generalise(concrete):
|
|
125
|
+
return show([0 if isinstance(s, int) else s for s in concrete]).replace("[0]", "[]")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------- loading
|
|
129
|
+
|
|
130
|
+
def num(value, what):
|
|
131
|
+
try:
|
|
132
|
+
return Decimal(str(value))
|
|
133
|
+
except (InvalidOperation, ValueError):
|
|
134
|
+
raise BadInput("{} must be a number, got {!r}".format(what, value))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def load_rules(path):
|
|
138
|
+
try:
|
|
139
|
+
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
140
|
+
except (OSError, ValueError) as exc:
|
|
141
|
+
raise BadInput("cannot read rules file {}: {}".format(path, exc))
|
|
142
|
+
if isinstance(raw, dict) and "rules" in raw:
|
|
143
|
+
items = raw["rules"]
|
|
144
|
+
elif isinstance(raw, dict):
|
|
145
|
+
items = []
|
|
146
|
+
for p, spec in raw.items():
|
|
147
|
+
spec = {"rule": spec} if isinstance(spec, str) else dict(spec)
|
|
148
|
+
spec["path"] = p
|
|
149
|
+
items.append(spec)
|
|
150
|
+
else:
|
|
151
|
+
items = raw
|
|
152
|
+
if not isinstance(items, list):
|
|
153
|
+
raise BadInput("rules file must hold a list of rules")
|
|
154
|
+
rules = []
|
|
155
|
+
for i, r in enumerate(items, 1):
|
|
156
|
+
if not isinstance(r, dict) or "path" not in r or "rule" not in r:
|
|
157
|
+
raise BadInput("rule {} needs a path and a rule".format(i))
|
|
158
|
+
if r["rule"] not in RULES:
|
|
159
|
+
raise BadInput("rule {}: unknown rule {!r} (use one of {})".format(i, r["rule"], ", ".join(RULES)))
|
|
160
|
+
rule = {"n": i, "path": r["path"], "pat": parse_path(r["path"]), "rule": r["rule"],
|
|
161
|
+
"reason": str(r.get("reason", "")).strip(), "hits": 0, "subtree": False}
|
|
162
|
+
if r["rule"] == "regex":
|
|
163
|
+
try:
|
|
164
|
+
rule["re"] = re.compile(r["pattern"])
|
|
165
|
+
except (KeyError, re.error) as exc:
|
|
166
|
+
raise BadInput("rule {}: bad or missing pattern: {}".format(i, exc))
|
|
167
|
+
rule["replace"] = r.get("replace", "<norm>")
|
|
168
|
+
elif r["rule"] == "tolerance":
|
|
169
|
+
if "abs" not in r and "rel" not in r:
|
|
170
|
+
raise BadInput("rule {}: tolerance needs abs or rel".format(i))
|
|
171
|
+
rule["abs"] = num(r.get("abs", 0), "rule {} abs".format(i))
|
|
172
|
+
rule["rel"] = num(r.get("rel", 0), "rule {} rel".format(i))
|
|
173
|
+
elif r["rule"] == "round":
|
|
174
|
+
if not isinstance(r.get("digits"), int):
|
|
175
|
+
raise BadInput("rule {}: round needs integer digits".format(i))
|
|
176
|
+
rule["digits"] = r["digits"]
|
|
177
|
+
mode = r.get("mode", "half_even")
|
|
178
|
+
if mode not in ("half_even", "half_up"):
|
|
179
|
+
raise BadInput("rule {}: mode must be half_even or half_up".format(i))
|
|
180
|
+
rule["mode"] = ROUND_HALF_EVEN if mode == "half_even" else ROUND_HALF_UP
|
|
181
|
+
elif r["rule"] == "sort":
|
|
182
|
+
rule["by"] = r.get("by")
|
|
183
|
+
rules.append(rule)
|
|
184
|
+
return rules
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def load_accepted(path):
|
|
188
|
+
try:
|
|
189
|
+
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
190
|
+
except (OSError, ValueError) as exc:
|
|
191
|
+
raise BadInput("cannot read accepted-changes file {}: {}".format(path, exc))
|
|
192
|
+
items = raw.get("accepted") if isinstance(raw, dict) else raw
|
|
193
|
+
if not isinstance(items, list):
|
|
194
|
+
raise BadInput("accepted-changes file must hold a list under \"accepted\"")
|
|
195
|
+
out = []
|
|
196
|
+
for i, a in enumerate(items, 1):
|
|
197
|
+
if not isinstance(a, dict) or not a.get("path"):
|
|
198
|
+
raise BadInput("accepted change {} needs a path".format(i))
|
|
199
|
+
if not str(a.get("approver", "")).strip():
|
|
200
|
+
raise BadInput("accepted change {} ({}) has no approver".format(i, a["path"]))
|
|
201
|
+
cases = a.get("cases")
|
|
202
|
+
out.append({"path": a["path"], "pat": parse_path(a["path"]), "approver": a["approver"],
|
|
203
|
+
"reason": a.get("reason", ""), "cases": set(cases) if cases else None, "hits": 0})
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def parse_json(text, where):
|
|
208
|
+
try:
|
|
209
|
+
return json.loads(text, parse_float=Decimal)
|
|
210
|
+
except ValueError as exc:
|
|
211
|
+
raise BadInput("{}: not valid JSON: {}".format(where, exc))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def load_side(path, key):
|
|
215
|
+
p = Path(path)
|
|
216
|
+
cases = OrderedDict()
|
|
217
|
+
if p.is_dir():
|
|
218
|
+
for f in sorted(p.iterdir()):
|
|
219
|
+
if f.name.startswith(".") or not f.is_file():
|
|
220
|
+
continue
|
|
221
|
+
if f.stem in cases:
|
|
222
|
+
raise BadInput("{}: two files for case {}".format(path, f.stem))
|
|
223
|
+
text = f.read_text(encoding="utf-8")
|
|
224
|
+
cases[f.stem] = parse_json(text, str(f)) if f.suffix == ".json" else text
|
|
225
|
+
elif p.is_file():
|
|
226
|
+
with p.open(encoding="utf-8") as fh:
|
|
227
|
+
for n, line in enumerate(fh, 1):
|
|
228
|
+
if not line.strip():
|
|
229
|
+
continue
|
|
230
|
+
rec = parse_json(line, "{} line {}".format(path, n))
|
|
231
|
+
if not isinstance(rec, dict) or key not in rec:
|
|
232
|
+
raise BadInput("{} line {}: no {!r} field".format(path, n, key))
|
|
233
|
+
cid = str(rec[key])
|
|
234
|
+
if cid in cases:
|
|
235
|
+
raise BadInput("{} line {}: duplicate case id {}".format(path, n, cid))
|
|
236
|
+
cases[cid] = rec
|
|
237
|
+
else:
|
|
238
|
+
raise BadInput("not found: " + str(path))
|
|
239
|
+
if not cases:
|
|
240
|
+
raise BadInput("no cases in " + str(path))
|
|
241
|
+
return cases
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------- normalising
|
|
245
|
+
|
|
246
|
+
def canon(value):
|
|
247
|
+
return json.dumps(value, sort_keys=True, default=str, ensure_ascii=False)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def is_num(v):
|
|
251
|
+
return isinstance(v, (int, Decimal)) and not isinstance(v, bool)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def apply_rule(rule, value):
|
|
255
|
+
kind = rule["rule"]
|
|
256
|
+
if kind == "ignore":
|
|
257
|
+
if isinstance(value, (dict, list)):
|
|
258
|
+
rule["subtree"] = True
|
|
259
|
+
rule["hits"] += 1
|
|
260
|
+
return IGNORED
|
|
261
|
+
if kind == "regex" and isinstance(value, str):
|
|
262
|
+
rule["hits"] += 1
|
|
263
|
+
return rule["re"].sub(rule["replace"], value)
|
|
264
|
+
if kind == "round" and is_num(value):
|
|
265
|
+
rule["hits"] += 1
|
|
266
|
+
q = Decimal(1).scaleb(-rule["digits"])
|
|
267
|
+
return Decimal(value).quantize(q, rounding=rule["mode"])
|
|
268
|
+
if kind == "sort" and isinstance(value, list):
|
|
269
|
+
rule["hits"] += 1
|
|
270
|
+
by = rule.get("by")
|
|
271
|
+
if by:
|
|
272
|
+
return sorted(value, key=lambda e: canon(e.get(by) if isinstance(e, dict) else e))
|
|
273
|
+
return sorted(value, key=canon)
|
|
274
|
+
return value
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def normalise(value, rules, here=()):
|
|
278
|
+
if isinstance(value, dict):
|
|
279
|
+
value = OrderedDict((k, normalise(v, rules, here + (k,))) for k, v in value.items())
|
|
280
|
+
elif isinstance(value, list):
|
|
281
|
+
value = [normalise(v, rules, here + (i,)) for i, v in enumerate(value)]
|
|
282
|
+
for rule in rules:
|
|
283
|
+
if matches(rule["pat"], list(here)):
|
|
284
|
+
value = apply_rule(rule, value)
|
|
285
|
+
return value
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------- comparing
|
|
289
|
+
|
|
290
|
+
def within(a, b, rule):
|
|
291
|
+
a, b = Decimal(a), Decimal(b)
|
|
292
|
+
gap = abs(a - b)
|
|
293
|
+
return gap <= rule["abs"] or gap <= rule["rel"] * max(abs(a), abs(b))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def compare(old, new, tolerances, here, diffs):
|
|
297
|
+
tol = next((t for t in tolerances if matches(t["pat"], list(here))), None)
|
|
298
|
+
if is_num(old) and is_num(new):
|
|
299
|
+
if Decimal(old) == Decimal(new):
|
|
300
|
+
return
|
|
301
|
+
if tol and within(old, new, tol):
|
|
302
|
+
tol["hits"] += 1
|
|
303
|
+
return
|
|
304
|
+
diffs.append((here, "changed", old, new))
|
|
305
|
+
return
|
|
306
|
+
if isinstance(old, dict) and isinstance(new, dict):
|
|
307
|
+
for k in old:
|
|
308
|
+
if k not in new:
|
|
309
|
+
diffs.append((here + (k,), "missing in new", old[k], ABSENT))
|
|
310
|
+
else:
|
|
311
|
+
compare(old[k], new[k], tolerances, here + (k,), diffs)
|
|
312
|
+
for k in new:
|
|
313
|
+
if k not in old:
|
|
314
|
+
diffs.append((here + (k,), "added in new", ABSENT, new[k]))
|
|
315
|
+
return
|
|
316
|
+
if isinstance(old, list) and isinstance(new, list):
|
|
317
|
+
if len(old) != len(new):
|
|
318
|
+
diffs.append((here, "list length {} -> {}".format(len(old), len(new)), len(old), len(new)))
|
|
319
|
+
for i in range(min(len(old), len(new))):
|
|
320
|
+
compare(old[i], new[i], tolerances, here + (i,), diffs)
|
|
321
|
+
return
|
|
322
|
+
if type(old) is not type(new) and not (is_num(old) and is_num(new)):
|
|
323
|
+
diffs.append((here, "type {} -> {}".format(tname(old), tname(new)), old, new))
|
|
324
|
+
return
|
|
325
|
+
if isinstance(old, str) and ("\n" in old or "\n" in new):
|
|
326
|
+
a, b = old.split("\n"), new.split("\n")
|
|
327
|
+
for n in range(max(len(a), len(b))):
|
|
328
|
+
la = a[n] if n < len(a) else ABSENT
|
|
329
|
+
lb = b[n] if n < len(b) else ABSENT
|
|
330
|
+
if la != lb:
|
|
331
|
+
diffs.append((here, "line {} changed".format(n + 1), la, lb))
|
|
332
|
+
return
|
|
333
|
+
if old != new:
|
|
334
|
+
diffs.append((here, "changed", old, new))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def tname(v):
|
|
338
|
+
if v is None:
|
|
339
|
+
return "null"
|
|
340
|
+
if isinstance(v, bool):
|
|
341
|
+
return "bool"
|
|
342
|
+
if is_num(v):
|
|
343
|
+
return "number"
|
|
344
|
+
return {str: "string", dict: "object", list: "list"}.get(type(v), type(v).__name__)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def plain(v):
|
|
348
|
+
if v is ABSENT:
|
|
349
|
+
return "(absent)"
|
|
350
|
+
if isinstance(v, Decimal):
|
|
351
|
+
return int(v) if v == v.to_integral_value() and "." not in str(v) else float(v)
|
|
352
|
+
if isinstance(v, dict):
|
|
353
|
+
return {k: plain(x) for k, x in v.items()}
|
|
354
|
+
if isinstance(v, list):
|
|
355
|
+
return [plain(x) for x in v]
|
|
356
|
+
return v
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def short(v, width=80):
|
|
360
|
+
if v is ABSENT:
|
|
361
|
+
return "(absent)"
|
|
362
|
+
text = v.replace("\n", "\\n") if isinstance(v, str) else json.dumps(plain(v), ensure_ascii=False, default=str)
|
|
363
|
+
return text if len(text) <= width else text[: width - 3] + "..."
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def run(args):
|
|
367
|
+
rules = load_rules(args.rules) if args.rules else []
|
|
368
|
+
accepted = load_accepted(args.accepted) if args.accepted else []
|
|
369
|
+
old = load_side(args.old, args.key)
|
|
370
|
+
new = load_side(args.new, args.key)
|
|
371
|
+
transforms = [r for r in rules if r["rule"] in ("ignore", "regex", "round")]
|
|
372
|
+
sorts = [r for r in rules if r["rule"] == "sort"]
|
|
373
|
+
tolerances = [r for r in rules if r["rule"] == "tolerance"]
|
|
374
|
+
|
|
375
|
+
matched, mismatched, accepted_only = [], [], []
|
|
376
|
+
all_diffs, accepted_diffs = [], []
|
|
377
|
+
for cid in old:
|
|
378
|
+
if cid not in new:
|
|
379
|
+
continue
|
|
380
|
+
a = normalise(normalise(old[cid], transforms), sorts)
|
|
381
|
+
b = normalise(normalise(new[cid], transforms), sorts)
|
|
382
|
+
diffs = []
|
|
383
|
+
compare(a, b, tolerances, (), diffs)
|
|
384
|
+
open_diffs = []
|
|
385
|
+
for d in diffs:
|
|
386
|
+
hit = next((x for x in accepted if matches(x["pat"], list(d[0]))
|
|
387
|
+
and (x["cases"] is None or cid in x["cases"])), None)
|
|
388
|
+
if hit:
|
|
389
|
+
hit["hits"] += 1
|
|
390
|
+
accepted_diffs.append((cid,) + d + (hit,))
|
|
391
|
+
else:
|
|
392
|
+
open_diffs.append(d)
|
|
393
|
+
if open_diffs:
|
|
394
|
+
mismatched.append(cid)
|
|
395
|
+
all_diffs.extend((cid,) + d for d in open_diffs)
|
|
396
|
+
elif diffs:
|
|
397
|
+
accepted_only.append(cid)
|
|
398
|
+
else:
|
|
399
|
+
matched.append(cid)
|
|
400
|
+
|
|
401
|
+
missing = [c for c in old if c not in new]
|
|
402
|
+
extra = [c for c in new if c not in old]
|
|
403
|
+
fields = OrderedDict()
|
|
404
|
+
for cid, path, kind, _, _ in all_diffs:
|
|
405
|
+
f = fields.setdefault(generalise(path), {"diffs": 0, "cases": [], "kinds": []})
|
|
406
|
+
f["diffs"] += 1
|
|
407
|
+
if cid not in f["cases"]:
|
|
408
|
+
f["cases"].append(cid)
|
|
409
|
+
if kind not in f["kinds"]:
|
|
410
|
+
f["kinds"].append(kind)
|
|
411
|
+
|
|
412
|
+
if mismatched or missing or extra:
|
|
413
|
+
verdict = "NOT EQUIVALENT"
|
|
414
|
+
elif accepted_only:
|
|
415
|
+
verdict = "EQUIVALENT EXCEPT ACCEPTED CHANGES"
|
|
416
|
+
else:
|
|
417
|
+
verdict = "EQUIVALENT"
|
|
418
|
+
|
|
419
|
+
warnings = []
|
|
420
|
+
for r in rules:
|
|
421
|
+
label = "rule {} ({} {})".format(r["n"], r["rule"], r["path"])
|
|
422
|
+
if not r["reason"]:
|
|
423
|
+
warnings.append(label + " has no reason")
|
|
424
|
+
if r["hits"] == 0 and r["rule"] != "tolerance":
|
|
425
|
+
warnings.append(label + " matched nothing: check the path")
|
|
426
|
+
if r["subtree"]:
|
|
427
|
+
warnings.append(label + " ignores a whole object or list: name the leaf fields instead")
|
|
428
|
+
last = next((v for k, v in reversed(r["pat"]) if k == "key"), "")
|
|
429
|
+
if r["rule"] in ("tolerance", "round") and any(w in last.lower() for w in MONEY_WORDS):
|
|
430
|
+
warnings.append(label + " loosens a money-like field: compare money exactly in minor units")
|
|
431
|
+
if r["rule"] == "tolerance" and r["hits"]:
|
|
432
|
+
warnings.append("{} absorbed {} difference(s)".format(label, r["hits"]))
|
|
433
|
+
for a in accepted:
|
|
434
|
+
if a["hits"] == 0:
|
|
435
|
+
warnings.append("accepted change at {} matched no difference".format(a["path"]))
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
"verdict": verdict,
|
|
439
|
+
"cases": {"old": len(old), "new": len(new), "compared": len(old) - len(missing),
|
|
440
|
+
"matched": len(matched), "mismatched": len(mismatched),
|
|
441
|
+
"accepted_only": len(accepted_only),
|
|
442
|
+
"missing_in_new": missing, "extra_in_new": extra},
|
|
443
|
+
"mismatched_cases": mismatched,
|
|
444
|
+
"fields": [{"path": p, "diffs": f["diffs"], "cases": len(f["cases"]),
|
|
445
|
+
"case_ids": f["cases"], "kinds": f["kinds"]} for p, f in fields.items()],
|
|
446
|
+
"diffs": [{"case": c, "path": show(p), "kind": k, "old": plain(o), "new": plain(n)}
|
|
447
|
+
for c, p, k, o, n in all_diffs[: args.max_diffs]],
|
|
448
|
+
"diffs_total": len(all_diffs),
|
|
449
|
+
"accepted": [{"case": c, "path": show(p), "old": plain(o), "new": plain(n),
|
|
450
|
+
"approver": h["approver"]} for c, p, _, o, n, h in accepted_diffs],
|
|
451
|
+
"rules": [{"n": r["n"], "path": r["path"], "rule": r["rule"], "reason": r["reason"],
|
|
452
|
+
"hits": r["hits"]} for r in rules],
|
|
453
|
+
"warnings": warnings,
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def render(rep, max_diffs):
|
|
458
|
+
c = rep["cases"]
|
|
459
|
+
head = "Verdict after rules: " + rep["verdict"]
|
|
460
|
+
if rep["warnings"]:
|
|
461
|
+
head += " ({} warning{} at the end: read them before trusting this)".format(
|
|
462
|
+
len(rep["warnings"]), "" if len(rep["warnings"]) == 1 else "s")
|
|
463
|
+
out = [head, ""]
|
|
464
|
+
out.append("Cases: {} old, {} new, {} compared: {} matched, {} mismatched, {} accepted-only".format(
|
|
465
|
+
c["old"], c["new"], c["compared"], c["matched"], c["mismatched"], c["accepted_only"]))
|
|
466
|
+
if c["missing_in_new"]:
|
|
467
|
+
out.append("Missing in new: " + ", ".join(c["missing_in_new"]))
|
|
468
|
+
if c["extra_in_new"]:
|
|
469
|
+
out.append("Only in new: " + ", ".join(c["extra_in_new"]))
|
|
470
|
+
if rep["fields"]:
|
|
471
|
+
out += ["", "Differences by field:", " {:<40} {:>6} {:>6} {}".format("path", "diffs", "cases", "case ids")]
|
|
472
|
+
for f in rep["fields"]:
|
|
473
|
+
ids = ", ".join(f["case_ids"][:8]) + (" ..." if len(f["case_ids"]) > 8 else "")
|
|
474
|
+
out.append(" {:<40} {:>6} {:>6} {}".format(f["path"], f["diffs"], f["cases"], ids))
|
|
475
|
+
if rep["diffs"]:
|
|
476
|
+
out += ["", "First {} of {} differences:".format(len(rep["diffs"]), rep["diffs_total"])]
|
|
477
|
+
for d in rep["diffs"]:
|
|
478
|
+
out.append(" {} {} [{}] old={} new={}".format(
|
|
479
|
+
d["case"], d["path"], d["kind"], short(d["old"]), short(d["new"])))
|
|
480
|
+
if rep["accepted"]:
|
|
481
|
+
out += ["", "Accepted changes ({}):".format(len(rep["accepted"]))]
|
|
482
|
+
for a in rep["accepted"][:max_diffs]:
|
|
483
|
+
out.append(" {} {} old={} new={} approved by {}".format(
|
|
484
|
+
a["case"], a["path"], short(a["old"]), short(a["new"]), a["approver"]))
|
|
485
|
+
if rep["rules"]:
|
|
486
|
+
out += ["", "Rules applied:"]
|
|
487
|
+
for r in rep["rules"]:
|
|
488
|
+
out.append(" {}. {} {} ({} hit{}) {}".format(
|
|
489
|
+
r["n"], r["rule"], r["path"], r["hits"], "" if r["hits"] == 1 else "s",
|
|
490
|
+
r["reason"] or "NO REASON GIVEN"))
|
|
491
|
+
if rep["warnings"]:
|
|
492
|
+
out += ["", "Warnings:"] + [" - " + w for w in rep["warnings"]]
|
|
493
|
+
return "\n".join(out) + "\n"
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def main(argv=None):
|
|
497
|
+
ap = argparse.ArgumentParser(description="Golden-master diff of old vs new recorded outputs.")
|
|
498
|
+
ap.add_argument("old", help="directory or JSONL file of the old system's outputs")
|
|
499
|
+
ap.add_argument("new", help="directory or JSONL file of the new system's outputs")
|
|
500
|
+
ap.add_argument("--rules", help="normalisation rules (JSON)")
|
|
501
|
+
ap.add_argument("--accepted", help="approved intended changes (JSON)")
|
|
502
|
+
ap.add_argument("--key", default="case_id", help="case id field in JSONL records (default case_id)")
|
|
503
|
+
ap.add_argument("--max-diffs", type=int, default=20, help="differences to list (default 20)")
|
|
504
|
+
ap.add_argument("--json", action="store_true", help="print the report as JSON")
|
|
505
|
+
ap.add_argument("-o", "--output", help="write the report to this path instead of stdout")
|
|
506
|
+
args = ap.parse_args(argv)
|
|
507
|
+
try:
|
|
508
|
+
rep = run(args)
|
|
509
|
+
except BadInput as exc:
|
|
510
|
+
sys.stderr.write("error: {}\n".format(exc))
|
|
511
|
+
return 2
|
|
512
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
513
|
+
sys.stderr.write("error: {}\n".format(exc))
|
|
514
|
+
return 2
|
|
515
|
+
text = json.dumps(rep, indent=2, ensure_ascii=False, default=str) + "\n" if args.json else render(rep, args.max_diffs)
|
|
516
|
+
if args.output:
|
|
517
|
+
Path(args.output).write_text(text, encoding="utf-8")
|
|
518
|
+
else:
|
|
519
|
+
sys.stdout.write(text)
|
|
520
|
+
return 0 if rep["verdict"] != "NOT EQUIVALENT" else 1
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
if __name__ == "__main__":
|
|
524
|
+
sys.exit(main())
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"rules": [
|
|
3
|
+
{"path": "body.created_at", "rule": "ignore", "reason": "wall-clock time of the call; varies old-vs-old"},
|
|
4
|
+
{"path": "body.id", "rule": "regex", "pattern": "^[0-9a-f-]{36}$", "replace": "<uuid>", "reason": "random UUID per call; format still checked"},
|
|
5
|
+
{"path": "body.items", "rule": "sort", "by": "code", "reason": "built from a hash map; no consumer reads the order"}
|
|
6
|
+
]
|
|
7
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "For frontend engineers and the people who review their pages: read a Lighthouse report for what actually slows the page for real users, and choose how each route is rendered from what it needs.",
|
|
3
|
+
"display_name": "Frontend",
|
|
4
|
+
"keywords": [
|
|
5
|
+
"frontend",
|
|
6
|
+
"web-performance",
|
|
7
|
+
"core-web-vitals",
|
|
8
|
+
"bundle-size",
|
|
9
|
+
"rendering",
|
|
10
|
+
"ssr"
|
|
11
|
+
],
|
|
12
|
+
"links": {
|
|
13
|
+
"documentation": "https://sananthanarayan.github.io/skilldrop/packs/frontend/"
|
|
14
|
+
},
|
|
15
|
+
"maintainers": [
|
|
16
|
+
{
|
|
17
|
+
"name": "sananthanarayan"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"first-value": {
|
|
21
|
+
"audience-posture": "technical",
|
|
22
|
+
"prerequisites": [
|
|
23
|
+
"A Lighthouse JSON report for a slow page (`npx lighthouse <url> --output=json --output-path=report.json`), and its field data from PageSpeed Insights or Search Console if you have it"
|
|
24
|
+
],
|
|
25
|
+
"starter-task": "Find out what actually makes a page slow for real users",
|
|
26
|
+
"starter-prompt": "Here's report.json from Lighthouse for our product page. We score 92 in the lab, but Search Console says LCP is poor on mobile. What's making it slow for real users, and what should we fix first?",
|
|
27
|
+
"verification": "web-performance-review leads with the field verdict (which vital fails at p75), names the LCP element and the phase that dominates it, and ranks fixes by how much each moves that vital, not by Lighthouse's estimated savings.",
|
|
28
|
+
"recovery": "If the reply leads with the lab score or a generic checklist, ask for it by name (\"use web-performance-review\") and include the PageSpeed Insights JSON, which carries the field data."
|
|
29
|
+
},
|
|
30
|
+
"journey": {
|
|
31
|
+
"when": "A page is slow, or you're deciding how a new app's routes should be rendered.",
|
|
32
|
+
"you_bring": [
|
|
33
|
+
"a Lighthouse or PageSpeed Insights JSON, with field data if you have it",
|
|
34
|
+
"the app's routes, how fresh each one's data must be, and the framework"
|
|
35
|
+
],
|
|
36
|
+
"steps": [
|
|
37
|
+
{
|
|
38
|
+
"skill": "rendering-strategy",
|
|
39
|
+
"you_do": "Describe the routes, their data and traffic, and the framework.",
|
|
40
|
+
"you_get": "A route table: how each page is rendered and cached, what invalidates it, and what it shows when its data source is slow.",
|
|
41
|
+
"decide": "Which routes change rendering mode first."
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"skill": "web-performance-review",
|
|
45
|
+
"you_do": "Hand over the Lighthouse or PageSpeed Insights JSON, with field data.",
|
|
46
|
+
"you_get": "Which vital fails for real users, why, and the fixes ranked by how much each moves it.",
|
|
47
|
+
"decide": "Which fix ships first, and how you'll measure it in the field."
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
"you_end_with": "Pages rendered the way their data needs, and fixes chosen by what real users feel."
|
|
51
|
+
},
|
|
52
|
+
"requires": [
|
|
53
|
+
"core"
|
|
54
|
+
]
|
|
55
|
+
}
|