@renzrollon/interlock 1.0.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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- package/workflows/ship.js +565 -0
package/lib/drift.mjs
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// Spec drift — has the written spec fallen behind the code it describes?
|
|
2
|
+
//
|
|
3
|
+
// Spec drift is the standing criticism of every spec-driven framework: the spec
|
|
4
|
+
// is written once, the code moves, and nothing notices until someone reads a
|
|
5
|
+
// document that has been wrong for a month. Worse for agents than for people —
|
|
6
|
+
// a stale doc misleads the next reader, a stale spec misleads every future run.
|
|
7
|
+
//
|
|
8
|
+
// OpenSpec's answer is `openspec archive`, which merges a completed change's
|
|
9
|
+
// delta specs into `openspec/specs/`. That answer is correct and Interlock does
|
|
10
|
+
// not replace it. What Interlock adds is noticing when it has not happened.
|
|
11
|
+
//
|
|
12
|
+
// Two signals, deliberately kept apart because their confidence is not the same:
|
|
13
|
+
//
|
|
14
|
+
// A. UNARCHIVED Every task in a change is ticked, but the change is still
|
|
15
|
+
// sitting in openspec/changes/. Its deltas never reached the
|
|
16
|
+
// living specs, so the living specs are stale by construction.
|
|
17
|
+
// Read straight off the filesystem. This cannot false-positive.
|
|
18
|
+
//
|
|
19
|
+
// B. STALE A living spec links to files that have been committed since
|
|
20
|
+
// the spec itself was last touched. Derived from the graph's
|
|
21
|
+
// `implements_spec` edges, which are INFERRED — regex path
|
|
22
|
+
// mentions, not a parsed contract. This is a hint, not a fact.
|
|
23
|
+
//
|
|
24
|
+
// Merging them into one score would launder B's uncertainty through A's
|
|
25
|
+
// precision, so they stay separate all the way to the printed report.
|
|
26
|
+
//
|
|
27
|
+
// WHY THIS NEVER BLOCKS. Every other gating subcommand in `interlock` exits
|
|
28
|
+
// non-zero when it blocks, so the obvious assumption is that this one does too.
|
|
29
|
+
// It does not, and that is deliberate: signal B rests on INFERRED edges, and
|
|
30
|
+
// `shared/TOOL-ECONOMY.md` says to treat those as hints needing verification. A
|
|
31
|
+
// blocking gate built on a regex that matched a filename in a sentence would be
|
|
32
|
+
// wrong often enough to be turned off, and a gate everyone disables protects
|
|
33
|
+
// nothing. The Spec Growth Engine (arXiv 2606.27045) can block a merge on graph
|
|
34
|
+
// disagreement because its specs are machine-readable contracts; prose specs
|
|
35
|
+
// cannot carry that weight. So: report, and let a person decide.
|
|
36
|
+
//
|
|
37
|
+
// Exposed to skills as `interlock drift [--json]`.
|
|
38
|
+
|
|
39
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
40
|
+
import { execFileSync } from 'node:child_process'
|
|
41
|
+
import { join } from 'node:path'
|
|
42
|
+
import { inspectChange, isCoverableSource, listChanges } from './artifacts.mjs'
|
|
43
|
+
|
|
44
|
+
const GRAPH_PATH = join('.claude', 'graph', 'graph.json')
|
|
45
|
+
|
|
46
|
+
/** Living specs only. A spec inside an in-flight change is *supposed* to move. */
|
|
47
|
+
const LIVING_SPEC_PREFIX = 'openspec/specs/'
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Only weight-1 edges, which are the `file:` targets extracted from an explicit
|
|
51
|
+
* path mention. The 0.5 edges are symbol-name guesses and are far looser — a
|
|
52
|
+
* spec saying "the AuthService" links to every AuthService in the repo, so a
|
|
53
|
+
* staleness claim built on them would be noise.
|
|
54
|
+
*/
|
|
55
|
+
const RELIABLE_EDGE_WEIGHT = 1
|
|
56
|
+
|
|
57
|
+
/** Last commit time for a path, as an ISO string, or null when git has nothing. */
|
|
58
|
+
function gitLastCommit(root, path) {
|
|
59
|
+
try {
|
|
60
|
+
const out = execFileSync('git', ['log', '-1', '--format=%cI', '--', path], {
|
|
61
|
+
cwd: root,
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
64
|
+
})
|
|
65
|
+
const trimmed = out.trim()
|
|
66
|
+
return trimmed || null
|
|
67
|
+
} catch {
|
|
68
|
+
// Not a git repo, git missing, or a path git has never seen. All three mean
|
|
69
|
+
// "no timestamp available", which is a skip — never a zero and never a now.
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Signal A — changes that are complete but were never archived.
|
|
76
|
+
*
|
|
77
|
+
* A change qualifies when it has tasks and none remain. A change with zero
|
|
78
|
+
* tasks is not "complete", it is unstarted or malformed, and reporting it as
|
|
79
|
+
* drift would train people to ignore the report.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} root
|
|
82
|
+
* @returns {Array<{change: string, tasks: number, path: string}>}
|
|
83
|
+
*/
|
|
84
|
+
export function detectUnarchived(root = '.') {
|
|
85
|
+
const found = []
|
|
86
|
+
for (const name of listChanges(root)) {
|
|
87
|
+
const info = inspectChange(root, name)
|
|
88
|
+
if (!info || !info.exists) continue
|
|
89
|
+
const tasks = info.tasks || {}
|
|
90
|
+
const total = Number(tasks.total) || 0
|
|
91
|
+
const remaining = Number(tasks.remaining) || 0
|
|
92
|
+
if (total > 0 && remaining === 0) {
|
|
93
|
+
found.push({ change: name, tasks: total, path: info.path })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return found
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read the graph, or say why we could not. Never reports absence as cleanliness. */
|
|
100
|
+
function readGraph(root) {
|
|
101
|
+
const path = join(root, GRAPH_PATH)
|
|
102
|
+
if (!existsSync(path)) {
|
|
103
|
+
return { links: null, reason: 'no graph has been built — run /interlock:bootstrap once' }
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
107
|
+
// node-link format: edges live under `links`, not `edges`.
|
|
108
|
+
if (!Array.isArray(parsed.links)) {
|
|
109
|
+
return { links: null, reason: 'graph.json has no links array — rebuild it' }
|
|
110
|
+
}
|
|
111
|
+
const meta = parsed.graph && typeof parsed.graph === 'object' ? parsed.graph : {}
|
|
112
|
+
return {
|
|
113
|
+
links: parsed.links,
|
|
114
|
+
nodes: Array.isArray(parsed.nodes) ? parsed.nodes : [],
|
|
115
|
+
builtAt: typeof meta.built_at === 'string' ? meta.built_at : null,
|
|
116
|
+
reason: null
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return { links: null, reason: `graph.json could not be read: ${err.message}` }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** spec path -> set of files it claims to describe. Living specs, weight-1 edges only. */
|
|
124
|
+
function specToFiles(links) {
|
|
125
|
+
const bySpec = new Map()
|
|
126
|
+
for (const link of links) {
|
|
127
|
+
if (!link || link.relation !== 'implements_spec') continue
|
|
128
|
+
if (link.weight !== RELIABLE_EDGE_WEIGHT) continue
|
|
129
|
+
const spec = typeof link.source_file === 'string' ? link.source_file : ''
|
|
130
|
+
if (!spec.startsWith(LIVING_SPEC_PREFIX)) continue
|
|
131
|
+
const target = typeof link.target === 'string' ? link.target : ''
|
|
132
|
+
if (!target.startsWith('file:')) continue
|
|
133
|
+
const file = target.slice('file:'.length)
|
|
134
|
+
if (!file) continue
|
|
135
|
+
if (!bySpec.has(spec)) bySpec.set(spec, new Set())
|
|
136
|
+
bySpec.get(spec).add(file)
|
|
137
|
+
}
|
|
138
|
+
return bySpec
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Signal B — living specs that no longer match the code they describe.
|
|
143
|
+
*
|
|
144
|
+
* Two tiers, and the split is the point:
|
|
145
|
+
*
|
|
146
|
+
* broken A spec cites a file that is not there any more. The edge was gated
|
|
147
|
+
* by `knownFiles` when the graph was built, so the file provably
|
|
148
|
+
* existed then. Evidence.
|
|
149
|
+
* aging A spec is older than a file it cites that DOES still exist. A
|
|
150
|
+
* proxy: a file can be refactored without the behaviour the spec
|
|
151
|
+
* describes changing at all. Advisory.
|
|
152
|
+
*
|
|
153
|
+
* A file that is gone appears in `broken` only — never in both. `aging` would
|
|
154
|
+
* otherwise report the same file a second time under a weaker heading, and the
|
|
155
|
+
* reader would have to work out that the two findings are one.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} root
|
|
158
|
+
* @param {{lastCommit?: (path: string) => string|null, exists?: (path: string) => boolean}} [opts]
|
|
159
|
+
* Both injected so this is testable without a git repo or a real tree.
|
|
160
|
+
*/
|
|
161
|
+
export function detectStaleSpecs(root = '.', opts = {}) {
|
|
162
|
+
const lastCommit =
|
|
163
|
+
typeof opts.lastCommit === 'function' ? opts.lastCommit : path => gitLastCommit(root, path)
|
|
164
|
+
const exists =
|
|
165
|
+
typeof opts.exists === 'function' ? opts.exists : path => existsSync(join(root, path))
|
|
166
|
+
|
|
167
|
+
const { links, builtAt, reason } = readGraph(root)
|
|
168
|
+
if (!links) return { skipped: true, reason, broken: [], aging: [], graphBuiltAt: null }
|
|
169
|
+
|
|
170
|
+
const bySpec = specToFiles(links)
|
|
171
|
+
|
|
172
|
+
const broken = []
|
|
173
|
+
const aging = []
|
|
174
|
+
for (const [spec, files] of [...bySpec].sort(([a], [b]) => a.localeCompare(b))) {
|
|
175
|
+
const present = []
|
|
176
|
+
for (const file of [...files].sort()) {
|
|
177
|
+
if (exists(file)) present.push(file)
|
|
178
|
+
else broken.push({ spec, file })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const specTime = lastCommit(spec)
|
|
182
|
+
// A spec git has never seen cannot be compared against anything. Skipping it
|
|
183
|
+
// is the honest outcome; guessing a timestamp would invent the finding.
|
|
184
|
+
if (!specTime) continue
|
|
185
|
+
|
|
186
|
+
const newer = []
|
|
187
|
+
for (const file of present) {
|
|
188
|
+
const fileTime = lastCommit(file)
|
|
189
|
+
if (!fileTime) continue
|
|
190
|
+
if (fileTime > specTime) newer.push({ file, committed: fileTime })
|
|
191
|
+
}
|
|
192
|
+
if (newer.length) {
|
|
193
|
+
aging.push({ spec, specCommitted: specTime, newerFiles: newer, linkedFiles: files.size })
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// `indexed` separates "checked them, all fresh" from "there was nothing to
|
|
198
|
+
// check" — the same distinction readGraph draws above. A repo with no living
|
|
199
|
+
// specs reporting "none stale" reads like a passed check, and it is not one.
|
|
200
|
+
return { skipped: false, reason: null, broken, aging, indexed: bySpec.size, graphBuiltAt: builtAt }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Signal C — changed source files that no living spec describes.
|
|
205
|
+
*
|
|
206
|
+
* §5.4 of the Spec Growth Engine calls an unowned source file a hard error. That
|
|
207
|
+
* works there because every node is required to have a SPEC.md; run it repo-wide
|
|
208
|
+
* on a brownfield project and it reports the whole tree. Scoped to the files one
|
|
209
|
+
* change actually touched, it is the useful version: "you just wrote code
|
|
210
|
+
* nothing describes."
|
|
211
|
+
*
|
|
212
|
+
* The repo-wide coverage figure travels with the finding on purpose. "2 files
|
|
213
|
+
* have no spec" reads as an alarm; "2 of 6, in a repo where 34% of source files
|
|
214
|
+
* have one" reads as what it is. No threshold decides whether to report — a
|
|
215
|
+
* number nobody can defend would drift, which is the argument in lib/limits.mjs.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} root
|
|
218
|
+
* @param {string[]} changed repo-relative paths from the caller's diff
|
|
219
|
+
*/
|
|
220
|
+
export function detectOrphans(root = '.', changed = [], opts = {}) {
|
|
221
|
+
const { links, nodes, reason } = readGraph(root)
|
|
222
|
+
if (!links) return { skipped: true, reason, orphans: [], considered: 0 }
|
|
223
|
+
|
|
224
|
+
const bySpec = specToFiles(links)
|
|
225
|
+
if (bySpec.size === 0) {
|
|
226
|
+
// No spec describes any file, so every file is "unowned" and the finding
|
|
227
|
+
// means nothing. Reporting it would be the loudest possible way to say
|
|
228
|
+
// nothing at all.
|
|
229
|
+
return {
|
|
230
|
+
skipped: true,
|
|
231
|
+
reason: 'no living spec links to any file — orphan detection needs spec coverage to mean anything',
|
|
232
|
+
orphans: [],
|
|
233
|
+
considered: 0
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const owned = new Set()
|
|
238
|
+
for (const files of bySpec.values()) for (const f of files) owned.add(f)
|
|
239
|
+
|
|
240
|
+
// Only real source counts. A doc or a config file having no spec is not a gap.
|
|
241
|
+
const considered = (Array.isArray(changed) ? changed : []).filter(isCoverableSource)
|
|
242
|
+
const orphans = considered.filter(f => !owned.has(f)).sort()
|
|
243
|
+
|
|
244
|
+
// Denominator from the graph's own file nodes, filtered the same way, so the
|
|
245
|
+
// ratio compares like with like.
|
|
246
|
+
const allSource = (nodes || [])
|
|
247
|
+
.filter(n => n && n.type === 'file' && typeof n.source_file === 'string')
|
|
248
|
+
.map(n => n.source_file)
|
|
249
|
+
.filter(isCoverableSource)
|
|
250
|
+
const ownedSource = allSource.filter(f => owned.has(f))
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
skipped: false,
|
|
254
|
+
reason: null,
|
|
255
|
+
orphans,
|
|
256
|
+
considered: considered.length,
|
|
257
|
+
coverage: {
|
|
258
|
+
owned: ownedSource.length,
|
|
259
|
+
total: allSource.length,
|
|
260
|
+
percent: allSource.length ? Math.round((ownedSource.length / allSource.length) * 100) : 0
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Every signal, in one report.
|
|
267
|
+
*
|
|
268
|
+
* @param {string} root
|
|
269
|
+
* @param {{
|
|
270
|
+
* lastCommit?: (path: string) => string|null,
|
|
271
|
+
* exists?: (path: string) => boolean,
|
|
272
|
+
* changed?: string[]
|
|
273
|
+
* }} [opts]
|
|
274
|
+
* `changed` is the caller's diff. Orphan detection is only meaningful against
|
|
275
|
+
* one, so it is computed only when a diff is supplied — absent, the signal is
|
|
276
|
+
* reported as not-run rather than as clean.
|
|
277
|
+
*/
|
|
278
|
+
export function analyzeDrift(root = '.', opts = {}) {
|
|
279
|
+
const unarchived = detectUnarchived(root)
|
|
280
|
+
const stale = detectStaleSpecs(root, opts)
|
|
281
|
+
const changed = Array.isArray(opts.changed) ? opts.changed : null
|
|
282
|
+
const orphans = changed
|
|
283
|
+
? detectOrphans(root, changed, opts)
|
|
284
|
+
: { skipped: true, reason: 'no --changed file list was supplied', orphans: [], considered: 0 }
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
root,
|
|
288
|
+
unarchived,
|
|
289
|
+
stale,
|
|
290
|
+
orphans,
|
|
291
|
+
// A convenience for callers that only want to know whether to print anything.
|
|
292
|
+
// Never a pass/fail: this command has no fail.
|
|
293
|
+
hasFindings:
|
|
294
|
+
unarchived.length > 0 ||
|
|
295
|
+
stale.broken.length > 0 ||
|
|
296
|
+
stale.aging.length > 0 ||
|
|
297
|
+
orphans.orphans.length > 0
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* `2026-08-13 14:02:31` — seconds, not date. A spec and the file that outran it
|
|
303
|
+
* are often committed minutes apart, and two identical-looking timestamps under
|
|
304
|
+
* a "has fallen behind" heading read like a bug in the report rather than a
|
|
305
|
+
* narrow margin. The extra characters are cheaper than that doubt.
|
|
306
|
+
*/
|
|
307
|
+
function stamp(iso) {
|
|
308
|
+
return typeof iso === 'string' ? iso.slice(0, 19).replace('T', ' ') : String(iso)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Human-readable report. Signal A first, because it is the one that is certain. */
|
|
312
|
+
export function formatDrift(report) {
|
|
313
|
+
const lines = []
|
|
314
|
+
const { unarchived, stale, orphans } = report
|
|
315
|
+
|
|
316
|
+
if (unarchived.length) {
|
|
317
|
+
lines.push(`UNARCHIVED — ${unarchived.length} completed change${unarchived.length === 1 ? '' : 's'} still in openspec/changes/`)
|
|
318
|
+
lines.push('')
|
|
319
|
+
for (const item of unarchived) {
|
|
320
|
+
lines.push(` ${item.change} (${item.tasks} task${item.tasks === 1 ? '' : 's'}, all done)`)
|
|
321
|
+
}
|
|
322
|
+
lines.push('')
|
|
323
|
+
lines.push(' Its delta specs never reached openspec/specs/, so the living specs do not')
|
|
324
|
+
lines.push(' describe what shipped. Archive each one once its MR has merged:')
|
|
325
|
+
lines.push('')
|
|
326
|
+
for (const item of unarchived) lines.push(` openspec archive ${item.change}`)
|
|
327
|
+
lines.push('')
|
|
328
|
+
} else {
|
|
329
|
+
lines.push('UNARCHIVED — none. Every completed change has been archived.')
|
|
330
|
+
lines.push('')
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Broken references before aging ones: one is a fact about the tree as it is
|
|
334
|
+
// now, the other is an inference from two dates. Printing them at the same
|
|
335
|
+
// weight would tell the reader they deserve the same attention.
|
|
336
|
+
if (stale.skipped) {
|
|
337
|
+
lines.push(`SPEC REFERENCES — not checked: ${stale.reason}`)
|
|
338
|
+
} else if (!stale.indexed) {
|
|
339
|
+
lines.push('SPEC REFERENCES — nothing to check: no living spec under openspec/specs/ links')
|
|
340
|
+
lines.push(' to a file the graph knows. That is not a clean result, it is an empty one.')
|
|
341
|
+
} else {
|
|
342
|
+
if (stale.broken.length) {
|
|
343
|
+
lines.push(
|
|
344
|
+
`BROKEN REFERENCES — ${stale.broken.length} spec→file link${stale.broken.length === 1 ? '' : 's'} whose file no longer exists`
|
|
345
|
+
)
|
|
346
|
+
lines.push('')
|
|
347
|
+
for (const item of stale.broken) lines.push(` ${item.spec} → ${item.file} (missing)`)
|
|
348
|
+
lines.push('')
|
|
349
|
+
lines.push(' The file existed when the graph was built and does not exist now, so the spec')
|
|
350
|
+
lines.push(' describes something that is not there. Either the spec needs updating or the')
|
|
351
|
+
lines.push(' file was renamed and the graph has not caught up.')
|
|
352
|
+
if (stale.graphBuiltAt) lines.push(` Graph built ${stamp(stale.graphBuiltAt)} — rebuild it if that is old.`)
|
|
353
|
+
lines.push('')
|
|
354
|
+
} else {
|
|
355
|
+
lines.push(
|
|
356
|
+
`BROKEN REFERENCES — none. Every file cited by ${stale.indexed} living spec${stale.indexed === 1 ? '' : 's'} still exists.`
|
|
357
|
+
)
|
|
358
|
+
lines.push('')
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (stale.aging.length) {
|
|
362
|
+
lines.push(`AGING SPECS — ${stale.aging.length} older than code they describe (advisory)`)
|
|
363
|
+
lines.push('')
|
|
364
|
+
for (const item of stale.aging) {
|
|
365
|
+
lines.push(` ${item.spec} last touched ${stamp(item.specCommitted)}`)
|
|
366
|
+
for (const f of item.newerFiles) {
|
|
367
|
+
lines.push(` ${f.file} committed ${stamp(f.committed)}`)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
lines.push('')
|
|
371
|
+
lines.push(' Weaker than the above: this compares dates, not behaviour, on INFERRED graph')
|
|
372
|
+
lines.push(' edges. A file can be refactored without the spec becoming wrong. Read before')
|
|
373
|
+
lines.push(' believing.')
|
|
374
|
+
} else {
|
|
375
|
+
lines.push('AGING SPECS — none is older than the files it describes.')
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (orphans.skipped) {
|
|
380
|
+
lines.push('')
|
|
381
|
+
lines.push(`ORPHAN CODE — not checked: ${orphans.reason}`)
|
|
382
|
+
} else {
|
|
383
|
+
lines.push('')
|
|
384
|
+
const c = orphans.coverage
|
|
385
|
+
if (orphans.orphans.length) {
|
|
386
|
+
lines.push(
|
|
387
|
+
`ORPHAN CODE — ${orphans.orphans.length} of ${orphans.considered} changed source file${orphans.considered === 1 ? '' : 's'} described by no spec`
|
|
388
|
+
)
|
|
389
|
+
lines.push('')
|
|
390
|
+
for (const f of orphans.orphans) lines.push(` ${f}`)
|
|
391
|
+
lines.push('')
|
|
392
|
+
lines.push(` Repo-wide, ${c.owned} of ${c.total} source files (${c.percent}%) have a spec. Read this`)
|
|
393
|
+
lines.push(' count against that: on a repo still being specced, unowned files are expected.')
|
|
394
|
+
} else if (!orphans.considered) {
|
|
395
|
+
// Vacuous truth reads as a bug. "All 0 files are covered" invites the
|
|
396
|
+
// reader to wonder whether the check ran at all.
|
|
397
|
+
lines.push('ORPHAN CODE — nothing to check: no changed file was a source file.')
|
|
398
|
+
} else {
|
|
399
|
+
lines.push(
|
|
400
|
+
`ORPHAN CODE — none. All ${orphans.considered} changed source file${orphans.considered === 1 ? '' : 's'} ` +
|
|
401
|
+
`${orphans.considered === 1 ? 'is' : 'are'} described by a spec (repo-wide ${c.percent}%).`
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
lines.push('')
|
|
407
|
+
lines.push('Nothing here blocks. Drift is reported so a person can decide.')
|
|
408
|
+
return lines.join('\n')
|
|
409
|
+
}
|