@bongos/core 1.19.510 → 1.19.511
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/.bongos-core.json +20 -20
- package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +16 -2
- package/docs/module-api-changelog.md +2 -0
- package/modules/copy-desk/proposals.js +13 -0
- package/modules/copy-desk/routes/copy-desk.js +9 -2
- package/modules/copy-desk/tests/copy_proposals.mjs +41 -0
- package/modules/lifecycle/conflict-resolve.js +59 -3
- package/modules/public-landing/public/assets/cosmos.css +3 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/copy-apply.js +54 -4
- package/scripts/gds/git-merge-regen.js +18 -0
- package/src/module-api.js +1 -1
- package/tests/conflict_resolve.mjs +118 -4
- package/tests/git_merge_regen.mjs +128 -2
- package/tests/projects_hub.mjs +13 -0
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.511",
|
|
6
|
+
"core_contract": "1.19.511",
|
|
7
|
+
"source_commit": "3a3d1626dee489511d9d444d6cd3c78620a5fffd",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-03T18:
|
|
9
|
+
"built_at": "2026-09-03T18:50:51.590Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 428,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 2403,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "61291b79de3b99190857d7ed49c124e41ac765c57c9ad792cf8cfa1e62f002cb",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1672,7 +1672,7 @@
|
|
|
1672
1672
|
{
|
|
1673
1673
|
"path": "docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md",
|
|
1674
1674
|
"mode": "0000644",
|
|
1675
|
-
"sha256": "
|
|
1675
|
+
"sha256": "ab2970017b4776ee7d0be1ee22933d86123f57b8935a1f1bb0844e5f367fae07"
|
|
1676
1676
|
},
|
|
1677
1677
|
{
|
|
1678
1678
|
"path": "docs/adr/0234-idea-routing-capture-time-promotion-landing-matrix-homeless-inbox.md",
|
|
@@ -2582,7 +2582,7 @@
|
|
|
2582
2582
|
{
|
|
2583
2583
|
"path": "docs/module-api-changelog.md",
|
|
2584
2584
|
"mode": "0000644",
|
|
2585
|
-
"sha256": "
|
|
2585
|
+
"sha256": "2e35f3f9c7c2c961b5c8274d20202664564cb33d59b046ef4d2e190c15c05afe"
|
|
2586
2586
|
},
|
|
2587
2587
|
{
|
|
2588
2588
|
"path": "docs/modules-contract.md",
|
|
@@ -3697,7 +3697,7 @@
|
|
|
3697
3697
|
{
|
|
3698
3698
|
"path": "modules/copy-desk/proposals.js",
|
|
3699
3699
|
"mode": "0000644",
|
|
3700
|
-
"sha256": "
|
|
3700
|
+
"sha256": "de674674cf2f80fb36569cad67be4f1ba3a94e938b690b8f1d0c96446df346bf"
|
|
3701
3701
|
},
|
|
3702
3702
|
{
|
|
3703
3703
|
"path": "modules/copy-desk/registry.js",
|
|
@@ -3707,7 +3707,7 @@
|
|
|
3707
3707
|
{
|
|
3708
3708
|
"path": "modules/copy-desk/routes/copy-desk.js",
|
|
3709
3709
|
"mode": "0000644",
|
|
3710
|
-
"sha256": "
|
|
3710
|
+
"sha256": "7231f021bb49bd78cb247a6d8e8a0f6e9256ade52f2f9bb1438b54b6ddf400f8"
|
|
3711
3711
|
},
|
|
3712
3712
|
{
|
|
3713
3713
|
"path": "modules/copy-desk/tests/copy_flags.mjs",
|
|
@@ -3722,7 +3722,7 @@
|
|
|
3722
3722
|
{
|
|
3723
3723
|
"path": "modules/copy-desk/tests/copy_proposals.mjs",
|
|
3724
3724
|
"mode": "0000644",
|
|
3725
|
-
"sha256": "
|
|
3725
|
+
"sha256": "e435042dea0436e0f4fa7f2a95b16b8647ff0723290741e26776e76c9d1c6973"
|
|
3726
3726
|
},
|
|
3727
3727
|
{
|
|
3728
3728
|
"path": "modules/copy-desk/tests/copy_queue.mjs",
|
|
@@ -5237,7 +5237,7 @@
|
|
|
5237
5237
|
{
|
|
5238
5238
|
"path": "modules/lifecycle/conflict-resolve.js",
|
|
5239
5239
|
"mode": "0000644",
|
|
5240
|
-
"sha256": "
|
|
5240
|
+
"sha256": "1a281a4233b3805550a05ed6bd38b163ab18db658d48675f055b90d2ba722a16"
|
|
5241
5241
|
},
|
|
5242
5242
|
{
|
|
5243
5243
|
"path": "modules/lifecycle/criterion-suggest.js",
|
|
@@ -6237,7 +6237,7 @@
|
|
|
6237
6237
|
{
|
|
6238
6238
|
"path": "modules/public-landing/public/assets/cosmos.css",
|
|
6239
6239
|
"mode": "0000644",
|
|
6240
|
-
"sha256": "
|
|
6240
|
+
"sha256": "bb2fa6d0a1c87aeb3e08038faf1d3ae0f06a1a41f9fe6d83f357146bc7e73045"
|
|
6241
6241
|
},
|
|
6242
6242
|
{
|
|
6243
6243
|
"path": "modules/public-landing/public/assets/verro-medallion.png",
|
|
@@ -7242,12 +7242,12 @@
|
|
|
7242
7242
|
{
|
|
7243
7243
|
"path": "package-lock.json",
|
|
7244
7244
|
"mode": "0000644",
|
|
7245
|
-
"sha256": "
|
|
7245
|
+
"sha256": "0c5b45784b5275f6c58d456a275a390b6ef111efbcc1fb7653750120a8a84981"
|
|
7246
7246
|
},
|
|
7247
7247
|
{
|
|
7248
7248
|
"path": "package.json",
|
|
7249
7249
|
"mode": "0000644",
|
|
7250
|
-
"sha256": "
|
|
7250
|
+
"sha256": "2605b735d7f5d07c4eb993212d4901e7043138693d4d2bb56492f9fb18c0baf0"
|
|
7251
7251
|
},
|
|
7252
7252
|
{
|
|
7253
7253
|
"path": "public-docs/index.html",
|
|
@@ -7532,7 +7532,7 @@
|
|
|
7532
7532
|
{
|
|
7533
7533
|
"path": "scripts/gds/copy-apply.js",
|
|
7534
7534
|
"mode": "0000644",
|
|
7535
|
-
"sha256": "
|
|
7535
|
+
"sha256": "05326c8016794742acc10a95ae0f1c7b630581231a7f0f86aef449a8c17c2c40"
|
|
7536
7536
|
},
|
|
7537
7537
|
{
|
|
7538
7538
|
"path": "scripts/gds/copy-inventory.js",
|
|
@@ -7762,7 +7762,7 @@
|
|
|
7762
7762
|
{
|
|
7763
7763
|
"path": "scripts/gds/git-merge-regen.js",
|
|
7764
7764
|
"mode": "0000644",
|
|
7765
|
-
"sha256": "
|
|
7765
|
+
"sha256": "f6f9c93faeb589d07105027655e42bd842d8d766119812b16bd534d58aa50816"
|
|
7766
7766
|
},
|
|
7767
7767
|
{
|
|
7768
7768
|
"path": "scripts/gds/go-live.js",
|
|
@@ -8917,7 +8917,7 @@
|
|
|
8917
8917
|
{
|
|
8918
8918
|
"path": "src/module-api.js",
|
|
8919
8919
|
"mode": "0000644",
|
|
8920
|
-
"sha256": "
|
|
8920
|
+
"sha256": "ac752bde3af7292c3b4a1857045652f7d3badf0d1bbdf02cf3fd47f2ac8c40fc"
|
|
8921
8921
|
},
|
|
8922
8922
|
{
|
|
8923
8923
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9512,7 +9512,7 @@
|
|
|
9512
9512
|
{
|
|
9513
9513
|
"path": "tests/conflict_resolve.mjs",
|
|
9514
9514
|
"mode": "0000644",
|
|
9515
|
-
"sha256": "
|
|
9515
|
+
"sha256": "7ad7be450b9350e7888f075944888ddf2a0e2d57a23568f410a9ff17bd340dec"
|
|
9516
9516
|
},
|
|
9517
9517
|
{
|
|
9518
9518
|
"path": "tests/connections_api.mjs",
|
|
@@ -9907,7 +9907,7 @@
|
|
|
9907
9907
|
{
|
|
9908
9908
|
"path": "tests/git_merge_regen.mjs",
|
|
9909
9909
|
"mode": "0000644",
|
|
9910
|
-
"sha256": "
|
|
9910
|
+
"sha256": "f4e7ab5eec2d13404080c408c0a70819c4b398b38fa61474b9c4cc57069d119b"
|
|
9911
9911
|
},
|
|
9912
9912
|
{
|
|
9913
9913
|
"path": "tests/github_push_ancestry.mjs",
|
|
@@ -11112,7 +11112,7 @@
|
|
|
11112
11112
|
{
|
|
11113
11113
|
"path": "tests/projects_hub.mjs",
|
|
11114
11114
|
"mode": "0000644",
|
|
11115
|
-
"sha256": "
|
|
11115
|
+
"sha256": "712e02763f7e9f3e6f4acc17a5a757a4aee11e9234909dcf50923b85679e09e6"
|
|
11116
11116
|
},
|
|
11117
11117
|
{
|
|
11118
11118
|
"path": "tests/projects_hub_pre_uat.mjs",
|
|
@@ -114,7 +114,12 @@ afterwards buys a port widening and a failure mode for a string.
|
|
|
114
114
|
So the bare call, `node scripts/gds/copy-apply.js`, acts on **the task the caller is holding**.
|
|
115
115
|
Holding no claim is a named refusal that says to claim it first. The command may only run under a
|
|
116
116
|
claim — that is the whole feature — and a claim already names a task, so the constraint and the
|
|
117
|
-
ergonomics are the same fact. `--task <id>` remains for someone holding several
|
|
117
|
+
ergonomics are the same fact. `--task <id>` remains for someone holding several — and it is *checked*
|
|
118
|
+
against the same claim list the bare call reads, refusing `claim_not_held` for a task the caller does
|
|
119
|
+
not hold. It shipped unchecked (task 1003493 fixed it): a rule stated in this ADR, in the module's
|
|
120
|
+
`CLAUDE.md` and in the script's own header, enforced on one of its two paths, is the kind of claim
|
|
121
|
+
that rots. The one path with no claim to prove is `--patch-file`, a local JSON patch used by the
|
|
122
|
+
tests, which lands nothing a proposal task authorised.
|
|
118
123
|
|
|
119
124
|
### 7. Proposing is open to every rank, and lands in `backlog`
|
|
120
125
|
|
|
@@ -130,7 +135,16 @@ before anyone can claim it. Landing it `ready` would let anyone with an opinion
|
|
|
130
135
|
## Consequences
|
|
131
136
|
|
|
132
137
|
- **The write surface of `copy-desk` is now three verbs, not two**, and `tests/copy_no_cms.mjs` says so
|
|
133
|
-
in a diff a reviewer can see — which is exactly what R02 built that list for.
|
|
138
|
+
in a diff a reviewer can see — which is exactly what R02 built that list for. To be exact about what
|
|
139
|
+
that test proves (task 1003493): it pins that no route **in this module** can write a rendered
|
|
140
|
+
string, which is narrower than "no route can write a rendered string to production directly". That
|
|
141
|
+
is the right scope for the feature — the module is the write surface this ADR adds — but the wider
|
|
142
|
+
claim is not something a module-scoped test can carry, and reading it as if it were is how a
|
|
143
|
+
no-CMS guarantee quietly becomes an assumption.
|
|
144
|
+
- **A proposal lands only in the version being BUILT.** `proposals.resolveTargetVersion` returns the
|
|
145
|
+
`building` version or `null`, and the route refuses `no_open_version` — there is deliberately no
|
|
146
|
+
"first version in the list" fallback (task 1003493), because that row can be a shipped version, and
|
|
147
|
+
a proposal landing on sealed history is worse than a proposal that was not accepted.
|
|
134
148
|
- **`scripts/gds/copy-apply.js` is the only thing in this feature that writes a surface file**, it is a
|
|
135
149
|
command rather than a route, and the test asserts no file in the module's request path imports it.
|
|
136
150
|
Naming it is fine and required: the task tells its claimer the exact line to run.
|
|
@@ -1457,5 +1457,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1457
1457
|
landed since 1.19.508 with no explicit bump. run 33790542026. (task 1002620)
|
|
1458
1458
|
1.19.510 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1459
1459
|
landed since 1.19.509 with no explicit bump. run 33791237400. (task 1002620)
|
|
1460
|
+
1.19.511 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1461
|
+
landed since 1.19.510 with no explicit bump. run 33792868990. (task 1002620)
|
|
1460
1462
|
---------------------------------------------------------------------------
|
|
1461
1463
|
```
|
|
@@ -260,8 +260,21 @@ function proposalSourceRef(patch) {
|
|
|
260
260
|
return `copy-proposal-${patch.string_id}-${hash.toString(36)}`;
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
// Which version a proposal's task lands in — the version currently BEING BUILT,
|
|
264
|
+
// and nothing else (task 1003493). There is deliberately no fallback: the route
|
|
265
|
+
// used to take `versions[0]` when nothing was building, which is whatever row
|
|
266
|
+
// the query ordered first and may be a shipped version — sealed history that
|
|
267
|
+
// cannot take new tasks. A proposal that lands on a closed version is worse than
|
|
268
|
+
// one that was not accepted, so the caller's answer here is a refusal, not a
|
|
269
|
+
// guess. Pure, so the rule is pinned in copy_proposals.mjs rather than only
|
|
270
|
+
// reachable through a live route.
|
|
271
|
+
function resolveTargetVersion(versions) {
|
|
272
|
+
return (versions || []).find((v) => v && v.status === 'building') || null;
|
|
273
|
+
}
|
|
274
|
+
|
|
263
275
|
module.exports = {
|
|
264
276
|
PROPOSAL_SCHEMA,
|
|
277
|
+
resolveTargetVersion,
|
|
265
278
|
FENCE,
|
|
266
279
|
PLACEHOLDER,
|
|
267
280
|
MIN_WHY_CHARS,
|
|
@@ -391,9 +391,16 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
391
391
|
// how every other unattributed create-vector picks. `createTask` then
|
|
392
392
|
// defaults the goal to that version's catch-all, so a copy change is
|
|
393
393
|
// ordinary work on the board rather than a special case.
|
|
394
|
+
// No `|| versions[0]` fallback (task 1003493) — see resolveTargetVersion:
|
|
395
|
+
// with nothing building, the first row back is whatever the query ordered
|
|
396
|
+
// first, possibly a shipped version, which is sealed history.
|
|
394
397
|
const versions = await lifecycle.listVersions();
|
|
395
|
-
const building =
|
|
396
|
-
if (!building)
|
|
398
|
+
const building = proposals.resolveTargetVersion(versions);
|
|
399
|
+
if (!building) {
|
|
400
|
+
return res.fail('no_open_version', 503, {
|
|
401
|
+
what_this_means: 'No version is currently being built, so there is nowhere for a copy change to land. Nothing was recorded.',
|
|
402
|
+
});
|
|
403
|
+
}
|
|
397
404
|
|
|
398
405
|
const sourceRef = proposals.proposalSourceRef(patch);
|
|
399
406
|
const existing = lifecycle.findTaskBySourceRef
|
|
@@ -275,5 +275,46 @@ await test('the applier re-checks the placeholder arithmetic against the real so
|
|
|
275
275
|
assert.equal(r.code, 'placeholder_mismatch');
|
|
276
276
|
});
|
|
277
277
|
|
|
278
|
+
// --- task 1003493: the two rules the grader found stated but not enforced ----
|
|
279
|
+
|
|
280
|
+
await test('--task proves the claim it names instead of standing in for one', () => {
|
|
281
|
+
const held = [{ task_id: '1003114' }, { task_id: 1003493 }];
|
|
282
|
+
|
|
283
|
+
const wrong = applier.claimGateFor(1003112, held);
|
|
284
|
+
assert.equal(wrong.ok, false);
|
|
285
|
+
assert.equal(wrong.code, 'claim_not_held');
|
|
286
|
+
assert.deepEqual(wrong.detail.held, ['1003114', '1003493'], 'the refusal names the claims you DO hold');
|
|
287
|
+
assert.ok(/claim.js 1003112/.test(wrong.detail.what_this_means), 'and the line that fixes it');
|
|
288
|
+
|
|
289
|
+
// A held claim passes, and an id given as a number matches a claim stored as
|
|
290
|
+
// a string — the ids arrive as both, and a type mismatch here would refuse
|
|
291
|
+
// the legitimate call, which is the failure mode that gets a check removed.
|
|
292
|
+
assert.equal(applier.claimGateFor(1003493, held).ok, true);
|
|
293
|
+
assert.equal(applier.claimGateFor('1003114', held).ok, true);
|
|
294
|
+
|
|
295
|
+
// Holding nothing at all cannot pass, whatever id is named.
|
|
296
|
+
assert.equal(applier.claimGateFor(1003114, []).ok, false);
|
|
297
|
+
assert.equal(applier.claimGateFor(1003114, undefined).ok, false);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await test('a proposal refuses rather than landing on an arbitrary version', () => {
|
|
301
|
+
const shipped = { id: 'BONGOS-V0', status: 'shipped' };
|
|
302
|
+
const building = { id: 'BONGOS-V1', status: 'building' };
|
|
303
|
+
const planning = { id: 'BONGOS-V2', status: 'planning' };
|
|
304
|
+
|
|
305
|
+
// The version being built wins wherever it sits in the list.
|
|
306
|
+
assert.equal(proposals.resolveTargetVersion([shipped, building, planning]).id, 'BONGOS-V1');
|
|
307
|
+
assert.equal(proposals.resolveTargetVersion([building]).id, 'BONGOS-V1');
|
|
308
|
+
|
|
309
|
+
// With nothing building there is NO fallback — not the first row, not a
|
|
310
|
+
// shipped version, not a planning one. null is what makes the route's
|
|
311
|
+
// no_open_version refusal reachable.
|
|
312
|
+
assert.equal(proposals.resolveTargetVersion([shipped, planning]), null);
|
|
313
|
+
assert.equal(proposals.resolveTargetVersion([shipped]), null);
|
|
314
|
+
assert.equal(proposals.resolveTargetVersion([]), null);
|
|
315
|
+
assert.equal(proposals.resolveTargetVersion(null), null);
|
|
316
|
+
assert.equal(proposals.resolveTargetVersion([null, undefined]), null);
|
|
317
|
+
});
|
|
318
|
+
|
|
278
319
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
279
320
|
process.exit(failed ? 1 : 0);
|
|
@@ -138,11 +138,13 @@ async function hasConflictMarkers(gitIn) {
|
|
|
138
138
|
// resolveBranchConflicts — attempt to auto-resolve a generated-file / append-index
|
|
139
139
|
// merge conflict on `branch` against origin/main, server-side. Returns a structured
|
|
140
140
|
// result (never throws for the expected outcomes; throws only on misconfiguration):
|
|
141
|
-
// { resolved: true, head_sha, branch }
|
|
141
|
+
// { resolved: true, head_sha, branch, regenerated } — merged + pushed
|
|
142
142
|
// { resolved: false, skipped: '<why>' [, conflicted: [...] ] } — left as-is
|
|
143
143
|
// skipped reasons: disabled | unconfigured | already_up_to_date | clone_failed |
|
|
144
144
|
// fetch_failed | checkout_failed | markers_remain | push_failed |
|
|
145
|
-
// unresolvable_conflict (with conflicted[]).
|
|
145
|
+
// unresolvable_conflict (with conflicted[]) | generator_failed | commit_failed.
|
|
146
|
+
// `regenerated` says whether step 7b had to commit post-merge generator drift on top
|
|
147
|
+
// of the merge (task 1003546) — head_sha is that commit when it did.
|
|
146
148
|
async function resolveBranchConflicts({ branch } = {}, deps = {}) {
|
|
147
149
|
// Kill-switch: deps.disabled wins for tests; otherwise the env flag (matches the
|
|
148
150
|
// publish-reconciler's PUBLISH_RECONCILER_DISABLED idiom).
|
|
@@ -163,6 +165,7 @@ async function resolveBranchConflicts({ branch } = {}, deps = {}) {
|
|
|
163
165
|
const exec = deps.exec || defaultExec;
|
|
164
166
|
const repoRoot = deps.repoRoot || REPO_ROOT;
|
|
165
167
|
const register = deps.registerMergeDriver || registerMergeDriver;
|
|
168
|
+
const runGenerators = deps.runGenerators || defaultRunGenerators;
|
|
166
169
|
const tmp = (deps.mkdtemp || (() => fs.mkdtempSync(path.join(os.tmpdir(), 'otb-resolve-'))))();
|
|
167
170
|
const work = path.join(tmp, 'work');
|
|
168
171
|
const git = (args, opts) => exec(['-C', work, ...args], opts);
|
|
@@ -225,6 +228,49 @@ async function resolveBranchConflicts({ branch } = {}, deps = {}) {
|
|
|
225
228
|
// not our case (mergeIfGreen reported a conflict), so report a no-op.
|
|
226
229
|
const after = (await git(['rev-parse', 'HEAD'])).stdout.trim();
|
|
227
230
|
if (after === before) return { resolved: false, skipped: 'already_up_to_date' };
|
|
231
|
+
let head = after;
|
|
232
|
+
|
|
233
|
+
// 7b. REGENERATE ON THE MERGED TREE — the step that makes step 6 trustworthy
|
|
234
|
+
// (task 1003546). The `otb-regen` driver is invoked BY git, DURING the merge,
|
|
235
|
+
// and every artifact it rebuilds is produced by SCANNING THE WORKING TREE
|
|
236
|
+
// (copy-inventory.js, gen-repo-map.js, gen-file-map.js). At driver time that
|
|
237
|
+
// tree is still the PRE-merge (ours) tree, so the driver writes an artifact
|
|
238
|
+
// describing the branch, git then completes the merge underneath it, and the
|
|
239
|
+
// result is a clean merge — exit 0, no unmerged paths, no markers — carrying a
|
|
240
|
+
// generated file that matches NEITHER side nor the merged tree. Nothing
|
|
241
|
+
// downstream can catch it: it is only detectable by regenerating again.
|
|
242
|
+
//
|
|
243
|
+
// Measured on the real repo (task 1003546): two branches each shifting lines
|
|
244
|
+
// in modules/hall-ui/public/task-detail.js merge clean, and the merge commit's
|
|
245
|
+
// docs/copy-registry.json is byte-identical to ours — the other side's line
|
|
246
|
+
// numbers are silently DISCARDED (`"line": 464` where the merged tree says
|
|
247
|
+
// 466). That is the PR #536 / task 1003544 strand exactly. Task 1003543 had
|
|
248
|
+
// given these files the driver, which converted the loud CONFLICT they used to
|
|
249
|
+
// throw into this silent staleness — strictly harder to see.
|
|
250
|
+
//
|
|
251
|
+
// A line-numbered or aggregate-carrying artifact cannot be resolved from the
|
|
252
|
+
// three merge sides by construction; the only correct value is a fresh scan of
|
|
253
|
+
// the FINAL tree. So we rebuild here, after the merge commit exists, and
|
|
254
|
+
// commit any drift on top. Ordinary case is a no-op (the driver's guess was
|
|
255
|
+
// right, or nothing generated moved) and we push the merge alone.
|
|
256
|
+
const gen = await runGenerators({ work });
|
|
257
|
+
if (gen && gen.code !== 0) {
|
|
258
|
+
// A generator hard-error / a missing hand-written note (gen-file-map exit 2).
|
|
259
|
+
// We cannot certify the merged tree, and pushing an uncertifiable one is what
|
|
260
|
+
// stranded #536 — leave it, exactly the module's "any failure is a no-op for
|
|
261
|
+
// the strand" posture. The reconciler's attempt cap still escalates to a human.
|
|
262
|
+
return { resolved: false, skipped: 'generator_failed' };
|
|
263
|
+
}
|
|
264
|
+
const drift = (await git(['status', '--porcelain'])).stdout.trim();
|
|
265
|
+
if (drift) {
|
|
266
|
+
await git(['add', '-A']);
|
|
267
|
+
const c = await git([
|
|
268
|
+
'commit', '-m',
|
|
269
|
+
'chore(gds): regenerate generated artifacts from the merged tree (conflict-resolve self-heal, task 1003546)',
|
|
270
|
+
]);
|
|
271
|
+
if (c.code !== 0) return { resolved: false, skipped: 'commit_failed' };
|
|
272
|
+
head = (await git(['rev-parse', 'HEAD'])).stdout.trim();
|
|
273
|
+
}
|
|
228
274
|
|
|
229
275
|
// 8. Belt-and-braces: a clean `git merge` already implies no unmerged paths and
|
|
230
276
|
// no markers, but assert both before we push anything.
|
|
@@ -238,7 +284,7 @@ async function resolveBranchConflicts({ branch } = {}, deps = {}) {
|
|
|
238
284
|
const p = await git(['push', authUrl, `HEAD:refs/heads/${branch}`]);
|
|
239
285
|
if (p.code !== 0) return { resolved: false, skipped: 'push_failed' };
|
|
240
286
|
|
|
241
|
-
return { resolved: true, head_sha:
|
|
287
|
+
return { resolved: true, head_sha: head, branch, regenerated: Boolean(drift) };
|
|
242
288
|
} finally {
|
|
243
289
|
try {
|
|
244
290
|
(deps.rmtmp || ((d) => fs.rmSync(d, { recursive: true, force: true })))(tmp);
|
|
@@ -255,6 +301,16 @@ const GENERATOR_SCRIPTS = [
|
|
|
255
301
|
'scripts/gds/gen-repo-map.js',
|
|
256
302
|
'scripts/gds/gen-file-map.js',
|
|
257
303
|
'scripts/gds/gen-session-index.js',
|
|
304
|
+
// task 1003546: copy-inventory.js writes docs/copy-registry.json +
|
|
305
|
+
// docs/copy-inventory.md, and its rows carry a `line` per copy string — so ANY
|
|
306
|
+
// merge that shifts lines in a scanned UI file re-stales it and `unit`
|
|
307
|
+
// (tests/copy_inventory.mjs) goes red on the merge ref. Its absence here did not
|
|
308
|
+
// merely fail to heal that: with the other three producing no diff, the caller
|
|
309
|
+
// returns `no_regen_diff`, whose documented meaning is "a REAL check failure, not
|
|
310
|
+
// staleness" — so copy-registry staleness was MISDIAGNOSED as a genuine failure,
|
|
311
|
+
// burned the attempt cap, and announceStrand flagged the task needs_rebase
|
|
312
|
+
// (PR #536 / task 1003544).
|
|
313
|
+
'scripts/gds/copy-inventory.js',
|
|
258
314
|
];
|
|
259
315
|
|
|
260
316
|
// Run the CLONE's generator scripts in WRITE mode against the merged working tree.
|
|
@@ -1501,7 +1501,9 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
1501
1501
|
#view-new #wizSignin:hover{background:color-mix(in srgb,var(--ink) 88%,var(--bg));}
|
|
1502
1502
|
/* the error alert: the error ink between two hairlines, no plate */
|
|
1503
1503
|
#view-new .alert{background:none;border:0;border-top:1px solid var(--err);border-bottom:1px solid var(--err);border-radius:0;padding:12px 0;font-weight:400;}
|
|
1504
|
-
|
|
1504
|
+
/* one fold for the whole wizard: 840 is where the page's bands and ledger stack
|
|
1505
|
+
(cosmos's createSplit collapse), so the rail folds with them, not before them */
|
|
1506
|
+
@media (max-width:840px){
|
|
1505
1507
|
#view-new .steps{grid-template-columns:repeat(3,1fr);gap:18px 10px;margin-bottom:30px;}
|
|
1506
1508
|
#view-new .steps .snum{font-size:18px;}
|
|
1507
1509
|
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.511",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.511",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.511",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
//
|
|
33
33
|
// Run: node scripts/gds/copy-apply.js (the task you are HOLDING — the normal call)
|
|
34
34
|
// node scripts/gds/copy-apply.js --dry-run (say what would change, write nothing)
|
|
35
|
-
// node scripts/gds/copy-apply.js --task 1234 (a
|
|
35
|
+
// node scripts/gds/copy-apply.js --task 1234 (a claim you hold, when you hold more than one)
|
|
36
36
|
// node scripts/gds/copy-apply.js --patch-file p.json (a local patch — used by the tests)
|
|
37
37
|
//
|
|
38
38
|
// WHY THE BARE FORM DEFAULTS TO YOUR CLAIM rather than taking an id. The task a
|
|
@@ -43,6 +43,13 @@
|
|
|
43
43
|
// task. So the bare call reads the claim, and holding no claim is a refusal that
|
|
44
44
|
// says to go and claim the task — the methodology's own rule, enforced by the
|
|
45
45
|
// command instead of restated in its output.
|
|
46
|
+
//
|
|
47
|
+
// --task DOES NOT OPT OUT OF THAT (task 1003493). It selects which of your
|
|
48
|
+
// claims to act under when you hold several; it is checked against the same
|
|
49
|
+
// /me claim list the bare call reads, and refuses `claim_not_held` for a task
|
|
50
|
+
// you do not hold. The one path that never touches a task is --patch-file,
|
|
51
|
+
// which reads a local JSON patch and is the tests' entry point — it lands
|
|
52
|
+
// nothing a proposal task authorised, so there is no claim for it to prove.
|
|
46
53
|
|
|
47
54
|
const fs = require('node:fs');
|
|
48
55
|
const path = require('node:path');
|
|
@@ -208,13 +215,50 @@ function refuse(result) {
|
|
|
208
215
|
return 2;
|
|
209
216
|
}
|
|
210
217
|
|
|
218
|
+
// Every claim this session holds. One reader, so the bare call and --task are
|
|
219
|
+
// answered from the same source of truth — the shape of bug where a rule is
|
|
220
|
+
// enforced on one path and merely stated on the other (task 1003493).
|
|
221
|
+
async function activeClaims() {
|
|
222
|
+
const me = await apiCall('GET', '/api/gds/me');
|
|
223
|
+
if (!me.ok) return { ok: false, code: 'no_session', detail: { status: me.status, run: 'node scripts/gds/setup.js' } };
|
|
224
|
+
return { ok: true, claims: me.data.active_claims || (me.data.active_claim ? [me.data.active_claim] : []) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --task <id> names a task; this proves the caller actually HOLDS it. Without
|
|
228
|
+
// this the header's "may only be run under a claim" was true of the bare call
|
|
229
|
+
// and false of the named one — a rule stated three times in prose (here, ADR
|
|
230
|
+
// 0233, modules/copy-desk/CLAUDE.md) and enforced on one of two paths. The
|
|
231
|
+
// refusal names the claims you do hold, because the usual cause is a typo'd id.
|
|
232
|
+
// Pure so the gate is testable without a session: given the id asked for and
|
|
233
|
+
// the claims held, either accept or produce the named refusal.
|
|
234
|
+
function claimGateFor(taskId, claims) {
|
|
235
|
+
const wanted = String(taskId);
|
|
236
|
+
const held = (claims || []).map((c) => String(c.task_id));
|
|
237
|
+
if (held.includes(wanted)) return { ok: true, taskId: wanted };
|
|
238
|
+
return {
|
|
239
|
+
ok: false,
|
|
240
|
+
code: 'claim_not_held',
|
|
241
|
+
detail: {
|
|
242
|
+
task: wanted,
|
|
243
|
+
held,
|
|
244
|
+
what_this_means: `A copy change lands under a claim like every other change, and you do not hold one on #${wanted}. Claim it first (node scripts/gds/claim.js ${wanted}), then run this again.`,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function claimOnTask(taskId) {
|
|
250
|
+
const held = await activeClaims();
|
|
251
|
+
if (!held.ok) return held;
|
|
252
|
+
return claimGateFor(taskId, held.claims);
|
|
253
|
+
}
|
|
254
|
+
|
|
211
255
|
// The task this session is holding. Returns a named refusal rather than an id
|
|
212
256
|
// when there is nothing to act under — "claim it first" is the correct answer,
|
|
213
257
|
// not an inconvenience to route around.
|
|
214
258
|
async function claimedTaskId() {
|
|
215
|
-
const
|
|
216
|
-
if (!
|
|
217
|
-
const claims =
|
|
259
|
+
const held = await activeClaims();
|
|
260
|
+
if (!held.ok) return held;
|
|
261
|
+
const claims = held.claims;
|
|
218
262
|
if (!claims.length) {
|
|
219
263
|
return {
|
|
220
264
|
ok: false,
|
|
@@ -271,6 +315,11 @@ async function main() {
|
|
|
271
315
|
if (!held.ok) return cliExit(refuse(held));
|
|
272
316
|
taskId = held.taskId;
|
|
273
317
|
console.log(`copy-apply: the task you are holding — #${taskId}`);
|
|
318
|
+
} else if (taskId) {
|
|
319
|
+
// --task picks WHICH claim to act under; it never substitutes for holding one.
|
|
320
|
+
const held = await claimOnTask(taskId);
|
|
321
|
+
if (!held.ok) return cliExit(refuse(held));
|
|
322
|
+
taskId = held.taskId;
|
|
274
323
|
}
|
|
275
324
|
|
|
276
325
|
const loaded = await loadPatch({ taskId, patchFile });
|
|
@@ -335,4 +384,5 @@ module.exports = {
|
|
|
335
384
|
locateOccurrence,
|
|
336
385
|
reinjectInterpolations,
|
|
337
386
|
applyPatchToSource,
|
|
387
|
+
claimGateFor,
|
|
338
388
|
};
|
|
@@ -20,6 +20,24 @@
|
|
|
20
20
|
// parts of nested docs; it succeeds ONLY if no conflict markers remain. A genuine
|
|
21
21
|
// hand-written conflict falls through to a normal git conflict — it never invents a
|
|
22
22
|
// resolution or clobbers a real edit.
|
|
23
|
+
//
|
|
24
|
+
// ⚠ THE DRIVER'S OUTPUT IS PROVISIONAL FOR ANY TREE-SCANNING ARTIFACT (task 1003546).
|
|
25
|
+
// Every rule below that rebuilds by SCANNING THE WORKING TREE — copy-inventory.js's
|
|
26
|
+
// registry pair, gen-repo-map's map + nested blocks, gen-file-map's sections — is
|
|
27
|
+
// invoked BY git, DURING the merge, when that tree is still the PRE-merge (ours)
|
|
28
|
+
// tree. So the driver writes an artifact describing the branch, git completes the
|
|
29
|
+
// merge underneath it, and the merge lands CLEAN (exit 0, no unmerged paths, no
|
|
30
|
+
// markers) carrying content that matches neither side nor the merged tree. Measured:
|
|
31
|
+
// two branches each shifting lines in one scanned UI file merge clean and the merge
|
|
32
|
+
// commit's docs/copy-registry.json is byte-identical to ours, silently dropping the
|
|
33
|
+
// other side's line numbers (PR #536 / task 1003544).
|
|
34
|
+
// It is still worth running — it clears the markers so the merge completes at all —
|
|
35
|
+
// but it is NOT the authority. Whoever OWNS the merge must regenerate afterwards, on
|
|
36
|
+
// the final tree, and commit the drift: the server does it in conflict-resolve.js
|
|
37
|
+
// step 7b, and a post-merge `resolveInWorktree` call (ship-preflight-steps.js) is
|
|
38
|
+
// correct for the same reason — by then git has written every cleanly-merged path.
|
|
39
|
+
// Do NOT add a rule here and assume the merge is thereby correct; a line-numbered or
|
|
40
|
+
// aggregate-carrying artifact cannot be resolved from the three merge sides at all.
|
|
23
41
|
|
|
24
42
|
const fs = require('node:fs');
|
|
25
43
|
const os = require('node:os');
|
package/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.511'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -52,9 +52,21 @@ function mkExec(scn = {}) {
|
|
|
52
52
|
if (cmd === 'checkout') return ret(scn.checkout ?? 0);
|
|
53
53
|
if (cmd === 'rev-parse') {
|
|
54
54
|
revCount += 1;
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// 1st = the pre-merge tip, 2nd = the merge commit, 3rd = the post-merge
|
|
56
|
+
// regeneration commit (task 1003546 step 7b), when there was drift to commit.
|
|
57
|
+
if (revCount === 1) return { code: 0, stdout: `${scn.before ?? 'aaaaaaa'}\n`, stderr: '' };
|
|
58
|
+
if (revCount === 2) return { code: 0, stdout: `${scn.after ?? 'bbbbbbb'}\n`, stderr: '' };
|
|
59
|
+
return { code: 0, stdout: `${scn.regenSha ?? scn.after ?? 'bbbbbbb'}\n`, stderr: '' };
|
|
57
60
|
}
|
|
61
|
+
if (cmd === 'status') {
|
|
62
|
+
// --porcelain after step 7b's regeneration: a non-empty body = the merged tree
|
|
63
|
+
// disagreed with the generated artifacts the merge produced. Default OFF so the
|
|
64
|
+
// pre-1003546 cases keep asserting the plain merge-and-push path.
|
|
65
|
+
const drift = scn.drift ?? false;
|
|
66
|
+
return { code: 0, stdout: drift ? ' M docs/copy-registry.json\n' : '', stderr: '' };
|
|
67
|
+
}
|
|
68
|
+
if (cmd === 'add') return ret(0);
|
|
69
|
+
if (cmd === 'commit') return ret(scn.commit ?? 0);
|
|
58
70
|
if (cmd === 'merge' && a[1] === '--abort') return ret(0);
|
|
59
71
|
if (cmd === 'merge') {
|
|
60
72
|
if (scn.merge === 'conflict') { mergeOutcome = 'conflict'; return { code: 1, stdout: 'CONFLICT (content)', stderr: '' }; }
|
|
@@ -79,8 +91,13 @@ function mkExec(scn = {}) {
|
|
|
79
91
|
function mkDeps(scn = {}, over = {}) {
|
|
80
92
|
const reg = [];
|
|
81
93
|
const exec = mkExec(scn);
|
|
94
|
+
const genCalls = [];
|
|
82
95
|
return {
|
|
83
96
|
exec,
|
|
97
|
+
// task 1003546: resolveBranchConflicts regenerates on the MERGED tree before it
|
|
98
|
+
// pushes, so it takes the same runGenerators seam refreshStaleGenerated does.
|
|
99
|
+
// Stubbed for every case — the real one would spawn node against a fake clone.
|
|
100
|
+
runGenerators: over.runGenerators || (async (opts) => { genCalls.push(opts); return { code: scn.genCode ?? 0 }; }),
|
|
84
101
|
githubPush: {
|
|
85
102
|
isConfigured: () => (over.configured === undefined ? true : over.configured),
|
|
86
103
|
isSafeBranch: (b) => (over.safeBranch === undefined ? (typeof b === 'string' && b.length > 0 && !b.startsWith('-')) : over.safeBranch),
|
|
@@ -94,6 +111,7 @@ function mkDeps(scn = {}, over = {}) {
|
|
|
94
111
|
rmtmp: () => {},
|
|
95
112
|
_exec: exec,
|
|
96
113
|
_reg: reg,
|
|
114
|
+
_gen: genCalls,
|
|
97
115
|
};
|
|
98
116
|
}
|
|
99
117
|
|
|
@@ -377,7 +395,11 @@ test('1915 defaultRunGenerators strips *_INSTANCE_ROOT from the child env and pi
|
|
|
377
395
|
const leaked = Object.keys(call.opts.env || {}).filter((k) => /_INSTANCE_ROOT$/.test(k));
|
|
378
396
|
assert.deepEqual(leaked, [], 'no *_INSTANCE_ROOT key survives into the generator child env');
|
|
379
397
|
assert.ok(call.opts.env.PATH, 'benign env (PATH) is preserved so node still resolves');
|
|
380
|
-
|
|
398
|
+
// Separator-normalised: defaultRunGenerators builds this with path.join, which
|
|
399
|
+
// emits backslashes on win32 — the bare startsWith made this assertion fail on
|
|
400
|
+
// every Windows builder for a reason unrelated to what it tests (task 1003546).
|
|
401
|
+
assert.ok(String(call.argv[0]).replace(/\\/g, '/').startsWith('/tmp/clone-xyz'),
|
|
402
|
+
'the generator script path is under the clone');
|
|
381
403
|
}
|
|
382
404
|
} finally {
|
|
383
405
|
if (prev.g === undefined) delete process.env.GDS_INSTANCE_ROOT; else process.env.GDS_INSTANCE_ROOT = prev.g;
|
|
@@ -455,8 +477,100 @@ test('1915 defaultRunGenerators reports the worst child exit code (0 all-clean;
|
|
|
455
477
|
assert.equal(badGen.code, 2, 'a single failing generator surfaces its non-zero code');
|
|
456
478
|
});
|
|
457
479
|
|
|
480
|
+
// ── task 1003546: the merged tree is regenerated BEFORE the push ────────────────
|
|
481
|
+
// The `otb-regen` driver runs DURING the merge and rebuilds by scanning the working
|
|
482
|
+
// tree, which at that moment is still the PRE-merge tree. So a merge can come out
|
|
483
|
+
// clean — exit 0, no unmerged paths, no markers — carrying a generated artifact that
|
|
484
|
+
// describes neither side nor the merged tree, and nothing downstream can see it.
|
|
485
|
+
// resolveBranchConflicts must therefore regenerate on the FINAL tree and commit the
|
|
486
|
+
// drift before pushing (PR #536 / task 1003544: the registry kept the branch's line
|
|
487
|
+
// numbers and `unit` stayed red through the heal).
|
|
488
|
+
|
|
489
|
+
test('1003546 clean merge → generators run on the merged tree, drift committed, head_sha is the regen commit', async () => {
|
|
490
|
+
const deps = mkDeps({ merge: 'clean', before: 'oldsha1', after: 'mergesha', drift: true, regenSha: 'regensha' });
|
|
491
|
+
const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr1' }, deps);
|
|
492
|
+
assert.equal(r.resolved, true);
|
|
493
|
+
assert.equal(deps._gen.length, 1, 'generators ran exactly once against the merged tree');
|
|
494
|
+
assert.ok(ran(deps._exec, 'commit'), 'post-merge generator drift is committed');
|
|
495
|
+
assert.equal(r.regenerated, true, 'the result reports that a regeneration was needed');
|
|
496
|
+
assert.equal(r.head_sha, 'regensha', 'head_sha is the regen commit, not the bare merge commit');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('1003546 the regeneration happens AFTER the merge and its commit BEFORE the push (ordering is the whole point)', async () => {
|
|
500
|
+
const order = [];
|
|
501
|
+
const deps = mkDeps({ merge: 'clean', drift: true });
|
|
502
|
+
const baseExec = deps.exec;
|
|
503
|
+
deps.exec = async (args) => {
|
|
504
|
+
const a = args[0] === '-C' ? args.slice(2) : args;
|
|
505
|
+
if (['merge', 'commit', 'push'].includes(a[0])) order.push(a[0]);
|
|
506
|
+
return baseExec(args);
|
|
507
|
+
};
|
|
508
|
+
deps.runGenerators = async () => { order.push('__generators__'); return { code: 0 }; };
|
|
509
|
+
const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr2' }, deps);
|
|
510
|
+
assert.equal(r.resolved, true);
|
|
511
|
+
assert.deepEqual(order, ['merge', '__generators__', 'commit', 'push'],
|
|
512
|
+
'a pre-merge regeneration (or a push before the regen commit) is exactly the bug');
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test('1003546 no post-merge drift → the merge alone is pushed, nothing extra committed', async () => {
|
|
516
|
+
const deps = mkDeps({ merge: 'clean', after: 'mergesha', drift: false });
|
|
517
|
+
const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr3' }, deps);
|
|
518
|
+
assert.equal(r.resolved, true);
|
|
519
|
+
assert.equal(r.regenerated, false);
|
|
520
|
+
assert.equal(r.head_sha, 'mergesha', 'the merge commit is the head when regen is a no-op');
|
|
521
|
+
assert.ok(!ran(deps._exec, 'commit'), 'nothing is committed when the merge was already correct');
|
|
522
|
+
assert.ok(ran(deps._exec, 'push'), 'the resolved branch still lands');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test('1003546 a failing generator never pushes an uncertifiable merged tree', async () => {
|
|
526
|
+
const deps = mkDeps({ merge: 'clean', drift: true, genCode: 2 });
|
|
527
|
+
const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr4' }, deps);
|
|
528
|
+
assert.equal(r.resolved, false);
|
|
529
|
+
assert.equal(r.skipped, 'generator_failed');
|
|
530
|
+
assert.ok(!ran(deps._exec, 'push'), 'a tree we cannot certify is left for a human, never pushed');
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('1003546 a failing post-merge commit is reported, not pushed', async () => {
|
|
534
|
+
const deps = mkDeps({ merge: 'clean', drift: true, commit: 1 });
|
|
535
|
+
const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr5' }, deps);
|
|
536
|
+
assert.equal(r.resolved, false);
|
|
537
|
+
assert.equal(r.skipped, 'commit_failed');
|
|
538
|
+
assert.ok(!ran(deps._exec, 'push'), 'no push without the regeneration commit');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// THE INVARIANT THAT WOULD HAVE CAUGHT #536 ON DAY ONE. Two hand-maintained lists
|
|
542
|
+
// name the same artifacts: REGENERABLE (what the merge driver claims to heal) and
|
|
543
|
+
// GENERATOR_SCRIPTS (what the server actually re-runs). copy-inventory.js was added
|
|
544
|
+
// to the first and not the second, so the copy registry was "covered" by the driver
|
|
545
|
+
// and regenerated by nobody. Pin them as one set, via each artifact's PRODUCER.
|
|
546
|
+
test('1003546 every REGENERABLE artifact has a producer, and every producer is in GENERATOR_SCRIPTS', async () => {
|
|
547
|
+
const regen = require('../scripts/gds/git-merge-regen.js');
|
|
548
|
+
const G = require('../scripts/gds/gen-repo-map.js');
|
|
549
|
+
const CI = require('../scripts/gds/copy-inventory.js');
|
|
550
|
+
|
|
551
|
+
// Derived from each generator's own exports where it publishes them, so this map
|
|
552
|
+
// cannot silently drift from the generator it names.
|
|
553
|
+
const PRODUCED_BY = new Map([
|
|
554
|
+
['docs/repo-map.md', 'scripts/gds/gen-repo-map.js'],
|
|
555
|
+
['docs/file-map.md', 'scripts/gds/gen-file-map.js'],
|
|
556
|
+
['docs/session-log-index.md', 'scripts/gds/gen-session-index.js'],
|
|
557
|
+
[CI.REGISTRY_REL, 'scripts/gds/copy-inventory.js'],
|
|
558
|
+
[CI.REPORT_REL, 'scripts/gds/copy-inventory.js'],
|
|
559
|
+
...G.NESTED_DIRS.map((d) => [`${d}/CLAUDE.md`, 'scripts/gds/gen-repo-map.js']),
|
|
560
|
+
]);
|
|
561
|
+
|
|
562
|
+
for (const p of regen.REGENERABLE) {
|
|
563
|
+
const producer = PRODUCED_BY.get(p);
|
|
564
|
+
assert.ok(producer, `REGENERABLE knows ${p} but this test has no producer for it — name the generator that writes it`);
|
|
565
|
+
assert.ok(
|
|
566
|
+
cr.GENERATOR_SCRIPTS.includes(producer),
|
|
567
|
+
`${p} is healed by the merge driver but ${producer} is not in GENERATOR_SCRIPTS, so the server never re-runs it on the merged tree (the task 1003544 strand)`
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
458
572
|
// Drain the microtask queue, then report + exit with the right code.
|
|
459
573
|
setTimeout(() => {
|
|
460
|
-
console.log(`\nconflict-resolve (task 1385 + 1915) tests: ${passed} passed, ${failed} failed`);
|
|
574
|
+
console.log(`\nconflict-resolve (task 1385 + 1915 + 1003546) tests: ${passed} passed, ${failed} failed`);
|
|
461
575
|
process.exit(failed > 0 ? 1 : 0);
|
|
462
576
|
}, 100);
|
|
@@ -22,6 +22,12 @@
|
|
|
22
22
|
// 9. END-TO-END for docs/copy-registry.json: two branches that each regenerate
|
|
23
23
|
// it differently merge without conflict markers via the real .gitattributes +
|
|
24
24
|
// git-config wiring, and the result equals a fresh regeneration.
|
|
25
|
+
// 10. REGRESSION (task 1003546) — the driver runs MID-merge, so a tree-scanning
|
|
26
|
+
// artifact heals to the PRE-merge tree: two branches shifting line numbers in
|
|
27
|
+
// the same scanned UI file merge CLEAN and leave the registry STALE. Pins why
|
|
28
|
+
// the merge's OWNER must regenerate afterwards (conflict-resolve.js step 7b).
|
|
29
|
+
// Needs a real clone (the driver's __dirname must BE the merging tree), so it
|
|
30
|
+
// skips where the environment cannot host that.
|
|
25
31
|
//
|
|
26
32
|
// Run: node tests/git_merge_regen.mjs
|
|
27
33
|
|
|
@@ -135,7 +141,15 @@ function withBlockBody(src, body) {
|
|
|
135
141
|
try {
|
|
136
142
|
g('init', '-q');
|
|
137
143
|
g('config', 'user.email', 't@t'); g('config', 'user.name', 't');
|
|
138
|
-
|
|
144
|
+
// The fixture repo carries none of the repo's eol rules, so on a Windows box
|
|
145
|
+
// with the usual global core.autocrlf=true git would check the merge result out
|
|
146
|
+
// as CRLF while the generators render LF — a byte comparison below would fail on
|
|
147
|
+
// line endings alone. Pin it so the fixture is platform-neutral (task 1003546).
|
|
148
|
+
g('config', 'core.autocrlf', 'false');
|
|
149
|
+
// Forward slashes: `git config` strips backslashes from the value, so a win32
|
|
150
|
+
// path.join() driver path reaches node with its separators gone and the driver
|
|
151
|
+
// never loads — this fixture was silently dead on Windows (task 1003546).
|
|
152
|
+
g('config', 'merge.otb-regen.driver', `node ${driver.replace(/\\/g, '/')} %O %A %B %P`);
|
|
139
153
|
fs.writeFileSync(path.join(tmp, '.gitattributes'), 'docs/repo-map.md merge=otb-regen\n');
|
|
140
154
|
fs.mkdirSync(path.join(tmp, 'docs'));
|
|
141
155
|
writeMap('base\n'); g('add', '-A'); g('commit', '-qm', 'base');
|
|
@@ -213,7 +227,15 @@ function withBlockBody(src, body) {
|
|
|
213
227
|
try {
|
|
214
228
|
g('init', '-q');
|
|
215
229
|
g('config', 'user.email', 't@t'); g('config', 'user.name', 't');
|
|
216
|
-
|
|
230
|
+
// The fixture repo carries none of the repo's eol rules, so on a Windows box
|
|
231
|
+
// with the usual global core.autocrlf=true git would check the merge result out
|
|
232
|
+
// as CRLF while the generators render LF — a byte comparison below would fail on
|
|
233
|
+
// line endings alone. Pin it so the fixture is platform-neutral (task 1003546).
|
|
234
|
+
g('config', 'core.autocrlf', 'false');
|
|
235
|
+
// Forward slashes: `git config` strips backslashes from the value, so a win32
|
|
236
|
+
// path.join() driver path reaches node with its separators gone and the driver
|
|
237
|
+
// never loads — this fixture was silently dead on Windows (task 1003546).
|
|
238
|
+
g('config', 'merge.otb-regen.driver', `node ${driver.replace(/\\/g, '/')} %O %A %B %P`);
|
|
217
239
|
fs.writeFileSync(path.join(tmp, '.gitattributes'), 'docs/copy-registry.json merge=otb-regen\n');
|
|
218
240
|
fs.mkdirSync(path.join(tmp, 'docs'));
|
|
219
241
|
writeReg('{"schema":0}\n'); g('add', '-A'); g('commit', '-qm', 'base');
|
|
@@ -235,4 +257,108 @@ function withBlockBody(src, body) {
|
|
|
235
257
|
}
|
|
236
258
|
}
|
|
237
259
|
|
|
260
|
+
// ── 10) REGRESSION (task 1003546): the driver runs MID-merge, so a tree-scanning ──
|
|
261
|
+
// artifact heals to the PRE-merge tree and the merge lands clean but STALE.
|
|
262
|
+
//
|
|
263
|
+
// Cases 8 + 9 prove the copy-registry rule regenerates and that a real merge comes
|
|
264
|
+
// out marker-free — but their fixture repos contain no scanned SOURCE, so the scan
|
|
265
|
+
// returns the same bytes whichever tree it reads and they are blind to WHEN it runs.
|
|
266
|
+
// This case supplies the missing half: two branches that each shift line numbers in
|
|
267
|
+
// the SAME scanned UI file, in a real clone where the driver's own `__dirname` is
|
|
268
|
+
// the merging tree. The merge exits 0 with no unmerged paths and no markers — and
|
|
269
|
+
// keeps OUR registry verbatim, silently discarding the other side's line numbers.
|
|
270
|
+
// Measured before the fix: `"line": 464` where the merged tree says 466; that is the
|
|
271
|
+
// PR #536 / task 1003544 strand, and the reason conflict-resolve.js regenerates
|
|
272
|
+
// AFTER the merge (step 7b) instead of trusting the driver.
|
|
273
|
+
//
|
|
274
|
+
// It SKIPS rather than fails where the environment cannot host the fixture (no local
|
|
275
|
+
// clone, the scanned file moved, or a base whose registry is already stale) — a
|
|
276
|
+
// characterization test must never red for a reason it cannot attribute.
|
|
277
|
+
{
|
|
278
|
+
const SCANNED = 'modules/hall-ui/public/task-detail.js';
|
|
279
|
+
const REGEN_PATHS = ['docs/copy-registry.json', 'docs/copy-inventory.md'];
|
|
280
|
+
// Short clone path: a deep temp dir blows Windows' MAX_PATH on the clone's
|
|
281
|
+
// .git/objects/info/commit-graphs entries.
|
|
282
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-regen-e2e-'));
|
|
283
|
+
const work = path.join(root, 'w');
|
|
284
|
+
const g = (...a) => spawnSync('git', a, { cwd: work, encoding: 'utf8' });
|
|
285
|
+
const gen = (...a) => spawnSync('node', [path.join(work, 'scripts/gds/copy-inventory.js'), ...a],
|
|
286
|
+
{ cwd: work, encoding: 'utf8' });
|
|
287
|
+
// "Does regeneration change tracked CONTENT?" — `git diff` applies .gitattributes
|
|
288
|
+
// (`*.json text eol=lf`), so it ignores the worktree line endings that make a raw
|
|
289
|
+
// byte comparison stale on a Windows checkout.
|
|
290
|
+
const regenDirty = () => { gen(); return g('diff', '--quiet', '--', ...REGEN_PATHS).status !== 0; };
|
|
291
|
+
const scannedFile = () => path.join(work, SCANNED);
|
|
292
|
+
|
|
293
|
+
let skip = null;
|
|
294
|
+
try {
|
|
295
|
+
if (spawnSync('git', ['clone', '--local', '-q', repo, work], { encoding: 'utf8' }).status !== 0) {
|
|
296
|
+
skip = 'the repo could not be locally cloned';
|
|
297
|
+
}
|
|
298
|
+
if (!skip) {
|
|
299
|
+
g('config', 'user.email', 't@t');
|
|
300
|
+
g('config', 'user.name', 't');
|
|
301
|
+
g('config', 'core.autocrlf', 'false');
|
|
302
|
+
g('config', 'merge.otb-regen.name', 'regen');
|
|
303
|
+
g('config', 'merge.otb-regen.driver',
|
|
304
|
+
`node ${path.join(work, 'scripts/gds/git-merge-regen.js').replace(/\\/g, '/')} %O %A %B %P`);
|
|
305
|
+
|
|
306
|
+
if (!fs.existsSync(scannedFile())) skip = `${SCANNED} is gone — repoint this fixture at another scanned surface`;
|
|
307
|
+
else if (fs.readFileSync(scannedFile(), 'utf8').split('\n').length < 420) skip = `${SCANNED} is too short for two distant edits`;
|
|
308
|
+
}
|
|
309
|
+
if (!skip && regenDirty()) {
|
|
310
|
+
skip = 'the committed copy registry is already stale at this base — nothing could be attributed to the merge';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!skip) {
|
|
314
|
+
g('checkout', '--', ...REGEN_PATHS);
|
|
315
|
+
const pad = (atLine, tag) => {
|
|
316
|
+
const lines = fs.readFileSync(scannedFile(), 'utf8').split('\n');
|
|
317
|
+
lines.splice(atLine, 0, `// pad ${tag}`, `// pad ${tag}`);
|
|
318
|
+
fs.writeFileSync(scannedFile(), lines.join('\n'));
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// Base is HEAD itself — never a branch NAME, which does not exist in a CI
|
|
322
|
+
// detached checkout.
|
|
323
|
+
const base = g('rev-parse', 'HEAD').stdout.trim();
|
|
324
|
+
g('checkout', '-q', '-B', 'fixture-ours', base);
|
|
325
|
+
pad(10, 'OURS');
|
|
326
|
+
gen();
|
|
327
|
+
g('add', '-A'); g('commit', '-qm', 'ours: shift copy lines near the top + regen');
|
|
328
|
+
|
|
329
|
+
g('checkout', '-q', '-B', 'fixture-theirs', base);
|
|
330
|
+
pad(400, 'THEIRS');
|
|
331
|
+
gen();
|
|
332
|
+
g('add', '-A'); g('commit', '-qm', 'theirs: shift copy lines far below + regen');
|
|
333
|
+
|
|
334
|
+
g('checkout', '-q', 'fixture-ours');
|
|
335
|
+
const m = g('merge', '--no-edit', 'fixture-theirs');
|
|
336
|
+
|
|
337
|
+
// 1. The merge lands CLEAN — the driver did clear the textual conflict.
|
|
338
|
+
assert.equal(m.status, 0, `the driver must resolve the registry conflict; got ${m.status} :: ${m.stdout}${m.stderr}`);
|
|
339
|
+
assert.equal(g('diff', '--name-only', '--diff-filter=U').stdout.trim(), '', 'no path may be left unmerged');
|
|
340
|
+
const healed = fs.readFileSync(path.join(work, 'docs', 'copy-registry.json'), 'utf8');
|
|
341
|
+
assert.ok(!MARKERS.test(healed), 'the merged registry must carry no conflict markers');
|
|
342
|
+
|
|
343
|
+
// 2. THE HAZARD: it kept OURS verbatim, because the scan ran before the merge.
|
|
344
|
+
assert.equal(
|
|
345
|
+
g('diff', '--quiet', 'HEAD^1', 'HEAD', '--', 'docs/copy-registry.json').status, 0,
|
|
346
|
+
'expected the merge to keep ours byte-for-byte (the mid-merge scan)'
|
|
347
|
+
);
|
|
348
|
+
assert.equal(regenDirty(), true,
|
|
349
|
+
'expected the merged tree to be STALE — the driver cannot see the merged tree, which is WHY '
|
|
350
|
+
+ 'conflict-resolve.js regenerates after the merge (step 7b). If this assertion now fails, git has '
|
|
351
|
+
+ 'begun running merge drivers against the merged tree: re-read step 7b, it may be simplifiable.');
|
|
352
|
+
|
|
353
|
+
// 3. THE CURE step 7b relies on: a post-merge regeneration is sufficient.
|
|
354
|
+
g('add', '-A'); g('commit', '-qm', 'regenerate from the merged tree');
|
|
355
|
+
assert.equal(regenDirty(), false, 'a post-merge regeneration must leave the registry fresh');
|
|
356
|
+
} else {
|
|
357
|
+
console.log(`git_merge_regen: case 10 SKIPPED — ${skip}`);
|
|
358
|
+
}
|
|
359
|
+
} finally {
|
|
360
|
+
try { fs.rmSync(root, { recursive: true, force: true }); } catch (_) { /* best effort */ }
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
238
364
|
console.log('git_merge_regen: all assertions passed');
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -1995,6 +1995,19 @@ test('the wizard takes the world by scope: no card ground, the six-step strip as
|
|
|
1995
1995
|
assert.doesNotMatch(block, /#[0-9a-f]{3,8}\b/i, 'every colour is a token');
|
|
1996
1996
|
});
|
|
1997
1997
|
|
|
1998
|
+
/* ── one fold for the whole wizard (task 1003538, a task 1003346 follow-up). The rail folded
|
|
1999
|
+
at the sheet's 900 while the wizard's fields, choice bands and review ledger folded at the
|
|
2000
|
+
page's 840 (cosmos's createSplit collapse), so between the two the rail was already three-up
|
|
2001
|
+
beside full-width bands. Both sheets now name the same width. ── */
|
|
2002
|
+
test('the wizard folds at one width: the rail\'s media rule sits at 840px with the bands, and no 900px rule is left in the #view-new block (task 1003538)', () => {
|
|
2003
|
+
const at = COSMOS.indexOf('THE CREATE WIZARD, IN THE WORLD');
|
|
2004
|
+
assert.ok(at > -1, 'the #view-new block exists in cosmos.css');
|
|
2005
|
+
const block = COSMOS.slice(at);
|
|
2006
|
+
assert.match(block, /@media \(max-width:840px\)\{\s*#view-new \.steps\{grid-template-columns:repeat\(3,1fr\);/, 'the rail folds to three columns at 840, the width the bands fold at');
|
|
2007
|
+
assert.doesNotMatch(block, /@media \(max-width:900px\)/, 'no 900px rule is left in the wizard block');
|
|
2008
|
+
assert.match(STYLE, /@media \(max-width:840px\)\{[\s\S]*?\.review\{grid-template-columns:1fr;/, 'the page\'s own wizard block still folds its bands and ledger at that same 840');
|
|
2009
|
+
});
|
|
2010
|
+
|
|
1998
2011
|
test('the wizard\'s own structures wear the world: hairline fields, cells of one band, the review as a ledger, the human steps as a numbered ledger, the standup strip as the rail (task 1003346)', () => {
|
|
1999
2012
|
const at = STYLE.indexOf('the create wizard (task 1002763');
|
|
2000
2013
|
assert.ok(at > -1, 'the wizard block is still the page\'s own');
|