@1aboveio/skills 1.0.0 → 1.0.1

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 (70) hide show
  1. package/package.json +2 -2
  2. package/skills/app-dev/airflow/SKILL.md +1 -1
  3. package/skills/app-dev/android-foundation/SKILL.md +1 -1
  4. package/skills/app-dev/app-debug/SKILL.md +1 -1
  5. package/skills/app-dev/better-auth/SKILL.md +1 -1
  6. package/skills/app-dev/data-catalog/SKILL.md +9 -6
  7. package/skills/app-dev/data-catalog/references/data-catalog-conventions.md +3 -2
  8. package/skills/app-dev/data-catalog/references/field-definitions.md +13 -6
  9. package/skills/app-dev/data-catalog/references/format.md +15 -6
  10. package/skills/app-dev/data-catalog/scripts/data_catalog.py +72 -29
  11. package/skills/app-dev/data-catalog/scripts/test_data_catalog.py +43 -11
  12. package/skills/app-dev/ios-foundation/SKILL.md +1 -1
  13. package/skills/app-dev/monorepo/SKILL.md +1 -1
  14. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  15. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  16. package/skills/app-dev/pyspark/SKILL.md +1 -1
  17. package/skills/app-dev/python-backend/SKILL.md +1 -1
  18. package/skills/app-dev/shadcn/SKILL.md +1 -1
  19. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  20. package/skills/cicd/cloud-build/SKILL.md +1 -1
  21. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  22. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  23. package/skills/cicd/docker/SKILL.md +1 -1
  24. package/skills/cicd/github-actions/SKILL.md +1 -1
  25. package/skills/cicd/github-flow/SKILL.md +1 -1
  26. package/skills/cicd/google-cloud/SKILL.md +1 -1
  27. package/skills/cicd/mergify/SKILL.md +1 -1
  28. package/skills/cicd/podman/SKILL.md +1 -1
  29. package/skills/content/article-review/SKILL.md +1 -1
  30. package/skills/content/article-to-image/SKILL.md +1 -1
  31. package/skills/content/flowchart-svg/SKILL.md +1 -1
  32. package/skills/content/image-gen/SKILL.md +1 -1
  33. package/skills/content/mermaid/SKILL.md +1 -1
  34. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  35. package/skills/content/wechat-publisher/SKILL.md +1 -1
  36. package/skills/content/wechat-search/SKILL.md +1 -1
  37. package/skills/content/xai-search/SKILL.md +1 -1
  38. package/skills/core/distill-writing/SKILL.md +1 -1
  39. package/skills/core/doc-to-md/SKILL.md +1 -1
  40. package/skills/core/editorial-review/SKILL.md +1 -1
  41. package/skills/core/house-style/SKILL.md +1 -1
  42. package/skills/core/polish-cn/SKILL.md +1 -1
  43. package/skills/core/polish-cn/references/models.md +1 -1
  44. package/skills/core/reg-parser/SKILL.md +1 -1
  45. package/skills/core/terminology/SKILL.md +1 -1
  46. package/skills/core/write-doc/SKILL.md +1 -1
  47. package/skills/engineering/better-review/SKILL.md +1 -1
  48. package/skills/engineering/e2e-test/SKILL.md +1 -1
  49. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  50. package/skills/engineering/engineering-runtime/coherence/workflow.json +12 -12
  51. package/skills/engineering/ensure-coverage/SKILL.md +1 -1
  52. package/skills/engineering/golden-path/SKILL.md +1 -1
  53. package/skills/engineering/harness-runtime/SKILL.md +1 -1
  54. package/skills/engineering/harness-runtime/model-catalog-seed.test.mjs +4 -4
  55. package/skills/engineering/harness-runtime/references/model-catalog-seed.json +4 -4
  56. package/skills/engineering/resolve-issues/SKILL.md +1 -1
  57. package/skills/engineering/resolve-issues/scripts/preflight-models.test.mjs +1 -1
  58. package/skills/engineering/resolve-release/SKILL.md +1 -1
  59. package/skills/engineering/rush-issues/SKILL.md +1 -1
  60. package/skills/engineering/rush-release/SKILL.md +1 -1
  61. package/skills/engineering/test-quality/SKILL.md +1 -1
  62. package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
  63. package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
  64. package/skills/risk-compliance/contract-review/SKILL.md +1 -1
  65. package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
  66. package/skills/risk-compliance/fraud-analysis/SKILL.md +1 -1
  67. package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
  68. package/skills/risk-compliance/report-writing/SKILL.md +1 -1
  69. package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
  70. package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1aboveio/skills",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Every 1aboveio skill package in one: install once, then choose which packages Pi loads with npx @1aboveio/skills select.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -139,7 +139,7 @@
139
139
  ]
140
140
  }
141
141
  ],
142
- "digest": "730da168eda4e3eedfb448db5dae6f8d0bc8c518bcb35a2c43e0b0acf538151e"
142
+ "digest": "0ff8c0fb72780cd0c50dfbeb86b50015c24ea65a76541075531b52e627d221a8"
143
143
  },
144
144
  "publishConfig": {
145
145
  "registry": "https://registry.npmjs.org/",
@@ -2,7 +2,7 @@
2
2
  name: airflow
3
3
  description: Airflow DAG architecture and conventions. Use when designing, writing, reviewing, testing, or debugging Airflow DAGs and tasks, including TaskGroups, data intervals, timezone-aware date parameters, CLI validation, environment-based scheduling, and pytest integrity tests.
4
4
  user-invocable: true
5
- release-version: 0.36.0
5
+ release-version: 0.38.0
6
6
  ---
7
7
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
8
8
  # Airflow DAG Development Patterns
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: android-foundation
3
3
  description: "Prove an Expo / React Native app on a native Android emulator, and fix it when that proof fails. Use before you fix and re-run or re-dispatch a failed Android CI job (`gh workflow run android.yml`, `gh run rerun`): a re-run is not a debugger, so reproduce the failing journey checkpoint off CI first. Also use when a UiAutomator journey or its Python harness tests fail (a CHECK/FAIL line, a wait that timed out, a control not found, adb offline, a lost Metro bridge, a fixture failing on the runner), to build the debug APK and run the device journey, to set up a fresh Android workspace (SDK/NDK/JDK, Gradle caches, private AVD, disposable PostgreSQL), or to add the emulator job to CI. NOT for workflow YAML, CI speed or runner design (github-actions), browser tests (e2e-test), iOS (ios-foundation), plain Jest component tests, or signing and store release."
4
- release-version: 0.36.0
4
+ release-version: 0.38.0
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # Android foundation
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: app-debug
3
3
  description: "Diagnose runtime failures in deployed Cloud Run services — 5xx/422 errors, tracebacks, Next.js SSR/hydration or Server Action failures, DB/Secret Manager connection errors, cold-start timeouts, container OOM. NOT for build or deploy failures (cloud-debug)."
4
- release-version: 0.36.0
4
+ release-version: 0.38.0
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # App Debug
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: better-auth
3
3
  description: "Add or debug Better Auth for Next.js App Router: login, sessions, protected routes, middleware, OAuth (including Feishu/Lark), cookies, Prisma, and Auth.js migration."
4
- release-version: 0.36.0
4
+ release-version: 0.38.0
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # Better Auth for Next.js
@@ -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.38.0
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # Data catalog
@@ -68,7 +68,8 @@ Never document tables during setup unless the task asks for it.
68
68
  Example: adding `refund_cnt_30d` to the SELECT means adding its `Field` in the
69
69
  same commit, then running `generate`.
70
70
  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
71
+ Shared filters, window anchor and currency go once in the table notes, in every
72
+ language; the row
72
73
  gives what one value measures, its unit, and 0/null meaning; `source` gives
73
74
  lineage in one line. Follow the eleven principles in
74
75
  [references/field-definitions.md](references/field-definitions.md).
@@ -178,16 +179,18 @@ script is clean when its columns were `unknown`.
178
179
 
179
180
  ## Backfill one table
180
181
 
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.
182
+ 1. Pick the script that builds the table from the audit. Backfill, migration, and
183
+ copy scripts that write the same table share its definitions and need none of
184
+ their own.
183
185
  2. Read the SQL or DataFrame code for every output column. Reuse an existing
184
186
  hand-written dictionary as a draft, and check every row against the code.
185
187
  3. Add `CATALOG_TABLE` and `CATALOG_FIELDS` in contract order. Keep the literal
186
188
  contract while you write.
187
189
  4. Run `generate` until it passes, then derive the contract and run the repo's
188
190
  schema tests (rule 5).
189
- 5. Rerun the audit for that script, and fix `relative-definition` and
190
- `thin-definition` findings.
191
+ 5. Rerun the audit for that script and fix its writing findings (`repeated-text`,
192
+ `code-in-description`, `long-definition`, `thin-definition`,
193
+ `relative-definition`), or leave one only when the text really belongs in that row.
191
194
  6. Commit the script and pages together. Replace the old hand-written dictionary
192
195
  with a link to the generated page.
193
196
 
@@ -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
@@ -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; ..."`
@@ -66,10 +66,10 @@ column that says only what is particular to that column.
66
66
 
67
67
  | Fact | Goes in |
68
68
  |---|---|
69
- | What one row is, which rows exist | `Table.grain` |
69
+ | What one row is, which rows exist | `Table` `grain_<lang>` |
70
70
  | 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` |
71
+ | Filters, window anchor, currency, time zone shared by all columns | `Table` `notes_<lang>` |
72
+ | Meaning of a term many columns use, list cleaning rules | `Table` `notes_<lang>` |
73
73
  | What this column measures, its unit in words, 0/null meaning | `Field` language text |
74
74
  | Upstream column, aggregation, filters only this column adds | `Field.source` |
75
75
  | Exact interval / unit code | `Field.window` / `Field.unit` |
@@ -81,15 +81,22 @@ Table:
81
81
  ```python
82
82
  CATALOG_TABLE = Table(
83
83
  "dwd.customer_daily",
84
- grain="One row per (dt, cust_id) for each customer with a posted collection before dt.",
85
84
  en="Daily customer collection metrics for risk features.",
86
85
  zh="客户每日收款指标,供风险特征使用。",
87
- notes=(
86
+ grain_en="One row per (dt, cust_id) for each customer with a posted collection before dt.",
87
+ grain_zh="每个 (dt, cust_id) 一行;只有 dt 之前有已入账收款的客户才有行。",
88
+ notes_en=(
88
89
  "All metrics count posted collections (status 'POSTED') from non-test customers.",
89
90
  "Windows end the day before dt: 'last 7 days' is [dt-7d, dt).",
90
91
  "Amounts are converted to USD at the transaction date's rate; collections with no rate are left out of every metric.",
91
92
  "Every metric is 0, not empty, if the customer had no collections in its window.",
92
93
  ),
94
+ notes_zh=(
95
+ "所有指标只计非测试客户的已入账(status 为 'POSTED')收款。",
96
+ "窗口截至 dt 前一天:「近 7 天」指 [dt-7d, dt),含 dt-7d,不含 dt。",
97
+ "金额按交易日汇率换算为美元;若某笔收款当天没有汇率,则所有指标都不计这笔收款。",
98
+ "若客户在窗口内没有收款,则指标为 0,而不是空值。",
99
+ ),
93
100
  )
94
101
  ```
95
102
 
@@ -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 = (
@@ -37,7 +42,9 @@ PUBLISHED_COLUMNS = tuple(f.name for f in CATALOG_FIELDS)
37
42
  ```
38
43
 
39
44
  - `Field(name, <one keyword per language>, source, window=None, unit=None)`.
40
- - `Table(name, grain, <one keyword per language>, notes=())`.
45
+ - `Table(name, <one keyword per language>, grain_<lang>=..., notes_<lang>=(...))`: a
46
+ grain for every language, and the same number of notes in every language (notes are
47
+ optional).
41
48
  - Pass only the name positionally; everything else by keyword.
42
49
  - The language keywords come from `languages` in `data-catalog.json`. `init`
43
50
  regenerates `catalog_fields.py` with exactly those, so a missing language is a
@@ -49,10 +56,12 @@ PUBLISHED_COLUMNS = tuple(f.name for f in CATALOG_FIELDS)
49
56
 
50
57
  ```sql
51
58
  -- @table ads.merchant_monthly
52
- -- grain: One row per (month, merchant_id).
59
+ -- grain_en: One row per (month, merchant_id).
60
+ -- grain_zh: 每个 (month, merchant_id) 一行。
53
61
  -- en: Monthly settled volume per merchant.
54
62
  -- zh: 商户月度结算金额。
55
- -- notes: Built on the 1st of each month; Counts settled rows (status 'SETTLED') only; Excludes test merchants
63
+ -- notes_en: Built on the 1st of each month; Counts settled rows (status 'SETTLED') only; Excludes test merchants
64
+ -- notes_zh: 每月 1 日构建;只计已结算(status 为 'SETTLED')的记录;不含测试商户
56
65
  -- @field merchant_id
57
66
  -- en: Merchant that settled the volume; key of dim_merchant.
58
67
  -- zh: 结算该金额的商户,dim_merchant 主键。
@@ -70,7 +79,7 @@ SELECT ...
70
79
  - A directive line is `-- @table <name>` or `-- @field <name>`.
71
80
  - A key line is exactly two spaces after `-- `, then `key: value`.
72
81
  - A continuation line has four or more spaces and joins the previous key with a space.
73
- - `notes` on `@table` splits on `;`.
82
+ - `notes_<lang>` on `@table` splits on `;` or `;`.
74
83
  - The block ends at the first non-comment line. One `@table` per file.
75
84
 
76
85
  ## data-catalog.json
@@ -157,9 +157,9 @@ class FieldDoc:
157
157
  class TableDoc:
158
158
  name: str
159
159
  script: str
160
- grain: str
160
+ grain: dict # language -> what one row is
161
161
  text: dict
162
- notes: list
162
+ notes: dict # language -> [shared rules]
163
163
  fields: list
164
164
  line: int
165
165
  contracts: dict = field(default_factory=dict) # name -> list[str] | "derived" | "unchecked"
@@ -263,21 +263,54 @@ def build_field(values: dict, languages: list, where: str, line: int) -> FieldDo
263
263
 
264
264
  def build_table(values: dict, fields: list, languages: list, where: str, line: int, script: str) -> TableDoc:
265
265
  name = require_text(values, "name", where, line, "Table")
266
- known = {"name", "grain", "notes", *languages}
266
+ if "grain" in values or "notes" in values:
267
+ keys = ", ".join(f"grain_{lang}=..., notes_{lang}=(...)" for lang in languages)
268
+ raise CatalogError(f"{where}:{line}: Table {name!r}: grain and notes are written per language: {keys}")
269
+ known = {"name", *languages, *(f"grain_{lang}" for lang in languages), *(f"notes_{lang}" for lang in languages)}
267
270
  unknown = sorted(set(values) - known)
268
271
  if unknown:
269
272
  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
273
  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)
274
+ grain = {lang: require_text(values, f"grain_{lang}", where, line, f"Table {name!r}") for lang in languages}
275
+ notes = {}
276
+ for lang in languages:
277
+ raw = values.get(f"notes_{lang}") or ()
278
+ raw = (raw,) if isinstance(raw, str) else raw
279
+ notes[lang] = [n.strip() for n in raw if n and n.strip()]
280
+ counts = {lang: len(n) for lang, n in notes.items()}
281
+ if len(set(counts.values())) > 1:
282
+ raise CatalogError(f"{where}:{line}: Table {name!r}: every language needs the same notes, got {counts}")
283
+ return TableDoc(name, script, grain, text, notes, fields, line)
284
+
285
+
286
+ def struct_fields(node: ast.AST, names: dict):
287
+ """Column names of a literal StructType([StructField("name", ...), ...]), or None."""
288
+ if call_name(node) != "StructType" or not node.args:
289
+ return None
290
+ try:
291
+ items = resolve_sequence(node.args[0], names, "", frozenset())
292
+ except CatalogError:
293
+ return None
294
+ out = []
295
+ for item in items:
296
+ if call_name(item) != "StructField" or not item.args:
297
+ return None
298
+ try:
299
+ value = eval_text(item.args[0], names, "")
300
+ except CatalogError:
301
+ return None
302
+ if not isinstance(value, str):
303
+ return None
304
+ out.append(value)
305
+ return out
276
306
 
277
307
 
278
308
  def contract_state(node: ast.AST, names: dict):
279
309
  if any(isinstance(n, ast.Name) and n.id == FIELDS_VAR for n in ast.walk(node)):
280
310
  return "derived"
311
+ columns = struct_fields(node, names)
312
+ if columns is not None:
313
+ return columns
281
314
  try:
282
315
  items = resolve_sequence(node, names, "", frozenset())
283
316
  values = [eval_text(item, names, "") for item in items]
@@ -363,14 +396,12 @@ def parse_sql(text: str, where: str, languages: list) -> TableDoc | None:
363
396
  fields = []
364
397
  for kind, name, values, line, _ in entries:
365
398
  if kind == "field":
366
- if "notes" in values:
367
- raise CatalogError(f"{where}:{line}: `notes` belongs on @table, not @field")
399
+ if any(k.startswith(("notes", "grain")) for k in values):
400
+ raise CatalogError(f"{where}:{line}: `grain_*` and `notes_*` belong on @table, not @field")
368
401
  fields.append(build_field({"name": name, **values}, languages, where, line))
369
402
  _, 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(";"))
403
+ values = {"name": name, **{k: tuple(n.strip() for n in re.split(r"[;;]", v)) if k.startswith("notes_") else v
404
+ for k, v in values.items()}}
374
405
  return build_table(values, fields, languages, where, line, where)
375
406
 
376
407
 
@@ -467,6 +498,9 @@ def cell(text: str | None) -> str:
467
498
  return text.replace("\\", "\\\\").replace("|", "\\|").replace("\r\n", "\n").replace("\n", "<br>")
468
499
 
469
500
 
501
+ SECTION_LABELS = {"en": ("Grain:", "Notes:"), "zh": ("粒度:", "说明:"), "zh_hant": ("粒度:", "說明:")}
502
+
503
+
470
504
  def label(lang: str) -> str:
471
505
  return LANGUAGE_LABELS.get(lang, lang)
472
506
 
@@ -496,17 +530,19 @@ def render_table(table: TableDoc, root: Path, config: dict) -> str:
496
530
  langs = config["languages"]
497
531
  out = [GENERATED_MARK, "", f"# `{table.name}`", ""]
498
532
  out.append(f"- **Script:** [{table.script}]({link_to(root, config['output'], table.script)})")
499
- out.append(f"- **Grain:** {cell(table.grain)}")
500
533
  out.append(f"- **Fields:** {len(table.fields)}")
501
534
  out.append("")
502
535
  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)
536
+ grain_label, notes_label = SECTION_LABELS.get(lang, SECTION_LABELS["en"])
537
+ if len(langs) > 1:
538
+ out += [f"## {label(lang)}", ""]
539
+ out += [cell(table.text[lang]), "", f"- **{grain_label}** {cell(table.grain[lang])}"]
540
+ if table.notes[lang]:
541
+ out.append(f"- **{notes_label}**")
542
+ out.extend(f" - {cell(note)}" for note in table.notes[lang])
509
543
  out.append("")
544
+ if len(langs) > 1:
545
+ out += ["## Fields", ""]
510
546
  has_window = any(f.window for f in table.fields)
511
547
  has_unit = any(f.unit for f in table.fields)
512
548
  head = ["Field", *(label(lang) for lang in langs), "Source"]
@@ -539,7 +575,7 @@ def render_index(tables: list, root: Path, config: dict) -> str:
539
575
  out += ["| Table | Summary | Grain | Fields | Script |", "|---|---|---|---|---|"]
540
576
  for t in tables:
541
577
  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} |")
578
+ out.append(f"| [`{t.name}`]({page_name(t)}) | {cell(t.text[lang])} | {cell(t.grain[lang])} | {len(t.fields)} | {script} |")
543
579
  return "\n".join(out) + "\n"
544
580
 
545
581
 
@@ -1019,11 +1055,13 @@ def preview(items: list, limit: int = 12) -> str:
1019
1055
  return shown + (f", … (+{len(items) - limit})" if len(items) > limit else "")
1020
1056
 
1021
1057
 
1022
- def cmd_audit(root: Path, globs: list, as_json: bool, verbose: bool, table_names: list, schema_names: list) -> int:
1058
+ def cmd_audit(root: Path, globs: list, as_json: bool, verbose: bool, table_names: list, schema_names: list,
1059
+ contracts: list) -> int:
1023
1060
  config_path = root / CONFIG_FILE
1024
1061
  config = load_config(root) if config_path.exists() else dict(DEFAULT_CONFIG)
1025
1062
  config = {**config, "tableNames": [*config["tableNames"], *table_names],
1026
- "schemaNames": [*config["schemaNames"], *schema_names]}
1063
+ "schemaNames": [*config["schemaNames"], *schema_names],
1064
+ "contracts": [*config["contracts"], *contracts]}
1027
1065
  if globs:
1028
1066
  patterns, exclude = globs, config.get("exclude", [])
1029
1067
  elif config_path.exists():
@@ -1138,7 +1176,8 @@ def definitions(table: TableDoc | None) -> tuple[dict, tuple]:
1138
1176
  if table is None:
1139
1177
  return {}, ()
1140
1178
  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))
1179
+ notes = tuple((lang, tuple(n)) for lang, n in sorted(table.notes.items()))
1180
+ return fields, (tuple(sorted(table.grain.items())), tuple(sorted(table.text.items())), notes)
1142
1181
 
1143
1182
 
1144
1183
  def drift_items(root: Path, config: dict, base: str) -> list:
@@ -1212,12 +1251,14 @@ def cmd_drift(root: Path, base: str) -> int:
1212
1251
 
1213
1252
  def render_fields_module(languages: list) -> str:
1214
1253
  lang_fields = "\n".join(f" {lang}: str" for lang in languages)
1254
+ grain_fields = "\n".join(f" grain_{lang}: str" for lang in languages)
1255
+ notes_fields = "\n".join(f" notes_{lang}: tuple[str, ...] = ()" for lang in languages)
1215
1256
  return f'''"""Column metadata types for the data catalog.
1216
1257
 
1217
1258
  Generated by data_catalog.py init from data-catalog.json; rerun init after changing
1218
1259
  `languages` there, never edit this file. Table scripts declare:
1219
1260
 
1220
- CATALOG_TABLE = Table("schema.table", grain=..., {", ".join(f"{lang}=..." for lang in languages)})
1261
+ CATALOG_TABLE = Table("schema.table", {", ".join(f"{lang}=..., grain_{lang}=..." for lang in languages)})
1221
1262
  {FIELDS_VAR} = (Field("column", {", ".join(f"{lang}=..." for lang in languages)}, source=...), ...)
1222
1263
  PUBLISHED_COLUMNS = tuple(f.name for f in {FIELDS_VAR})
1223
1264
  """
@@ -1238,9 +1279,9 @@ class Field:
1238
1279
  @dataclass(frozen=True)
1239
1280
  class Table:
1240
1281
  name: str
1241
- grain: str
1242
1282
  {lang_fields}
1243
- notes: tuple[str, ...] = ()
1283
+ {grain_fields}
1284
+ {notes_fields}
1244
1285
  '''
1245
1286
 
1246
1287
 
@@ -1367,6 +1408,8 @@ def main(argv: list | None = None) -> int:
1367
1408
  help="extra variable/attribute whose string value is the table written; repeatable")
1368
1409
  p_audit.add_argument("--schema-name", action="append", default=[], metavar="NAME",
1369
1410
  help="extra variable/attribute holding that table's schema; repeatable")
1411
+ p_audit.add_argument("--contract", action="append", default=[], metavar="NAME",
1412
+ help="extra column-contract name (tuple of names or StructType); repeatable")
1370
1413
  p_drift = sub.add_parser("drift", help="local check: code changed since a git base while its definitions did not")
1371
1414
  p_drift.add_argument("--base", default="HEAD", help="git ref to compare with (default HEAD: uncommitted work)")
1372
1415
  sub.add_parser("version", help="print the engine identity")
@@ -1382,7 +1425,7 @@ def main(argv: list | None = None) -> int:
1382
1425
  return cmd_generate(root, args.check)
1383
1426
  if args.command == "drift":
1384
1427
  return cmd_drift(root, args.base)
1385
- return cmd_audit(root, args.globs, args.json, args.verbose, args.table_name, args.schema_name)
1428
+ return cmd_audit(root, args.globs, args.json, args.verbose, args.table_name, args.schema_name, args.contract)
1386
1429
  except CatalogError as exc:
1387
1430
  print(f"data-catalog: {exc}", file=sys.stderr)
1388
1431
  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,7 +115,10 @@ 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. | "
@@ -192,6 +199,18 @@ SELECT dt, SUM(amt) AS coll_amt_7d_usd, COUNT(*) AS coll_cnt_7d FROM dwd.txn GRO
192
199
  root = make_repo({"jobs/a.py": imported}, BILINGUAL)
193
200
  self.assertIn("imported lists cannot be read", run(root, "generate")[2])
194
201
 
202
+ def test_table_grain_and_notes_are_per_language_with_matching_notes(self):
203
+ old = PY_SCRIPT.replace('grain_en="one row per (dt, cust_id)",', 'grain="one row per (dt, cust_id)",')
204
+ root = make_repo({"jobs/a.py": old}, BILINGUAL)
205
+ code, _, err = run(root, "generate")
206
+ self.assertEqual(code, 1)
207
+ self.assertIn("grain and notes are written per language: grain_en=..., notes_en=(...), grain_zh=...", err)
208
+ uneven = PY_SCRIPT.replace('notes_zh=("金额经 dim_forex 换算为 USD。",),', 'notes_zh=(),')
209
+ root = make_repo({"jobs/a.py": uneven}, BILINGUAL)
210
+ code, _, err = run(root, "generate")
211
+ self.assertEqual(code, 1)
212
+ self.assertIn("every language needs the same notes, got {'en': 1, 'zh': 0}", err)
213
+
195
214
  def test_same_table_in_two_scripts_is_an_error(self):
196
215
  root = make_repo({"jobs/a.py": PY_SCRIPT, "jobs/b.py": PY_SCRIPT}, BILINGUAL)
197
216
  code, _, err = run(root, "generate")
@@ -238,6 +257,18 @@ class AuditTest(unittest.TestCase):
238
257
  "writes dwd.undocumented but carries no CATALOG_TABLE/CATALOG_FIELDS (or -- @table block); "
239
258
  "also written by jobs/backfill_b.py (put the metadata in the script that builds the table)")
240
259
 
260
+ def test_a_structtype_contract_is_read_for_its_column_names(self):
261
+ script = '''
262
+ L1A_PUBLISHED_SCHEMA = StructType([
263
+ StructField("dt", DateType(), False),
264
+ StructField("cust_id", StringType(), False),
265
+ ])
266
+ TARGET_TABLE = "dwd.flow"
267
+ '''
268
+ root = make_repo({"jobs/flow.py": script})
269
+ [entry] = json.loads(run(root, "audit", "--json", "--contract", "L1A_PUBLISHED_SCHEMA", "jobs/*.py")[1])["scripts"]
270
+ self.assertEqual((entry["columns"], entry["columnsFrom"]), (["dt", "cust_id"], "L1A_PUBLISHED_SCHEMA"))
271
+
241
272
  def test_class_attributes_name_the_table_with_their_schema(self):
242
273
  script = '''
243
274
  class OrdersEtl(Etl):
@@ -311,7 +342,7 @@ class AuditTest(unittest.TestCase):
311
342
  ("c_amt", " ".join(["word"] * 31), "近 7 天收款总额。", "sum of amount"),
312
343
  ])
313
344
  script = f'''
314
- CATALOG_TABLE = Table("dwd.t", grain="One row per id.", en="Table.", zh="表。")
345
+ CATALOG_TABLE = Table("dwd.t", en="Table.", zh="表。", grain_en="One row per id.", grain_zh="每个 id 一行。")
315
346
  CATALOG_FIELDS = (
316
347
  {rows}
317
348
  )
@@ -329,8 +360,9 @@ CATALOG_FIELDS = (
329
360
 
330
361
  def test_shared_vocabulary_and_plain_units_are_not_flagged(self):
331
362
  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."))
363
+ CATALOG_TABLE = Table("dwd.t", en="Table.", zh="表。", grain_en="One row per id.", grain_zh="每个 id 一行。",
364
+ notes_en=("All metrics count posted collections.", "Every metric is 0 if there were no collections."),
365
+ notes_zh=("所有指标只计已入账收款。", "若窗口内无收款,则指标为 0。"))
334
366
  CATALOG_FIELDS = (
335
367
  Field("a", en="Number of collections in the last 7 days.", zh="近 7 天收款笔数。",
336
368
  source="count of dwd.transaction rows", window="[dt-7d, dt)"),
@@ -390,9 +422,9 @@ class ReviewFixTest(unittest.TestCase):
390
422
  self.assertIn("no column contract and no readable SELECT writing ads.merchant_monthly", err)
391
423
 
392
424
  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')
425
+ a = PY_SCRIPT.replace('"dwd.customer_daily",\n en=', '"ads.a_b",\n en=')
426
+ b = PY_SCRIPT.replace('"dwd.customer_daily",\n en=', '"ads.a b",\n en=')
427
+ readme = PY_SCRIPT.replace('"dwd.customer_daily",\n en=', '"README",\n en=')
396
428
  root = make_repo({"jobs/a.py": a, "jobs/b.py": b, "jobs/c.py": readme}, BILINGUAL)
397
429
  code, _, err = run(root, "generate")
398
430
  self.assertEqual(code, 1)
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: ios-foundation
3
3
  description: "Set up and prove an Expo / React Native native iOS workspace from a clean checkout to a manual Login smoke on an owned iOS Simulator: isolated worktree and private caches, Xcode/runtime/CocoaPods preflight, frozen install with `pod install --deployment`, platform-scoped prebuild, an ad-hoc-signed Debug simulator build, a private simulator by exact UDID, Metro on a free port, a disposable PostgreSQL backend with owned cleanup, and the evidence record (setup proof only; feature work reports the smoke result). Use for 'set up iOS', 'build the iOS app', 'run the app on the iOS simulator', 'add iOS to the React Native app', 'prove the iOS build', or when a pod install, xcodebuild, simulator boot, Metro connection or Keychain session fails. NOT for Android (android-foundation), signing, TestFlight or App Store release, or general workflow YAML (github-actions)."
4
- release-version: 0.36.0
4
+ release-version: 0.38.0
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # iOS foundation
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: monorepo
3
3
  description: Turborepo architecture for shared packages, pnpm workspaces, DAL boundaries, and pipelines.
4
- release-version: 0.36.0
4
+ release-version: 0.38.0
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # Turborepo Monorepo Architecture
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: nextjs-fullstack
3
3
  description: "Build or debug Next.js App Router features with React 19 — pages/layouts/route handlers, Server/Client Components, Server Actions, forms, middleware, DAL, strict TypeScript, Zod — plus our conventions: nuqs URL state, SWR cache, Skeleton/Progress + Sonner feedback, drag-and-drop upload, the portal app shell, list pages as filter/sort/paginate tables. shadcn component mechanics: see the shadcn skill."
4
- release-version: 0.36.0
4
+ release-version: 0.38.0
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # Next.js 16 Fullstack Development
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: prisma-setup
3
3
  description: Use when adding or debugging Prisma with PostgreSQL, Next.js, or Turborepo, including schema.prisma, prisma.config.ts, client generation, migrations, seeds, relations, adapters, and DAL/database-layer setup. Triggers on phrases like "Prisma schema", "prisma migrate", "schema.prisma", "seed the database", "PrismaClient error", "relation issue", or "database layer".
4
- release-version: 0.36.0
4
+ release-version: 0.38.0
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # Prisma v7 Setup Guide
@@ -12,7 +12,7 @@ description: >
12
12
  Connect, spark-submit, Hudi commits, backfills, validate-only canaries,
13
13
  cluster reconciliation, or Spark event-log profiling — including warehouse
14
14
  job timeouts, empty Hudi writes, or EMR/Dataproc performance regressions.
15
- release-version: 0.36.0
15
+ release-version: 0.38.0
16
16
  ---
17
17
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
18
18
  # PySpark
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: python-backend
3
3
  description: "Python backend architecture for FastAPI, Tortoise ORM, Pydantic Settings, loguru, and uv. Also: uv.lock installs in CI/Docker via a proxy or mirror (`uv sync --frozen` bypasses it), slow uv installs on runners, relocking."
4
- release-version: 0.36.0
4
+ release-version: 0.38.0
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # Python Backend Conventions
@@ -2,7 +2,7 @@
2
2
  name: shadcn
3
3
  description: "Add or debug shadcn/ui in a React/Next.js project: components.json, shadcn init/add, presets, registries, Tailwind styling, base/radix/aria composition, component fixes, and list-index pages — where the default is the DiceUI data table with filter/sort/pagination in the URL via nuqs, not a bare shadcn Table."
4
4
  allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
5
- release-version: 0.36.0
5
+ release-version: 0.38.0
6
6
  ---
7
7
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
8
8
  # shadcn/ui
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: zod-v4
3
3
  description: Zod v4 schema conventions and v3 migration for errors, validators, objects, enums, records, and async parsing.
4
- release-version: 0.36.0
4
+ release-version: 0.38.0
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # Zod v3 to v4 Migration