changeledger 0.10.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/bin/changeledger.mjs +1 -1
- package/package.json +1 -1
- package/src/commands/check.mjs +1 -1
- package/src/commands/commit.mjs +7 -2
- package/src/config-migration.mjs +25 -2
- package/src/git.mjs +30 -12
- package/src/viewer/domain.mjs +12 -0
- package/src/viewer/public/app.js +15 -0
- package/templates/config.yml +5 -1
- package/templates/contract/implement.md +11 -9
package/bin/changeledger.mjs
CHANGED
|
@@ -201,7 +201,7 @@ program
|
|
|
201
201
|
'',
|
|
202
202
|
'When --id is omitted, the single in-progress change is used automatically;',
|
|
203
203
|
'zero or multiple in-progress changes require --id explicitly. Repeat --id',
|
|
204
|
-
'for a multi-id
|
|
204
|
+
'for a multi-id commit: the clean subject gets a ChangeLedger: [#A] [#B] body.',
|
|
205
205
|
'',
|
|
206
206
|
'Examples:',
|
|
207
207
|
' changeledger commit -m "feat(cli): add helper"',
|
package/package.json
CHANGED
package/src/commands/check.mjs
CHANGED
|
@@ -41,7 +41,7 @@ function checkCommits(args, commitsIdx, cwd, output, json) {
|
|
|
41
41
|
|
|
42
42
|
const errors = violations.map((v) => ({
|
|
43
43
|
file: '(commits)',
|
|
44
|
-
message: `${v.sha}
|
|
44
|
+
message: `${v.sha} ${v.reason}: "${v.subject}"`,
|
|
45
45
|
}));
|
|
46
46
|
|
|
47
47
|
if (json) {
|
package/src/commands/commit.mjs
CHANGED
|
@@ -33,7 +33,12 @@ export function commit({ message, ids = [] } = {}, cwd = process.cwd(), run = mu
|
|
|
33
33
|
resolvedIds = [active[0].frontmatter.id];
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
const
|
|
37
|
-
|
|
36
|
+
const markers = resolvedIds.map((id) => `[#${id}]`).join(' ');
|
|
37
|
+
const multiple = resolvedIds.length > 1;
|
|
38
|
+
const subject = multiple ? message : `${message} ${markers}`;
|
|
39
|
+
const args = multiple
|
|
40
|
+
? ['commit', '-m', subject, '-m', `ChangeLedger: ${markers}`]
|
|
41
|
+
: ['commit', '-m', subject];
|
|
42
|
+
run(args, repo.repoRoot);
|
|
38
43
|
return subject;
|
|
39
44
|
}
|
package/src/config-migration.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { parseDocument } from 'yaml';
|
|
|
4
4
|
import { writeFileAtomic } from './atomic-write.mjs';
|
|
5
5
|
import { templatesDir } from './paths.mjs';
|
|
6
6
|
|
|
7
|
-
export const SUPPORTED_SCHEMA_VERSION =
|
|
7
|
+
export const SUPPORTED_SCHEMA_VERSION = 3;
|
|
8
8
|
|
|
9
9
|
const CANONICAL_STATUSES = [
|
|
10
10
|
'draft',
|
|
@@ -76,7 +76,8 @@ export function buildMigration(originalText) {
|
|
|
76
76
|
if (current < 1) {
|
|
77
77
|
migrateToV1(doc, config, changes);
|
|
78
78
|
}
|
|
79
|
-
migrateToV2(doc, config, changes);
|
|
79
|
+
if (current < 2) migrateToV2(doc, config, changes);
|
|
80
|
+
if (current < 3) migrateToV3(doc, config, changes);
|
|
80
81
|
|
|
81
82
|
// No line wrapping and no flow padding: keeps untouched flow sequences
|
|
82
83
|
// (statuses, stages) byte-identical to their common written form.
|
|
@@ -164,6 +165,28 @@ function migrateToV2(doc, config, changes) {
|
|
|
164
165
|
}
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
// 2 → 3: expose Git integration without inventing a repository-specific branch.
|
|
169
|
+
// Existing git settings and comments remain byte-for-byte owned by the source doc.
|
|
170
|
+
function migrateToV3(doc, config, changes) {
|
|
171
|
+
if (!Object.hasOwn(config, 'git')) {
|
|
172
|
+
setBlankGitSection(doc);
|
|
173
|
+
changes.push('added git section');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function setBlankGitSection(doc) {
|
|
178
|
+
const gitNode = parseDocument('git:\n integration_branch:\n').get('git', true);
|
|
179
|
+
doc.set('git', gitNode);
|
|
180
|
+
const gitPair = doc.contents.items.find(
|
|
181
|
+
(pair) => pair.key?.value === 'git' || pair.key === 'git',
|
|
182
|
+
);
|
|
183
|
+
if (!gitPair) return;
|
|
184
|
+
if (typeof gitPair.key === 'string') gitPair.key = doc.createNode(gitPair.key);
|
|
185
|
+
gitPair.key.spaceBefore = true;
|
|
186
|
+
gitPair.key.commentBefore =
|
|
187
|
+
' Git integration: change branches start from and merge into this branch';
|
|
188
|
+
}
|
|
189
|
+
|
|
167
190
|
// Apply migration to a file (or dry-run). Returns summary string.
|
|
168
191
|
export function applyMigration(configFile, { dryRun = false } = {}) {
|
|
169
192
|
let original;
|
package/src/git.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
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
|
// Repo-location env vars git itself exports while running a hook (e.g. this
|
|
10
11
|
// project's own pre-commit). Left inherited, a child `git` call would silently
|
|
@@ -111,36 +112,53 @@ export function defaultBaseBranch(repoRoot, run = defaultRun) {
|
|
|
111
112
|
);
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
// Commits in `range` (e.g. `main..HEAD`): sha, subject and whether each is
|
|
115
|
-
// merge (more than one parent) — the git metadata `check --commits` lints.
|
|
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.
|
|
116
117
|
export function commitsInRange(repoRoot, range, run = defaultRun) {
|
|
117
118
|
let out;
|
|
118
119
|
try {
|
|
119
|
-
out = run(['log', range, `--pretty=format:%H${SEP}%P${SEP}%s`], repoRoot);
|
|
120
|
+
out = run(['log', range, `--pretty=format:%H${SEP}%P${SEP}%s${SEP}%b${RECORD_SEP}`], repoRoot);
|
|
120
121
|
} catch (e) {
|
|
121
122
|
throw new Error(`git log failed for range "${range}": ${e.message}`);
|
|
122
123
|
}
|
|
123
124
|
return out
|
|
124
|
-
.split(
|
|
125
|
+
.split(RECORD_SEP)
|
|
126
|
+
.map((record) => record.trim())
|
|
125
127
|
.filter(Boolean)
|
|
126
|
-
.map((
|
|
127
|
-
const [sha, parents, subject] =
|
|
128
|
+
.map((record) => {
|
|
129
|
+
const [sha, parents, subject, body = ''] = record.split(SEP);
|
|
128
130
|
return {
|
|
129
131
|
sha,
|
|
130
132
|
subject,
|
|
133
|
+
body: body.trim(),
|
|
131
134
|
isMerge: parents.trim().split(/\s+/).filter(Boolean).length > 1,
|
|
132
135
|
};
|
|
133
136
|
});
|
|
134
137
|
}
|
|
135
138
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
139
|
+
const MARKER_RE = /\[#[^\]\s]+\]$/;
|
|
140
|
+
const ANY_MARKER_RE = /\[#[^\]\s]+\]/g;
|
|
141
|
+
const MULTI_BODY_RE = /^ChangeLedger: (\[#[^\]\s]+\])( \[#[^\]\s]+\])+$/;
|
|
142
|
+
|
|
140
143
|
export function hasCommitMarker(subject) {
|
|
141
144
|
return MARKER_RE.test(subject.trim());
|
|
142
145
|
}
|
|
143
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
|
+
|
|
144
162
|
// Lints `range`: every non-merge commit must carry a well-formed `[#id]`
|
|
145
163
|
// marker, except `chore(release)` prep commits. Returns only the violations
|
|
146
164
|
// (sha + subject); never throws for a clean range.
|
|
@@ -150,8 +168,8 @@ export function lintCommitRange(repoRoot, range, run = defaultRun) {
|
|
|
150
168
|
for (const c of commits) {
|
|
151
169
|
if (c.isMerge) continue;
|
|
152
170
|
if (/^chore\(release\):/.test(c.subject)) continue;
|
|
153
|
-
|
|
154
|
-
|
|
171
|
+
const reason = commitMarkerViolation(c);
|
|
172
|
+
if (reason) violations.push({ sha: c.sha.slice(0, 7), subject: c.subject, reason });
|
|
155
173
|
}
|
|
156
174
|
return violations;
|
|
157
175
|
}
|
package/src/viewer/domain.mjs
CHANGED
|
@@ -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
|
@@ -1094,6 +1094,14 @@ function formEditorTemplate(config) {
|
|
|
1094
1094
|
</label>
|
|
1095
1095
|
</fieldset>
|
|
1096
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
|
+
|
|
1097
1105
|
<fieldset class="config-group">
|
|
1098
1106
|
<legend>Lifecycle statuses</legend>
|
|
1099
1107
|
<label>Status order (one per line)
|
|
@@ -1339,6 +1347,13 @@ export function collectFormPatch(formEl, currentConfig) {
|
|
|
1339
1347
|
if (els.specs_dir && els.specs_dir.value !== (currentConfig.specs_dir ?? '.changeledger/specs')) {
|
|
1340
1348
|
patch.specs_dir = els.specs_dir.value;
|
|
1341
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
|
+
}
|
|
1342
1357
|
|
|
1343
1358
|
const statuses = listFromControl(els.statuses);
|
|
1344
1359
|
if (els.statuses && !sameList(statuses, currentConfig.statuses ?? [])) patch.statuses = statuses;
|
package/templates/config.yml
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
# Schema version — used to detect and migrate outdated configurations.
|
|
5
5
|
# Run `changeledger config migrate --dry-run` to inspect available migrations.
|
|
6
|
-
schema_version:
|
|
6
|
+
schema_version: 3
|
|
7
7
|
|
|
8
8
|
# Language for generated documentation CONTENT. Structure (headings, keys, enums)
|
|
9
9
|
# is always English. See `changeledger context spec`.
|
|
@@ -35,6 +35,10 @@ release:
|
|
|
35
35
|
changes_dir: .changeledger/changes
|
|
36
36
|
specs_dir: .changeledger/specs
|
|
37
37
|
|
|
38
|
+
# Git integration: change branches start from and merge into this branch
|
|
39
|
+
git:
|
|
40
|
+
integration_branch:
|
|
41
|
+
|
|
38
42
|
# Valid lifecycle statuses (order = progress)
|
|
39
43
|
statuses: [draft, approved, in-progress, in-review, in-validation, blocked, done, discarded]
|
|
40
44
|
|
|
@@ -37,15 +37,17 @@ Commit messages use the canonical shape:
|
|
|
37
37
|
feat(scope): description [#20260629-234939]
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
Use the actual change id and the appropriate conventional type.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
40
|
+
Use the actual change id and the appropriate conventional type. One change keeps
|
|
41
|
+
its marker at the end of the subject. Referencing more than one change keeps the
|
|
42
|
+
subject clean and puts separate brackets in one canonical body line:
|
|
43
|
+
`ChangeLedger: [#A] [#B]` — never a comma list in one bracket. Ledger
|
|
44
|
+
meta-commits (status, review, log, archive) carry markers like any other commit;
|
|
45
|
+
only merge commits and `chore(release)` prep are exempt. `changeledger commit -m
|
|
46
|
+
"<type>(<scope>): <desc>" [--id <id>]...` composes and creates the commit for
|
|
47
|
+
you: it resolves the single `in-progress` change automatically when `--id` is
|
|
48
|
+
omitted, and fails without committing if that is ambiguous or the subject isn't
|
|
49
|
+
conventional. `changeledger check --commits [<base>]` lints `<base>..HEAD` for
|
|
50
|
+
either canonical marker placement with the same
|
|
49
51
|
exemptions — run it before requesting review. If shared files make a combined commit
|
|
50
52
|
unavoidable, record it in Log or the handoff and name every change sharing the
|
|
51
53
|
surface.
|