@1aboveio/skills 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +24 -7
  2. package/bin/select-core.mjs +182 -50
  3. package/extensions/hide-copies.ts +21 -0
  4. package/package.json +115 -3
  5. package/skills/app-dev/airflow/SKILL.md +1 -1
  6. package/skills/app-dev/android-foundation/SKILL.md +1 -1
  7. package/skills/app-dev/app-debug/SKILL.md +1 -1
  8. package/skills/app-dev/better-auth/SKILL.md +1 -1
  9. package/skills/app-dev/data-catalog/SKILL.md +10 -6
  10. package/skills/app-dev/data-catalog/references/data-catalog-conventions.md +5 -3
  11. package/skills/app-dev/data-catalog/references/field-definitions.md +15 -7
  12. package/skills/app-dev/data-catalog/references/format.md +21 -8
  13. package/skills/app-dev/data-catalog/scripts/data_catalog.py +87 -32
  14. package/skills/app-dev/data-catalog/scripts/test_data_catalog.py +58 -11
  15. package/skills/app-dev/data-quality/LICENSE +3 -0
  16. package/skills/app-dev/data-quality/SKILL.md +96 -0
  17. package/skills/app-dev/data-quality/agents/openai.yaml +3 -0
  18. package/skills/app-dev/data-quality/references/checks.md +56 -0
  19. package/skills/app-dev/data-quality/release-check.mjs +93 -0
  20. package/skills/app-dev/data-quality/scripts/data-quality.test.mjs +12 -0
  21. package/skills/app-dev/data-quality/scripts/data_quality.py +405 -0
  22. package/skills/app-dev/data-quality/scripts/test_data_quality.py +277 -0
  23. package/skills/app-dev/ios-foundation/SKILL.md +1 -1
  24. package/skills/app-dev/monorepo/SKILL.md +1 -1
  25. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  26. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  27. package/skills/app-dev/pyspark/SKILL.md +1 -1
  28. package/skills/app-dev/python-backend/SKILL.md +1 -1
  29. package/skills/app-dev/shadcn/SKILL.md +1 -1
  30. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  31. package/skills/cicd/cloud-build/SKILL.md +1 -1
  32. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  33. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  34. package/skills/cicd/docker/SKILL.md +1 -1
  35. package/skills/cicd/github-actions/SKILL.md +1 -1
  36. package/skills/cicd/github-flow/SKILL.md +1 -1
  37. package/skills/cicd/google-cloud/SKILL.md +1 -1
  38. package/skills/cicd/mergify/SKILL.md +1 -1
  39. package/skills/cicd/podman/SKILL.md +1 -1
  40. package/skills/content/article-review/SKILL.md +1 -1
  41. package/skills/content/article-to-image/SKILL.md +1 -1
  42. package/skills/content/flowchart-svg/SKILL.md +1 -1
  43. package/skills/content/image-gen/SKILL.md +1 -1
  44. package/skills/content/mermaid/SKILL.md +1 -1
  45. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  46. package/skills/content/wechat-publisher/SKILL.md +1 -1
  47. package/skills/content/wechat-search/SKILL.md +1 -1
  48. package/skills/content/xai-search/SKILL.md +1 -1
  49. package/skills/core/distill-writing/SKILL.md +1 -1
  50. package/skills/core/doc-to-md/SKILL.md +1 -1
  51. package/skills/core/editorial-review/SKILL.md +1 -1
  52. package/skills/core/house-style/SKILL.md +1 -1
  53. package/skills/core/plain-language/LICENSE +3 -0
  54. package/skills/core/plain-language/SKILL.md +57 -0
  55. package/skills/core/plain-language/release-check.mjs +93 -0
  56. package/skills/core/polish-cn/SKILL.md +1 -1
  57. package/skills/core/polish-cn/references/models.md +1 -1
  58. package/skills/core/reg-parser/SKILL.md +1 -1
  59. package/skills/core/terminology/SKILL.md +1 -1
  60. package/skills/core/write-doc/SKILL.md +1 -1
  61. package/skills/engineering/better-review/SKILL.md +1 -1
  62. package/skills/engineering/e2e-test/SKILL.md +1 -1
  63. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  64. package/skills/engineering/engineering-runtime/coherence/workflow.json +12 -12
  65. package/skills/engineering/ensure-coverage/SKILL.md +1 -1
  66. package/skills/engineering/golden-path/SKILL.md +1 -1
  67. package/skills/engineering/harness-runtime/SKILL.md +1 -1
  68. package/skills/engineering/harness-runtime/model-catalog-seed.test.mjs +4 -4
  69. package/skills/engineering/harness-runtime/references/model-catalog-seed.json +4 -4
  70. package/skills/engineering/resolve-issues/SKILL.md +1 -1
  71. package/skills/engineering/resolve-issues/scripts/preflight-models.test.mjs +1 -1
  72. package/skills/engineering/resolve-release/SKILL.md +1 -1
  73. package/skills/engineering/rush-issues/SKILL.md +1 -1
  74. package/skills/engineering/rush-release/SKILL.md +1 -1
  75. package/skills/engineering/test-quality/SKILL.md +1 -1
  76. package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
  77. package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
  78. package/skills/risk-compliance/contract-review/SKILL.md +1 -1
  79. package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
  80. package/skills/risk-compliance/fraud-analysis/SKILL.md +1 -1
  81. package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
  82. package/skills/risk-compliance/report-writing/SKILL.md +1 -1
  83. package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
  84. package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: data-catalog
3
3
  description: "Column definitions for warehouse tables, written inside the Spark/PySpark or SQL script that builds each table (CATALOG_TABLE / CATALOG_FIELDS, or a `-- @field` comment block), with a generated markdown catalog in docs/catalog/ and a CI check that fails a PR whose catalog is stale. Use when writing or changing a table script that adds, renames, removes, or changes the meaning of an output column or feature; when asked what a warehouse column or feature means; to set a repo up ('init the data catalog', 'generate the data dictionary', '字段字典', 'add catalog CI'); or to audit existing SQL or Spark scripts for undocumented columns. NOT for implementing or tuning the Spark job itself (pyspark), DAG wiring (airflow), or general workflow YAML (github-actions)."
4
- release-version: 0.36.0
4
+ release-version: 0.39.0
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # Data catalog
@@ -42,6 +42,7 @@ Never document tables during setup unless the task asks for it.
42
42
  | CI says the catalog is stale | `python3 <engine> generate`, commit the pages |
43
43
  | Before committing a table-script change, or reviewing one | `python3 <engine> drift` (`--base origin/main` for a whole branch) |
44
44
  | `Field`/`Table` arguments, the SQL block, config keys, exit codes | [references/format.md](references/format.md) |
45
+ | Does the table's data match its definitions? | the data-quality skill |
45
46
 
46
47
  `<engine>` is `scripts/data-catalog/data_catalog.py` in the repo, or
47
48
  `<skill-base-dir>/scripts/data_catalog.py` before the repo has a copy. When
@@ -68,7 +69,8 @@ Never document tables during setup unless the task asks for it.
68
69
  Example: adding `refund_cnt_30d` to the SELECT means adding its `Field` in the
69
70
  same commit, then running `generate`.
70
71
  2. **Each row says only what is particular to its column, in business words.**
71
- Shared filters, window anchor and currency go once in the table notes; the row
72
+ Shared filters, window anchor and currency go once in the table notes, in every
73
+ language; the row
72
74
  gives what one value measures, its unit, and 0/null meaning; `source` gives
73
75
  lineage in one line. Follow the eleven principles in
74
76
  [references/field-definitions.md](references/field-definitions.md).
@@ -178,16 +180,18 @@ script is clean when its columns were `unknown`.
178
180
 
179
181
  ## Backfill one table
180
182
 
181
- 1. Pick the script that builds the table from the audit. Its backfill, migrate, and
182
- isolated twins share the definitions and need none of their own.
183
+ 1. Pick the script that builds the table from the audit. Backfill, migration, and
184
+ copy scripts that write the same table share its definitions and need none of
185
+ their own.
183
186
  2. Read the SQL or DataFrame code for every output column. Reuse an existing
184
187
  hand-written dictionary as a draft, and check every row against the code.
185
188
  3. Add `CATALOG_TABLE` and `CATALOG_FIELDS` in contract order. Keep the literal
186
189
  contract while you write.
187
190
  4. Run `generate` until it passes, then derive the contract and run the repo's
188
191
  schema tests (rule 5).
189
- 5. Rerun the audit for that script, and fix `relative-definition` and
190
- `thin-definition` findings.
192
+ 5. Rerun the audit for that script and fix its writing findings (`repeated-text`,
193
+ `code-in-description`, `long-definition`, `thin-definition`,
194
+ `relative-definition`), or leave one only when the text really belongs in that row.
191
195
  6. Commit the script and pages together. Replace the old hand-written dictionary
192
196
  with a link to the generated page.
193
197
 
@@ -7,7 +7,8 @@ table. `docs/catalog/` is generated from those definitions; never edit it by han
7
7
 
8
8
  **Where definitions live**
9
9
 
10
- - Python: `CATALOG_TABLE = Table("schema.table", grain=..., <language>=...)` and
10
+ - Python: `CATALOG_TABLE = Table("schema.table", <language>=..., grain_<language>=...,
11
+ notes_<language>=(...))` (grain and notes in every language) and
11
12
  `CATALOG_FIELDS = (Field("column", <language>=..., source=..., window=..., unit=...), ...)`,
12
13
  imported `from catalog_fields import Field, Table`. Use plain string literals.
13
14
  - SQL: a comment block above the first statement: `-- @table schema.table`, then one
@@ -30,7 +31,7 @@ table. `docs/catalog/` is generated from those definitions; never edit it by han
30
31
 
31
32
  1. Say each fact once, at the widest level where it is true. Filters, the window
32
33
  anchor, currency, time zone, terms many columns use ("personal", "platform") and
33
- list cleaning rules go in the table's `grain` or `notes`; a row says only what
34
+ list cleaning rules go in the table's grain or notes; a row says only what
34
35
  differs for that column.
35
36
  2. The description is meaning in business words: what one value measures, its unit
36
37
  and form (a ratio says 0–1 or percent; a time says date or timestamp and its
@@ -51,7 +52,8 @@ table. `docs/catalog/` is generated from those definitions; never edit it by han
51
52
  collections.", not "0 when none".
52
53
  9. Never restate the column name or write "same as above".
53
54
  10. Cover every case: what the column holds for each kind of row, including when it
54
- is empty; list an enumerated column's values; say so if it is always empty today.
55
+ is empty; list an enumerated column's values in the text and in `values=(...)`;
56
+ say so if it is always empty today.
55
57
  11. Write related columns as a set: a count and its amount, a value and its missing
56
58
  flag, a legacy column and its replacement use the same scope and wording, and any
57
59
  difference is stated in both rows.
@@ -8,7 +8,7 @@ column that says only what is particular to that column.
8
8
 
9
9
  1. **Say each fact once, at the widest level where it is true.** A filter, window
10
10
  anchor, currency rule, or row-inclusion rule that holds for every column goes in
11
- the table's `grain` or `notes`, never in each row. So does the meaning of a term
11
+ the table's grain or notes, never in each row. So does the meaning of a term
12
12
  many columns use ("personal", "platform", "own name") and how lists are cleaned
13
13
  (case, sorting, empty items). A row carries only what differs for that column.
14
14
  Bad (every row): `"Posted collections only, test customers excluded, [dt-7d, dt), excluding dt; ..."`
@@ -50,7 +50,8 @@ column that says only what is particular to that column.
50
50
  `"同上,30 天窗口"` tell the reader nothing.
51
51
  10. **Cover every case.** Say what the column holds for every kind of row (each
52
52
  transaction type, each branch of the code), including when it is empty. List
53
- the values an enumerated column can take, with a Chinese gloss for English codes.
53
+ the values an enumerated column can take, with a Chinese gloss for English codes,
54
+ and put the same list in `values=(...)` so the data can be checked against it.
54
55
  A column that is always empty or constant today says so.
55
56
  Bad: `"换汇交易为 COMPLETED,内部转账为空。"` (collections and payouts unsaid)
56
57
  Good: `"换汇交易为 COMPLETED;收款、付款和内部转账为空。"`
@@ -66,10 +67,10 @@ column that says only what is particular to that column.
66
67
 
67
68
  | Fact | Goes in |
68
69
  |---|---|
69
- | What one row is, which rows exist | `Table.grain` |
70
+ | What one row is, which rows exist | `Table` `grain_<lang>` |
70
71
  | What the table is for, its main source | `Table` language text |
71
- | Filters, window anchor, currency, time zone shared by all columns | `Table.notes` |
72
- | Meaning of a term many columns use, list cleaning rules | `Table.notes` |
72
+ | Filters, window anchor, currency, time zone shared by all columns | `Table` `notes_<lang>` |
73
+ | Meaning of a term many columns use, list cleaning rules | `Table` `notes_<lang>` |
73
74
  | What this column measures, its unit in words, 0/null meaning | `Field` language text |
74
75
  | Upstream column, aggregation, filters only this column adds | `Field.source` |
75
76
  | Exact interval / unit code | `Field.window` / `Field.unit` |
@@ -81,15 +82,22 @@ Table:
81
82
  ```python
82
83
  CATALOG_TABLE = Table(
83
84
  "dwd.customer_daily",
84
- grain="One row per (dt, cust_id) for each customer with a posted collection before dt.",
85
85
  en="Daily customer collection metrics for risk features.",
86
86
  zh="客户每日收款指标,供风险特征使用。",
87
- notes=(
87
+ grain_en="One row per (dt, cust_id) for each customer with a posted collection before dt.",
88
+ grain_zh="每个 (dt, cust_id) 一行;只有 dt 之前有已入账收款的客户才有行。",
89
+ notes_en=(
88
90
  "All metrics count posted collections (status 'POSTED') from non-test customers.",
89
91
  "Windows end the day before dt: 'last 7 days' is [dt-7d, dt).",
90
92
  "Amounts are converted to USD at the transaction date's rate; collections with no rate are left out of every metric.",
91
93
  "Every metric is 0, not empty, if the customer had no collections in its window.",
92
94
  ),
95
+ notes_zh=(
96
+ "所有指标只计非测试客户的已入账(status 为 'POSTED')收款。",
97
+ "窗口截至 dt 前一天:「近 7 天」指 [dt-7d, dt),含 dt-7d,不含 dt。",
98
+ "金额按交易日汇率换算为美元;若某笔收款当天没有汇率,则所有指标都不计这笔收款。",
99
+ "若客户在窗口内没有收款,则指标为 0,而不是空值。",
100
+ ),
93
101
  )
94
102
  ```
95
103
 
@@ -7,13 +7,18 @@ from catalog_fields import Field, Table
7
7
 
8
8
  CATALOG_TABLE = Table(
9
9
  "dwd.customer_daily",
10
- grain="One row per (dt, cust_id) for each customer with a posted transaction that day.",
11
10
  en="Daily customer collection rollup used by risk features.",
12
11
  zh="客户每日收款汇总,供风险特征使用。",
13
- notes=(
12
+ grain_en="One row per (dt, cust_id) for each customer with a posted transaction that day.",
13
+ grain_zh="每个 (dt, cust_id) 一行;只有当天有已入账交易的客户才有行。",
14
+ notes_en=(
14
15
  "All metrics count posted collections (status 'POSTED').",
15
16
  "Amounts are converted to USD at the transaction date's rate.",
16
17
  ),
18
+ notes_zh=(
19
+ "所有指标只计已入账(status 为 'POSTED')的收款。",
20
+ "金额按交易日汇率换算为美元。",
21
+ ),
17
22
  )
18
23
 
19
24
  KEYS = (
@@ -36,8 +41,13 @@ CATALOG_FIELDS = (
36
41
  PUBLISHED_COLUMNS = tuple(f.name for f in CATALOG_FIELDS)
37
42
  ```
38
43
 
39
- - `Field(name, <one keyword per language>, source, window=None, unit=None)`.
40
- - `Table(name, grain, <one keyword per language>, notes=())`.
44
+ - `Field(name, <one keyword per language>, source, window=None, unit=None, values=())`.
45
+ `values` lists the values an enumerated column can take, as distinct strings
46
+ (`values=("是", "否", "无法判定")`); the data-quality skill checks the data against it.
47
+ Never use it on identifiers or free text.
48
+ - `Table(name, <one keyword per language>, grain_<lang>=..., notes_<lang>=(...))`: a
49
+ grain for every language, and the same number of notes in every language (notes are
50
+ optional).
41
51
  - Pass only the name positionally; everything else by keyword.
42
52
  - The language keywords come from `languages` in `data-catalog.json`. `init`
43
53
  regenerates `catalog_fields.py` with exactly those, so a missing language is a
@@ -49,10 +59,12 @@ PUBLISHED_COLUMNS = tuple(f.name for f in CATALOG_FIELDS)
49
59
 
50
60
  ```sql
51
61
  -- @table ads.merchant_monthly
52
- -- grain: One row per (month, merchant_id).
62
+ -- grain_en: One row per (month, merchant_id).
63
+ -- grain_zh: 每个 (month, merchant_id) 一行。
53
64
  -- en: Monthly settled volume per merchant.
54
65
  -- zh: 商户月度结算金额。
55
- -- notes: Built on the 1st of each month; Counts settled rows (status 'SETTLED') only; Excludes test merchants
66
+ -- notes_en: Built on the 1st of each month; Counts settled rows (status 'SETTLED') only; Excludes test merchants
67
+ -- notes_zh: 每月 1 日构建;只计已结算(status 为 'SETTLED')的记录;不含测试商户
56
68
  -- @field merchant_id
57
69
  -- en: Merchant that settled the volume; key of dim_merchant.
58
70
  -- zh: 结算该金额的商户,dim_merchant 主键。
@@ -68,9 +80,10 @@ SELECT ...
68
80
  ```
69
81
 
70
82
  - A directive line is `-- @table <name>` or `-- @field <name>`.
71
- - A key line is exactly two spaces after `-- `, then `key: value`.
83
+ - A key line is exactly two spaces after `-- `, then `key: value`. `values:` takes a
84
+ list split on `;` or `;`.
72
85
  - A continuation line has four or more spaces and joins the previous key with a space.
73
- - `notes` on `@table` splits on `;`.
86
+ - `notes_<lang>` on `@table` splits on `;` or `;`.
74
87
  - The block ends at the first non-comment line. One `@table` per file.
75
88
 
76
89
  ## data-catalog.json
@@ -49,7 +49,7 @@ DEFAULT_CONFIG = {
49
49
  "tableNames": [],
50
50
  "schemaNames": [],
51
51
  }
52
- FIELD_EXTRAS = ("source", "window", "unit")
52
+ FIELD_EXTRAS = ("source", "window", "unit", "values")
53
53
  LANGUAGE_LABELS = {"en": "English", "zh": "中文", "zh_hant": "繁體中文", "ja": "日本語", "ko": "한국어"}
54
54
  LANGUAGE_RE = re.compile(r"^[a-z]{2}(_[a-z]+)?$")
55
55
  SKIP_DIRS = {"node_modules", "__pycache__", "venv"}
@@ -151,15 +151,16 @@ class FieldDoc:
151
151
  window: str | None
152
152
  unit: str | None
153
153
  line: int
154
+ values: tuple = () # the values an enumerated column can take; checked by data-quality
154
155
 
155
156
 
156
157
  @dataclass
157
158
  class TableDoc:
158
159
  name: str
159
160
  script: str
160
- grain: str
161
+ grain: dict # language -> what one row is
161
162
  text: dict
162
- notes: list
163
+ notes: dict # language -> [shared rules]
163
164
  fields: list
164
165
  line: int
165
166
  contracts: dict = field(default_factory=dict) # name -> list[str] | "derived" | "unchecked"
@@ -258,26 +259,66 @@ def build_field(values: dict, languages: list, where: str, line: int) -> FieldDo
258
259
  f"languages configured: {languages}")
259
260
  text = {lang: require_text(values, lang, where, line, f"Field {name!r}") for lang in languages}
260
261
  source = require_text(values, "source", where, line, f"Field {name!r}")
261
- return FieldDoc(name, text, source, (values.get("window") or None), (values.get("unit") or None), line)
262
+ allowed = values.get("values") or ()
263
+ if isinstance(allowed, str):
264
+ allowed = tuple(v.strip() for v in re.split(r"[;;]", allowed))
265
+ if not isinstance(allowed, tuple) or not all(isinstance(v, str) and v != "" for v in allowed):
266
+ raise CatalogError(f"{where}:{line}: Field {name!r}: values must be a tuple of non-empty strings")
267
+ if len(set(allowed)) != len(allowed):
268
+ raise CatalogError(f"{where}:{line}: Field {name!r}: values lists a value twice")
269
+ return FieldDoc(name, text, source, (values.get("window") or None), (values.get("unit") or None), line, allowed)
262
270
 
263
271
 
264
272
  def build_table(values: dict, fields: list, languages: list, where: str, line: int, script: str) -> TableDoc:
265
273
  name = require_text(values, "name", where, line, "Table")
266
- known = {"name", "grain", "notes", *languages}
274
+ if "grain" in values or "notes" in values:
275
+ keys = ", ".join(f"grain_{lang}=..., notes_{lang}=(...)" for lang in languages)
276
+ raise CatalogError(f"{where}:{line}: Table {name!r}: grain and notes are written per language: {keys}")
277
+ known = {"name", *languages, *(f"grain_{lang}" for lang in languages), *(f"notes_{lang}" for lang in languages)}
267
278
  unknown = sorted(set(values) - known)
268
279
  if unknown:
269
280
  raise CatalogError(f"{where}:{line}: Table {name!r} has unknown argument(s) {unknown}")
270
- grain = require_text(values, "grain", where, line, f"Table {name!r}")
271
281
  text = {lang: require_text(values, lang, where, line, f"Table {name!r}") for lang in languages}
272
- notes = values.get("notes") or ()
273
- if isinstance(notes, str):
274
- notes = (notes,)
275
- return TableDoc(name, script, grain, text, [n.strip() for n in notes if n and n.strip()], fields, line)
282
+ grain = {lang: require_text(values, f"grain_{lang}", where, line, f"Table {name!r}") for lang in languages}
283
+ notes = {}
284
+ for lang in languages:
285
+ raw = values.get(f"notes_{lang}") or ()
286
+ raw = (raw,) if isinstance(raw, str) else raw
287
+ notes[lang] = [n.strip() for n in raw if n and n.strip()]
288
+ counts = {lang: len(n) for lang, n in notes.items()}
289
+ if len(set(counts.values())) > 1:
290
+ raise CatalogError(f"{where}:{line}: Table {name!r}: every language needs the same notes, got {counts}")
291
+ return TableDoc(name, script, grain, text, notes, fields, line)
292
+
293
+
294
+ def struct_fields(node: ast.AST, names: dict):
295
+ """Column names of a literal StructType([StructField("name", ...), ...]), or None."""
296
+ if call_name(node) != "StructType" or not node.args:
297
+ return None
298
+ try:
299
+ items = resolve_sequence(node.args[0], names, "", frozenset())
300
+ except CatalogError:
301
+ return None
302
+ out = []
303
+ for item in items:
304
+ if call_name(item) != "StructField" or not item.args:
305
+ return None
306
+ try:
307
+ value = eval_text(item.args[0], names, "")
308
+ except CatalogError:
309
+ return None
310
+ if not isinstance(value, str):
311
+ return None
312
+ out.append(value)
313
+ return out
276
314
 
277
315
 
278
316
  def contract_state(node: ast.AST, names: dict):
279
317
  if any(isinstance(n, ast.Name) and n.id == FIELDS_VAR for n in ast.walk(node)):
280
318
  return "derived"
319
+ columns = struct_fields(node, names)
320
+ if columns is not None:
321
+ return columns
281
322
  try:
282
323
  items = resolve_sequence(node, names, "", frozenset())
283
324
  values = [eval_text(item, names, "") for item in items]
@@ -363,14 +404,12 @@ def parse_sql(text: str, where: str, languages: list) -> TableDoc | None:
363
404
  fields = []
364
405
  for kind, name, values, line, _ in entries:
365
406
  if kind == "field":
366
- if "notes" in values:
367
- raise CatalogError(f"{where}:{line}: `notes` belongs on @table, not @field")
407
+ if any(k.startswith(("notes", "grain")) for k in values):
408
+ raise CatalogError(f"{where}:{line}: `grain_*` and `notes_*` belong on @table, not @field")
368
409
  fields.append(build_field({"name": name, **values}, languages, where, line))
369
410
  _, name, values, line, _ = tables[0]
370
- notes = values.pop("notes", None)
371
- values = {"name": name, **values}
372
- if notes:
373
- values["notes"] = tuple(n.strip() for n in notes.split(";"))
411
+ values = {"name": name, **{k: tuple(n.strip() for n in re.split(r"[;;]", v)) if k.startswith("notes_") else v
412
+ for k, v in values.items()}}
374
413
  return build_table(values, fields, languages, where, line, where)
375
414
 
376
415
 
@@ -467,6 +506,9 @@ def cell(text: str | None) -> str:
467
506
  return text.replace("\\", "\\\\").replace("|", "\\|").replace("\r\n", "\n").replace("\n", "<br>")
468
507
 
469
508
 
509
+ SECTION_LABELS = {"en": ("Grain:", "Notes:"), "zh": ("粒度:", "说明:"), "zh_hant": ("粒度:", "說明:")}
510
+
511
+
470
512
  def label(lang: str) -> str:
471
513
  return LANGUAGE_LABELS.get(lang, lang)
472
514
 
@@ -496,28 +538,33 @@ def render_table(table: TableDoc, root: Path, config: dict) -> str:
496
538
  langs = config["languages"]
497
539
  out = [GENERATED_MARK, "", f"# `{table.name}`", ""]
498
540
  out.append(f"- **Script:** [{table.script}]({link_to(root, config['output'], table.script)})")
499
- out.append(f"- **Grain:** {cell(table.grain)}")
500
541
  out.append(f"- **Fields:** {len(table.fields)}")
501
542
  out.append("")
502
543
  for lang in langs:
503
- out.append(f"**{label(lang)}:** {cell(table.text[lang])}" if len(langs) > 1 else cell(table.text[lang]))
504
- out.append("")
505
- if table.notes:
506
- out.append("**Notes:**")
507
- out.append("")
508
- out.extend(f"- {cell(note)}" for note in table.notes)
544
+ grain_label, notes_label = SECTION_LABELS.get(lang, SECTION_LABELS["en"])
545
+ if len(langs) > 1:
546
+ out += [f"## {label(lang)}", ""]
547
+ out += [cell(table.text[lang]), "", f"- **{grain_label}** {cell(table.grain[lang])}"]
548
+ if table.notes[lang]:
549
+ out.append(f"- **{notes_label}**")
550
+ out.extend(f" - {cell(note)}" for note in table.notes[lang])
509
551
  out.append("")
552
+ if len(langs) > 1:
553
+ out += ["## Fields", ""]
510
554
  has_window = any(f.window for f in table.fields)
511
555
  has_unit = any(f.unit for f in table.fields)
556
+ has_values = any(f.values for f in table.fields)
512
557
  head = ["Field", *(label(lang) for lang in langs), "Source"]
513
558
  head += ["Window"] if has_window else []
514
559
  head += ["Unit"] if has_unit else []
560
+ head += ["Values"] if has_values else []
515
561
  out.append("| " + " | ".join(head) + " |")
516
562
  out.append("|" + "---|" * len(head))
517
563
  for f in table.fields:
518
564
  row = [f"`{f.name}`", *(cell(f.text[lang]) for lang in langs), cell(f.source)]
519
565
  row += [cell(f.window)] if has_window else []
520
566
  row += [cell(f.unit)] if has_unit else []
567
+ row += [cell(", ".join(f"`{v}`" for v in f.values))] if has_values else []
521
568
  out.append("| " + " | ".join(row) + " |")
522
569
  return "\n".join(out) + "\n"
523
570
 
@@ -539,7 +586,7 @@ def render_index(tables: list, root: Path, config: dict) -> str:
539
586
  out += ["| Table | Summary | Grain | Fields | Script |", "|---|---|---|---|---|"]
540
587
  for t in tables:
541
588
  script = f"[{t.script}]({link_to(root, config['output'], t.script)})"
542
- out.append(f"| [`{t.name}`]({page_name(t)}) | {cell(t.text[lang])} | {cell(t.grain)} | {len(t.fields)} | {script} |")
589
+ out.append(f"| [`{t.name}`]({page_name(t)}) | {cell(t.text[lang])} | {cell(t.grain[lang])} | {len(t.fields)} | {script} |")
543
590
  return "\n".join(out) + "\n"
544
591
 
545
592
 
@@ -1019,11 +1066,13 @@ def preview(items: list, limit: int = 12) -> str:
1019
1066
  return shown + (f", … (+{len(items) - limit})" if len(items) > limit else "")
1020
1067
 
1021
1068
 
1022
- def cmd_audit(root: Path, globs: list, as_json: bool, verbose: bool, table_names: list, schema_names: list) -> int:
1069
+ def cmd_audit(root: Path, globs: list, as_json: bool, verbose: bool, table_names: list, schema_names: list,
1070
+ contracts: list) -> int:
1023
1071
  config_path = root / CONFIG_FILE
1024
1072
  config = load_config(root) if config_path.exists() else dict(DEFAULT_CONFIG)
1025
1073
  config = {**config, "tableNames": [*config["tableNames"], *table_names],
1026
- "schemaNames": [*config["schemaNames"], *schema_names]}
1074
+ "schemaNames": [*config["schemaNames"], *schema_names],
1075
+ "contracts": [*config["contracts"], *contracts]}
1027
1076
  if globs:
1028
1077
  patterns, exclude = globs, config.get("exclude", [])
1029
1078
  elif config_path.exists():
@@ -1137,8 +1186,9 @@ def code_without_metadata(text: str, suffix: str) -> str:
1137
1186
  def definitions(table: TableDoc | None) -> tuple[dict, tuple]:
1138
1187
  if table is None:
1139
1188
  return {}, ()
1140
- fields = {f.name: (tuple(sorted(f.text.items())), f.source, f.window, f.unit) for f in table.fields}
1141
- return fields, (table.grain, tuple(sorted(table.text.items())), tuple(table.notes))
1189
+ fields = {f.name: (tuple(sorted(f.text.items())), f.source, f.window, f.unit, f.values) for f in table.fields}
1190
+ notes = tuple((lang, tuple(n)) for lang, n in sorted(table.notes.items()))
1191
+ return fields, (tuple(sorted(table.grain.items())), tuple(sorted(table.text.items())), notes)
1142
1192
 
1143
1193
 
1144
1194
  def drift_items(root: Path, config: dict, base: str) -> list:
@@ -1212,12 +1262,14 @@ def cmd_drift(root: Path, base: str) -> int:
1212
1262
 
1213
1263
  def render_fields_module(languages: list) -> str:
1214
1264
  lang_fields = "\n".join(f" {lang}: str" for lang in languages)
1265
+ grain_fields = "\n".join(f" grain_{lang}: str" for lang in languages)
1266
+ notes_fields = "\n".join(f" notes_{lang}: tuple[str, ...] = ()" for lang in languages)
1215
1267
  return f'''"""Column metadata types for the data catalog.
1216
1268
 
1217
1269
  Generated by data_catalog.py init from data-catalog.json; rerun init after changing
1218
1270
  `languages` there, never edit this file. Table scripts declare:
1219
1271
 
1220
- CATALOG_TABLE = Table("schema.table", grain=..., {", ".join(f"{lang}=..." for lang in languages)})
1272
+ CATALOG_TABLE = Table("schema.table", {", ".join(f"{lang}=..., grain_{lang}=..." for lang in languages)})
1221
1273
  {FIELDS_VAR} = (Field("column", {", ".join(f"{lang}=..." for lang in languages)}, source=...), ...)
1222
1274
  PUBLISHED_COLUMNS = tuple(f.name for f in {FIELDS_VAR})
1223
1275
  """
@@ -1233,14 +1285,15 @@ class Field:
1233
1285
  source: str
1234
1286
  window: str | None = None
1235
1287
  unit: str | None = None
1288
+ values: tuple[str, ...] = ()
1236
1289
 
1237
1290
 
1238
1291
  @dataclass(frozen=True)
1239
1292
  class Table:
1240
1293
  name: str
1241
- grain: str
1242
1294
  {lang_fields}
1243
- notes: tuple[str, ...] = ()
1295
+ {grain_fields}
1296
+ {notes_fields}
1244
1297
  '''
1245
1298
 
1246
1299
 
@@ -1367,6 +1420,8 @@ def main(argv: list | None = None) -> int:
1367
1420
  help="extra variable/attribute whose string value is the table written; repeatable")
1368
1421
  p_audit.add_argument("--schema-name", action="append", default=[], metavar="NAME",
1369
1422
  help="extra variable/attribute holding that table's schema; repeatable")
1423
+ p_audit.add_argument("--contract", action="append", default=[], metavar="NAME",
1424
+ help="extra column-contract name (tuple of names or StructType); repeatable")
1370
1425
  p_drift = sub.add_parser("drift", help="local check: code changed since a git base while its definitions did not")
1371
1426
  p_drift.add_argument("--base", default="HEAD", help="git ref to compare with (default HEAD: uncommitted work)")
1372
1427
  sub.add_parser("version", help="print the engine identity")
@@ -1382,7 +1437,7 @@ def main(argv: list | None = None) -> int:
1382
1437
  return cmd_generate(root, args.check)
1383
1438
  if args.command == "drift":
1384
1439
  return cmd_drift(root, args.base)
1385
- return cmd_audit(root, args.globs, args.json, args.verbose, args.table_name, args.schema_name)
1440
+ return cmd_audit(root, args.globs, args.json, args.verbose, args.table_name, args.schema_name, args.contract)
1386
1441
  except CatalogError as exc:
1387
1442
  print(f"data-catalog: {exc}", file=sys.stderr)
1388
1443
  return 2
@@ -25,10 +25,12 @@ WINDOW_NOTE = "[dt-7d, dt)"
25
25
 
26
26
  CATALOG_TABLE = Table(
27
27
  "dwd.customer_daily",
28
- grain="one row per (dt, cust_id)",
29
28
  en="Daily customer collection rollup.",
30
29
  zh="客户每日收款汇总。",
31
- notes=("Amounts in USD via dim_forex.",),
30
+ grain_en="one row per (dt, cust_id)",
31
+ grain_zh="每个 (dt, cust_id) 一行",
32
+ notes_en=("Amounts in USD via dim_forex.",),
33
+ notes_zh=("金额经 dim_forex 换算为 USD。",),
32
34
  )
33
35
  KEYS = (
34
36
  Field("dt", en="Snapshot date.", zh="快照日期。", source="Run parameter `dt`."),
@@ -50,10 +52,12 @@ PUBLISHED_COLUMNS = tuple(f.name for f in CATALOG_FIELDS)
50
52
 
51
53
  SQL_SCRIPT = """\
52
54
  -- @table ads.merchant_monthly
53
- -- grain: one row per (month, merchant_id)
54
55
  -- en: Monthly merchant volume.
55
56
  -- zh: 商户月度交易量。
56
- -- notes: Built monthly; Excludes test merchants
57
+ -- grain_en: one row per (month, merchant_id)
58
+ -- grain_zh: 每个 (month, merchant_id) 一行
59
+ -- notes_en: Built monthly; Excludes test merchants
60
+ -- notes_zh: 每月构建;不含测试商户
57
61
  -- @field merchant_id
58
62
  -- en: Merchant id.
59
63
  -- zh: 商户 ID。
@@ -111,12 +115,30 @@ class GenerateTest(unittest.TestCase):
111
115
  self.assertIn("| `volume_usd` | Settled volume in the month. | 当月结算金额。 | "
112
116
  "dwd_settlement settled rows, summed amount_usd | USD |", sql_page)
113
117
  self.assertNotIn("Window", sql_page)
114
- self.assertIn("- Excludes test merchants", sql_page)
118
+ self.assertIn(" - Excludes test merchants", sql_page)
119
+ self.assertIn("## 中文", sql_page)
120
+ self.assertIn("- **粒度:** 每个 (month, merchant_id) 一行", sql_page)
121
+ self.assertIn(" - 不含测试商户", sql_page)
115
122
  index = (root / "docs/catalog/README.md").read_text(encoding="utf-8")
116
123
  self.assertLess(index.index("ads.merchant_monthly"), index.index("dwd.customer_daily"))
117
124
  self.assertIn("| [`dwd.customer_daily`](dwd.customer_daily.md) | Daily customer collection rollup. | "
118
125
  "one row per (dt, cust_id) | 2 |", index)
119
126
 
127
+ def test_values_render_and_must_be_distinct_strings(self):
128
+ script = PY_SCRIPT.replace('source="Run parameter `dt`."),', 'source="Run parameter `dt`.", values=("a", "b")),')
129
+ root = make_repo({"jobs/a.py": script}, BILINGUAL)
130
+ self.assertEqual(run(root, "generate")[0], 0)
131
+ page = (root / "docs/catalog/dwd.customer_daily.md").read_text(encoding="utf-8")
132
+ self.assertIn("| Unit | Values |", page)
133
+ self.assertIn("| `a`, `b` |", page)
134
+ twice = script.replace('values=("a", "b")', 'values=("a", "a")')
135
+ root = make_repo({"jobs/a.py": twice}, BILINGUAL)
136
+ self.assertIn("values lists a value twice", run(root, "generate")[2])
137
+ sql = SQL_SCRIPT.replace("-- source: dim_merchant.merchant_id", "-- source: dim_merchant.merchant_id\n-- values: m1;m2")
138
+ root = make_repo({"sql/m.sql": sql}, BILINGUAL)
139
+ self.assertEqual(run(root, "generate")[0], 0)
140
+ self.assertIn("`m1`, `m2`", (root / "docs/catalog/ads.merchant_monthly.md").read_text(encoding="utf-8"))
141
+
120
142
  def test_check_passes_when_current_and_fails_after_a_definition_changes(self):
121
143
  root = make_repo({"jobs/dwd/customer_daily.py": PY_SCRIPT}, BILINGUAL)
122
144
  self.assertEqual(run(root, "generate")[0], 0)
@@ -192,6 +214,18 @@ SELECT dt, SUM(amt) AS coll_amt_7d_usd, COUNT(*) AS coll_cnt_7d FROM dwd.txn GRO
192
214
  root = make_repo({"jobs/a.py": imported}, BILINGUAL)
193
215
  self.assertIn("imported lists cannot be read", run(root, "generate")[2])
194
216
 
217
+ def test_table_grain_and_notes_are_per_language_with_matching_notes(self):
218
+ old = PY_SCRIPT.replace('grain_en="one row per (dt, cust_id)",', 'grain="one row per (dt, cust_id)",')
219
+ root = make_repo({"jobs/a.py": old}, BILINGUAL)
220
+ code, _, err = run(root, "generate")
221
+ self.assertEqual(code, 1)
222
+ self.assertIn("grain and notes are written per language: grain_en=..., notes_en=(...), grain_zh=...", err)
223
+ uneven = PY_SCRIPT.replace('notes_zh=("金额经 dim_forex 换算为 USD。",),', 'notes_zh=(),')
224
+ root = make_repo({"jobs/a.py": uneven}, BILINGUAL)
225
+ code, _, err = run(root, "generate")
226
+ self.assertEqual(code, 1)
227
+ self.assertIn("every language needs the same notes, got {'en': 1, 'zh': 0}", err)
228
+
195
229
  def test_same_table_in_two_scripts_is_an_error(self):
196
230
  root = make_repo({"jobs/a.py": PY_SCRIPT, "jobs/b.py": PY_SCRIPT}, BILINGUAL)
197
231
  code, _, err = run(root, "generate")
@@ -238,6 +272,18 @@ class AuditTest(unittest.TestCase):
238
272
  "writes dwd.undocumented but carries no CATALOG_TABLE/CATALOG_FIELDS (or -- @table block); "
239
273
  "also written by jobs/backfill_b.py (put the metadata in the script that builds the table)")
240
274
 
275
+ def test_a_structtype_contract_is_read_for_its_column_names(self):
276
+ script = '''
277
+ L1A_PUBLISHED_SCHEMA = StructType([
278
+ StructField("dt", DateType(), False),
279
+ StructField("cust_id", StringType(), False),
280
+ ])
281
+ TARGET_TABLE = "dwd.flow"
282
+ '''
283
+ root = make_repo({"jobs/flow.py": script})
284
+ [entry] = json.loads(run(root, "audit", "--json", "--contract", "L1A_PUBLISHED_SCHEMA", "jobs/*.py")[1])["scripts"]
285
+ self.assertEqual((entry["columns"], entry["columnsFrom"]), (["dt", "cust_id"], "L1A_PUBLISHED_SCHEMA"))
286
+
241
287
  def test_class_attributes_name_the_table_with_their_schema(self):
242
288
  script = '''
243
289
  class OrdersEtl(Etl):
@@ -311,7 +357,7 @@ class AuditTest(unittest.TestCase):
311
357
  ("c_amt", " ".join(["word"] * 31), "近 7 天收款总额。", "sum of amount"),
312
358
  ])
313
359
  script = f'''
314
- CATALOG_TABLE = Table("dwd.t", grain="One row per id.", en="Table.", zh="表。")
360
+ CATALOG_TABLE = Table("dwd.t", en="Table.", zh="表。", grain_en="One row per id.", grain_zh="每个 id 一行。")
315
361
  CATALOG_FIELDS = (
316
362
  {rows}
317
363
  )
@@ -329,8 +375,9 @@ CATALOG_FIELDS = (
329
375
 
330
376
  def test_shared_vocabulary_and_plain_units_are_not_flagged(self):
331
377
  script = '''
332
- CATALOG_TABLE = Table("dwd.t", grain="One row per id.", en="Table.", zh="表。",
333
- notes=("All metrics count posted collections.", "Every metric is 0 if there were no collections."))
378
+ CATALOG_TABLE = Table("dwd.t", en="Table.", zh="表。", grain_en="One row per id.", grain_zh="每个 id 一行。",
379
+ notes_en=("All metrics count posted collections.", "Every metric is 0 if there were no collections."),
380
+ notes_zh=("所有指标只计已入账收款。", "若窗口内无收款,则指标为 0。"))
334
381
  CATALOG_FIELDS = (
335
382
  Field("a", en="Number of collections in the last 7 days.", zh="近 7 天收款笔数。",
336
383
  source="count of dwd.transaction rows", window="[dt-7d, dt)"),
@@ -390,9 +437,9 @@ class ReviewFixTest(unittest.TestCase):
390
437
  self.assertIn("no column contract and no readable SELECT writing ads.merchant_monthly", err)
391
438
 
392
439
  def test_tables_mapping_to_one_page_or_the_index_are_errors(self):
393
- a = PY_SCRIPT.replace('"dwd.customer_daily",\n grain', '"ads.a_b",\n grain')
394
- b = PY_SCRIPT.replace('"dwd.customer_daily",\n grain', '"ads.a b",\n grain')
395
- readme = PY_SCRIPT.replace('"dwd.customer_daily",\n grain', '"README",\n grain')
440
+ a = PY_SCRIPT.replace('"dwd.customer_daily",\n en=', '"ads.a_b",\n en=')
441
+ b = PY_SCRIPT.replace('"dwd.customer_daily",\n en=', '"ads.a b",\n en=')
442
+ readme = PY_SCRIPT.replace('"dwd.customer_daily",\n en=', '"README",\n en=')
396
443
  root = make_repo({"jobs/a.py": a, "jobs/b.py": b, "jobs/c.py": readme}, BILINGUAL)
397
444
  code, _, err = run(root, "generate")
398
445
  self.assertEqual(code, 1)
@@ -0,0 +1,3 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 1AboveIO