htmlineage 0.0.0 → 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +109 -3
  3. package/index.js +4 -0
  4. package/package.json +53 -3
  5. package/skills/htmlineage/SKILL.md +173 -0
  6. package/skills/htmlineage/references/example/.lineage/august-retention.json +167 -0
  7. package/skills/htmlineage/references/example/README.md +51 -0
  8. package/skills/htmlineage/references/example/august-retention.html +111 -0
  9. package/skills/htmlineage/references/format.md +218 -0
  10. package/skills/htmlineage/references/review.md +79 -0
  11. package/src/check/rules.js +421 -0
  12. package/src/check/summary.js +154 -0
  13. package/src/cli.js +99 -0
  14. package/src/commands/check.js +74 -0
  15. package/src/commands/index.js +7 -0
  16. package/src/commands/sql.js +65 -0
  17. package/src/commands/view.js +82 -0
  18. package/src/errors.js +39 -0
  19. package/src/graph/duplicates.js +78 -0
  20. package/src/graph/expr.js +56 -0
  21. package/src/graph/ids.js +24 -0
  22. package/src/graph/numbers.js +61 -0
  23. package/src/graph/parse.js +196 -0
  24. package/src/graph/schema.js +491 -0
  25. package/src/graph/trace.js +45 -0
  26. package/src/help.js +25 -0
  27. package/src/html/embed.js +142 -0
  28. package/src/html/page.js +101 -0
  29. package/src/meta.js +13 -0
  30. package/src/sql/compose.js +215 -0
  31. package/src/sql/style.js +193 -0
  32. package/src/viewer/client/bridge.js +143 -0
  33. package/src/viewer/client/favicon.svg +6 -0
  34. package/src/viewer/client/fonts/JetBrainsMono-Bold.woff2 +0 -0
  35. package/src/viewer/client/fonts/JetBrainsMono-Medium.woff2 +0 -0
  36. package/src/viewer/client/fonts/JetBrainsMono-Regular.woff2 +0 -0
  37. package/src/viewer/client/fonts/JetBrainsMono-SemiBold.woff2 +0 -0
  38. package/src/viewer/client/fonts/OFL-JetBrainsMono.txt +93 -0
  39. package/src/viewer/client/fonts/OFL-SourceSerif4.txt +93 -0
  40. package/src/viewer/client/fonts/SourceSerif4-Medium.woff2 +0 -0
  41. package/src/viewer/client/fonts/SourceSerif4-Regular.woff2 +0 -0
  42. package/src/viewer/client/fonts/SourceSerif4-SemiBold.woff2 +0 -0
  43. package/src/viewer/client/index.html +95 -0
  44. package/src/viewer/client/lineage.js +674 -0
  45. package/src/viewer/client/themes.css +76 -0
  46. package/src/viewer/client/viewer.css +1420 -0
  47. package/src/viewer/client/viewer.js +765 -0
  48. package/src/viewer/payload.js +45 -0
  49. package/src/viewer/server.js +188 -0
@@ -0,0 +1,167 @@
1
+ {
2
+ "lineage": "1.0",
3
+ "nodes": {
4
+ "q1": {
5
+ "step": "source",
6
+ "kind": "sql",
7
+ "desc": "Monthly 30-day retention inputs for each channel, region and signup cohort, January to August 2026",
8
+ "mart": "marts.retention_by_channel",
9
+ "period": {
10
+ "from": "2026-01-01",
11
+ "to": "2026-08-31"
12
+ },
13
+ "sql": "-- 30-day retention inputs by channel, region and cohort month, Jan to Aug 2026\nselect\n channel,\n region,\n cohort_month,\n cohort_customers,\n retained_30d\nfrom marts.retention_by_channel\nwhere cohort_month between '2026-01-01' and '2026-08-31'",
14
+ "columns": [
15
+ "channel",
16
+ "region",
17
+ "cohort_month",
18
+ "cohort_customers",
19
+ "retained_30d"
20
+ ],
21
+ "rows": null
22
+ },
23
+ "q2": {
24
+ "step": "source",
25
+ "kind": "sql",
26
+ "desc": "New customers for each channel who signed up in August 2026",
27
+ "mart": "marts.acquisition_by_channel",
28
+ "period": {
29
+ "from": "2026-08-01",
30
+ "to": "2026-08-31"
31
+ },
32
+ "sql": "-- New customers by channel, August 2026\nselect\n channel,\n signup_month,\n new_customers\nfrom marts.acquisition_by_channel\nwhere signup_month between '2026-08-01' and '2026-08-31'",
33
+ "columns": ["channel", "signup_month", "new_customers"],
34
+ "rows": {
35
+ "channel": ["paid_social", "paid_search", "organic", "referral"],
36
+ "signup_month": [
37
+ "2026-08-01",
38
+ "2026-08-01",
39
+ "2026-08-01",
40
+ "2026-08-01"
41
+ ],
42
+ "new_customers": [4120, 3310, 3870, 1900]
43
+ }
44
+ },
45
+ "x1": {
46
+ "step": "source",
47
+ "kind": "external",
48
+ "type": "csv",
49
+ "ref": "channel-retention-targets.csv",
50
+ "given": "Retention targets by channel for August 2026, shared as a CSV by the growth lead",
51
+ "columns": ["channel", "target_retention"],
52
+ "rows": {
53
+ "channel": ["paid_social", "paid_search", "organic", "referral"],
54
+ "target_retention": [0.45, 0.4, 0.43, 0.44]
55
+ }
56
+ },
57
+ "v1": {
58
+ "step": "view",
59
+ "desc": "Retention for each channel and month, with each channel ranked against the others in its month",
60
+ "from": ["q1"],
61
+ "sql": "-- Monthly 30-day retention by channel, with each channel's rank in its month\nselect\n channel,\n cohort_month,\n sum(retained_30d) / sum(cohort_customers) as retention,\n rank() over (\n partition by cohort_month\n order by sum(retained_30d) / sum(cohort_customers) desc\n ) as retention_rank\nfrom q1\ngroup by channel, cohort_month",
62
+ "columns": ["channel", "cohort_month", "retention", "retention_rank"],
63
+ "encoding": {
64
+ "x": "cohort_month",
65
+ "y": "retention",
66
+ "series": "channel",
67
+ "visual": "line"
68
+ },
69
+ "rows": null
70
+ },
71
+ "v2": {
72
+ "step": "view",
73
+ "desc": "August new customers and retention for each channel, side by side",
74
+ "from": ["q1", "q2"],
75
+ "sql": "-- August new customers and 30-day retention by channel\nselect\n q2.channel,\n q2.new_customers,\n sum(q1.retained_30d) / sum(q1.cohort_customers) as retention\nfrom q2\njoin q1\n on q1.channel = q2.channel\n and q1.cohort_month = q2.signup_month\ngroup by q2.channel, q2.new_customers\norder by retention desc",
76
+ "columns": ["channel", "new_customers", "retention"],
77
+ "encoding": {
78
+ "visual": "table"
79
+ },
80
+ "rows": null
81
+ },
82
+ "f1": {
83
+ "step": "figure",
84
+ "desc": "Paid social's retention for the August 2026 signup cohort",
85
+ "from": ["v1"],
86
+ "sql": "select retention\nfrom v1\nwhere channel = 'paid_social'\n and cohort_month = '2026-08-01'",
87
+ "value": 0.466,
88
+ "display": "46.6%",
89
+ "label": "Paid social retention, August"
90
+ },
91
+ "f2": {
92
+ "step": "figure",
93
+ "desc": "Paid social's retention for the July 2026 signup cohort",
94
+ "from": ["v1"],
95
+ "sql": "select retention\nfrom v1\nwhere channel = 'paid_social'\n and cohort_month = '2026-07-01'",
96
+ "value": 0.417,
97
+ "display": "41.7%",
98
+ "label": "Paid social retention, July"
99
+ },
100
+ "f3": {
101
+ "step": "figure",
102
+ "desc": "August retention minus July retention, as a share of July retention",
103
+ "from": ["f1", "f2"],
104
+ "expr": "(f1 - f2) / f2",
105
+ "value": 0.1175,
106
+ "display": "11.8%",
107
+ "label": "Change in paid social retention, August vs July"
108
+ },
109
+ "f4": {
110
+ "step": "figure",
111
+ "desc": "Paid social's retention rank among the channels in August 2026",
112
+ "from": ["v1"],
113
+ "sql": "select retention_rank\nfrom v1\nwhere channel = 'paid_social'\n and cohort_month = '2026-08-01'",
114
+ "value": 1,
115
+ "display": "#1",
116
+ "label": "Paid social's retention rank among channels, August"
117
+ },
118
+ "f5": {
119
+ "step": "figure",
120
+ "desc": "Paid social's August new customers as a share of all channels' August new customers",
121
+ "from": ["v2"],
122
+ "sql": "select customer_share\nfrom (\n select\n channel,\n new_customers / sum(new_customers) over () as customer_share\n from v2\n) as shares\nwhere channel = 'paid_social'",
123
+ "value": 0.3121,
124
+ "display": "31.2%",
125
+ "label": "Paid social share of August new customers"
126
+ },
127
+ "f6": {
128
+ "step": "figure",
129
+ "desc": "The channel with the highest retention in August 2026",
130
+ "from": ["v2"],
131
+ "sql": "select channel\nfrom v2\norder by retention desc\nlimit 1",
132
+ "value": "paid_social",
133
+ "display": "Paid social",
134
+ "label": "Channel with the highest August retention"
135
+ },
136
+ "f7": {
137
+ "step": "figure",
138
+ "desc": "Paid social's August 2026 retention target",
139
+ "from": ["x1"],
140
+ "sql": "select target_retention\nfrom x1\nwhere channel = 'paid_social'",
141
+ "value": 0.45,
142
+ "display": "45%",
143
+ "label": "Paid social retention target, August"
144
+ },
145
+ "i1": {
146
+ "step": "insight",
147
+ "from": ["f6"],
148
+ "text": "Paid social led August retention."
149
+ },
150
+ "i2": {
151
+ "step": "insight",
152
+ "from": ["f1", "f2", "f3", "f4"],
153
+ "text": "Paid social 30-day retention rose to 46.6% in August from July's 41.7%, a relative gain of 11.8%, and ranked #1 of the four channels."
154
+ },
155
+ "i3": {
156
+ "step": "insight",
157
+ "from": ["f5", "i2"],
158
+ "text": "Paid social brought in 31.2% of August's new customers, so its gain lifts retention across the whole base, not just one corner of it."
159
+ },
160
+ "i4": {
161
+ "step": "insight",
162
+ "from": [],
163
+ "ungrounded": true,
164
+ "text": "Paid social's gain is likely to hold into September."
165
+ }
166
+ }
167
+ }
@@ -0,0 +1,51 @@
1
+ # Worked example
2
+
3
+ A short report as you should leave it before you run `check`: the graph sits
4
+ in `.lineage/august-retention.json`, the page in `august-retention.html` has
5
+ no embedded block, and the graph header holds only `lineage` and `nodes`.
6
+ Read the JSON beside the page. `check` embeds the graph for you.
7
+
8
+ ## Prompt
9
+
10
+ > Build a one-page August retention report for the growth review: paid social's
11
+ > 30-day retention against July, how it ranks among the channels, and its share
12
+ > of new customers. Compare retention with the targets the growth lead sent.
13
+
14
+ ## Build order
15
+
16
+ The queries and steps in the order they were made. Each id appears once.
17
+
18
+ 1. `q1` source: one wide pinned query on `marts.retention_by_channel`, January to August 2026.
19
+ 2. `q2` source: one wide pinned query on `marts.acquisition_by_channel`, August 2026.
20
+ 3. `x1` external source: the retention targets the growth lead shared as a CSV. You were handed it, so it has `given` and no `sql`.
21
+ 4. `v1` view: retention by channel and month, with a `rank() over` column. It is the line chart.
22
+ 5. `v2` view: August new customers and retention by channel. It is the table.
23
+ 6. `f1` figure: paid social's August retention, picked from `v1`.
24
+ 7. `f2` figure: its July retention, picked from `v1`.
25
+ 8. `f3` figure: the change on July, an `expr` over `f1` and `f2`, with no `sql`.
26
+ 9. `f4` figure: its August rank, picked from `v1`.
27
+ 10. `f5` figure: its share of August new customers, a subquery over `v2`.
28
+ 11. `f6` figure: the top channel's name. A figure can hold text.
29
+ 12. `f7` figure: paid social's August target, picked from `x1`.
30
+ 13. `i1` insight: the headline, resting on `f6`.
31
+ 14. `i2` insight: the retention paragraph, resting on `f1`, `f2`, `f3` and `f4`.
32
+ 15. `i3` insight: the share paragraph, resting on `f5` and the insight `i2`.
33
+ 16. `i4` insight: a forecast no query backs. It has `from: []` and `ungrounded: true`.
34
+
35
+ ## Check output
36
+
37
+ ```text
38
+ august-retention.html
39
+
40
+ sources 3 (1 external)
41
+ views 2 all shown on the page
42
+ figures 7 6 fully traced, 1 partially traced
43
+ insights 4 3 fully traced, 1 ungrounded
44
+
45
+ needs attention
46
+ i4 ungrounded "Paid social's gain is likely to hold int…"
47
+ x1 external channel-retention-targets.csv — a reader will be asked to confirm this with you
48
+ ```
49
+
50
+ `i4` and `x1` are listed on purpose. They show a reader what is not backed by a query.
51
+ Everything else passes with no error and no warning.
@@ -0,0 +1,111 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>August retention by channel</title>
7
+ <style>
8
+ :root { --ink: #1d2128; --muted: #5f6570; --line: #e4e6ea; --accent: #2a6fdb; }
9
+ body { margin: 0; background: #fff; color: var(--ink); font: 16px/1.55 -apple-system, "Segoe UI", Roboto, sans-serif; }
10
+ main { max-width: 760px; margin: 0 auto; padding: 40px 20px 64px; }
11
+ .eyebrow { margin: 0; color: var(--muted); font-size: 13px; letter-spacing: .04em; text-transform: uppercase; }
12
+ h1 { margin: 6px 0 4px; font-size: 30px; line-height: 1.2; }
13
+ .subtitle { margin: 0 0 28px; color: var(--muted); }
14
+ .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 28px; }
15
+ .kpi { border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }
16
+ .kpi-label { color: var(--muted); font-size: 13px; }
17
+ .kpi-value { font-size: 26px; font-weight: 600; }
18
+ .kpi-note { color: var(--muted); font-size: 13px; }
19
+ h2 { margin: 32px 0 8px; font-size: 18px; }
20
+ figure { margin: 0; }
21
+ svg { width: 100%; height: auto; }
22
+ svg .grid { stroke: var(--line); }
23
+ svg .tick { fill: var(--muted); font-size: 11px; }
24
+ svg .series { font-size: 12px; }
25
+ table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
26
+ th, td { padding: 8px 10px; border-bottom: 1px solid var(--line); text-align: right; }
27
+ th:first-child, td:first-child { text-align: left; }
28
+ th { color: var(--muted); font-weight: 500; font-size: 13px; }
29
+ footer { margin-top: 40px; color: var(--muted); font-size: 13px; }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <main>
34
+ <header>
35
+ <p class="eyebrow">Growth review · August 2026</p>
36
+ <h1 data-lineage="i1"><span data-lineage="f6">Paid social</span> led August retention</h1>
37
+ <p class="subtitle">30-day retention by acquisition channel, cohorts from January to August 2026</p>
38
+ </header>
39
+
40
+ <section class="kpis">
41
+ <div class="kpi">
42
+ <div class="kpi-label">Paid social retention, August</div>
43
+ <div class="kpi-value" data-lineage="f1">46.6%</div>
44
+ <div class="kpi-note">vs <span data-lineage="f2">41.7%</span> in July</div>
45
+ <div class="kpi-note">Target <span data-lineage="f7">45%</span></div>
46
+ </div>
47
+ <div class="kpi">
48
+ <div class="kpi-label">Change on July</div>
49
+ <div class="kpi-value" data-lineage="f3">11.8%</div>
50
+ <div class="kpi-note">relative change</div>
51
+ </div>
52
+ <div class="kpi">
53
+ <div class="kpi-label">Share of August new customers</div>
54
+ <div class="kpi-value" data-lineage="f5">31.2%</div>
55
+ <div class="kpi-note">paid social</div>
56
+ </div>
57
+ </section>
58
+
59
+ <p data-lineage="i2">Paid social 30-day retention rose to <span data-lineage="f1">46.6%</span> in August from July's <span data-lineage="f2">41.7%</span>, a relative gain of <span data-lineage="f3">11.8%</span>, and ranked <span data-lineage="f4">#1</span> of the four channels.</p>
60
+
61
+ <p data-lineage="i3">Paid social brought in <span data-lineage="f5">31.2%</span> of August's new customers, so its gain lifts retention across the whole base, not just one corner of it.</p>
62
+
63
+ <p data-lineage="i4">Paid social's gain is likely to hold into September.</p>
64
+
65
+ <h2>Retention by channel, month by month</h2>
66
+ <figure data-lineage="v1">
67
+ <svg viewBox="0 0 640 260" role="img" aria-label="30-day retention by channel, January to August 2026">
68
+ <line class="grid" x1="44" x2="544" y1="232.0" y2="232.0"/>
69
+ <text class="tick" x="36" y="236.0" text-anchor="end">36%</text>
70
+ <line class="grid" x1="44" x2="544" y1="160.0" y2="160.0"/>
71
+ <text class="tick" x="36" y="164.0" text-anchor="end">40%</text>
72
+ <line class="grid" x1="44" x2="544" y1="88.0" y2="88.0"/>
73
+ <text class="tick" x="36" y="92.0" text-anchor="end">44%</text>
74
+ <line class="grid" x1="44" x2="544" y1="16.0" y2="16.0"/>
75
+ <text class="tick" x="36" y="20.0" text-anchor="end">48%</text>
76
+ <text class="tick" x="44.0" y="252" text-anchor="middle">Jan</text>
77
+ <text class="tick" x="115.4" y="252" text-anchor="middle">Feb</text>
78
+ <text class="tick" x="186.9" y="252" text-anchor="middle">Mar</text>
79
+ <text class="tick" x="258.3" y="252" text-anchor="middle">Apr</text>
80
+ <text class="tick" x="329.7" y="252" text-anchor="middle">May</text>
81
+ <text class="tick" x="401.1" y="252" text-anchor="middle">Jun</text>
82
+ <text class="tick" x="472.6" y="252" text-anchor="middle">Jul</text>
83
+ <text class="tick" x="544.0" y="252" text-anchor="middle">Aug</text>
84
+ <polyline fill="none" stroke="#d8dadf" stroke-width="1.5" points="44.0,196.0 115.4,188.8 186.9,179.8 258.3,185.2 329.7,176.2 401.1,172.6 472.6,174.4 544.0,181.6"/>
85
+ <text class="series" x="552" y="185.6" fill="#9a9ea6">paid search</text>
86
+ <polyline fill="none" stroke="#b5b9c0" stroke-width="1.5" points="44.0,115.0 115.4,109.6 186.9,106.0 258.3,113.2 329.7,100.6 401.1,107.8 472.6,109.6 544.0,104.2"/>
87
+ <text class="series" x="552" y="108.2" fill="#b5b9c0">organic</text>
88
+ <polyline fill="none" stroke="#8a8f98" stroke-width="1.5" points="44.0,73.6 115.4,68.2 186.9,61.0 258.3,71.8 329.7,55.6 401.1,66.4 472.6,61.0 544.0,66.4"/>
89
+ <text class="series" x="552" y="70.4" fill="#8a8f98">referral</text>
90
+ <polyline fill="none" stroke="#2a6fdb" stroke-width="2.5" points="44.0,174.4 115.4,163.6 186.9,151.0 258.3,158.2 329.7,142.0 401.1,143.8 472.6,129.4 544.0,41.2"/>
91
+ <text class="series" x="552" y="45.2" fill="#2a6fdb">paid social</text>
92
+ </svg>
93
+ </figure>
94
+
95
+ <h2>August by channel</h2>
96
+ <table data-lineage="v2">
97
+ <thead>
98
+ <tr><th>Channel</th><th>New customers</th><th>30-day retention</th></tr>
99
+ </thead>
100
+ <tbody>
101
+ <tr><td>Paid social</td><td>4,120</td><td>46.6%</td></tr>
102
+ <tr><td>Referral</td><td>1,900</td><td>45.2%</td></tr>
103
+ <tr><td>Organic</td><td>3,870</td><td>43.1%</td></tr>
104
+ <tr><td>Paid search</td><td>3,310</td><td>38.8%</td></tr>
105
+ </tbody>
106
+ </table>
107
+
108
+ <footer>Source: marts.retention_by_channel and marts.acquisition_by_channel. Retention is the 30-day active rate of each signup cohort: customers still active a month after signup, over customers in the cohort.</footer>
109
+ </main>
110
+ </body>
111
+ </html>
@@ -0,0 +1,218 @@
1
+ # The lineage graph: field reference
2
+
3
+ ## Files
4
+
5
+ A report is one HTML file. Its lineage graph is one JSON object kept in
6
+ `.lineage/<report-stem>.json`, beside the report. For `report.html` that is
7
+ `.lineage/report.json`. You write the graph there. `htmlineage check` embeds
8
+ it in the report as a `<script type="application/lineage+json">` block, so you
9
+ never write that block by hand.
10
+
11
+ The graph is a header and a `nodes` map keyed by id:
12
+
13
+ ```json
14
+ {
15
+ "lineage": "1.0",
16
+ "nodes": {
17
+ "q1": {},
18
+ "v1": {},
19
+ "f1": {},
20
+ "i1": {}
21
+ }
22
+ }
23
+ ```
24
+
25
+ ## Kinds of node
26
+
27
+ Every value on the page reaches it by one path: source, then view, then
28
+ figure, then insight. A node's `step` says which it is.
29
+
30
+ ```json
31
+ {
32
+ "step": "source",
33
+ "kind": "sql",
34
+ "desc": "Retention by channel and cohort month, January to August 2026",
35
+ "mart": "marts.retention_by_channel",
36
+ "period": { "from": "2026-01-01", "to": "2026-08-31" },
37
+ "sql": "select\n channel,\n cohort_month,\n retention_30d\nfrom marts.retention_by_channel\nwhere cohort_month between '2026-01-01' and '2026-08-31'",
38
+ "columns": ["channel", "cohort_month", "retention_30d"]
39
+ }
40
+ ```
41
+
42
+ ```json
43
+ {
44
+ "step": "source",
45
+ "kind": "external",
46
+ "type": "csv",
47
+ "ref": "q3-targets.csv",
48
+ "given": "A CSV with columns channel, target_retention, shared by the builder"
49
+ }
50
+ ```
51
+
52
+ ```json
53
+ {
54
+ "step": "view",
55
+ "from": ["q1"],
56
+ "desc": "Average 30-day retention for each channel and cohort month",
57
+ "sql": "select\n channel,\n cohort_month,\n avg(retention_30d) as retention\nfrom q1\ngroup by channel, cohort_month",
58
+ "columns": ["channel", "cohort_month", "retention"],
59
+ "encoding": { "x": "cohort_month", "y": "retention", "series": "channel" }
60
+ }
61
+ ```
62
+
63
+ ```json
64
+ {
65
+ "step": "figure",
66
+ "from": ["v1"],
67
+ "desc": "Paid social retention for August 2026",
68
+ "sql": "select retention\nfrom v1\nwhere channel = 'paid_social'\n and cohort_month = '2026-08-01'",
69
+ "value": 0.466,
70
+ "display": "46.6%",
71
+ "label": "Paid social retention, August"
72
+ }
73
+ ```
74
+
75
+ ```json
76
+ {
77
+ "step": "figure",
78
+ "from": ["f1", "f2"],
79
+ "desc": "Change in paid social retention from July to August, relative to July",
80
+ "expr": "(f1 - f2) / f2",
81
+ "value": 0.1175,
82
+ "display": "11.8%",
83
+ "label": "Change in paid social retention, August vs July"
84
+ }
85
+ ```
86
+
87
+ ```json
88
+ {
89
+ "step": "insight",
90
+ "from": ["f3"],
91
+ "text": "Paid social retention rose by a relative 11.8%."
92
+ }
93
+ ```
94
+
95
+ A claim that rests on nothing you queried is still written down, marked
96
+ `"ungrounded": true` with `"from": []`.
97
+
98
+ ## What may rest on what
99
+
100
+ - A view's `from` lists sources and views.
101
+ - A figure with `sql` lists sources and views in `from`. A figure with `expr`
102
+ lists figures.
103
+ - An insight's `from` lists views, figures and insights. Never a source:
104
+ sources do not appear on the page.
105
+ - A figure never gets its own query. It picks one cell from a view or source
106
+ that exists, or it derives from other figures.
107
+
108
+ ## Ids
109
+
110
+ - Lowercase letters, digits and `_`, starting with a letter.
111
+ - Start with the step's letter: `q` for a sql source, `x` for an external
112
+ source, `v` for a view, `f` for a figure, `i` for an insight. For example
113
+ `q1`, `x1`, `v1`, `f1`, `i1`.
114
+ - Each id is used once.
115
+
116
+ ## Binding to the page
117
+
118
+ Put `data-lineage="<id>"` on the element that shows the node:
119
+
120
+ ```html
121
+ <span data-lineage="f3">11.8%</span>
122
+ <p data-lineage="i1">
123
+ Paid social retention rose by a relative
124
+ <span data-lineage="f3">11.8%</span>.
125
+ </p>
126
+ <div data-lineage="v1"><svg></svg></div>
127
+ <table data-lineage="v2"></table>
128
+ ```
129
+
130
+ - Views, figures and insights bind. Sources never do.
131
+ - Every view, figure and insight binds at least once. A figure shown in
132
+ several places binds in each.
133
+ - A number inside a sentence sits in its own `data-lineage` span.
134
+ - A chart binds on the container around the SVG, not the SVG.
135
+ - The attribute adds no style and no behaviour.
136
+
137
+ ## Fields
138
+
139
+ Each table below lists every field a node of that kind may carry. Required
140
+ means an error if it is missing. Expected means a warning if it is missing.
141
+
142
+ ### sql source
143
+
144
+ | Field | Required | Type | Use |
145
+ | ----------- | -------- | ------ | ------------------------------------------------------------------- |
146
+ | `step` | required | string | `"source"` |
147
+ | `kind` | required | string | `"sql"` |
148
+ | `desc` | expected | string | One line, 120 characters at most: what the query pulls, in words |
149
+ | `how` | optional | string | Leave out |
150
+ | `mart` | expected | string | The mart the query reads |
151
+ | `period` | expected | object | The pinned range, `{ "from": "2026-01-01", "to": "2026-08-31" }` |
152
+ | `sql` | expected | string | The query, with the period as literal dates |
153
+ | `columns` | expected | list | Output column names |
154
+ | `rows` | optional | object | Up to 10 rows, columnar: `{ "column": [values] }`; or `null` |
155
+ | `transform` | optional | string | A judgement the SQL does not show, such as "excluded the test week" |
156
+
157
+ ### external source
158
+
159
+ | Field | Required | Type | Use |
160
+ | ----------- | -------- | ------ | --------------------------------------------- |
161
+ | `step` | required | string | `"source"` |
162
+ | `kind` | required | string | `"external"` |
163
+ | `type` | expected | string | What kind of thing it is, such as `csv` |
164
+ | `ref` | expected | string | How a reader can find it, such as a file name |
165
+ | `given` | expected | string | What you were handed, in words |
166
+ | `columns` | optional | list | Column names, if it has them |
167
+ | `rows` | optional | object | Up to 10 rows, columnar; or `null` |
168
+ | `transform` | optional | string | A judgement the data does not show |
169
+
170
+ ### view
171
+
172
+ | Field | Required | Type | Use |
173
+ | ------------ | -------- | ------ | -------------------------------------------------------------------------------- |
174
+ | `step` | required | string | `"view"` |
175
+ | `from` | required | list | Ids of the sources and views it reads |
176
+ | `desc` | expected | string | One line, 120 characters at most: what it computes and how, in words |
177
+ | `sql` | required | string | A SELECT over the parents, which it names by id as table names |
178
+ | `columns` | expected | list | Output column names |
179
+ | `encoding` | optional | object | For charts: `x`, `y`, `series`, `visual`, each a string |
180
+ | `rows` | optional | object | Up to 10 rows, columnar; or `null`. A view shown as a table needs none |
181
+ | `transform` | optional | string | A judgement the SQL does not show |
182
+ | `ungrounded` | optional | bool | `true` when it has no source: `from` may then be empty and `sql` may be left out |
183
+
184
+ ### figure
185
+
186
+ | Field | Required | Type | Use |
187
+ | ------------ | -------------------- | ------------- | --------------------------------------------------------------------------------------------- |
188
+ | `step` | required | string | `"figure"` |
189
+ | `from` | required | list | Ids it reads: sources and views with `sql`, figures with `expr` |
190
+ | `desc` | expected | string | One line, 120 characters at most: how it was computed, in words |
191
+ | `sql` | one of `sql`, `expr` | string | A single SELECT returning one cell, ideally a plain pick of one column from one row |
192
+ | `expr` | one of `sql`, `expr` | string | A SQL scalar expression over figure ids, such as `(f1 - f2) / f2`. No comments |
193
+ | `value` | expected | number/string | The number or name the page shows |
194
+ | `display` | expected | string | The text exactly as shown on the page, such as `"46.6%"` |
195
+ | `label` | expected | string | What the number is, in words |
196
+ | `transform` | optional | string | A judgement the SQL does not show |
197
+ | `ungrounded` | optional | bool | `true` when it has no source: `from` may then be empty and neither `sql` nor `expr` is needed |
198
+
199
+ ### insight
200
+
201
+ | Field | Required | Type | Use |
202
+ | ------------ | -------- | ------ | --------------------------------------------------------------------- |
203
+ | `step` | required | string | `"insight"` |
204
+ | `from` | required | list | Ids of the views, figures and insights the claim rests on |
205
+ | `text` | required | string | The claim, as it reads on the page |
206
+ | `transform` | optional | string | A judgement behind the claim that its sources do not show |
207
+ | `ungrounded` | optional | bool | `true` when the claim rests on nothing here: `from` may then be empty |
208
+
209
+ ### header
210
+
211
+ | Field | Required | Type | Use |
212
+ | -------------- | -------- | ------ | ------------------------------------------------------ |
213
+ | `lineage` | required | string | The format version, `"1.0"` |
214
+ | `nodes` | required | object | Every node, keyed by id |
215
+ | `report_id` | optional | string | A UUID, minted at the first build and kept on rebuilds |
216
+ | `built_at` | optional | string | When the report was built, an ISO timestamp |
217
+ | `builder_tool` | optional | string | The tool that built it, such as `report-builder` |
218
+ | `signature` | optional | string | Leave out |
@@ -0,0 +1,79 @@
1
+ # Reviewing a report
2
+
3
+ Use this when you are asked to review a report someone else built, or to check
4
+ one before it is shared. Your job is to show the reviewer what each number and
5
+ claim rests on and where that is weak. You do not decide whether the report is
6
+ right, and you do not change it.
7
+
8
+ ## 1. Read the check summary
9
+
10
+ ```
11
+ npx htmlineage check <report>.html --no-embed
12
+ ```
13
+
14
+ `--no-embed` leaves the file untouched. Read the summary. It lists errors
15
+ last, but take them first:
16
+
17
+ - **Errors.** The lineage is broken. Say so first: nothing below can be traced
18
+ with confidence until the builder fixes it, and `htmlineage sql` refuses
19
+ a report with errors.
20
+ - **Ungrounded.** Claims and numbers that rest on nothing queried: a forecast,
21
+ an opinion, outside knowledge. List each one with its text. They may be
22
+ fine, but the reader should know they are not backed by data.
23
+ - **External.** Data the builder was handed: a CSV, a number someone said.
24
+ Name each one and who it came from (its `given`). A reviewer may want to
25
+ confirm it with that person.
26
+ - **Warnings.** Each is a gap a reader could trip on. The ones that matter
27
+ most: a number in prose with no lineage (C2), a page that shows a different
28
+ value from the graph (C3), and a source without literal dates (C7).
29
+
30
+ ## 2. Trace the numbers that carry the report
31
+
32
+ Pick the headline numbers and the ones the main claims rest on. For each, take
33
+ its id from the element's `data-lineage` attribute and run:
34
+
35
+ ```
36
+ npx htmlineage sql <report>.html --id <id>
37
+ ```
38
+
39
+ Read the script against the claim on the page:
40
+
41
+ - **Population.** Does the source cover who the claim is about? A claim about
42
+ all customers over a source filtered to one region is a finding.
43
+ - **Period.** Do the literal dates match the period the page names?
44
+ - **Comparison.** Is a change measured against the base the text says (last
45
+ month, last year, target)?
46
+ - **Definition.** Does the metric match its name: a 30-day rate computed over
47
+ 30 days, a share over the right total?
48
+ - **Descriptions.** Does each step's `desc` say what its SQL does? A `desc`
49
+ that promises more than the SQL is a finding.
50
+
51
+ `npx htmlineage sql <report>.html` with no `--id` composes the whole report into
52
+ one script, if the reviewer wants everything at once.
53
+
54
+ ## 3. Rerun, if you can and are asked to
55
+
56
+ If you have read access to the warehouse and the reviewer wants the numbers
57
+ reproduced, run the script from step 2 and compare its result with the figure's
58
+ `display` on the page. Run it as it is; do not rewrite it first. Report any
59
+ difference with both values. Because every source has literal dates, the script
60
+ should give the same result as when the report was built, unless the
61
+ underlying data has changed since.
62
+
63
+ Never run anything that writes to the warehouse.
64
+
65
+ ## 4. Report back
66
+
67
+ A short list, most serious first:
68
+
69
+ 1. Errors, if any.
70
+ 2. Findings from step 2 and step 3: the id, what the page says, what the SQL
71
+ does, and why it matters.
72
+ 3. Ungrounded claims and external data, so the reader knows what to take on
73
+ trust.
74
+ 4. Anything you checked and found sound, briefly. "f1, f3 and f5 trace cleanly
75
+ to the August retention query" is useful to a reviewer.
76
+
77
+ Do not edit the report or its graph. If the builder should change something,
78
+ say what and why. For a person to click through the lineage themselves, point
79
+ them to `npx htmlineage view <report>.html`.