1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
//! Code age analysis — entity → time since last modification.
//!
//! Reference date is `opts.age_time_now` if set, else today (UTC).
//!
//! ## What's emitted (modern default)
//!
//! For each file (or canonical-lineage entity under `--use-canonical-lineage`),
//! the analysis emits:
//!
//! - `path` — the entity identifier
//! - `age_months` — whole calendar months between the latest qualifying
//! commit and the anchor date (interval-month semantics: Mar 15 →
//! Apr 1 = 0 months, not 1 — see inline SQL comment below)
//! - `age_days` — whole days between the latest qualifying commit and
//! the anchor — finer-grained precision than code-maat's months-only
//! output, useful for sort tie-breaking and recency triage
//! - `last_modified` — calendar date of the latest qualifying commit
//! (context column — helps the operator see WHEN rather than just
//! HOW LONG AGO)
//!
//! Code-maat emits only `entity, age-months`. We add the extra columns
//! because they cost nothing at query time and answer follow-up
//! questions ("how recently?", "is this a stale stale or a fresh stale?")
//! without re-running the analysis.
//!
//! ## Anchor-date filter
//!
//! `--age-time-now` lets the operator anchor the "now" used by the age
//! calculation. To make the back-test pattern (`--age-time-now <past>`)
//! return historically-faithful results, the SQL filters out commits
//! whose date is AFTER the anchor — same semantics as code-maat's
//! `changes-within-time-span`. Without this filter the back-test
//! returned NEGATIVE ages for files modified between the anchor and
//! today, which is meaningless output.
//!
//! Research basis: see `docs/research-foundations.md` entry "code-age"
//! (inspired by Dan North's "short software half-life" talk;
//! quantitative analysis in Tornhill, *Your Code as a Crime Scene*,
//! 2015).
use params;
use crateFactsDb;
use crate::;
// `WHERE commits.date <= anchor`: inclusive of commits made AT the
// anchor moment. The natural mental model for `--age-time-now 2026-06-01`
// is "as of June 1st, including that day's commits", and with the
// default anchor (`now`) we want to include all commits up to and
// including the current second. Code-maat used a strict `<` operator
// because their day-precision dates never encountered the equality case
// in real repos; codelore stores TIMESTAMP and so must be explicit.
// `age_months` uses interval-month semantics (whole calendar
// months elapsed between MAX(commit) and anchor), NOT `DATE_DIFF`'s
// month-boundary-crossing count. Concretely:
//
// - Mar 15 → Apr 1 → DATE_DIFF = 1 month (boundary crossed)
// → interval = 0 months (not yet a full month)
// - Mar 15 → Apr 16 → DATE_DIFF = 1, interval = 1 (full month + 1 day)
// - Mar 31 → Apr 30 → DATE_DIFF = 1, interval = 0 (one day short)
//
// `joda-time`'s `(tc/interval start end)` followed by `tc/in-months` is
// the reference semantic (that's what code-maat uses). The closed-form
// computation is: 12*(yr-yr) + (mo-mo), minus 1 if the day-of-month
// hasn't been reached yet.
//
// Implemented inline in SQL so the analysis stays as a single
// parameterised query (no post-processing in Rust). The
// `EXTRACT(year/month/day FROM ...)` calls work on both `DATE` and
// `TIMESTAMP` types, so we don't need to cast the anchor or the
// `MAX(...)` aggregate to a specific shape.
// Code-age filters to files that are LIVE AS OF THE ANCHOR
// MOMENT (not just live at HEAD — back-test pattern needs the historical
// view). The `live_paths_at_anchor` CTE takes the same anchor parameter
// as the anchor-date filter and selects paths whose latest change
// at-or-before anchor is not a deletion. This drops 2-year-old deleted
// files from current-anchor reports AND correctly resurrects files in
// back-test mode that were deleted later.
const SQL: &str = "
WITH live_paths_at_anchor AS (
SELECT path FROM (
SELECT c.path,
arg_max(
c.change_type,
ROW(commits.date, -commits.rowid)
) AS change_type
FROM changes c
INNER JOIN commits ON commits.rev = c.rev
WHERE commits.date <= CAST(? AS TIMESTAMP)
GROUP BY c.path
) WHERE change_type != 'deleted'
),
per_path AS (
SELECT
changes.path,
MAX(commits.date) AS last_at,
-- (rev, path) is the changes PK so rev is unique within each
-- `GROUP BY changes.path` group. Plain COUNT skips DuckDB's
-- distinct-tracking overhead.
COUNT(changes.rev) AS n_revs
FROM changes
INNER JOIN commits ON changes.rev = commits.rev
INNER JOIN live_paths_at_anchor USING (path)
WHERE commits.date <= CAST(? AS TIMESTAMP)
GROUP BY changes.path
)
SELECT
path,
(
12 * (EXTRACT(year FROM CAST(? AS TIMESTAMP))
- EXTRACT(year FROM last_at))
+ (EXTRACT(month FROM CAST(? AS TIMESTAMP))
- EXTRACT(month FROM last_at))
- CASE WHEN EXTRACT(day FROM CAST(? AS TIMESTAMP))
< EXTRACT(day FROM last_at) THEN 1 ELSE 0 END
)::INTEGER AS age_months,
DATE_DIFF('day', last_at, CAST(? AS TIMESTAMP)) AS age_days,
CAST(CAST(last_at AS DATE) AS TEXT) AS last_modified
FROM per_path
WHERE n_revs >= ?
ORDER BY age_months ASC, age_days ASC, path ASC
LIMIT ?
";