changeledger 0.9.0 → 0.11.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/AGENTS.md +5 -1
- package/README.md +5 -0
- package/bin/changeledger.mjs +127 -20
- package/package.json +1 -1
- package/src/check.mjs +12 -0
- package/src/commands/agent-context.mjs +2 -1
- package/src/commands/agent-prompt.mjs +1 -1
- package/src/commands/agent.mjs +3 -3
- package/src/commands/check.mjs +55 -0
- package/src/commands/commit.mjs +44 -0
- package/src/commands/context.mjs +57 -23
- package/src/commands/fix.mjs +72 -0
- package/src/commands/register.mjs +9 -1
- package/src/commands/search.mjs +56 -0
- package/src/config-migration.mjs +67 -9
- package/src/config.mjs +13 -0
- package/src/contract.mjs +67 -13
- package/src/fix.mjs +127 -0
- package/src/framing.mjs +8 -0
- package/src/git.mjs +137 -2
- package/src/metrics.mjs +42 -0
- package/src/search.mjs +162 -0
- package/src/viewer/domain.mjs +13 -1
- package/src/viewer/public/app.js +76 -11
- package/src/viewer/public/styles.css +76 -5
- package/src/viewer/public/view-renderers.js +160 -48
- package/src/viewer/server/router.mjs +40 -6
- package/templates/config.yml +11 -1
- package/templates/contract/agent-contexts/audit.md +22 -0
- package/templates/contract/agent-prompts/audit.md +37 -0
- package/templates/contract/agent-prompts/implementation.md +4 -0
- package/templates/contract/close.md +1 -1
- package/templates/contract/core.md +14 -4
- package/templates/contract/delegation.md +2 -1
- package/templates/contract/implement.md +16 -2
- package/templates/contract/spec.md +14 -1
package/src/git.mjs
CHANGED
|
@@ -5,9 +5,58 @@
|
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
|
|
7
7
|
const SEP = String.fromCharCode(31); // ASCII unit separator — safe field delimiter
|
|
8
|
+
const RECORD_SEP = String.fromCharCode(30);
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
// Repo-location env vars git itself exports while running a hook (e.g. this
|
|
11
|
+
// project's own pre-commit). Left inherited, a child `git` call would silently
|
|
12
|
+
// target the hook's repo/worktree instead of the given `cwd` — strip them so
|
|
13
|
+
// every invocation stays anchored on `cwd`.
|
|
14
|
+
const GIT_LOCATION_ENV_VARS = [
|
|
15
|
+
'GIT_DIR',
|
|
16
|
+
'GIT_WORK_TREE',
|
|
17
|
+
'GIT_INDEX_FILE',
|
|
18
|
+
'GIT_OBJECT_DIRECTORY',
|
|
19
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
20
|
+
'GIT_COMMON_DIR',
|
|
21
|
+
'GIT_CEILING_DIRECTORIES',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function sanitizedEnv() {
|
|
25
|
+
const env = { ...process.env };
|
|
26
|
+
for (const key of GIT_LOCATION_ENV_VARS) delete env[key];
|
|
27
|
+
return env;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Exported so other commands (e.g. `changeledger commit`) share the same
|
|
31
|
+
// GIT_* sanitization instead of re-implementing it.
|
|
32
|
+
export function defaultRun(args, cwd) {
|
|
33
|
+
return execFileSync('git', args, {
|
|
34
|
+
cwd,
|
|
35
|
+
env: sanitizedEnv(),
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Run variant for mutating git commands (e.g. `commit`), where git's stderr is
|
|
42
|
+
// the only clue to a failure (failed hook, nothing staged, missing identity,
|
|
43
|
+
// lock). Pipes stderr and, on failure, throws an Error whose message includes
|
|
44
|
+
// the captured diagnostic. Query paths keep `defaultRun` and degrade silently.
|
|
45
|
+
export function mutatingRun(args, cwd) {
|
|
46
|
+
try {
|
|
47
|
+
return execFileSync('git', args, {
|
|
48
|
+
cwd,
|
|
49
|
+
env: sanitizedEnv(),
|
|
50
|
+
encoding: 'utf8',
|
|
51
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
52
|
+
});
|
|
53
|
+
} catch (e) {
|
|
54
|
+
const detail = [e.stderr, e.stdout]
|
|
55
|
+
.map((s) => (typeof s === 'string' ? s.trim() : ''))
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.join('\n');
|
|
58
|
+
throw new Error(detail ? `${e.message}\n${detail}` : e.message, { cause: e });
|
|
59
|
+
}
|
|
11
60
|
}
|
|
12
61
|
|
|
13
62
|
// Local git identity (`git config user.name`), or '' if unavailable. Tolerant.
|
|
@@ -39,6 +88,92 @@ export function ownerHandle(cwd, run = defaultRun, ghRun = defaultGhRun) {
|
|
|
39
88
|
return githubLogin(ghRun) || gitUser(cwd, run);
|
|
40
89
|
}
|
|
41
90
|
|
|
91
|
+
// Detects the branch `changeledger check --commits` should diff against when
|
|
92
|
+
// no base is given: the remote's HEAD if configured, else a local `main` or
|
|
93
|
+
// `master`. Throws with actionable guidance if neither is resolvable.
|
|
94
|
+
export function defaultBaseBranch(repoRoot, run = defaultRun) {
|
|
95
|
+
try {
|
|
96
|
+
const out = run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], repoRoot);
|
|
97
|
+
const name = out.trim().replace(/^origin\//, '');
|
|
98
|
+
if (name) return name;
|
|
99
|
+
} catch {
|
|
100
|
+
// no configured remote HEAD — fall through to local candidates
|
|
101
|
+
}
|
|
102
|
+
for (const candidate of ['main', 'master']) {
|
|
103
|
+
try {
|
|
104
|
+
run(['rev-parse', '--verify', '--quiet', candidate], repoRoot);
|
|
105
|
+
return candidate;
|
|
106
|
+
} catch {
|
|
107
|
+
// candidate branch does not exist locally — try the next one
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
throw new Error(
|
|
111
|
+
'Could not detect a default branch (no origin/HEAD, main, or master); pass one explicitly: changeledger check --commits <base>',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Commits in `range` (e.g. `main..HEAD`): sha, subject, body and whether each is
|
|
116
|
+
// a merge (more than one parent) — the git metadata `check --commits` lints.
|
|
117
|
+
export function commitsInRange(repoRoot, range, run = defaultRun) {
|
|
118
|
+
let out;
|
|
119
|
+
try {
|
|
120
|
+
out = run(['log', range, `--pretty=format:%H${SEP}%P${SEP}%s${SEP}%b${RECORD_SEP}`], repoRoot);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
throw new Error(`git log failed for range "${range}": ${e.message}`);
|
|
123
|
+
}
|
|
124
|
+
return out
|
|
125
|
+
.split(RECORD_SEP)
|
|
126
|
+
.map((record) => record.trim())
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.map((record) => {
|
|
129
|
+
const [sha, parents, subject, body = ''] = record.split(SEP);
|
|
130
|
+
return {
|
|
131
|
+
sha,
|
|
132
|
+
subject,
|
|
133
|
+
body: body.trim(),
|
|
134
|
+
isMerge: parents.trim().split(/\s+/).filter(Boolean).length > 1,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const MARKER_RE = /\[#[^\]\s]+\]$/;
|
|
140
|
+
const ANY_MARKER_RE = /\[#[^\]\s]+\]/g;
|
|
141
|
+
const MULTI_BODY_RE = /^ChangeLedger: (\[#[^\]\s]+\])( \[#[^\]\s]+\])+$/;
|
|
142
|
+
|
|
143
|
+
export function hasCommitMarker(subject) {
|
|
144
|
+
return MARKER_RE.test(subject.trim());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function commitMarkerViolation({ subject, body }) {
|
|
148
|
+
const subjectMarkers = subject.match(ANY_MARKER_RE) ?? [];
|
|
149
|
+
const trimmedBody = body.trim();
|
|
150
|
+
const hasBodyLabel = trimmedBody.includes('ChangeLedger:');
|
|
151
|
+
const validMultiBody = MULTI_BODY_RE.test(trimmedBody);
|
|
152
|
+
|
|
153
|
+
if (subjectMarkers.length > 1) return 'multiple [#id] markers must be in the body';
|
|
154
|
+
if (hasBodyLabel && !validMultiBody) return 'malformed ChangeLedger body';
|
|
155
|
+
if (subjectMarkers.length === 1 && hasCommitMarker(subject) && !hasBodyLabel) return null;
|
|
156
|
+
if (subjectMarkers.length === 0 && validMultiBody) return null;
|
|
157
|
+
if (subjectMarkers.length === 1 && validMultiBody)
|
|
158
|
+
return 'ambiguous [#id] markers in both subject and body';
|
|
159
|
+
return 'missing [#id] marker';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Lints `range`: every non-merge commit must carry a well-formed `[#id]`
|
|
163
|
+
// marker, except `chore(release)` prep commits. Returns only the violations
|
|
164
|
+
// (sha + subject); never throws for a clean range.
|
|
165
|
+
export function lintCommitRange(repoRoot, range, run = defaultRun) {
|
|
166
|
+
const commits = commitsInRange(repoRoot, range, run);
|
|
167
|
+
const violations = [];
|
|
168
|
+
for (const c of commits) {
|
|
169
|
+
if (c.isMerge) continue;
|
|
170
|
+
if (/^chore\(release\):/.test(c.subject)) continue;
|
|
171
|
+
const reason = commitMarkerViolation(c);
|
|
172
|
+
if (reason) violations.push({ sha: c.sha.slice(0, 7), subject: c.subject, reason });
|
|
173
|
+
}
|
|
174
|
+
return violations;
|
|
175
|
+
}
|
|
176
|
+
|
|
42
177
|
export function gitRefs(repoRoot, id, run = defaultRun) {
|
|
43
178
|
const refs = { commits: [], branches: [] };
|
|
44
179
|
if (!id) return refs;
|
package/src/metrics.mjs
CHANGED
|
@@ -70,6 +70,29 @@ function avg(nums) {
|
|
|
70
70
|
return nums.length ? Math.round(nums.reduce((a, b) => a + b, 0) / nums.length) : 0;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// Nearest-rank percentile over an ascending-sorted array; 0 for an empty input
|
|
74
|
+
// so callers never divide by zero or render NaN.
|
|
75
|
+
function percentile(sorted, p) {
|
|
76
|
+
if (!sorted.length) return 0;
|
|
77
|
+
const rank = Math.min(sorted.length, Math.max(1, Math.ceil((p / 100) * sorted.length)));
|
|
78
|
+
return sorted[rank - 1];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Count of `review → in-progress` verdicts (the `fail --retry` outcome) — the
|
|
82
|
+
// same event grammar as lifecycle transitions, filtered to the implicit
|
|
83
|
+
// review-verdict shape so an explicit `status:` move or a validation verdict
|
|
84
|
+
// isn't miscounted as a retry.
|
|
85
|
+
function reviewRetryCount(change) {
|
|
86
|
+
let count = 0;
|
|
87
|
+
for (const line of logBody(change).split('\n')) {
|
|
88
|
+
const event = parseLogEvent(line);
|
|
89
|
+
if (event && !event.explicit && event.from === 'in-review' && event.to === 'in-progress') {
|
|
90
|
+
count++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return count;
|
|
94
|
+
}
|
|
95
|
+
|
|
73
96
|
export function computeMetrics(changes = [], { now } = {}) {
|
|
74
97
|
const nowIso = now ?? '9999-12-31T23:59:59Z';
|
|
75
98
|
|
|
@@ -80,12 +103,17 @@ export function computeMetrics(changes = [], { now } = {}) {
|
|
|
80
103
|
const aging = [];
|
|
81
104
|
let blockedMs = 0;
|
|
82
105
|
const byType = new Map(); // type → { closed, cycles:[] }
|
|
106
|
+
const byOwner = new Map(); // owner → { closed, cycles:[] }
|
|
107
|
+
let reviewRetries = 0;
|
|
83
108
|
|
|
84
109
|
for (const c of changes) {
|
|
85
110
|
const status = c.frontmatter?.status;
|
|
86
111
|
const type = c.frontmatter?.type ?? 'unknown';
|
|
112
|
+
const owner = c.frontmatter?.owner || 'unassigned';
|
|
87
113
|
const created = c.frontmatter?.created;
|
|
88
114
|
|
|
115
|
+
reviewRetries += reviewRetryCount(c);
|
|
116
|
+
|
|
89
117
|
if (ACTIVE.includes(status)) wip[status] = (wip[status] ?? 0) + 1;
|
|
90
118
|
|
|
91
119
|
const segs = statusTimeline(c, nowIso);
|
|
@@ -114,6 +142,11 @@ export function computeMetrics(changes = [], { now } = {}) {
|
|
|
114
142
|
bt.closed += 1;
|
|
115
143
|
bt.cycles.push(cycleMs);
|
|
116
144
|
byType.set(type, bt);
|
|
145
|
+
|
|
146
|
+
const bo = byOwner.get(owner) ?? { closed: 0, cycles: [] };
|
|
147
|
+
bo.closed += 1;
|
|
148
|
+
bo.cycles.push(cycleMs);
|
|
149
|
+
byOwner.set(owner, bo);
|
|
117
150
|
}
|
|
118
151
|
}
|
|
119
152
|
}
|
|
@@ -130,17 +163,26 @@ export function computeMetrics(changes = [], { now } = {}) {
|
|
|
130
163
|
const byTypeArr = [...byType.entries()]
|
|
131
164
|
.map(([type, v]) => ({ type, closed: v.closed, avgCycleMs: avg(v.cycles) }))
|
|
132
165
|
.sort((a, b) => b.closed - a.closed);
|
|
166
|
+
const byOwnerArr = [...byOwner.entries()]
|
|
167
|
+
.map(([owner, v]) => ({ owner, closed: v.closed, avgCycleMs: avg(v.cycles) }))
|
|
168
|
+
.sort((a, b) => b.closed - a.closed);
|
|
169
|
+
const validationWaitMs = timeInStatus.find((t) => t.state === 'in-validation')?.avgMs ?? 0;
|
|
133
170
|
|
|
134
171
|
return {
|
|
135
172
|
count: perChange.length,
|
|
136
173
|
avgCycleMs: avg(cycles),
|
|
137
174
|
medianCycleMs: median(cycles),
|
|
175
|
+
p50CycleMs: percentile(cycles, 50),
|
|
176
|
+
p85CycleMs: percentile(cycles, 85),
|
|
138
177
|
perChange,
|
|
139
178
|
throughput,
|
|
140
179
|
timeInStatus,
|
|
141
180
|
wip,
|
|
142
181
|
aging: aging.sort((a, b) => b.ms - a.ms),
|
|
143
182
|
blockedMs,
|
|
183
|
+
validationWaitMs,
|
|
184
|
+
reviewRetries,
|
|
144
185
|
byType: byTypeArr,
|
|
186
|
+
byOwner: byOwnerArr,
|
|
145
187
|
};
|
|
146
188
|
}
|
package/src/search.mjs
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Deterministic lexical search over changes and specs. No embeddings, no
|
|
2
|
+
// network — a fixed-boost term-frequency score (title x3, headings/CR x2,
|
|
3
|
+
// body x1) with a stable tie-break, so repeated runs are byte-for-byte
|
|
4
|
+
// identical. See change 20260711-103758.
|
|
5
|
+
|
|
6
|
+
const ACCENTS = /[̀-ͯ]/g;
|
|
7
|
+
const CR_HEADING = /^###\s+CR\d+.*$/gm;
|
|
8
|
+
const MARKDOWN_HEADING = /^#{1,6}\s+(.+)$/gm;
|
|
9
|
+
|
|
10
|
+
// Lowercase + strip diacritics so "búsqueda" and "busqueda" match the same term.
|
|
11
|
+
export function normalize(text) {
|
|
12
|
+
return (text ?? '').normalize('NFD').replace(ACCENTS, '').toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function tokenize(query) {
|
|
16
|
+
return normalize(query)
|
|
17
|
+
.split(/\s+/)
|
|
18
|
+
.map((t) => t.trim())
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function countOccurrences(haystackNormalized, term) {
|
|
23
|
+
if (!term) return 0;
|
|
24
|
+
let count = 0;
|
|
25
|
+
let idx = 0;
|
|
26
|
+
for (;;) {
|
|
27
|
+
const found = haystackNormalized.indexOf(term, idx);
|
|
28
|
+
if (found === -1) break;
|
|
29
|
+
count++;
|
|
30
|
+
idx = found + term.length;
|
|
31
|
+
}
|
|
32
|
+
return count;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Locates the first case/accent-insensitive occurrence of `term` in `rawText`
|
|
36
|
+
// and returns a short surrounding snippet, or null when there is no match.
|
|
37
|
+
function findSnippet(rawText, term, span = 40) {
|
|
38
|
+
const normalized = normalize(rawText);
|
|
39
|
+
const idx = normalized.indexOf(term);
|
|
40
|
+
if (idx === -1) return null;
|
|
41
|
+
const start = Math.max(0, idx - span);
|
|
42
|
+
const end = Math.min(rawText.length, idx + term.length + span);
|
|
43
|
+
const prefix = start > 0 ? '…' : '';
|
|
44
|
+
const suffix = end < rawText.length ? '…' : '';
|
|
45
|
+
return `${prefix}${rawText.slice(start, end).replace(/\s+/g, ' ').trim()}${suffix}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Splits a change's stages into a heading/CR bucket (boost x2) and a body
|
|
49
|
+
// bucket (boost x1). CR heading lines are lifted out of the body so they are
|
|
50
|
+
// scored once, at the heading boost, not twice.
|
|
51
|
+
function changeHeadingsAndBody(stages) {
|
|
52
|
+
const headings = [];
|
|
53
|
+
const bodyParts = [];
|
|
54
|
+
for (const stage of stages) {
|
|
55
|
+
headings.push(stage.heading);
|
|
56
|
+
const crHeadings = stage.body.match(CR_HEADING) ?? [];
|
|
57
|
+
headings.push(...crHeadings);
|
|
58
|
+
bodyParts.push(stage.body.replace(CR_HEADING, ''));
|
|
59
|
+
}
|
|
60
|
+
return { headings: headings.join('\n'), body: bodyParts.join('\n\n') };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Same split for a spec's free-form body: its own `#`..`######` headings
|
|
64
|
+
// (boost x2) versus the remaining prose (boost x1).
|
|
65
|
+
function specHeadingsAndBody(body) {
|
|
66
|
+
const headings = [...body.matchAll(MARKDOWN_HEADING)].map((m) => m[1]);
|
|
67
|
+
const stripped = body.replace(MARKDOWN_HEADING, '');
|
|
68
|
+
return { headings: headings.join('\n'), body: stripped };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Builds the searchable corpus from a loaded repo (see repo.mjs `loadRepo`):
|
|
72
|
+
// every change — including archived ones — and every spec, reduced to the
|
|
73
|
+
// fields the scorer needs.
|
|
74
|
+
export function buildCorpus({ changes, specs }) {
|
|
75
|
+
const fromChanges = changes.map((c) => {
|
|
76
|
+
const { headings, body } = changeHeadingsAndBody(c.stages);
|
|
77
|
+
return {
|
|
78
|
+
ref: `#${c.frontmatter.id}`,
|
|
79
|
+
kind: 'change',
|
|
80
|
+
title: c.frontmatter.title ?? '',
|
|
81
|
+
status: c.frontmatter.status ?? null,
|
|
82
|
+
type: c.frontmatter.type ?? null,
|
|
83
|
+
headings,
|
|
84
|
+
body,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const fromSpecs = specs.map((s) => {
|
|
89
|
+
const slug = s.name.replace(/\.md$/, '');
|
|
90
|
+
const { headings, body } = specHeadingsAndBody(s.body);
|
|
91
|
+
return {
|
|
92
|
+
ref: `spec:${slug}`,
|
|
93
|
+
kind: 'spec',
|
|
94
|
+
title: s.frontmatter.title ?? slug,
|
|
95
|
+
status: null,
|
|
96
|
+
type: null,
|
|
97
|
+
headings,
|
|
98
|
+
body,
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return [...fromChanges, ...fromSpecs];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function scoreDocument(doc, terms) {
|
|
106
|
+
const titleN = normalize(doc.title);
|
|
107
|
+
const headingsN = normalize(doc.headings);
|
|
108
|
+
const bodyN = normalize(doc.body);
|
|
109
|
+
let score = 0;
|
|
110
|
+
for (const term of terms) {
|
|
111
|
+
score += countOccurrences(titleN, term) * 3;
|
|
112
|
+
score += countOccurrences(headingsN, term) * 2;
|
|
113
|
+
score += countOccurrences(bodyN, term) * 1;
|
|
114
|
+
}
|
|
115
|
+
return score;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildSnippet(doc, terms) {
|
|
119
|
+
for (const field of [doc.title, doc.headings, doc.body]) {
|
|
120
|
+
for (const term of terms) {
|
|
121
|
+
const snippet = findSnippet(field, term);
|
|
122
|
+
if (snippet) return snippet;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return '';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Scores and ranks `docs` (as produced by `buildCorpus`) against `query`.
|
|
129
|
+
// On an equal score, a spec sorts before a change — specs are the current
|
|
130
|
+
// persistent truth, so that's what an agent should read first. Among equals
|
|
131
|
+
// of the same kind, ties break by `ref` descending so the same repo state
|
|
132
|
+
// always yields the same byte-for-byte output.
|
|
133
|
+
export function searchDocuments(docs, query, { limit = 10, type, status } = {}) {
|
|
134
|
+
const terms = tokenize(query);
|
|
135
|
+
if (!terms.length) return [];
|
|
136
|
+
|
|
137
|
+
const hits = [];
|
|
138
|
+
for (const doc of docs) {
|
|
139
|
+
if (type && doc.type !== type) continue;
|
|
140
|
+
if (status && doc.status !== status) continue;
|
|
141
|
+
const score = scoreDocument(doc, terms);
|
|
142
|
+
if (score <= 0) continue;
|
|
143
|
+
hits.push({
|
|
144
|
+
ref: doc.ref,
|
|
145
|
+
kind: doc.kind,
|
|
146
|
+
title: doc.title,
|
|
147
|
+
type: doc.type,
|
|
148
|
+
status: doc.status,
|
|
149
|
+
score,
|
|
150
|
+
snippet: buildSnippet(doc, terms),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
hits.sort((a, b) => {
|
|
155
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
156
|
+
if (a.kind !== b.kind) return a.kind === 'spec' ? -1 : 1;
|
|
157
|
+
if (a.ref === b.ref) return 0;
|
|
158
|
+
return a.ref < b.ref ? 1 : -1;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return hits.slice(0, limit);
|
|
162
|
+
}
|
package/src/viewer/domain.mjs
CHANGED
|
@@ -407,7 +407,7 @@ export function previewConfigMigration(projects, id, rev) {
|
|
|
407
407
|
return {
|
|
408
408
|
code: 200,
|
|
409
409
|
body: {
|
|
410
|
-
summary: `Config migration
|
|
410
|
+
summary: `Config migration ${migrationResult.fromVersion} → ${SUPPORTED_SCHEMA_VERSION} (dry run)`,
|
|
411
411
|
changes: migrationResult.changes,
|
|
412
412
|
yaml: migrationResult.yaml,
|
|
413
413
|
},
|
|
@@ -468,6 +468,7 @@ const PATCH_ALLOWED = new Set([
|
|
|
468
468
|
'readiness',
|
|
469
469
|
'types',
|
|
470
470
|
'release',
|
|
471
|
+
'git',
|
|
471
472
|
]);
|
|
472
473
|
|
|
473
474
|
const CANONICAL_STATUSES_REQUIRED = new Set([
|
|
@@ -500,6 +501,8 @@ function applyPatch(doc, patch, currentConfig) {
|
|
|
500
501
|
applyReleasePatch(doc, value);
|
|
501
502
|
} else if (key === 'readiness') {
|
|
502
503
|
applyReadinessPatch(doc, value);
|
|
504
|
+
} else if (key === 'git') {
|
|
505
|
+
applyGitPatch(doc, value);
|
|
503
506
|
} else if (key === 'statuses') {
|
|
504
507
|
applyRequiredListPatch(doc, 'statuses', value, CANONICAL_STATUSES_REQUIRED);
|
|
505
508
|
} else if (key === 'stages') {
|
|
@@ -554,3 +557,12 @@ function applyReadinessPatch(doc, readinessPatch) {
|
|
|
554
557
|
doc.setIn(['readiness', 'verification_patterns'], readinessPatch.verification_patterns);
|
|
555
558
|
}
|
|
556
559
|
}
|
|
560
|
+
|
|
561
|
+
function applyGitPatch(doc, gitPatch) {
|
|
562
|
+
if (!gitPatch || typeof gitPatch !== 'object') return;
|
|
563
|
+
if (typeof gitPatch.integration_branch === 'string' && gitPatch.integration_branch.trim()) {
|
|
564
|
+
doc.setIn(['git', 'integration_branch'], gitPatch.integration_branch.trim());
|
|
565
|
+
} else if (gitPatch.integration_branch === null) {
|
|
566
|
+
doc.deleteIn(['git', 'integration_branch']);
|
|
567
|
+
}
|
|
568
|
+
}
|
package/src/viewer/public/app.js
CHANGED
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
tableRow,
|
|
49
49
|
validationPanel,
|
|
50
50
|
} from './view-parts.js';
|
|
51
|
-
import { graphSvg, metricsHtml, specsListHtml } from './view-renderers.js';
|
|
51
|
+
import { graphSvg, metricsHtml, sortSpecsByUpdated, specsListHtml } from './view-renderers.js';
|
|
52
52
|
|
|
53
53
|
export { cssIdent, esc, makeMermaidExpandable, safeHtml } from './security.js';
|
|
54
54
|
export { boardStatuses, isVisible, passesTombstones } from './state.js';
|
|
@@ -156,7 +156,7 @@ function renderChoiceFilter(host, label, choices, selected, toggle, clear, owner
|
|
|
156
156
|
html`<details class="filter-menu">
|
|
157
157
|
<summary class="filter-trigger">
|
|
158
158
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 3.25h12M4.25 8h7.5M6.5 12.75h3"></path></svg>
|
|
159
|
-
<span>${summary}</span>
|
|
159
|
+
<span data-choice-summary>${summary}</span>
|
|
160
160
|
<svg class="filter-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6.25 3.5 3.5 3.5-3.5"></path></svg>
|
|
161
161
|
</summary>
|
|
162
162
|
<div class="filter-popover">
|
|
@@ -169,19 +169,32 @@ function renderChoiceFilter(host, label, choices, selected, toggle, clear, owner
|
|
|
169
169
|
</details>`,
|
|
170
170
|
host,
|
|
171
171
|
);
|
|
172
|
+
const syncSummary = () => {
|
|
173
|
+
host.querySelector('[data-choice-summary]').textContent = choiceFilterSummary(
|
|
174
|
+
label,
|
|
175
|
+
selected,
|
|
176
|
+
owners && state.filters.includeUnassigned,
|
|
177
|
+
);
|
|
178
|
+
};
|
|
172
179
|
host.querySelectorAll('[data-choice]').forEach((input) => {
|
|
173
180
|
input.onchange = () => {
|
|
174
181
|
toggle(input.dataset.choice);
|
|
182
|
+
syncSummary();
|
|
175
183
|
render();
|
|
176
184
|
};
|
|
177
185
|
});
|
|
178
186
|
if (owners)
|
|
179
187
|
host.querySelector('[data-unassigned]').onchange = () => {
|
|
180
188
|
toggleUnassignedOwner();
|
|
189
|
+
syncSummary();
|
|
181
190
|
render();
|
|
182
191
|
};
|
|
183
192
|
host.querySelector('[data-clear]').onclick = () => {
|
|
184
193
|
clear();
|
|
194
|
+
host.querySelectorAll('[data-choice], [data-unassigned]').forEach((input) => {
|
|
195
|
+
input.checked = false;
|
|
196
|
+
});
|
|
197
|
+
syncSummary();
|
|
185
198
|
render();
|
|
186
199
|
};
|
|
187
200
|
}
|
|
@@ -722,8 +735,10 @@ function sortVal(c, key) {
|
|
|
722
735
|
/* Specs view */
|
|
723
736
|
function renderSpecs() {
|
|
724
737
|
const q = state.filters.text.toLowerCase();
|
|
725
|
-
const specs = (
|
|
726
|
-
(
|
|
738
|
+
const specs = sortSpecsByUpdated(
|
|
739
|
+
(state.repo.specs || []).filter(
|
|
740
|
+
(s) => !q || `${s.title} ${(s.tags || []).join(' ')} ${s.body}`.toLowerCase().includes(q),
|
|
741
|
+
),
|
|
727
742
|
);
|
|
728
743
|
litRender(specsListHtml(specs, fmtDateTime), $('#specs'));
|
|
729
744
|
$('#specs')
|
|
@@ -789,8 +804,32 @@ export function handleSpecBodyClick(event, _openSpecByName) {
|
|
|
789
804
|
|
|
790
805
|
const VIEWS = ['board', 'table', 'graph', 'specs', 'metrics', 'projects'];
|
|
791
806
|
|
|
792
|
-
|
|
793
|
-
|
|
807
|
+
// The shared metrics module is dynamic-imported once and cached: the client
|
|
808
|
+
// computes metrics itself, over the filtered set, instead of duplicating
|
|
809
|
+
// `computeMetrics` (20260711-155721 CR2). Served read-only from the CLI's own
|
|
810
|
+
// `src/metrics.mjs` by the router.
|
|
811
|
+
let sharedMetricsModule;
|
|
812
|
+
function loadMetricsModule() {
|
|
813
|
+
sharedMetricsModule ??= import('/shared/metrics.mjs');
|
|
814
|
+
return sharedMetricsModule;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// `computeMetrics` speaks the CLI's native change shape (`{ frontmatter,
|
|
818
|
+
// stages }`); `state.repo.changes` is already flattened for board/table
|
|
819
|
+
// rendering. Adapt back rather than reshaping the shared module's contract,
|
|
820
|
+
// which the server also relies on unchanged.
|
|
821
|
+
function toMetricsChange(c) {
|
|
822
|
+
return {
|
|
823
|
+
frontmatter: { id: c.id, type: c.type, status: c.status, owner: c.owner, created: c.created },
|
|
824
|
+
stages: c.stages,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function renderMetrics() {
|
|
829
|
+
const changes = visibleChanges();
|
|
830
|
+
const { computeMetrics } = await loadMetricsModule();
|
|
831
|
+
const metrics = computeMetrics(changes.map(toMetricsChange), { now: new Date().toISOString() });
|
|
832
|
+
litRender(metricsHtml(metrics, changes.length), $('#metrics'));
|
|
794
833
|
}
|
|
795
834
|
|
|
796
835
|
export function syncViewerShell(root = document, renderContent = true) {
|
|
@@ -825,7 +864,6 @@ let configMode = 'form'; // 'form' | 'raw'
|
|
|
825
864
|
let configDirty = false; // true when form/raw has unsaved edits
|
|
826
865
|
let migrationPreview = null; // null | { summary, changes, yaml } | { already_current }
|
|
827
866
|
|
|
828
|
-
const SUPPORTED_SCHEMA_VERSION = 1;
|
|
829
867
|
// Confirm dialog — uses native <dialog> for proper focus-trap, ESC and backdrop.
|
|
830
868
|
// _confirmImpl is replaceable in tests (JSDOM lacks showModal).
|
|
831
869
|
let _confirmImpl = null;
|
|
@@ -929,8 +967,9 @@ function configSectionTemplate(config, mode, preview) {
|
|
|
929
967
|
}
|
|
930
968
|
|
|
931
969
|
const schema = config.schemaVersion ?? 0;
|
|
932
|
-
const
|
|
933
|
-
const
|
|
970
|
+
const supported = config.supported;
|
|
971
|
+
const futureSch = schema > supported;
|
|
972
|
+
const outdated = schema < supported;
|
|
934
973
|
|
|
935
974
|
return html`<div class="config-section">
|
|
936
975
|
${
|
|
@@ -964,7 +1003,7 @@ function configSectionTemplate(config, mode, preview) {
|
|
|
964
1003
|
: outdated
|
|
965
1004
|
? html`<div class="config-migration-card">
|
|
966
1005
|
<h3>Migration required</h3>
|
|
967
|
-
<p>Config schema ${schema} is outdated. Preview and apply the migration to schema ${
|
|
1006
|
+
<p>Config schema ${schema} is outdated. Preview and apply the migration to schema ${supported} to enable the Form editor.</p>
|
|
968
1007
|
${
|
|
969
1008
|
preview?.already_current
|
|
970
1009
|
? html`<p class="config-migration-ok">Migration already applied.</p>`
|
|
@@ -1055,6 +1094,14 @@ function formEditorTemplate(config) {
|
|
|
1055
1094
|
</label>
|
|
1056
1095
|
</fieldset>
|
|
1057
1096
|
|
|
1097
|
+
<fieldset class="config-group">
|
|
1098
|
+
<legend>Git</legend>
|
|
1099
|
+
<label>Integration branch
|
|
1100
|
+
<input name="integration_branch" .value=${cfg.git?.integration_branch ?? ''} placeholder="Auto-detect" />
|
|
1101
|
+
</label>
|
|
1102
|
+
<p class="config-note">Change branches start from and merge into this branch. Leave empty to auto-detect.</p>
|
|
1103
|
+
</fieldset>
|
|
1104
|
+
|
|
1058
1105
|
<fieldset class="config-group">
|
|
1059
1106
|
<legend>Lifecycle statuses</legend>
|
|
1060
1107
|
<label>Status order (one per line)
|
|
@@ -1207,7 +1254,7 @@ async function openManagedProject(id, { reload = false } = {}) {
|
|
|
1207
1254
|
const structured = await getProjectConfigStructured(id);
|
|
1208
1255
|
managedConfig = { id, ...structured };
|
|
1209
1256
|
// Default to form for current schema, raw for future schema
|
|
1210
|
-
if (structured.schemaVersion >
|
|
1257
|
+
if (structured.schemaVersion > structured.supported) {
|
|
1211
1258
|
configMode = 'raw';
|
|
1212
1259
|
} else {
|
|
1213
1260
|
configMode = 'form';
|
|
@@ -1300,6 +1347,13 @@ export function collectFormPatch(formEl, currentConfig) {
|
|
|
1300
1347
|
if (els.specs_dir && els.specs_dir.value !== (currentConfig.specs_dir ?? '.changeledger/specs')) {
|
|
1301
1348
|
patch.specs_dir = els.specs_dir.value;
|
|
1302
1349
|
}
|
|
1350
|
+
if (els.integration_branch) {
|
|
1351
|
+
const currentBranch = currentConfig.git?.integration_branch ?? '';
|
|
1352
|
+
const proposedBranch = els.integration_branch.value.trim();
|
|
1353
|
+
if (proposedBranch !== currentBranch) {
|
|
1354
|
+
patch.git = { integration_branch: proposedBranch || null };
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1303
1357
|
|
|
1304
1358
|
const statuses = listFromControl(els.statuses);
|
|
1305
1359
|
if (els.statuses && !sameList(statuses, currentConfig.statuses ?? [])) patch.statuses = statuses;
|
|
@@ -1639,6 +1693,17 @@ function bootstrap() {
|
|
|
1639
1693
|
|
|
1640
1694
|
loadProjects();
|
|
1641
1695
|
setInterval(load, 5000);
|
|
1696
|
+
|
|
1697
|
+
// The topbar wraps to multiple rows at content-dependent widths, so the
|
|
1698
|
+
// CSS fallback constant cannot bound the projects panels; track the real
|
|
1699
|
+
// rendered height instead.
|
|
1700
|
+
const topbar = document.querySelector('.topbar');
|
|
1701
|
+
if (topbar && typeof ResizeObserver === 'function') {
|
|
1702
|
+
const syncHeaderHeight = () =>
|
|
1703
|
+
document.documentElement.style.setProperty('--header-height', `${topbar.offsetHeight}px`);
|
|
1704
|
+
new ResizeObserver(syncHeaderHeight).observe(topbar);
|
|
1705
|
+
syncHeaderHeight();
|
|
1706
|
+
}
|
|
1642
1707
|
}
|
|
1643
1708
|
|
|
1644
1709
|
export function closeFilterMenusOnOutsideClick(menus, target) {
|