@1aboveio/skills 1.0.10 → 1.0.11
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/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/resources.json +297 -216
- package/README.md +3 -3
- package/package.json +10 -8
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-foundation/SKILL.md +35 -37
- package/skills/app-dev/android-foundation/references/delivery-focus.md +4 -2
- package/skills/app-dev/android-foundation/references/feature-work.md +50 -0
- package/skills/app-dev/android-foundation/references/setup.md +24 -9
- package/skills/app-dev/android-foundation/references/workspace.md +81 -0
- package/skills/app-dev/android-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
- package/skills/app-dev/android-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
- package/skills/app-dev/android-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/SKILL.md +1 -1
- package/skills/app-dev/data-quality/SKILL.md +1 -1
- package/skills/app-dev/ios-foundation/SKILL.md +15 -10
- package/skills/app-dev/ios-foundation/references/setup.md +25 -9
- package/skills/app-dev/ios-foundation/references/workspace.md +82 -0
- package/skills/app-dev/ios-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
- package/skills/app-dev/ios-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
- package/skills/app-dev/ios-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/github-actions/SKILL.md +1 -1
- package/skills/cicd/github-flow/SKILL.md +1 -1
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +1 -1
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/xai-search/SKILL.md +1 -1
- package/skills/core/distill-writing/SKILL.md +3 -3
- package/skills/core/distill-writing/references/extraction.md +1 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/editorial-review/SKILL.md +82 -12
- package/skills/core/editorial-review/references/checklist.md +26 -1
- package/skills/core/house-style/SKILL.md +8 -3
- package/skills/core/house-style/references/analytical-reporting.md +123 -0
- package/skills/core/house-style/references/analytical-writing-patterns.md +79 -0
- package/skills/core/house-style/references/analytical-writing-research.md +129 -0
- package/skills/core/{write-doc → house-style}/references/bilingual.md +7 -5
- package/skills/core/house-style/references/definitions-and-metrics.md +86 -0
- package/skills/core/house-style/references/document-workflow.md +193 -0
- package/skills/core/house-style/references/initialization.md +66 -0
- package/skills/core/house-style/references/persistence.md +9 -4
- package/skills/core/house-style/references/rules.md +11 -4
- package/skills/core/house-style/scripts/init-guidance.mjs +213 -0
- package/skills/core/house-style/templates/analytical-report.md +54 -0
- package/skills/core/house-style/templates/gap-analysis.md +1 -1
- package/skills/core/plain-language/SKILL.md +2 -2
- package/skills/core/polish-cn/SKILL.md +9 -2
- package/skills/core/reg-parser/SKILL.md +3 -3
- package/skills/core/terminology/SKILL.md +8 -4
- package/skills/core/write-doc/SKILL.md +40 -151
- package/skills/core/write-report/SKILL.md +129 -0
- package/skills/core/{write-doc → write-report}/references/compliance-reports.md +4 -4
- package/skills/engineering/better-review/SKILL.md +37 -37
- package/skills/engineering/better-review/references/passes.md +35 -41
- package/skills/engineering/better-review/references/result.md +10 -9
- package/skills/engineering/e2e-test/SKILL.md +17 -17
- package/skills/engineering/e2e-test/references/authoring/playwright-config.md +11 -13
- package/skills/engineering/e2e-test/references/presentation-sweep.md +7 -7
- package/skills/engineering/e2e-test/references/reviewing.md +9 -11
- package/skills/engineering/e2e-test/references/route-manifest.md +1 -1
- package/skills/engineering/engineering/SKILL.md +124 -0
- package/skills/engineering/engineering/references/engineering-conventions.md +32 -0
- package/skills/engineering/engineering/references/feature-delivery.md +8 -0
- package/skills/engineering/engineering/references/init-scope.md +37 -0
- package/skills/engineering/engineering/scripts/init.mjs +160 -0
- package/skills/engineering/{contributing → engineering}/scripts/persist-conventions.mjs +1 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +19 -19
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/engineering/ensure-coverage/SKILL.md +22 -109
- package/skills/engineering/ensure-coverage/references/breadth/coverage-ledger.md +7 -5
- package/skills/engineering/ensure-coverage/references/breadth/surface-baseline.md +4 -4
- package/skills/engineering/ensure-coverage/references/breadth/surface-discovery.md +3 -3
- package/skills/engineering/ensure-coverage/references/depth/characterization.md +4 -65
- package/skills/engineering/ensure-coverage/references/depth/grading.md +5 -7
- package/skills/engineering/ensure-coverage/references/depth/mock-policy.md +6 -12
- package/skills/engineering/ensure-coverage/references/enforcement/ci-contract.md +1 -1
- package/skills/engineering/ensure-coverage/references/presentation/axis.md +6 -6
- package/skills/engineering/ensure-coverage/references/process/audit-mode.md +7 -23
- package/skills/engineering/ensure-coverage/references/process/output-template.md +18 -133
- package/skills/engineering/ensure-coverage/references/process/test-strategy.md +7 -12
- package/skills/engineering/ensure-coverage/scripts/README.md +12 -4
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/SKILL.md +3 -1
- package/skills/engineering/harness-runtime/bin/workspaces.mjs +52 -0
- package/skills/engineering/harness-runtime/native-workspace.mjs +239 -0
- package/skills/engineering/harness-runtime/references/workspaces.md +78 -0
- package/skills/engineering/harness-runtime/workspace-lifecycle.mjs +2154 -0
- package/skills/engineering/resolve-issues/SKILL.md +40 -61
- package/skills/engineering/resolve-issues/references/ci.md +13 -3
- package/skills/engineering/resolve-issues/references/delivery-progress.md +4 -3
- package/skills/engineering/resolve-issues/references/done.md +11 -2
- package/skills/engineering/resolve-issues/references/expire.md +9 -2
- package/skills/engineering/resolve-issues/references/implementation.md +33 -17
- package/skills/engineering/resolve-issues/references/preflight.md +3 -1
- package/skills/engineering/resolve-issues/references/principles.md +52 -0
- package/skills/engineering/resolve-issues/references/review.md +25 -15
- package/skills/engineering/resolve-issues/references/task-plan.md +64 -27
- package/skills/engineering/resolve-issues/references/workspaces.md +164 -0
- package/skills/engineering/resolve-issues/scripts/cleanup-runs.mjs +185 -317
- package/skills/engineering/resolve-issues/scripts/next-actions.mjs +44 -7
- package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +36 -2
- package/skills/engineering/resolve-issues/scripts/review-verdict.mjs +9 -8
- package/skills/engineering/resolve-issues/scripts/workspace-lifecycle.mjs +1 -0
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/rush-issues/SKILL.md +7 -5
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/test-quality/SKILL.md +19 -4
- package/skills/engineering/test-quality/references/lint.md +1 -1
- package/skills/engineering/test-quality/references/principles.md +2 -2
- package/skills/engineering/test-quality/references/testing-conventions.md +1 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +4 -8
- package/skills/risk-compliance/compliance-audit/references/audience-and-reports.md +12 -0
- package/skills/risk-compliance/contract-compare/SKILL.md +17 -1
- package/skills/risk-compliance/contract-review/SKILL.md +6 -1
- package/skills/risk-compliance/contract-review/references/report-stage.md +17 -0
- package/skills/risk-compliance/doc-authenticity/SKILL.md +11 -1
- package/skills/risk-compliance/fraud-detection/LICENSE +3 -0
- package/skills/risk-compliance/fraud-detection/SKILL.md +148 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/first-party-fraud.md +3 -2
- package/skills/risk-compliance/fraud-detection/references/archetypes/third-party-fraud.md +47 -0
- package/skills/risk-compliance/fraud-detection/references/collection-payout-features.md +151 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/contexts/bank-transfer.md +4 -3
- package/skills/risk-compliance/fraud-detection/references/contexts/card-payment.md +70 -0
- package/skills/risk-compliance/fraud-detection/references/contexts/payment-collection.md +34 -0
- package/skills/risk-compliance/fraud-detection/references/contexts/payout.md +34 -0
- package/skills/risk-compliance/fraud-detection/references/enrichment.md +74 -0
- package/skills/risk-compliance/fraud-detection/references/entities.md +48 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/feature-engineering.md +69 -33
- package/skills/risk-compliance/fraud-detection/references/feature-sql.md +150 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-rationale.md +4 -2
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-template.md +45 -23
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/review-checklist.md +60 -20
- package/skills/risk-compliance/fraud-detection/references/workflow.md +216 -0
- package/skills/risk-compliance/fraud-detection/release-check.mjs +95 -0
- package/skills/risk-compliance/payment-analysis/SKILL.md +36 -17
- package/skills/risk-compliance/payment-analysis/references/auth-rate-actions.md +2 -2
- package/skills/risk-compliance/payment-analysis/references/report-template.md +10 -2
- package/skills/risk-compliance/payment-analysis/references/visualization.md +11 -1
- package/skills/risk-compliance/portfolio-payment-analysis/LICENSE +3 -0
- package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +170 -0
- package/skills/risk-compliance/portfolio-payment-analysis/references/identity-and-reconciliation.md +46 -0
- package/skills/risk-compliance/portfolio-payment-analysis/references/metrics-and-reporting.md +170 -0
- package/skills/risk-compliance/portfolio-payment-analysis/references/risk-history.md +89 -0
- package/skills/risk-compliance/portfolio-payment-analysis/release-check.mjs +95 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/analyze.py +636 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/build_report.py +386 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/comparison_charts.py +425 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/csv_export.py +52 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/render.py +160 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/risk_history.py +435 -0
- package/skills/risk-compliance/similar-domain/SKILL.md +9 -4
- package/skills/risk-compliance/tld-detection/SKILL.md +124 -239
- package/skills/risk-compliance/tld-detection/agents/openai.yaml +1 -1
- package/skills/risk-compliance/tld-detection/references/clean.md +18 -51
- package/skills/risk-compliance/tld-detection/references/fx-conversion.md +56 -219
- package/skills/risk-compliance/tld-detection/references/metric-contract.md +109 -0
- package/skills/risk-compliance/tld-detection/references/pattern.md +99 -0
- package/skills/risk-compliance/tld-detection/references/report-template.md +49 -272
- package/skills/risk-compliance/tld-detection/scripts/detect_ladders.py +46 -13
- package/skills/risk-compliance/tld-detection/scripts/detect_patterns.py +346 -0
- package/skills/risk-compliance/tld-detection/scripts/detect_streams.py +51 -19
- package/skills/risk-compliance/tld-detection/scripts/profile_groups.py +109 -0
- package/skills/risk-compliance/tld-detection/scripts/render_charts.py +218 -0
- package/skills/risk-compliance/tld-detection/scripts/tld_common.py +243 -0
- package/skills/engineering/contributing/SKILL.md +0 -70
- package/skills/risk-compliance/fraud-analysis/SKILL.md +0 -114
- package/skills/risk-compliance/fraud-analysis/references/archetypes/third-party-fraud.md +0 -27
- package/skills/risk-compliance/fraud-analysis/references/contexts/card-payment.md +0 -30
- package/skills/risk-compliance/fraud-analysis/references/contexts/payment-collection.md +0 -20
- package/skills/risk-compliance/fraud-analysis/references/contexts/payout.md +0 -20
- package/skills/risk-compliance/fraud-analysis/references/workflow.md +0 -175
- package/skills/risk-compliance/tld-detection/references/chinese-polish.md +0 -55
- package/skills/risk-compliance/tld-detection/references/clustering.md +0 -235
- package/skills/risk-compliance/tld-detection/references/decision.md +0 -189
- package/skills/risk-compliance/tld-detection/references/streams.md +0 -141
- package/skills/risk-compliance/tld-detection/references/terminology.md +0 -95
- package/skills/risk-compliance/tld-detection/references/workflow.md +0 -95
- /package/skills/{engineering/contributing → core/write-report}/LICENSE +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/audit-report-template.md +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/external-assessment-template.md +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/management-report-template.md +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/vars-report-template.md +0 -0
- /package/skills/{engineering/contributing → core/write-report}/release-check.mjs +0 -0
- /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/LICENSE +0 -0
- /package/skills/engineering/{contributing → engineering}/references/tracker-conventions.md +0 -0
- /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/release-check.mjs +0 -0
- /package/skills/engineering/{contributing → engineering}/scripts/persist-conventions-core.mjs +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/authorized-payment-scam.md +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/mechanisms/account-takeover.md +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/taxonomy.md +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/terminology.md +0 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render a saved group profile as standalone, accessible offline HTML."""
|
|
3
|
+
import argparse
|
|
4
|
+
import html
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
|
|
9
|
+
from tld_common import _period, write_text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def esc(value):
|
|
13
|
+
return html.escape(str(value), quote=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def display(value, percent=False):
|
|
17
|
+
if value is None:
|
|
18
|
+
return 'N/A'
|
|
19
|
+
return f'{value * 100:.2f}%' if percent else str(value)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_profile(profile):
|
|
23
|
+
"""Check the dimensions and scalar values consumed by the renderer."""
|
|
24
|
+
def count(value, field):
|
|
25
|
+
if type(value) is not int or value < 0:
|
|
26
|
+
raise ValueError(f'{field} must be a nonnegative integer')
|
|
27
|
+
|
|
28
|
+
def rate(value, field):
|
|
29
|
+
if value is not None and (type(value) not in (int, float) or not 0 <= value <= 1 or not math.isfinite(value)):
|
|
30
|
+
raise ValueError(f'{field} must be null or a finite rate between 0 and 1')
|
|
31
|
+
|
|
32
|
+
def counts(values, length, field):
|
|
33
|
+
if not isinstance(values, list) or len(values) != length:
|
|
34
|
+
raise ValueError(f'{field} must contain {length} counts')
|
|
35
|
+
for value in values:
|
|
36
|
+
count(value, field)
|
|
37
|
+
|
|
38
|
+
def matches(value, expected, field):
|
|
39
|
+
if expected is None:
|
|
40
|
+
if value is not None:
|
|
41
|
+
raise ValueError(f'{field} must be null')
|
|
42
|
+
elif value is None or not math.isclose(value, expected, rel_tol=1e-9, abs_tol=1e-12):
|
|
43
|
+
raise ValueError(f'{field} is inconsistent with its counts')
|
|
44
|
+
|
|
45
|
+
def status(value, field, denominator):
|
|
46
|
+
if not isinstance(value, dict):
|
|
47
|
+
raise ValueError(f'{field} must be an object')
|
|
48
|
+
for key in ('numerator', 'denominator', 'unknown'):
|
|
49
|
+
count(value.get(key), f'{field}.{key}')
|
|
50
|
+
for key in ('rate', 'coverage'):
|
|
51
|
+
rate(value.get(key), f'{field}.{key}')
|
|
52
|
+
if key not in value:
|
|
53
|
+
raise ValueError(f'{field}.{key} is required')
|
|
54
|
+
n, yes, unknown = value['denominator'], value['numerator'], value['unknown']
|
|
55
|
+
if n != denominator or yes + unknown > n:
|
|
56
|
+
raise ValueError(f'{field} counts are inconsistent')
|
|
57
|
+
matches(value['rate'], yes / n if n and not unknown else None, f'{field}.rate')
|
|
58
|
+
matches(value['coverage'], (n - unknown) / n if n else None, f'{field}.coverage')
|
|
59
|
+
|
|
60
|
+
if not isinstance(profile, dict) or type(profile.get('schema_version')) is not int or profile['schema_version'] != 1:
|
|
61
|
+
raise ValueError('unsupported profile schema')
|
|
62
|
+
_, start, end, _ = _period(profile.get('period'))
|
|
63
|
+
groups = profile.get('groups')
|
|
64
|
+
if not isinstance(groups, list):
|
|
65
|
+
raise ValueError('groups must be an array')
|
|
66
|
+
for group in groups:
|
|
67
|
+
if not isinstance(group, dict) or not isinstance(group.get('id'), str) or not isinstance(group.get('scope'), dict):
|
|
68
|
+
raise ValueError('group requires an id and scope')
|
|
69
|
+
tiers = group.get('tiers')
|
|
70
|
+
tiers = tiers.get('tiers') if isinstance(tiers, dict) else None
|
|
71
|
+
if not isinstance(tiers, list) or not tiers or any(not isinstance(t, dict) or not isinstance(t.get('label'), str) for t in tiers):
|
|
72
|
+
raise ValueError('tiers must contain labelled tiers')
|
|
73
|
+
for name in ('group', 'out_group'):
|
|
74
|
+
pop = group.get(name)
|
|
75
|
+
if not isinstance(pop, dict) or not isinstance(pop.get('daily'), list):
|
|
76
|
+
raise ValueError(f'{name} requires daily records')
|
|
77
|
+
daily = pop['daily']
|
|
78
|
+
if len(daily) != (end - start).days + 1:
|
|
79
|
+
raise ValueError(f'{name} daily records must cover the period')
|
|
80
|
+
counts(pop.get('hours'), 24, f'{name}.hours')
|
|
81
|
+
total = pop.get('total')
|
|
82
|
+
if not isinstance(total, dict):
|
|
83
|
+
raise ValueError(f'{name}.total must be an object')
|
|
84
|
+
count(total.get('count'), f'{name}.total.count')
|
|
85
|
+
counts(total.get('tier_counts'), len(tiers), f'{name}.total.tier_counts')
|
|
86
|
+
for key in ('success', 'fraud'):
|
|
87
|
+
status(total.get(key), f'{name}.total.{key}', total['count'])
|
|
88
|
+
for i, day in enumerate(daily):
|
|
89
|
+
if not isinstance(day, dict) or day.get('date') != (start + timedelta(days=i)).isoformat():
|
|
90
|
+
raise ValueError(f'{name} daily dates must align with the period')
|
|
91
|
+
count(day.get('count'), f'{name}.daily.count')
|
|
92
|
+
if 'share' not in day:
|
|
93
|
+
raise ValueError(f'{name}.daily.share is required')
|
|
94
|
+
rate(day['share'], f'{name}.daily.share')
|
|
95
|
+
matches(day['share'], day['count'] / total['count'] if total['count'] else None, f'{name}.daily.share')
|
|
96
|
+
counts(day.get('hours'), 24, f'{name}.daily.hours')
|
|
97
|
+
counts(day.get('tier_counts'), len(tiers), f'{name}.daily.tier_counts')
|
|
98
|
+
if sum(day['hours']) != day['count'] or sum(day['tier_counts']) != day['count']:
|
|
99
|
+
raise ValueError(f'{name} daily counts do not reconcile')
|
|
100
|
+
for key in ('success', 'fraud'):
|
|
101
|
+
status(day.get(key), f'{name}.daily.{key}', day['count'])
|
|
102
|
+
if (sum(day['count'] for day in daily) != total['count']
|
|
103
|
+
or any(sum(day['hours'][i] for day in daily) != pop['hours'][i] for i in range(24))
|
|
104
|
+
or any(sum(day['tier_counts'][i] for day in daily) != total['tier_counts'][i] for i in range(len(tiers)))):
|
|
105
|
+
raise ValueError(f'{name} totals do not reconcile')
|
|
106
|
+
for key in ('success', 'fraud'):
|
|
107
|
+
for field in ('numerator', 'unknown'):
|
|
108
|
+
if sum(day[key][field] for day in daily) != total[key][field]:
|
|
109
|
+
raise ValueError(f'{name}.{key}.{field} does not reconcile')
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def chart(title, labels, series, percent=False, tier_count=None, context=None):
|
|
113
|
+
"""Draw supplied values on one shared scale; missing values break lines."""
|
|
114
|
+
max_value = 100 if percent else max([1] + [v for _, values in series for v in values if v is not None])
|
|
115
|
+
width, height, left, top, bottom = max(800, 100 * len(labels) + 80), 260, 60, 20, 200
|
|
116
|
+
def x(i):
|
|
117
|
+
return left + i * (width - left - 20) / max(1, len(labels) - 1)
|
|
118
|
+
def y(v):
|
|
119
|
+
return bottom - (v * (100 if percent else 1)) / max_value * (bottom - top)
|
|
120
|
+
def style(index):
|
|
121
|
+
# The tier controls hue; the population controls line and point shape.
|
|
122
|
+
n = tier_count or len(series)
|
|
123
|
+
color = f'hsl({index % n * 360 / n:.8f}, 80%, 35%)'
|
|
124
|
+
return color, '6 4' if tier_count and index // n else 'none', bool(tier_count and index // n)
|
|
125
|
+
|
|
126
|
+
def marker(color, square, x, y, title=''):
|
|
127
|
+
if square:
|
|
128
|
+
return f'<rect x="{x - 3:.2f}" y="{y - 3:.2f}" width="6" height="6" fill="{color}">{title}</rect>'
|
|
129
|
+
return f'<circle cx="{x:.2f}" cy="{y:.2f}" r="3" fill="{color}">{title}</circle>'
|
|
130
|
+
|
|
131
|
+
def legend(index, name):
|
|
132
|
+
color, dash, square = style(index)
|
|
133
|
+
return (f'<li><svg class="legend-key" viewBox="0 0 32 12" aria-hidden="true">'
|
|
134
|
+
f'<line x1="0" y1="6" x2="32" y2="6" stroke="{color}" stroke-dasharray="{dash}"/>'
|
|
135
|
+
+ marker(color, square, 16, 6) + f'</svg>{esc(name)}</li>')
|
|
136
|
+
|
|
137
|
+
out = [f'<section><h3>{esc(title)}</h3>', f'<p>{esc(context)}</p>' if context else '', '<ul class="legend" aria-label="Series legend">',
|
|
138
|
+
*[legend(i, name) for i, (name, _) in enumerate(series)],
|
|
139
|
+
'</ul>', f'<div class="scroll" role="region" aria-label="{esc(title)} chart" tabindex="0">',
|
|
140
|
+
f'<svg style="min-width:{width}px" viewBox="0 0 {width} {height}" role="img" aria-label="{esc(title)}"><title>{esc(title)}</title>',
|
|
141
|
+
f'<line x1="{left}" y1="{bottom}" x2="{width-20}" y2="{bottom}" stroke="black"/>',
|
|
142
|
+
f'<line x1="{left}" y1="{top}" x2="{left}" y2="{bottom}" stroke="black"/>',
|
|
143
|
+
f'<text x="2" y="{top+5}">{esc(display(max_value / 100 if percent else max_value, percent))}</text><text x="35" y="{bottom}">0</text>']
|
|
144
|
+
for i, label in enumerate(labels):
|
|
145
|
+
out.append(f'<text x="{x(i):.2f}" y="220" text-anchor="middle" font-size="9">{esc(label)}</text>')
|
|
146
|
+
for index, (name, values) in enumerate(series):
|
|
147
|
+
color, dash, square = style(index)
|
|
148
|
+
segment = []
|
|
149
|
+
def flush():
|
|
150
|
+
if len(segment) > 1:
|
|
151
|
+
points = ' '.join(segment)
|
|
152
|
+
out.append(f'<polyline fill="none" stroke="{color}" stroke-dasharray="{dash}" points="{points}"/>')
|
|
153
|
+
segment.clear()
|
|
154
|
+
for i, value in enumerate(values):
|
|
155
|
+
if value is None:
|
|
156
|
+
flush()
|
|
157
|
+
continue
|
|
158
|
+
px, py = x(i), y(value)
|
|
159
|
+
segment.append(f'{px:.2f},{py:.2f}')
|
|
160
|
+
out.append(marker(color, square, px, py, f'<title>{esc(name)}: {esc(labels[i])}: {esc(display(value, percent))}</title>'))
|
|
161
|
+
flush()
|
|
162
|
+
out.append('</svg></div><div class="scroll" role="region" aria-label="' + esc(title) + ' data table" tabindex="0"><table><caption>' + esc(title) + '</caption><thead><tr><th scope="col">Period</th>' + ''.join(f'<th scope="col">{esc(name)}</th>' for name, _ in series) + '</tr></thead><tbody>')
|
|
163
|
+
for i, label in enumerate(labels):
|
|
164
|
+
out.append(f'<tr><th scope="row">{esc(label)}</th>' + ''.join(f'<td>{esc(display(values[i], percent))}</td>' for _, values in series) + '</tr>')
|
|
165
|
+
out.append('</tbody></table></div></section>')
|
|
166
|
+
return ''.join(out)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def render(profile):
|
|
170
|
+
validate_profile(profile)
|
|
171
|
+
period = profile['period']
|
|
172
|
+
out = ['<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Group profiles</title>',
|
|
173
|
+
'<style>body{font:16px sans-serif;margin:1em}svg{display:block;width:100%;height:auto}.scroll{max-width:100%;overflow-x:auto}.scroll:focus{outline:2px solid #005a9c;outline-offset:2px}table{border-collapse:collapse;margin:1em 0}th,td{border:1px solid #888;padding:.3em;text-align:left;white-space:nowrap}section{margin:2em 0}.legend{display:flex;flex-wrap:wrap;gap:.5em 1em;list-style:none;padding:0}.legend-key{display:inline-block;width:2em;height:.75em;margin-right:.4em;vertical-align:middle}</style></head><body>',
|
|
174
|
+
f'<h1>Group profiles</h1><p>{esc(period["start"])} to {esc(period["end"])} inclusive; timezone {esc(period["timezone"])}</p>']
|
|
175
|
+
for group in profile['groups']:
|
|
176
|
+
out.append(f'<article><h2>Group {esc(group["id"])}</h2><p>Scope: {esc(json.dumps(group["scope"], ensure_ascii=False, sort_keys=True))}</p>')
|
|
177
|
+
populations = [('Group', group['group']), ('Out-group', group['out_group'])]
|
|
178
|
+
days = [d['date'] for d in group['group']['daily']]
|
|
179
|
+
tiers = group['tiers']['tiers']
|
|
180
|
+
series = [(f'{name}: {tier["label"]}', [day['tier_counts'][i] for day in pop['daily']]) for name, pop in populations for i, tier in enumerate(tiers)]
|
|
181
|
+
out.append(chart('Daily amount-tier counts', days, series, tier_count=len(tiers)))
|
|
182
|
+
for key, title, percent in [('count', 'Daily transaction counts', False), ('share', 'Daily shares', True)]:
|
|
183
|
+
context = (f"Daily share = daily count / that population's own whole-period count. "
|
|
184
|
+
f"Group total: {group['group']['total']['count']}; out-group total: {group['out_group']['total']['count']}."
|
|
185
|
+
if key == 'share' else None)
|
|
186
|
+
out.append(chart(title, days, [(name, [d[key] for d in pop['daily']]) for name, pop in populations], percent, context=context))
|
|
187
|
+
for key, title in [('success', 'Daily success rates'), ('fraud', 'Daily suspected-fraud rates')]:
|
|
188
|
+
context = ('Daily rate = status numerator / the same population’s daily transaction count; '
|
|
189
|
+
'N/A if the count is zero or any status is unknown. Denominators are in the status detail table.')
|
|
190
|
+
out.append(chart(title, days, [(name, [d[key]['rate'] for d in pop['daily']]) for name, pop in populations], True, context=context))
|
|
191
|
+
out.append(chart(title + ' status coverage', days, [(name, [d[key]['coverage'] for d in pop['daily']]) for name, pop in populations], True, context='Coverage = known statuses / the same population’s daily transaction count; N/A for an empty population.'))
|
|
192
|
+
out.append('<div class="scroll" role="region" aria-label="' + esc(title) + ' status detail" tabindex="0"><table><caption>' + esc(title) + ' status detail</caption><thead><tr><th scope="col">Date</th><th scope="col">Population</th><th scope="col">Numerator</th><th scope="col">Denominator</th><th scope="col">Unknown</th></tr></thead><tbody>')
|
|
193
|
+
for name, pop in populations:
|
|
194
|
+
for day in pop['daily']:
|
|
195
|
+
r = day[key]
|
|
196
|
+
out.append(f'<tr><th scope="row">{esc(day["date"])}</th><td>{esc(name)}</td><td>{esc(r["numerator"])}</td><td>{esc(r["denominator"])}</td><td>{esc(r["unknown"])}</td></tr>')
|
|
197
|
+
out.append('</tbody></table></div>')
|
|
198
|
+
hours = [f'{i:02d}' for i in range(24)]
|
|
199
|
+
out.append(chart('24-hour transaction counts', hours, [(name, pop['hours']) for name, pop in populations]))
|
|
200
|
+
out.append('<div class="scroll" role="region" aria-label="Date-by-hour counts" tabindex="0"><table><caption>Date-by-hour counts</caption><thead><tr><th scope="col">Date</th><th scope="col">Population</th>' + ''.join(f'<th scope="col">{h}</th>' for h in hours) + '</tr></thead><tbody>')
|
|
201
|
+
for name, pop in populations:
|
|
202
|
+
for day in pop['daily']:
|
|
203
|
+
out.append(f'<tr><th scope="row">{esc(day["date"])}</th><td>{esc(name)}</td>' + ''.join(f'<td>{esc(n)}</td>' for n in day['hours']) + '</tr>')
|
|
204
|
+
out.append('</tbody></table></div></article>')
|
|
205
|
+
return ''.join(out) + '</body></html>'
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main():
|
|
209
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
210
|
+
parser.add_argument('profile'); parser.add_argument('output'); parser.add_argument('--overwrite', action='store_true')
|
|
211
|
+
args = parser.parse_args()
|
|
212
|
+
with open(args.profile, encoding='utf-8') as f:
|
|
213
|
+
page = render(json.load(f))
|
|
214
|
+
write_text(args.output, page, overwrite=args.overwrite)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == '__main__':
|
|
218
|
+
main()
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Validated transaction and membership boundary shared by TLD command-line tools."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from datetime import date, datetime, timezone
|
|
9
|
+
from decimal import Decimal, InvalidOperation
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
12
|
+
|
|
13
|
+
VIEWS = ("usd", "bin_country")
|
|
14
|
+
REQUIRED = ("transaction_id", "transaction_time", "currency", "amount", "mcc",
|
|
15
|
+
"billing_descriptor", "mid", "card_scheme", "card_bin", "card_issuer",
|
|
16
|
+
"card_bin_country")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _string(value, field):
|
|
20
|
+
if not isinstance(value, str) or not value.strip():
|
|
21
|
+
raise ValueError(f"{field} must be a nonempty string")
|
|
22
|
+
return value.strip()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def decimal_string(value, field="amount"):
|
|
26
|
+
if not isinstance(value, str):
|
|
27
|
+
raise ValueError(f"{field} must be a decimal string")
|
|
28
|
+
try:
|
|
29
|
+
amount = Decimal(value)
|
|
30
|
+
except InvalidOperation as exc:
|
|
31
|
+
raise ValueError(f"{field} must be a finite nonnegative decimal") from exc
|
|
32
|
+
if not amount.is_finite() or amount < 0:
|
|
33
|
+
raise ValueError(f"{field} must be a finite nonnegative decimal")
|
|
34
|
+
if not amount:
|
|
35
|
+
return "0"
|
|
36
|
+
text = format(amount, "f")
|
|
37
|
+
return text.rstrip("0").rstrip(".") if "." in text else text
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _currency(value, field="currency"):
|
|
41
|
+
value = _string(value, field).upper()
|
|
42
|
+
if len(value) != 3 or not value.isascii() or not value.isalpha():
|
|
43
|
+
raise ValueError(f"{field} must be a three-letter currency code")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize_descriptor(value):
|
|
48
|
+
return " ".join(_string(value, "billing_descriptor").split()).casefold()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _period(value):
|
|
52
|
+
if not isinstance(value, dict) or set(value) != {"start", "end", "timezone"}:
|
|
53
|
+
raise ValueError("period requires start, end and timezone")
|
|
54
|
+
try:
|
|
55
|
+
start = date.fromisoformat(value["start"])
|
|
56
|
+
end = date.fromisoformat(value["end"])
|
|
57
|
+
zone = ZoneInfo(value["timezone"])
|
|
58
|
+
except (ValueError, TypeError, KeyError, ZoneInfoNotFoundError) as exc:
|
|
59
|
+
raise ValueError("period requires ISO dates and an IANA timezone") from exc
|
|
60
|
+
if start.isoformat() != value["start"] or end.isoformat() != value["end"] or end < start:
|
|
61
|
+
raise ValueError("period dates must be ordered YYYY-MM-DD dates")
|
|
62
|
+
return dict(value), start, end, zone
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _timestamp(value):
|
|
66
|
+
value = _string(value, "transaction_time")
|
|
67
|
+
try:
|
|
68
|
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
69
|
+
except ValueError as exc:
|
|
70
|
+
raise ValueError("transaction_time must be an ISO timestamp") from exc
|
|
71
|
+
if dt.tzinfo is None or dt.utcoffset() is None:
|
|
72
|
+
raise ValueError("transaction_time must include an explicit UTC offset")
|
|
73
|
+
return dt.astimezone(timezone.utc)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _transaction(raw):
|
|
77
|
+
if not isinstance(raw, dict):
|
|
78
|
+
raise ValueError("transaction must be an object")
|
|
79
|
+
for field in REQUIRED:
|
|
80
|
+
if field not in raw:
|
|
81
|
+
raise ValueError(f"missing required field: {field}")
|
|
82
|
+
row = {field: _string(raw[field], field) for field in REQUIRED
|
|
83
|
+
if field not in ("card_bin_country", "amount", "currency", "transaction_time")}
|
|
84
|
+
row["billing_descriptor"] = normalize_descriptor(row["billing_descriptor"])
|
|
85
|
+
row["transaction_time"] = _timestamp(raw["transaction_time"]).isoformat()
|
|
86
|
+
row["currency"] = _currency(raw["currency"])
|
|
87
|
+
row["amount"] = decimal_string(raw["amount"])
|
|
88
|
+
country = raw["card_bin_country"]
|
|
89
|
+
row["card_bin_country"] = _string(country, "card_bin_country").upper() if country is not None else None
|
|
90
|
+
for status in ("authorized", "suspected_fraud"):
|
|
91
|
+
value = raw.get(status)
|
|
92
|
+
if value is not None and type(value) is not bool:
|
|
93
|
+
raise ValueError(f"{status} must be true, false or null")
|
|
94
|
+
row[status] = value
|
|
95
|
+
views = raw.get("views")
|
|
96
|
+
if not isinstance(views, dict) or set(views) != set(VIEWS):
|
|
97
|
+
raise ValueError("views must contain exactly usd and bin_country")
|
|
98
|
+
row["views"] = {}
|
|
99
|
+
for name in VIEWS:
|
|
100
|
+
view = views[name]
|
|
101
|
+
if not isinstance(view, dict):
|
|
102
|
+
raise ValueError(f"{name} view must be an amount or explicit exclusion")
|
|
103
|
+
if set(view) == {"excluded_reason"}:
|
|
104
|
+
row["views"][name] = {"excluded_reason": _string(view["excluded_reason"], "excluded_reason")}
|
|
105
|
+
continue
|
|
106
|
+
if set(view) != {"currency", "amount"}:
|
|
107
|
+
raise ValueError(f"{name} view requires currency and amount, or excluded_reason")
|
|
108
|
+
currency = _currency(view["currency"])
|
|
109
|
+
if name == "usd" and currency != "USD":
|
|
110
|
+
raise ValueError("usd view must target USD")
|
|
111
|
+
if name == "bin_country" and country is None:
|
|
112
|
+
raise ValueError("missing BIN country requires an excluded bin_country view")
|
|
113
|
+
row["views"][name] = {"currency": currency, "amount": decimal_string(view["amount"])}
|
|
114
|
+
return row
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def dataset_fingerprint(data):
|
|
118
|
+
bound = {"schema_version": 1, "period": data["period"],
|
|
119
|
+
"transactions": sorted(data["transactions"], key=lambda row: row["transaction_id"])}
|
|
120
|
+
encoded = json.dumps(bound, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
|
121
|
+
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def validate_dataset(payload):
|
|
125
|
+
if not isinstance(payload, dict) or type(payload.get("schema_version")) is not int or payload["schema_version"] != 1:
|
|
126
|
+
raise ValueError("unsupported dataset schema_version")
|
|
127
|
+
period, start, end, zone = _period(payload.get("period"))
|
|
128
|
+
source = payload.get("transactions")
|
|
129
|
+
if not isinstance(source, list):
|
|
130
|
+
raise ValueError("transactions must be an array")
|
|
131
|
+
by_id = {}
|
|
132
|
+
duplicates = 0
|
|
133
|
+
for raw in source:
|
|
134
|
+
row = _transaction(raw)
|
|
135
|
+
key = row["transaction_id"]
|
|
136
|
+
if key in by_id:
|
|
137
|
+
if by_id[key] != row:
|
|
138
|
+
raise ValueError(f"conflicting records for transaction ID {key}")
|
|
139
|
+
duplicates += 1
|
|
140
|
+
else:
|
|
141
|
+
by_id[key] = row
|
|
142
|
+
selected = [row for row in by_id.values()
|
|
143
|
+
if start <= _timestamp(row["transaction_time"]).astimezone(zone).date() <= end]
|
|
144
|
+
selected.sort(key=lambda row: row["transaction_id"])
|
|
145
|
+
exclusions = {view: {} for view in VIEWS}
|
|
146
|
+
for row in selected:
|
|
147
|
+
for view in VIEWS:
|
|
148
|
+
reason = row["views"][view].get("excluded_reason")
|
|
149
|
+
if reason:
|
|
150
|
+
exclusions[view][reason] = exclusions[view].get(reason, 0) + 1
|
|
151
|
+
data = {"schema_version": 1, "period": period, "transactions": selected,
|
|
152
|
+
"census": {"source_rows": len(source), "duplicate_rows": duplicates,
|
|
153
|
+
"out_of_period": len(by_id) - len(selected), "transactions": len(selected),
|
|
154
|
+
"view_exclusions": exclusions}}
|
|
155
|
+
data["dataset_sha256"] = dataset_fingerprint(data)
|
|
156
|
+
return data
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def load_dataset(path):
|
|
160
|
+
return validate_dataset(json.loads(Path(path).read_text(encoding="utf-8")))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def view_rows(data):
|
|
164
|
+
zone = ZoneInfo(data["period"]["timezone"])
|
|
165
|
+
output = []
|
|
166
|
+
for tx in data["transactions"]:
|
|
167
|
+
local = _timestamp(tx["transaction_time"]).astimezone(zone)
|
|
168
|
+
for name in VIEWS:
|
|
169
|
+
converted = tx["views"][name]
|
|
170
|
+
if "excluded_reason" in converted:
|
|
171
|
+
continue
|
|
172
|
+
row = {key: value for key, value in tx.items() if key != "views"}
|
|
173
|
+
row.update({"view": name, "currency": converted["currency"], "amount": converted["amount"],
|
|
174
|
+
"charge_currency": tx["currency"], "charge_amount": tx["amount"],
|
|
175
|
+
"descriptor": tx["billing_descriptor"], "date": local.date().isoformat(), "hour": local.hour})
|
|
176
|
+
output.append(row)
|
|
177
|
+
return output
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def scope_of(row):
|
|
181
|
+
return {key: row[key] for key in ("mid", "descriptor", "view", "currency")}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def validate_groups(data, payload):
|
|
185
|
+
if not isinstance(payload, dict) or type(payload.get("schema_version")) is not int or payload["schema_version"] != 1:
|
|
186
|
+
raise ValueError("unsupported group schema_version")
|
|
187
|
+
if payload.get("period") != data["period"] or payload.get("dataset_sha256") != dataset_fingerprint(data):
|
|
188
|
+
raise ValueError("group manifest period or dataset digest is stale or mismatched")
|
|
189
|
+
groups = payload.get("groups")
|
|
190
|
+
if not isinstance(groups, list):
|
|
191
|
+
raise ValueError("groups must be an array")
|
|
192
|
+
eligible = {}
|
|
193
|
+
for row in view_rows(data):
|
|
194
|
+
scope = scope_of(row)
|
|
195
|
+
key = tuple(scope[k] for k in ("mid", "descriptor", "view", "currency"))
|
|
196
|
+
eligible.setdefault(key, set()).add(row["transaction_id"])
|
|
197
|
+
seen = set()
|
|
198
|
+
for group in groups:
|
|
199
|
+
if not isinstance(group, dict):
|
|
200
|
+
raise ValueError("group must be an object")
|
|
201
|
+
gid = _string(group.get("id"), "group id")
|
|
202
|
+
if gid in seen:
|
|
203
|
+
raise ValueError(f"duplicate group ID {gid}")
|
|
204
|
+
seen.add(gid)
|
|
205
|
+
if group.get("pattern") not in ("concentration", "escalating", "pricing"):
|
|
206
|
+
raise ValueError("unsupported group pattern")
|
|
207
|
+
scope = group.get("scope")
|
|
208
|
+
if not isinstance(scope, dict) or set(scope) != {"mid", "descriptor", "view", "currency"}:
|
|
209
|
+
raise ValueError("group scope requires mid, descriptor, view and currency")
|
|
210
|
+
fields = ("mid", "descriptor", "view", "currency")
|
|
211
|
+
key = tuple(_string(scope[k], k) for k in fields)
|
|
212
|
+
if any(scope[field] != value for field, value in zip(fields, key)):
|
|
213
|
+
raise ValueError("group scope must use canonical values without surrounding whitespace")
|
|
214
|
+
members = group.get("member_ids")
|
|
215
|
+
if not isinstance(members, list) or not members or any(not isinstance(m, str) or not m for m in members):
|
|
216
|
+
raise ValueError("group member_ids must be a nonempty list of IDs")
|
|
217
|
+
if len(set(members)) != len(members):
|
|
218
|
+
raise ValueError("duplicate member IDs in group")
|
|
219
|
+
if not set(members) <= eligible.get(key, set()):
|
|
220
|
+
raise ValueError(f"group {gid} has unknown, out-of-scope or excluded member IDs")
|
|
221
|
+
return groups
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def write_json(path, payload, overwrite=False):
|
|
225
|
+
text = json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False) + "\n"
|
|
226
|
+
write_text(path, text, overwrite)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def write_text(path, text, overwrite=False):
|
|
230
|
+
path = Path(path)
|
|
231
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
temporary = None
|
|
233
|
+
try:
|
|
234
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False) as handle:
|
|
235
|
+
temporary = Path(handle.name)
|
|
236
|
+
handle.write(text)
|
|
237
|
+
if overwrite:
|
|
238
|
+
os.replace(temporary, path)
|
|
239
|
+
else:
|
|
240
|
+
os.link(temporary, path)
|
|
241
|
+
finally:
|
|
242
|
+
if temporary is not None:
|
|
243
|
+
temporary.unlink(missing_ok=True)
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: contributing
|
|
3
|
-
description: "The conventions a repo's CONTRIBUTING.md holds, starting with tracker routing: which tracker an issue belongs in (GitHub for engineering, Linear for product), duplicate search, and linking back to the source requirement. Use before creating any issue, spec, epic, ticket or sub-issue on GitHub or Linear, including inside /to-spec, /to-tickets and /triage, and whenever work that started from a Linear issue turns into engineering work. Also writes the rules as the Issue tracking section of the repo's CONTRIBUTING.md. NOT for delivering a PR (github-flow) or splitting work into slices (to-tickets)."
|
|
4
|
-
release-version: 0.44.1
|
|
5
|
-
---
|
|
6
|
-
**Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
|
|
7
|
-
# Contributing
|
|
8
|
-
|
|
9
|
-
Rules every engineering skill follows, whatever skill or harness is running, kept as sections of the
|
|
10
|
-
repo's `CONTRIBUTING.md`. Each rule is the authority; another skill's "publish to the configured
|
|
11
|
-
tracker" means the tracker these rules pick.
|
|
12
|
-
|
|
13
|
-
## First use in a repo
|
|
14
|
-
|
|
15
|
-
Scripts live under **this skill's directory**: set `SKILL_DIR` to the directory you read this file
|
|
16
|
-
from, never your CWD. On the first trigger in a repo, write the `## Issue tracking` section into its
|
|
17
|
-
`CONTRIBUTING.md`, creating the file when it is missing:
|
|
18
|
-
|
|
19
|
-
```sh
|
|
20
|
-
node "$SKILL_DIR/scripts/persist-conventions.mjs" --repo-root <repo-root> --if-missing --write \
|
|
21
|
-
[--linear-project "<Linear project>"]... [--github-repo <owner/name>]
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
- **Pass every Linear project whose resources link to this repo.** Example: the `Checkout Risk`
|
|
25
|
-
project lists `https://github.com/acme/widgets`, so pass `--linear-project "Checkout Risk"`.
|
|
26
|
-
- **Pass `--github-repo` only when `origin` is not the GitHub repo.** Example: exit 2 with
|
|
27
|
-
`no GitHub origin remote` means pass it.
|
|
28
|
-
- **`--if-missing` never touches an existing section.** To rewrite it, run without `--if-missing`.
|
|
29
|
-
- **`AGENTS.md` indexes `CONTRIBUTING.md`.** The script adds a `## Contributing` pointer unless
|
|
30
|
-
`AGENTS.md` already names `CONTRIBUTING.md`, creates a missing `AGENTS.md`, and creates a missing
|
|
31
|
-
`CLAUDE.md` as a symlink to `AGENTS.md`, even when the section already exists.
|
|
32
|
-
- Report each file's result line; commit with the change you are making, or leave it for the user
|
|
33
|
-
when you make no other change.
|
|
34
|
-
|
|
35
|
-
## Tracker routing
|
|
36
|
-
|
|
37
|
-
| Work item | Tracker |
|
|
38
|
-
|---|---|
|
|
39
|
-
| Product goals, business requirements, priorities, milestones | Linear |
|
|
40
|
-
| Consulting engagements, reports, research deliverables | Linear |
|
|
41
|
-
| Engineering specs and implementation epics | GitHub |
|
|
42
|
-
| Implementation tickets, technical investigations, refactors, engineering bugs | GitHub |
|
|
43
|
-
| Delivery progress for stakeholders | A summary in Linear that links to GitHub |
|
|
44
|
-
|
|
45
|
-
Before you create an issue:
|
|
46
|
-
|
|
47
|
-
1. **Classify the work; the tracker follows the work, not where the request came from.** Example:
|
|
48
|
-
`/to-spec` run on a conversation about DEV-123 publishes a GitHub issue.
|
|
49
|
-
2. **Resolve the GitHub repo in this order: the repo's `CONTRIBUTING.md` Issue tracking section, the
|
|
50
|
-
GitHub links in the source Linear project's resources, then ask the user. Never fall back to
|
|
51
|
-
Linear.** Example: a project linking two repos gets the repo the work changes; a project linking
|
|
52
|
-
none gets a question, not a Linear ticket.
|
|
53
|
-
3. **Search before you create.** Example: `gh issue list --repo <owner/name> --state all --search
|
|
54
|
-
"<keywords>"`; reuse or comment on a match.
|
|
55
|
-
4. **A Linear issue is a source reference, never a parent for implementation tickets.** Example: put
|
|
56
|
-
`Source: <Linear URL>` in the GitHub epic body and file the tickets as GitHub sub-issues of the
|
|
57
|
-
epic.
|
|
58
|
-
5. **Link back once per epic.** Example: add the GitHub epic URL to the Linear requirement as a
|
|
59
|
-
link; never copy the GitHub tickets into Linear.
|
|
60
|
-
6. **Report progress in Linear as a summary; acceptance stays with the requester.** Example: when the
|
|
61
|
-
last sub-issue closes, comment `Engineering complete: <epic URL>` on the Linear requirement and
|
|
62
|
-
leave its status alone.
|
|
63
|
-
7. **An explicit destination from the user wins.** Example: "file this in Linear" files it in Linear.
|
|
64
|
-
|
|
65
|
-
## Common wrong answers
|
|
66
|
-
|
|
67
|
-
- "The request came from Linear, so the tickets go in Linear." The work type decides, rule 1.
|
|
68
|
-
- "No tracker is configured, so use the one the conversation used." Resolve the repo, rule 2.
|
|
69
|
-
- "Mirror the epic into Linear so stakeholders see it." Post a summary with a link, rule 5.
|
|
70
|
-
- "All sub-issues closed, so the Linear requirement is done." Engineering complete is not accepted, rule 6.
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: fraud-analysis
|
|
3
|
-
description: >
|
|
4
|
-
Analyze tabular payment, transfer, collection, payout, account, or fraud-case
|
|
5
|
-
data (CSV, XLSX, TXT) to find interpretable features and operable risk rules.
|
|
6
|
-
Supports third-party fraud (including stolen credentials and account takeover),
|
|
7
|
-
first-party fraud or deliberate account abuse, and authorized payment scams
|
|
8
|
-
(including APP scams), across card payments, collections, bank transfers, and
|
|
9
|
-
payouts. Produces temporal holdout metrics, domain-appropriate actions, strategy
|
|
10
|
-
impact, and a business report. Use for fraud, scam, mule, account-takeover,
|
|
11
|
-
chargeback, dispute, transfer, collection, or payout risk analysis even when the
|
|
12
|
-
user does not name this skill.
|
|
13
|
-
release-version: 0.44.1
|
|
14
|
-
---
|
|
15
|
-
**Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
|
|
16
|
-
# Fraud Analysis
|
|
17
|
-
|
|
18
|
-
Analyze fraud through composable dimensions rather than treating every case as
|
|
19
|
-
card fraud:
|
|
20
|
-
|
|
21
|
-
- **Archetype** — the genuine customer's relationship to the fraud
|
|
22
|
-
- **Mechanism** — how compromise, deception, or abuse occurred
|
|
23
|
-
- **Context** — the product or money-movement surface where a rule fires
|
|
24
|
-
|
|
25
|
-
Run this **10-step** loop:
|
|
26
|
-
|
|
27
|
-
0. Pre-flight → 1. Profile → 2. Label → 3. Time split → 4. Engineer features →
|
|
28
|
-
5. Shortlist → 6. Score rules → 7. Map actions + packages → 8. Write report →
|
|
29
|
-
9. Review
|
|
30
|
-
|
|
31
|
-
**Produce:** operable rules and portfolio impact in a business report.
|
|
32
|
-
**Do not produce:** a black-box model as the main answer.
|
|
33
|
-
|
|
34
|
-
> Paths under `references/` are relative to this skill directory. Step detail:
|
|
35
|
-
> [references/workflow.md](references/workflow.md).
|
|
36
|
-
|
|
37
|
-
## Required references
|
|
38
|
-
|
|
39
|
-
| File | When |
|
|
40
|
-
|---|---|
|
|
41
|
-
| [references/workflow.md](references/workflow.md) | Every step — full procedure |
|
|
42
|
-
| [references/taxonomy.md](references/taxonomy.md) | Step 1; select archetype, mechanism, and context |
|
|
43
|
-
| [references/feature-engineering.md](references/feature-engineering.md) | Step 4; shared decision-time rules |
|
|
44
|
-
| [references/report-template.md](references/report-template.md) | Step 8; common report contract and overlay selection |
|
|
45
|
-
| [references/terminology.md](references/terminology.md) | Step 8; fixed terms and language proofread |
|
|
46
|
-
| [references/review-checklist.md](references/review-checklist.md) | Step 9; common and selected-profile checks |
|
|
47
|
-
|
|
48
|
-
After Step 1, read only the selected profile references:
|
|
49
|
-
|
|
50
|
-
| Dimension | References |
|
|
51
|
-
|---|---|
|
|
52
|
-
| Archetype | `references/archetypes/<archetype>.md` |
|
|
53
|
-
| Mechanism | `references/mechanisms/<mechanism>.md` when present |
|
|
54
|
-
| Context | `references/contexts/<context>.md` for every selected context |
|
|
55
|
-
|
|
56
|
-
Current packs: archetypes `third-party-fraud`, `first-party-fraud`,
|
|
57
|
-
`authorized-payment-scam`; mechanism `account-takeover`; contexts
|
|
58
|
-
`card-payment`, `payment-collection`, `bank-transfer`, `payout`.
|
|
59
|
-
|
|
60
|
-
## Progress
|
|
61
|
-
|
|
62
|
-
Track a task plan. One item is `in_progress` at a time.
|
|
63
|
-
|
|
64
|
-
1. Pass pre-flight
|
|
65
|
-
2. Select analysis profile
|
|
66
|
-
3. Formalize label
|
|
67
|
-
4. Freeze time split
|
|
68
|
-
5. Engineer candidate features
|
|
69
|
-
6. Select shortlist
|
|
70
|
-
7. Score candidate rules
|
|
71
|
-
8. Map actions and packages
|
|
72
|
-
9. Write business report and proofread language
|
|
73
|
-
10. Independent review
|
|
74
|
-
|
|
75
|
-
Suggested notes: `preflight=…`, `profile=…`, `label=…`, `cut=…`,
|
|
76
|
-
`allowed/excluded=…`, `shortlist=…`, `N rules, holdout|in-sample`,
|
|
77
|
-
`packages=…`, `report=…`, `verdict=…`.
|
|
78
|
-
|
|
79
|
-
## Input
|
|
80
|
-
|
|
81
|
-
CSV / XLSX / TXT; one row per event or case; optional outcome column. A dataset
|
|
82
|
-
may contain more than one event type, but every scored rule needs a declared
|
|
83
|
-
event grain and decision point.
|
|
84
|
-
|
|
85
|
-
## Workflow
|
|
86
|
-
|
|
87
|
-
Follow [references/workflow.md](references/workflow.md) for each step. The map:
|
|
88
|
-
|
|
89
|
-
| Step | Do | Gate |
|
|
90
|
-
|---|---|---|
|
|
91
|
-
| 0 Pre-flight | Label provenance; pass / needs-user-input / descriptive-only / blocked | No supervised metrics until pass |
|
|
92
|
-
| 1 Profile | Archetype + mechanism(s) + context(s) from taxonomy | Read selected packs before Step 2 |
|
|
93
|
-
| 2 Label | Formalize positive / negative / unlabeled | Both classes defined before scoring |
|
|
94
|
-
| 3 Time split | Freeze train/holdout before features | No random shuffle as headline |
|
|
95
|
-
| 4 Features | Engineer candidates at decision time | Train-only thresholds |
|
|
96
|
-
| 5 Shortlist | Lift / support / stability / operability | Train only |
|
|
97
|
-
| 6 Score | occurrence → precision → recall on holdout | Headline = holdout |
|
|
98
|
-
| 7 Actions | Map to context packs + strategy packages | Freeze packages before report |
|
|
99
|
-
| 8 Report | Business report per template; grep terminology banned strings | No review process in the file |
|
|
100
|
-
| 9 Review | Checklist + selected packs | Prefer fresh independent reviewer |
|
|
101
|
-
|
|
102
|
-
## Output contract
|
|
103
|
-
|
|
104
|
-
| Item | Requirement |
|
|
105
|
-
|---|---|
|
|
106
|
-
| Analysis profile | Archetype, role, consent, technical authentication, mechanism, context, confidence, grain, decision point |
|
|
107
|
-
| Rule metrics | occurrence → precision → recall; headline window identified |
|
|
108
|
-
| Strategy coverage | triggered count + rate and amount + share, with currency |
|
|
109
|
-
| Domain impact | Measures and denominators from selected context packs only |
|
|
110
|
-
| Uncertainty | Proxy labels, immature outcomes, weak identity links, and unresolved attribution stated |
|
|
111
|
-
| Report file | Business content only; no reviewer model, verdict, or repair log |
|
|
112
|
-
|
|
113
|
-
Steps 0–7 are analysis and its entry gate. Step 8 writes the report. Step 9
|
|
114
|
-
reviews it.
|