@bongos/core 1.19.968 → 1.19.969
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 +40 -20
- package/.claude/skills/builder-cost/SKILL.md +5 -1
- package/docs/module-api-changelog.md +2 -0
- package/docs/recipes/parallel-goal-waves.md +7 -0
- package/modules/economy/cost-report.js +66 -0
- package/modules/economy/cost-summary-shape.js +13 -1
- package/modules/economy/cost.js +14 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/cost-report.js +63 -0
- package/scripts/gds/doctor.js +16 -1
- package/scripts/gds/install-git-hooks.js +72 -5
- package/scripts/gds/search-ingest-md.js +1 -1
- package/src/module-api.js +1 -1
- package/tests/cost_report.mjs +83 -0
- package/tests/cost_summary_shape.mjs +17 -1
- package/tests/merge_driver_register.mjs +150 -0
- package/tests/search_ingest.mjs +40 -4
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
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.969",
|
|
6
|
+
"core_contract": "1.19.969",
|
|
7
|
+
"source_commit": "55409bed0b968ea075ebd993255b3e71131ad01e",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-22T23:
|
|
9
|
+
"built_at": "2026-09-22T23:40:21.961Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 515,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2369,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2910,
|
|
20
|
+
"tree_sha256": "46d085a64c600456dab078e43c39f7b0bdff7a4d90ca202ebdfff0bb0cd98581",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
{
|
|
58
58
|
"path": ".claude/skills/builder-cost/SKILL.md",
|
|
59
59
|
"mode": "0000644",
|
|
60
|
-
"sha256": "
|
|
60
|
+
"sha256": "0f9b196e81b99a2313f6e1a0c0f091caaaf4b11b0d669076509ca60e3d10434a"
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
"path": ".claude/skills/builder-end/SKILL.md",
|
|
@@ -2697,7 +2697,7 @@
|
|
|
2697
2697
|
{
|
|
2698
2698
|
"path": "docs/module-api-changelog.md",
|
|
2699
2699
|
"mode": "0000644",
|
|
2700
|
-
"sha256": "
|
|
2700
|
+
"sha256": "8874af5deda6102925f3caea6c829bc6c637ec707eb7b6cf8ef67fa79b0919a7"
|
|
2701
2701
|
},
|
|
2702
2702
|
{
|
|
2703
2703
|
"path": "docs/modules-contract.md",
|
|
@@ -2892,7 +2892,7 @@
|
|
|
2892
2892
|
{
|
|
2893
2893
|
"path": "docs/recipes/parallel-goal-waves.md",
|
|
2894
2894
|
"mode": "0000644",
|
|
2895
|
-
"sha256": "
|
|
2895
|
+
"sha256": "4879badb2ab0678098d3f5cfa1002a6b4b50e8a691a5d4afc17b533803fbe262"
|
|
2896
2896
|
},
|
|
2897
2897
|
{
|
|
2898
2898
|
"path": "docs/recipes/private-npm-distribution.md",
|
|
@@ -4244,15 +4244,20 @@
|
|
|
4244
4244
|
"mode": "0000644",
|
|
4245
4245
|
"sha256": "fefd4908bd22aa3b747a7c8b4146e5a7e53731fe92612db105ea3512ae34300c"
|
|
4246
4246
|
},
|
|
4247
|
+
{
|
|
4248
|
+
"path": "modules/economy/cost-report.js",
|
|
4249
|
+
"mode": "0000644",
|
|
4250
|
+
"sha256": "484448390d675c3884cbee5d15405e1e7167bbc111f8461e06890750b7f68be4"
|
|
4251
|
+
},
|
|
4247
4252
|
{
|
|
4248
4253
|
"path": "modules/economy/cost-summary-shape.js",
|
|
4249
4254
|
"mode": "0000644",
|
|
4250
|
-
"sha256": "
|
|
4255
|
+
"sha256": "449251df02a484bc865c3844d0a85beffe5e3be920db0f09833f962df3e8b3d4"
|
|
4251
4256
|
},
|
|
4252
4257
|
{
|
|
4253
4258
|
"path": "modules/economy/cost.js",
|
|
4254
4259
|
"mode": "0000644",
|
|
4255
|
-
"sha256": "
|
|
4260
|
+
"sha256": "ce3ce114f5315c78a2feb810bce09cde84941765629f0e5a10a44aced2c34210"
|
|
4256
4261
|
},
|
|
4257
4262
|
{
|
|
4258
4263
|
"path": "modules/economy/credits.js",
|
|
@@ -8102,12 +8107,12 @@
|
|
|
8102
8107
|
{
|
|
8103
8108
|
"path": "package-lock.json",
|
|
8104
8109
|
"mode": "0000644",
|
|
8105
|
-
"sha256": "
|
|
8110
|
+
"sha256": "ede312c963ab08f5b2fec23b90c3c5fc829149893c08e83196be37b2a3ef11b5"
|
|
8106
8111
|
},
|
|
8107
8112
|
{
|
|
8108
8113
|
"path": "package.json",
|
|
8109
8114
|
"mode": "0000644",
|
|
8110
|
-
"sha256": "
|
|
8115
|
+
"sha256": "40a32e40062ce4becee8999e5b2cdb5dd54d5a0ad65885341afcf3651e002d55"
|
|
8111
8116
|
},
|
|
8112
8117
|
{
|
|
8113
8118
|
"path": "public-docs/index.html",
|
|
@@ -8509,6 +8514,11 @@
|
|
|
8509
8514
|
"mode": "0000644",
|
|
8510
8515
|
"sha256": "55ac5440a1c5481766198d6a89cef601fde816024b592b1e77f535c465a2df47"
|
|
8511
8516
|
},
|
|
8517
|
+
{
|
|
8518
|
+
"path": "scripts/gds/cost-report.js",
|
|
8519
|
+
"mode": "0000644",
|
|
8520
|
+
"sha256": "988d9e5dec9ed4bd35a3aea25fc841a832a2a251ee6eefa7fb0d86f7ee5b7b2e"
|
|
8521
|
+
},
|
|
8512
8522
|
{
|
|
8513
8523
|
"path": "scripts/gds/cost.js",
|
|
8514
8524
|
"mode": "0000644",
|
|
@@ -8607,7 +8617,7 @@
|
|
|
8607
8617
|
{
|
|
8608
8618
|
"path": "scripts/gds/doctor.js",
|
|
8609
8619
|
"mode": "0000644",
|
|
8610
|
-
"sha256": "
|
|
8620
|
+
"sha256": "be0f4da3a9885bdc555f720d853923b226ae993a6a965a934c728032a0575988"
|
|
8611
8621
|
},
|
|
8612
8622
|
{
|
|
8613
8623
|
"path": "scripts/gds/enroll-control-plane.js",
|
|
@@ -8832,7 +8842,7 @@
|
|
|
8832
8842
|
{
|
|
8833
8843
|
"path": "scripts/gds/install-git-hooks.js",
|
|
8834
8844
|
"mode": "0000644",
|
|
8835
|
-
"sha256": "
|
|
8845
|
+
"sha256": "6234ca799f99e5a16622043b0f43d42624efe5fb6862d74e4b8c2b7603cf8bef"
|
|
8836
8846
|
},
|
|
8837
8847
|
{
|
|
8838
8848
|
"path": "scripts/gds/instance-apex.js",
|
|
@@ -9237,7 +9247,7 @@
|
|
|
9237
9247
|
{
|
|
9238
9248
|
"path": "scripts/gds/search-ingest-md.js",
|
|
9239
9249
|
"mode": "0000644",
|
|
9240
|
-
"sha256": "
|
|
9250
|
+
"sha256": "7638bd1d2160ef1aa5b386dd555084cb33e1af02bcd9e2f55967cb32a8e0bba5"
|
|
9241
9251
|
},
|
|
9242
9252
|
{
|
|
9243
9253
|
"path": "scripts/gds/security-baseline-check.sh",
|
|
@@ -10112,7 +10122,7 @@
|
|
|
10112
10122
|
{
|
|
10113
10123
|
"path": "src/module-api.js",
|
|
10114
10124
|
"mode": "0000644",
|
|
10115
|
-
"sha256": "
|
|
10125
|
+
"sha256": "37d85c40aec959cbe0091ef9f1c2d14831fcc8271dcaf91a8d42c5d879a80878"
|
|
10116
10126
|
},
|
|
10117
10127
|
{
|
|
10118
10128
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11049,10 +11059,15 @@
|
|
|
11049
11059
|
"mode": "0000644",
|
|
11050
11060
|
"sha256": "8356250d8fc753afb2960d867f81586531053afd15f4e66da1a1b7b62b6b5db7"
|
|
11051
11061
|
},
|
|
11062
|
+
{
|
|
11063
|
+
"path": "tests/cost_report.mjs",
|
|
11064
|
+
"mode": "0000644",
|
|
11065
|
+
"sha256": "cb69ea9b53e37f8590bb1cd2e594df1a63fa07f452ad7b0bf7a607507f8e2c82"
|
|
11066
|
+
},
|
|
11052
11067
|
{
|
|
11053
11068
|
"path": "tests/cost_summary_shape.mjs",
|
|
11054
11069
|
"mode": "0000644",
|
|
11055
|
-
"sha256": "
|
|
11070
|
+
"sha256": "50f195ef2060df92016b58f541a28e7492dd95ad4da8bd0781bdc07007193338"
|
|
11056
11071
|
},
|
|
11057
11072
|
{
|
|
11058
11073
|
"path": "tests/credit_grant.mjs",
|
|
@@ -12624,6 +12639,11 @@
|
|
|
12624
12639
|
"mode": "0000644",
|
|
12625
12640
|
"sha256": "d05b6e354ac08cf5512a62b76b213df44ab1ef8639a1ba635319e9b5e8511a5c"
|
|
12626
12641
|
},
|
|
12642
|
+
{
|
|
12643
|
+
"path": "tests/merge_driver_register.mjs",
|
|
12644
|
+
"mode": "0000644",
|
|
12645
|
+
"sha256": "c75880064b52b6edb2689232eb1e6401567618f46b16581140685ccceee2d41c"
|
|
12646
|
+
},
|
|
12627
12647
|
{
|
|
12628
12648
|
"path": "tests/merge_lease.mjs",
|
|
12629
12649
|
"mode": "0000644",
|
|
@@ -13652,7 +13672,7 @@
|
|
|
13652
13672
|
{
|
|
13653
13673
|
"path": "tests/search_ingest.mjs",
|
|
13654
13674
|
"mode": "0000644",
|
|
13655
|
-
"sha256": "
|
|
13675
|
+
"sha256": "dd92de1e35d82fc4ea82c2f5cb99b506153e5386e4668846cba87e0f8d135d97"
|
|
13656
13676
|
},
|
|
13657
13677
|
{
|
|
13658
13678
|
"path": "tests/search_ingest_db.mjs",
|
|
@@ -42,7 +42,11 @@ The CLI hits `POST /api/bongos/cost`, which inserts a `cost_log` row.
|
|
|
42
42
|
- **Don't double-count.** Before logging an art-pipeline cost manually, verify it isn't already in the DB from the one-time cost-ledger backfill (those rows use `source: 'art-cost-ledger'`).
|
|
43
43
|
- **Don't log without a description for `other` category.**
|
|
44
44
|
|
|
45
|
+
## Reading the ledger back
|
|
46
|
+
|
|
47
|
+
To see what has been spent rather than add to it, run `node scripts/gds/cost-report.js` — total spend plus totals by month, category and source. The endpoint it reads is public, so no rank is needed, but the session still supplies the instance URL. `--source X`, `--category Y` and `--days N` scope the breakdowns (the header total stays project-wide); `--json` prints the raw payload. The newest month is labelled partial — it is spend to date, not a month's worth.
|
|
48
|
+
|
|
45
49
|
## Files this skill touches
|
|
46
50
|
|
|
47
51
|
- Reads: `~/.config/otb/gds-session.json`
|
|
48
|
-
- Calls: `POST /api/bongos/cost`
|
|
52
|
+
- Calls: `POST /api/bongos/cost` (logging), `GET /public/cost-summary` (the read-out, via `scripts/gds/cost-report.js`)
|
|
@@ -2410,5 +2410,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2410
2410
|
landed since 1.19.966 with no explicit bump. run 35795807836. (task 1002620)
|
|
2411
2411
|
1.19.968 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2412
2412
|
landed since 1.19.967 with no explicit bump. run 35797263467. (task 1002620)
|
|
2413
|
+
1.19.969 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2414
|
+
landed since 1.19.968 with no explicit bump. run 35798488315. (task 1002620)
|
|
2413
2415
|
---------------------------------------------------------------------------
|
|
2414
2416
|
```
|
|
@@ -105,6 +105,11 @@ One file will become the contention point. Here it was the module's nested `CLAU
|
|
|
105
105
|
**Generated files: always resolve by regenerating, never by picking a side.**
|
|
106
106
|
|
|
107
107
|
```bash
|
|
108
|
+
# --theirs only CLEARS THE CONFLICT MARKERS so git will accept the path as resolved
|
|
109
|
+
# (git add refuses a still-conflicted path). It is not a choice of content: the
|
|
110
|
+
# generator on the next line overwrites the file from source, so whichever side you
|
|
111
|
+
# took is discarded. --ours works identically here. This is the one place the "never
|
|
112
|
+
# pick a side" rule above does not apply, because nothing is being picked.
|
|
108
113
|
git checkout --theirs <file>
|
|
109
114
|
node scripts/gds/gen-repo-map.js # or gen-api-docs.js, gen-diagrams.js
|
|
110
115
|
git add <file> && git commit --no-edit
|
|
@@ -112,6 +117,8 @@ git add <file> && git commit --no-edit
|
|
|
112
117
|
|
|
113
118
|
Picking a side leaves the artefact inconsistent with source, and the freshness fitness check fails on the *next* task instead of yours. The generated symbol blocks and the OpenAPI spec both bit us this way.
|
|
114
119
|
|
|
120
|
+
**If the generator is not the last thing you run, you have picked a side.** That is the test to apply, not the flag you typed.
|
|
121
|
+
|
|
115
122
|
> If `ship.js` says *"auto-merge: bailed; task stays at confirmed"*, don't reach for `/merge-mode` first. Merge `origin/main` into the **task's own branch**, resolve there, re-run `fitness.js`, then re-run `ship.js <id>` — it resumes from `confirmed`. `/merge-mode` is an operator procedure that mutates the main worktree and assumes a bash/SSH environment.
|
|
116
123
|
|
|
117
124
|
---
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// modules/economy/cost-report.js — the spend read-out's text rendering (task
|
|
2
|
+
// 1001621: "what did this cost, by month, category and source", the figures
|
|
3
|
+
// hosting ROI is sized against). Pure (no DB, no I/O) so the CLI
|
|
4
|
+
// (scripts/gds/cost-report.js) stays a thin fetch-and-print shell and the
|
|
5
|
+
// wording is unit-testable DB-free (tests/cost_report.mjs). Reads a
|
|
6
|
+
// db.costSummary() payload / the GET /public/cost-summary response as-is.
|
|
7
|
+
|
|
8
|
+
function usd(n) {
|
|
9
|
+
return `$${(Number(n) || 0).toFixed(2)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// A dimension the server did not send is NOT the same as a dimension with no
|
|
13
|
+
// rows: the CLI reads a LIVE instance, which may predate the key. Say which,
|
|
14
|
+
// so an old server never reads as "nothing was spent".
|
|
15
|
+
function section(label, rows, render) {
|
|
16
|
+
if (!Array.isArray(rows)) return [` ${label}: unavailable (this server predates the rollup)`];
|
|
17
|
+
if (!rows.length) return [` ${label}: no spend recorded`];
|
|
18
|
+
return [` ${label}:`, ...rows.map((r) => ` - ${render(r)}`)];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The newest month is spend-to-date, not a month's worth, so an unlabelled row
|
|
22
|
+
// reads as a collapse in spend at the top of every month — in the one figure a
|
|
23
|
+
// hosting bill is sized against. Compared in UTC: a boundary mismatch with the
|
|
24
|
+
// server's timezone only drops the label for a few hours, which under-claims
|
|
25
|
+
// rather than calling a partial month complete.
|
|
26
|
+
function monthLabel(row, now) {
|
|
27
|
+
const current = now.toISOString().slice(0, 7);
|
|
28
|
+
const partial = row.month === current ? ' (partial — month to date)' : '';
|
|
29
|
+
return `${row.month}: ${usd(row.total_usd)}${partial}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function filterLabel(filters) {
|
|
33
|
+
const f = filters || {};
|
|
34
|
+
const parts = [];
|
|
35
|
+
if (f.source) parts.push(`source=${f.source}`);
|
|
36
|
+
if (f.category) parts.push(`category=${f.category}`);
|
|
37
|
+
if (f.days) parts.push(`last ${f.days} days`);
|
|
38
|
+
return parts.join(' · ');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render a spend rollup — total, then totals by month, category and source —
|
|
43
|
+
* from a cost-summary payload, for the `cost-report` CLI.
|
|
44
|
+
* @param {object} summary - db.costSummary() / GET /public/cost-summary.
|
|
45
|
+
* @param {object} [opts] - { now } — the clock, injectable so the partial-month
|
|
46
|
+
* label is testable.
|
|
47
|
+
* @returns {string} the printable report.
|
|
48
|
+
*/
|
|
49
|
+
function formatCostReport(summary, { now = new Date() } = {}) {
|
|
50
|
+
const s = summary || {};
|
|
51
|
+
const lines = ['Spend rollup — cost_log'];
|
|
52
|
+
// The header total stays project-wide whatever the filter: costSummary scopes
|
|
53
|
+
// only the breakdowns, so labelling it is what keeps the two figures honest.
|
|
54
|
+
lines.push(` total spend: ${usd(s.total_usd)} (project-wide, all time)`);
|
|
55
|
+
const label = filterLabel(s.applied_filters);
|
|
56
|
+
if (label) {
|
|
57
|
+
lines.push(` filter: ${label}`);
|
|
58
|
+
lines.push(` under filter: ${usd(s.filtered_total_usd)}`);
|
|
59
|
+
}
|
|
60
|
+
lines.push(...section('by month', s.by_month, (r) => monthLabel(r, now)));
|
|
61
|
+
lines.push(...section('by category', s.by_category, (r) => `${r.category}: ${usd(r.total_usd)}`));
|
|
62
|
+
lines.push(...section('by source', s.by_source, (r) => `${r.source}: ${usd(r.total_usd)}`));
|
|
63
|
+
return lines.join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { formatCostReport };
|
|
@@ -87,4 +87,16 @@ function shapeCostCalibration(rows) {
|
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
// by_month: cost_log rows grouped by calendar month (task 1001621), the axis the
|
|
91
|
+
// daily series cannot answer — "what does a month of this cost" is the figure
|
|
92
|
+
// hosting ROI is sized against. The month label is formatted in SQL (to_char),
|
|
93
|
+
// not here, so a driver Date + a UTC-shifted server can never move a row into
|
|
94
|
+
// the neighbouring month. Rows arrive pre-sorted oldest-first by SQL.
|
|
95
|
+
function shapeByMonth(rows) {
|
|
96
|
+
return (rows || []).map((r) => ({
|
|
97
|
+
month: String(r.month),
|
|
98
|
+
total_usd: round2(r.total_usd),
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { shapeBySource, shapeByTier, shapeSavings, shapeCostCalibration, shapeByMonth };
|
package/modules/economy/cost.js
CHANGED
|
@@ -327,6 +327,7 @@ async function costSummary(filters = {}) {
|
|
|
327
327
|
{ rows: filteredTotalRows },
|
|
328
328
|
{ rows: sourceOptRows },
|
|
329
329
|
{ rows: categoryOptRows },
|
|
330
|
+
{ rows: byMonthRows },
|
|
330
331
|
] = await Promise.all([
|
|
331
332
|
pool.query(`SELECT COALESCE(SUM(amount_usd), 0) AS total_usd FROM cost_log`),
|
|
332
333
|
pool.query(
|
|
@@ -423,6 +424,16 @@ async function costSummary(filters = {}) {
|
|
|
423
424
|
pool.query(
|
|
424
425
|
`SELECT DISTINCT category FROM cost_log WHERE category IS NOT NULL ORDER BY category`
|
|
425
426
|
),
|
|
427
|
+
// task 1001621: by_month — the axis `daily` cannot answer. A month of spend
|
|
428
|
+
// is what a hosting bill is compared against, and the day series is
|
|
429
|
+
// deliberately project-wide, so it cannot be re-bucketed per slice. This one
|
|
430
|
+
// honors the same filters as the other breakdowns. to_char formats the label
|
|
431
|
+
// in SQL so no driver Date/timezone can shift a row into the next month.
|
|
432
|
+
pool.query(
|
|
433
|
+
`SELECT to_char(recorded_at, 'YYYY-MM') AS month, COALESCE(SUM(amount_usd), 0) AS total_usd
|
|
434
|
+
FROM cost_log ${whereCl} GROUP BY month ORDER BY month`,
|
|
435
|
+
params
|
|
436
|
+
),
|
|
426
437
|
]);
|
|
427
438
|
|
|
428
439
|
// savings — the counterfactual ledger (cost_baseline, migration 124). Guarded:
|
|
@@ -474,6 +485,9 @@ async function costSummary(filters = {}) {
|
|
|
474
485
|
daily_credits: valueHistory.map((r) => ({ day: r.day, credits: Number(r.credits) })),
|
|
475
486
|
// System 1 / task 1345 — additive dimensions (existing keys keep shape).
|
|
476
487
|
by_source: costSummaryShape.shapeBySource(bySourceRows),
|
|
488
|
+
// task 1001621 — spend per calendar month, under the same filters as the
|
|
489
|
+
// other breakdowns. Additive; existing keys keep their shape.
|
|
490
|
+
by_month: costSummaryShape.shapeByMonth(byMonthRows),
|
|
477
491
|
by_tier: costSummaryShape.shapeByTier(byTierRows[0]),
|
|
478
492
|
savings: costSummaryShape.shapeSavings(savingsRows),
|
|
479
493
|
// task 572 / spend-audit F6 — cost-per-task & cost-per-credit medians.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.969",
|
|
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.969",
|
|
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.969",
|
|
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",
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Read the spend rollup: total spend, and totals by month, category and source.
|
|
3
|
+
//
|
|
4
|
+
// The read half of cost.js, which only LOGS spend — task 1001621 (ADR 0092
|
|
5
|
+
// finding): nothing rolled it up where a person could size hosting against real
|
|
6
|
+
// numbers. The endpoint it reads is public, so no RANK is needed — but the base
|
|
7
|
+
// URL still comes from your CLI session (or <PREFIX>_API_BASE), which is what
|
|
8
|
+
// says WHICH instance to ask.
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
// node scripts/gds/cost-report.js [--source X] [--category Y] [--days N] [--json]
|
|
12
|
+
//
|
|
13
|
+
// The filters scope the BREAKDOWNS only — the header total stays project-wide,
|
|
14
|
+
// which is how the server's treasury filter already behaves.
|
|
15
|
+
|
|
16
|
+
const { cliClient, arg, readSessionToken } = require('./cli-lib');
|
|
17
|
+
// A CLI may require a module directly (the backfill-cost.js / cost-classify
|
|
18
|
+
// precedent, BV1.R77 / ADR 0093 §1-2). The rendering is pure and lives there so
|
|
19
|
+
// it is testable without a server (tests/cost_report.mjs).
|
|
20
|
+
const { formatCostReport } = require('../../modules/economy/cost-report');
|
|
21
|
+
|
|
22
|
+
// arg() hands back whatever follows the flag, so `--source --json` would filter
|
|
23
|
+
// on the literal '--json' and silently report $0 — a wrong answer, not an error.
|
|
24
|
+
function value(name, args) {
|
|
25
|
+
const v = arg(name, args);
|
|
26
|
+
if (v == null) return null;
|
|
27
|
+
if (String(v).startsWith('--')) {
|
|
28
|
+
console.error(`${name} needs a value (got '${v}')`);
|
|
29
|
+
process.exit(2);
|
|
30
|
+
}
|
|
31
|
+
return v;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
(async () => {
|
|
35
|
+
const args = process.argv.slice(2);
|
|
36
|
+
const query = {};
|
|
37
|
+
const source = value('--source', args);
|
|
38
|
+
const category = value('--category', args);
|
|
39
|
+
const days = value('--days', args);
|
|
40
|
+
if (source) query.source = source;
|
|
41
|
+
if (category) query.category = category;
|
|
42
|
+
if (days) query.days = days;
|
|
43
|
+
|
|
44
|
+
const api = await cliClient();
|
|
45
|
+
const r = await api.public.getPublicCostSummary({ query });
|
|
46
|
+
if (!r.ok) {
|
|
47
|
+
console.error(`cost report failed (HTTP ${r.status}):`, r.data);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
console.log(args.includes('--json') ? JSON.stringify(r.data, null, 2) : formatCostReport(r.data));
|
|
51
|
+
})().catch((err) => {
|
|
52
|
+
// A session-less or offline run lands here, and a raw fetch stack says nothing
|
|
53
|
+
// about WHICH instance was tried. readSessionToken() returns null when not
|
|
54
|
+
// signed in, which is itself the answer in that case.
|
|
55
|
+
const session = readSessionToken();
|
|
56
|
+
const msg = (err && err.message) || String(err);
|
|
57
|
+
console.error(`cost-report failed (instance: ${session ? session.apiBase : 'no session — falling back to this checkout\'s default'}).`);
|
|
58
|
+
console.error(` cause: ${msg}`);
|
|
59
|
+
if (/fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN/.test(msg)) {
|
|
60
|
+
console.error(' The instance could not be reached. Sign in with `node scripts/gds/setup.js`, or name one with <PREFIX>_API_BASE.');
|
|
61
|
+
}
|
|
62
|
+
process.exit(1);
|
|
63
|
+
});
|
package/scripts/gds/doctor.js
CHANGED
|
@@ -28,6 +28,7 @@ const path = require('node:path');
|
|
|
28
28
|
const os = require('node:os');
|
|
29
29
|
const { execSync, spawnSync } = require('node:child_process');
|
|
30
30
|
const { cliClient, SESSION_PATH, loadSession } = require('./cli-lib');
|
|
31
|
+
const { classifyMergeDriverState, MERGE_DRIVER_NAME } = require('./install-git-hooks.js');
|
|
31
32
|
const { preflightChecks } = require('./preflight');
|
|
32
33
|
|
|
33
34
|
// ANSI helpers (only if stdout is a TTY; otherwise emit plain text)
|
|
@@ -253,6 +254,20 @@ function classifyPreCommitState({ preCommitPath, present, executable }) {
|
|
|
253
254
|
|
|
254
255
|
function checkGitPlumbing() {
|
|
255
256
|
section('Git plumbing');
|
|
257
|
+
|
|
258
|
+
// The merge-driver section is reported FIRST and unconditionally — it is checked
|
|
259
|
+
// even when core.hooksPath is unset, because the two are independent and the
|
|
260
|
+
// half-written-section wedge (task 1003846) is silent until a merge dies with
|
|
261
|
+
// `fatal: custom merge driver ... lacks command line`. A builder who hits that has
|
|
262
|
+
// no reason to connect it to a setup step that ran days ago, so doctor naming it
|
|
263
|
+
// is the whole point. Early-returning before this line would hide it on exactly
|
|
264
|
+
// the clones most likely to be mis-set-up.
|
|
265
|
+
const md = classifyMergeDriverState({
|
|
266
|
+
name: gitOut(`git config --get merge.${MERGE_DRIVER_NAME}.name`),
|
|
267
|
+
driver: gitOut(`git config --get merge.${MERGE_DRIVER_NAME}.driver`),
|
|
268
|
+
});
|
|
269
|
+
({ ok, warn, fail }[md.status])('git', md.message);
|
|
270
|
+
|
|
256
271
|
const verdict = classifyHooksState({
|
|
257
272
|
hooksPath: gitOut('git config --get core.hooksPath'),
|
|
258
273
|
worktreeRoot: gitOut('git rev-parse --show-toplevel'),
|
|
@@ -438,7 +453,7 @@ function checkRunnerDrift() {
|
|
|
438
453
|
// --------------------------------------------------------------------------
|
|
439
454
|
// Guarded so requiring this file for its pure helpers (tests/doctor_hooks.mjs)
|
|
440
455
|
// does not run the checks, which shell out to git and hit the network.
|
|
441
|
-
module.exports = { classifyHooksState, classifyPreCommitState, runnerDriftRows };
|
|
456
|
+
module.exports = { classifyHooksState, classifyMergeDriverState, classifyPreCommitState, runnerDriftRows };
|
|
442
457
|
|
|
443
458
|
if (require.main === module) (async () => {
|
|
444
459
|
console.log(`${C.bold}GDS Doctor${C.reset} — local-machine health check`);
|
|
@@ -30,21 +30,88 @@ function git(args) {
|
|
|
30
30
|
// the generated files (docs/repo-map.md + the nested CLAUDE.md blocks).
|
|
31
31
|
const MERGE_DRIVER_NAME = 'otb-regen';
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// ORDER MATTERS, and the wrong order is worse than not registering at all (task 1003846).
|
|
34
|
+
//
|
|
35
|
+
// These are two git-config writes, not one atomic operation. If the first succeeds and
|
|
36
|
+
// the second throws, the half-written section SURVIVES — and git does not treat a named
|
|
37
|
+
// driver with no command as "fall back to a text merge". It treats it as fatal:
|
|
38
|
+
//
|
|
39
|
+
// fatal: custom merge driver otb-regen lacks command line.
|
|
40
|
+
//
|
|
41
|
+
// exit 128, no unmerged paths, nothing that looks like a merge conflict. Every merge
|
|
42
|
+
// touching any `merge=otb-regen` path then dies — which is now the root CLAUDE.md, the
|
|
43
|
+
// file/session-log maps, two nested CLAUDE.md files, the copy-registry pair and the nine
|
|
44
|
+
// generated API artifacts. So the failure mode of the old order was "no merge in this
|
|
45
|
+
// clone can complete", where not registering at all merely yields an ordinary conflict.
|
|
46
|
+
//
|
|
47
|
+
// Both halves of the fix are verified, not assumed (see tests/merge_driver_register.mjs):
|
|
48
|
+
// - `.driver` alone IS a valid section — git runs it and the merge succeeds.
|
|
49
|
+
// - `.name` alone is the fatal state above.
|
|
50
|
+
// So writing `.driver` FIRST makes the intermediate state safe, and removing the whole
|
|
51
|
+
// section on failure makes even a mid-write abort leave no trace.
|
|
52
|
+
function registerMergeDriver(root, { run = execFileSync } = {}) {
|
|
34
53
|
// execFileSync (arg array, no shell) so the literal %O/%A/%B/%P placeholders and
|
|
35
54
|
// the spaces in the command survive verbatim on every platform (a shell would
|
|
36
55
|
// mangle %VARS on Windows cmd).
|
|
37
56
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
execFileSync('git', ['config', `merge.${MERGE_DRIVER_NAME}.driver`,
|
|
57
|
+
// .driver FIRST — a section carrying only .driver is valid and works.
|
|
58
|
+
run('git', ['config', `merge.${MERGE_DRIVER_NAME}.driver`,
|
|
41
59
|
'node scripts/gds/git-merge-regen.js %O %A %B %P'], { cwd: root, stdio: 'ignore' });
|
|
60
|
+
// .name SECOND — cosmetic (it is what `git config --list` shows); if this throws,
|
|
61
|
+
// the section left behind is the safe driver-only one.
|
|
62
|
+
run('git', ['config', `merge.${MERGE_DRIVER_NAME}.name`,
|
|
63
|
+
`${require('./cli-lib').productName()} generated-file regenerator (task 1243)`], { cwd: root, stdio: 'ignore' });
|
|
42
64
|
return { ok: true };
|
|
43
65
|
} catch (e) {
|
|
66
|
+
// Belt and braces: leave NO trace rather than a possibly-partial section. A missing
|
|
67
|
+
// section is a normal textual conflict; a partial one can be the exit-128 wedge.
|
|
68
|
+
// This removal is itself best-effort — if it throws (e.g. no section to remove, which
|
|
69
|
+
// git reports as an error) the original failure is still what we report.
|
|
70
|
+
try {
|
|
71
|
+
run('git', ['config', '--remove-section', `merge.${MERGE_DRIVER_NAME}`], { cwd: root, stdio: 'ignore' });
|
|
72
|
+
} catch (_) { /* nothing to clean up, or we cannot — report the original failure */ }
|
|
44
73
|
return { ok: false, error: e.message };
|
|
45
74
|
}
|
|
46
75
|
}
|
|
47
76
|
|
|
77
|
+
// The doctor-facing verdict for an already-registered driver, pure so it is testable
|
|
78
|
+
// without a git repo (the classifyHooksState precedent in doctor.js).
|
|
79
|
+
//
|
|
80
|
+
// The wedge state is INVISIBLE until the next merge dies, which is exactly why doctor
|
|
81
|
+
// should say so: a builder who hits `fatal: custom merge driver ... lacks command line`
|
|
82
|
+
// has no reason to connect it to a setup step that ran days earlier.
|
|
83
|
+
function classifyMergeDriverState({ name, driver }) {
|
|
84
|
+
const hasName = Boolean(name && String(name).trim());
|
|
85
|
+
const hasDriver = Boolean(driver && String(driver).trim());
|
|
86
|
+
const installer = 'node scripts/gds/install-git-hooks.js';
|
|
87
|
+
|
|
88
|
+
if (hasDriver) {
|
|
89
|
+
return {
|
|
90
|
+
status: 'ok',
|
|
91
|
+
code: 'registered',
|
|
92
|
+
message: `merge driver '${MERGE_DRIVER_NAME}' registered — generated-file conflicts auto-regenerate on merge.`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (hasName) {
|
|
96
|
+
return {
|
|
97
|
+
status: 'fail',
|
|
98
|
+
code: 'name-without-driver',
|
|
99
|
+
message:
|
|
100
|
+
`merge.${MERGE_DRIVER_NAME}.name is set but .driver is MISSING. Git treats this as fatal, not as a `
|
|
101
|
+
+ `fallback: every merge touching a \`merge=${MERGE_DRIVER_NAME}\` path will die with `
|
|
102
|
+
+ `"fatal: custom merge driver ${MERGE_DRIVER_NAME} lacks command line" (exit 128, no conflict shown). `
|
|
103
|
+
+ `Repair with: ${installer} — or clear it with: git config --remove-section merge.${MERGE_DRIVER_NAME}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
status: 'warn',
|
|
108
|
+
code: 'unregistered',
|
|
109
|
+
message:
|
|
110
|
+
`merge driver '${MERGE_DRIVER_NAME}' is not registered, so generated-file merges will raise ordinary `
|
|
111
|
+
+ `text conflicts instead of auto-regenerating (ship.js still self-heals). Register with: ${installer}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
48
115
|
// Resolve the configured hooks dir (absolute) for reporting / verification.
|
|
49
116
|
function configuredHooksDir() {
|
|
50
117
|
try {
|
|
@@ -114,4 +181,4 @@ if (require.main === module) {
|
|
|
114
181
|
}
|
|
115
182
|
}
|
|
116
183
|
|
|
117
|
-
module.exports = { activate, configuredHooksDir, registerMergeDriver, MERGE_DRIVER_NAME };
|
|
184
|
+
module.exports = { activate, classifyMergeDriverState, configuredHooksDir, registerMergeDriver, MERGE_DRIVER_NAME };
|
|
@@ -48,7 +48,7 @@ const SENSITIVE_DOC_GLOBS = [
|
|
|
48
48
|
'docs/canonical-permissions.md', // the rank-ladder / authority reference
|
|
49
49
|
'docs/security/', // threat models + red-team reports
|
|
50
50
|
'docs/audits/', // security + architecture audit findings
|
|
51
|
-
'docs/governance/', // the gate ENUMERATION + choke-point map
|
|
51
|
+
'docs/governance/', // the gate ENUMERATION + choke-point map
|
|
52
52
|
'docs/adr/0016-trust-boundary-server-enforced-permissions.md',
|
|
53
53
|
'docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md',
|
|
54
54
|
];
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.969'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// tests/cost_report.mjs — the spend read-out's text rendering (task 1001621).
|
|
2
|
+
// DB-free. Run: node tests/cost_report.mjs
|
|
3
|
+
|
|
4
|
+
import { strict as assert } from 'node:assert';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const { formatCostReport } = require('../modules/economy/cost-report.js');
|
|
8
|
+
|
|
9
|
+
let passed = 0, failed = 0;
|
|
10
|
+
function test(name, fn) {
|
|
11
|
+
try { fn(); console.log(` ok ${name}`); passed++; }
|
|
12
|
+
catch (err) { console.error(` FAIL ${name}`); console.error(' ', err.message); failed++; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const FULL = {
|
|
16
|
+
total_usd: 123.45,
|
|
17
|
+
filtered_total_usd: 123.45,
|
|
18
|
+
applied_filters: { source: null, category: null, days: null },
|
|
19
|
+
by_month: [
|
|
20
|
+
{ month: '2026-06', total_usd: 100 },
|
|
21
|
+
{ month: '2026-07', total_usd: 23.45 },
|
|
22
|
+
],
|
|
23
|
+
by_category: [{ category: 'api', total_usd: 120 }, { category: 'infra', total_usd: 3.45 }],
|
|
24
|
+
by_source: [{ source: 'session:interactive', total_usd: 123.45 }],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
test('renders the three dimensions the task asks for: month, category, source', () => {
|
|
28
|
+
const out = formatCostReport(FULL);
|
|
29
|
+
assert.match(out, /total spend:\s+\$123\.45/);
|
|
30
|
+
assert.match(out, /2026-06: \$100\.00/);
|
|
31
|
+
assert.match(out, /2026-07: \$23\.45/);
|
|
32
|
+
assert.match(out, /api: \$120\.00/);
|
|
33
|
+
assert.match(out, /session:interactive: \$123\.45/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('no filter applied → no filter line and no filtered subtotal', () => {
|
|
37
|
+
const out = formatCostReport(FULL);
|
|
38
|
+
assert.doesNotMatch(out, /filter:/);
|
|
39
|
+
assert.doesNotMatch(out, /under filter:/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a filter applied → the filter is named and the subtotal is separate from the total', () => {
|
|
43
|
+
const out = formatCostReport({
|
|
44
|
+
...FULL,
|
|
45
|
+
filtered_total_usd: 23.45,
|
|
46
|
+
applied_filters: { source: 'grader', category: 'api', days: 30 },
|
|
47
|
+
});
|
|
48
|
+
assert.match(out, /filter:.*source=grader/);
|
|
49
|
+
assert.match(out, /filter:.*category=api/);
|
|
50
|
+
assert.match(out, /filter:.*last 30 days/);
|
|
51
|
+
assert.match(out, /under filter:\s+\$23\.45/);
|
|
52
|
+
// the header total stays project-wide — costSummary never rescopes it
|
|
53
|
+
assert.match(out, /total spend:\s+\$123\.45/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a server predating by_month says so rather than reading as zero spend', () => {
|
|
57
|
+
const { by_month, ...older } = FULL;
|
|
58
|
+
const out = formatCostReport(older);
|
|
59
|
+
assert.match(out, /by month:\s+unavailable/);
|
|
60
|
+
assert.doesNotMatch(out, /2026-06/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the current month is labelled partial — it is spend to date, not a month', () => {
|
|
64
|
+
const out = formatCostReport(FULL, { now: new Date('2026-07-15T00:00:00Z') });
|
|
65
|
+
assert.match(out, /2026-07: \$23\.45\s+\(partial — month to date\)/);
|
|
66
|
+
// a month that has closed carries no such caveat
|
|
67
|
+
assert.match(out, /2026-06: \$100\.00\n/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('no row is labelled partial once the newest month has closed', () => {
|
|
71
|
+
const out = formatCostReport(FULL, { now: new Date('2026-09-22T00:00:00Z') });
|
|
72
|
+
assert.doesNotMatch(out, /partial/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('an empty by_month is reported as no spend, not as unavailable', () => {
|
|
76
|
+
const out = formatCostReport({ ...FULL, by_month: [] });
|
|
77
|
+
assert.match(out, /by month:/);
|
|
78
|
+
assert.doesNotMatch(out, /unavailable/);
|
|
79
|
+
assert.match(out, /no spend recorded/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
console.log(`\ncost_report: ${passed} passed, ${failed} failed`);
|
|
83
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { strict as assert } from 'node:assert';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
const require = createRequire(import.meta.url);
|
|
7
|
-
const { shapeBySource, shapeByTier, shapeSavings, shapeCostCalibration } = require('../modules/economy/cost-summary-shape.js');
|
|
7
|
+
const { shapeBySource, shapeByTier, shapeSavings, shapeCostCalibration, shapeByMonth } = require('../modules/economy/cost-summary-shape.js');
|
|
8
8
|
|
|
9
9
|
let passed = 0, failed = 0;
|
|
10
10
|
function test(name, fn) {
|
|
@@ -90,5 +90,21 @@ test('shapeCostCalibration empty/undefined → empty by_kind, null overall', ()
|
|
|
90
90
|
assert.deepEqual(shapeCostCalibration(undefined), { by_kind: [], overall: null });
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
+
test('shapeByMonth coerces numbers and preserves the SQL month ordering (task 1001621)', () => {
|
|
94
|
+
const out = shapeByMonth([
|
|
95
|
+
{ month: '2026-06', total_usd: '100.005' },
|
|
96
|
+
{ month: '2026-07', total_usd: 23.45 },
|
|
97
|
+
]);
|
|
98
|
+
assert.deepEqual(out, [
|
|
99
|
+
{ month: '2026-06', total_usd: 100.01 }, // round2
|
|
100
|
+
{ month: '2026-07', total_usd: 23.45 },
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('shapeByMonth handles empty/undefined input', () => {
|
|
105
|
+
assert.deepEqual(shapeByMonth([]), []);
|
|
106
|
+
assert.deepEqual(shapeByMonth(undefined), []);
|
|
107
|
+
});
|
|
108
|
+
|
|
93
109
|
console.log(`\ncost_summary_shape: ${passed} passed, ${failed} failed`);
|
|
94
110
|
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// tests/merge_driver_register.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1003846 — registerMergeDriver wrote two git-config keys in sequence inside one
|
|
4
|
+
// try, `.name` first. If the first succeeded and the second threw, the half-written
|
|
5
|
+
// section SURVIVED, and git does not treat a named driver with no command as "fall back
|
|
6
|
+
// to a text merge" — it treats it as fatal:
|
|
7
|
+
//
|
|
8
|
+
// fatal: custom merge driver otb-regen lacks command line.
|
|
9
|
+
//
|
|
10
|
+
// exit 128, no unmerged paths. Every merge touching any `merge=otb-regen` path then dies
|
|
11
|
+
// — the root CLAUDE.md, the file/session-log maps, two nested CLAUDE.md files, the
|
|
12
|
+
// copy-registry pair and the nine generated API artifacts. So the partial-write state was
|
|
13
|
+
// strictly WORSE than not registering at all, which merely yields an ordinary conflict.
|
|
14
|
+
//
|
|
15
|
+
// This file pins BOTH halves of the fix, and pins them against real git rather than a
|
|
16
|
+
// mock, because the whole finding rests on how git itself reads a partial section — a
|
|
17
|
+
// mock would only assert what we already believe. The first two tests would have passed
|
|
18
|
+
// on the buggy code too if they were written against a stub; they are written against a
|
|
19
|
+
// throwaway repo on disk precisely so they cannot.
|
|
20
|
+
//
|
|
21
|
+
// Run: node --test tests/merge_driver_register.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
// fileURLToPath, never URL.pathname — on Windows the latter yields '/C:/…', which is not
|
|
34
|
+
// a usable path. Enforced by fitness.js (task 1002889).
|
|
35
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
36
|
+
const { classifyMergeDriverState, registerMergeDriver, MERGE_DRIVER_NAME } =
|
|
37
|
+
require(path.join(REPO_ROOT, 'scripts/gds/install-git-hooks.js'));
|
|
38
|
+
|
|
39
|
+
// A throwaway repo with one file attributed to the driver and two divergent branches,
|
|
40
|
+
// so `git merge` actually has to consult the driver config.
|
|
41
|
+
function scratchRepo() {
|
|
42
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mergedriver-'));
|
|
43
|
+
const git = (args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
44
|
+
git(['init', '-q', '.']);
|
|
45
|
+
git(['config', 'user.email', 'test@example.com']);
|
|
46
|
+
git(['config', 'user.name', 'test']);
|
|
47
|
+
fs.writeFileSync(path.join(dir, 'f.md'), 'base\n');
|
|
48
|
+
fs.writeFileSync(path.join(dir, '.gitattributes'), `f.md merge=${MERGE_DRIVER_NAME}\n`);
|
|
49
|
+
git(['add', '-A']);
|
|
50
|
+
git(['commit', '-qm', 'base']);
|
|
51
|
+
const base = git(['branch', '--show-current']).trim();
|
|
52
|
+
git(['checkout', '-qb', 'other']);
|
|
53
|
+
fs.writeFileSync(path.join(dir, 'f.md'), 'theirs\n');
|
|
54
|
+
git(['commit', '-qam', 'other']);
|
|
55
|
+
git(['checkout', '-q', base]);
|
|
56
|
+
fs.writeFileSync(path.join(dir, 'f.md'), 'ours\n');
|
|
57
|
+
git(['commit', '-qam', 'mine']);
|
|
58
|
+
return { dir, git };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Returns null on a clean merge, or git's stderr on failure.
|
|
62
|
+
function tryMerge(dir) {
|
|
63
|
+
try {
|
|
64
|
+
execFileSync('git', ['merge', 'other'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
65
|
+
return null;
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return String(e.stderr || e.message);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test('THE BUG: a section with .name and no .driver makes every merge fatal', () => {
|
|
72
|
+
const { dir, git } = scratchRepo();
|
|
73
|
+
git(['config', `merge.${MERGE_DRIVER_NAME}.name`, 'half-written']);
|
|
74
|
+
const err = tryMerge(dir);
|
|
75
|
+
assert.ok(err, 'merge must FAIL in the half-written state — if this passes, the premise is gone');
|
|
76
|
+
assert.match(err, /lacks command line/,
|
|
77
|
+
'git must reject a named driver with no command (this is why .name cannot be written first)');
|
|
78
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('THE FIX RESTS ON THIS: a section with only .driver is valid and runs', () => {
|
|
82
|
+
const { dir, git } = scratchRepo();
|
|
83
|
+
// `cp %B %A` stands in for git-merge-regen.js: it resolves by taking theirs, which is
|
|
84
|
+
// observable in the file afterwards, so this asserts the driver RAN — not merely that
|
|
85
|
+
// the merge exited 0, which a fallback text merge would also do.
|
|
86
|
+
git(['config', `merge.${MERGE_DRIVER_NAME}.driver`, 'cp %B %A']);
|
|
87
|
+
assert.equal(tryMerge(dir), null, 'a driver-only section must merge cleanly');
|
|
88
|
+
assert.equal(fs.readFileSync(path.join(dir, 'f.md'), 'utf8'), 'theirs\n',
|
|
89
|
+
'the custom driver must have run — otherwise this proves nothing about ordering');
|
|
90
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('registerMergeDriver writes .driver BEFORE .name', () => {
|
|
94
|
+
const order = [];
|
|
95
|
+
const run = (_bin, args) => { order.push(args[1]); };
|
|
96
|
+
const res = registerMergeDriver('/nowhere', { run });
|
|
97
|
+
assert.equal(res.ok, true);
|
|
98
|
+
assert.deepEqual(order, [`merge.${MERGE_DRIVER_NAME}.driver`, `merge.${MERGE_DRIVER_NAME}.name`],
|
|
99
|
+
'order is the fix: the intermediate state after write #1 must be the SAFE one');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a failure mid-registration removes the whole section, leaving no trace', () => {
|
|
103
|
+
const calls = [];
|
|
104
|
+
const run = (_bin, args) => {
|
|
105
|
+
calls.push(args);
|
|
106
|
+
// Fail on the SECOND write (.name) — the exact partial-write this task is about.
|
|
107
|
+
if (args[1] === `merge.${MERGE_DRIVER_NAME}.name`) throw new Error('simulated config failure');
|
|
108
|
+
};
|
|
109
|
+
const res = registerMergeDriver('/nowhere', { run });
|
|
110
|
+
assert.equal(res.ok, false, 'a failed registration must report failure');
|
|
111
|
+
assert.match(res.error, /simulated config failure/, 'and must report the ORIGINAL error, not the cleanup');
|
|
112
|
+
assert.deepEqual(calls[calls.length - 1], ['config', '--remove-section', `merge.${MERGE_DRIVER_NAME}`],
|
|
113
|
+
'the last action must be removing the section, so nothing partial survives');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('cleanup failing does not mask the original error', () => {
|
|
117
|
+
const run = (_bin, args) => {
|
|
118
|
+
if (args[1] === `merge.${MERGE_DRIVER_NAME}.name`) throw new Error('original failure');
|
|
119
|
+
if (args[1] === '--remove-section') throw new Error('cleanup also failed');
|
|
120
|
+
};
|
|
121
|
+
const res = registerMergeDriver('/nowhere', { run });
|
|
122
|
+
assert.equal(res.ok, false);
|
|
123
|
+
assert.match(res.error, /original failure/, 'the reported error must be the one that matters');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('classifyMergeDriverState: name without driver is the FAIL doctor must surface', () => {
|
|
127
|
+
const v = classifyMergeDriverState({ name: 'something', driver: '' });
|
|
128
|
+
assert.equal(v.status, 'fail');
|
|
129
|
+
assert.equal(v.code, 'name-without-driver');
|
|
130
|
+
assert.match(v.message, /lacks command line/, 'the message must quote the fatal a builder will actually see');
|
|
131
|
+
assert.match(v.message, /--remove-section/, 'and must give a way out');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('classifyMergeDriverState: driver present is ok, with or without a name', () => {
|
|
135
|
+
assert.equal(classifyMergeDriverState({ name: 'n', driver: 'd' }).status, 'ok');
|
|
136
|
+
assert.equal(classifyMergeDriverState({ name: '', driver: 'd' }).status, 'ok',
|
|
137
|
+
'driver-only is a WORKING section (proved against real git above), so it must not be reported as a fault');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('classifyMergeDriverState: neither set is a warn, not a fail', () => {
|
|
141
|
+
const v = classifyMergeDriverState({ name: '', driver: '' });
|
|
142
|
+
assert.equal(v.status, 'warn');
|
|
143
|
+
assert.equal(v.code, 'unregistered');
|
|
144
|
+
assert.match(v.message, /ship\.js still self-heals/, 'absent is not a hole in the walls — say so');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('whitespace-only config values count as absent', () => {
|
|
148
|
+
assert.equal(classifyMergeDriverState({ name: ' ', driver: ' ' }).code, 'unregistered');
|
|
149
|
+
assert.equal(classifyMergeDriverState({ name: 'n', driver: ' ' }).code, 'name-without-driver');
|
|
150
|
+
});
|
package/tests/search_ingest.mjs
CHANGED
|
@@ -54,10 +54,46 @@ test('CLAUDE.md is xenos (write-protected but READ-PUBLIC — the carve-out)', (
|
|
|
54
54
|
assert.equal(minRankForDoc('CLAUDE.md'), 'xenos');
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
// EXHAUSTIVE, not a spot-check (task 1002882). The old version asserted three of the
|
|
58
|
+
// six entries with includes(), which meant the list could GAIN or LOSE an entry — a
|
|
59
|
+
// whole read-gated subtree appearing or vanishing — with nothing red. docs/governance/
|
|
60
|
+
// was added under task 1002598 and this test never noticed.
|
|
61
|
+
//
|
|
62
|
+
// A deepEqual pin makes every change to the read floor a deliberate, reviewed edit:
|
|
63
|
+
// you cannot widen or narrow what a sub-Metic search can reach without also editing
|
|
64
|
+
// this line and saying why. The functional assertions above already cover BEHAVIOUR
|
|
65
|
+
// (minRankForDoc returns 'metic'); this one states the list's INTENT, which is the
|
|
66
|
+
// half that was under-described.
|
|
67
|
+
//
|
|
68
|
+
// Keep it in sync with scripts/gds/publish-manifest.js:185, where SENSITIVE_DOC_EXCLUDE
|
|
69
|
+
// is derived from this same array by filtering to docs/security + docs/audits — a
|
|
70
|
+
// silent addition here changes the PUBLISH boundary too.
|
|
71
|
+
test('SENSITIVE_DOC_GLOBS is the curated source of truth (exhaustive pin)', () => {
|
|
72
|
+
assert.deepEqual(SENSITIVE_DOC_GLOBS, [
|
|
73
|
+
'docs/canonical-permissions.md',
|
|
74
|
+
'docs/security/',
|
|
75
|
+
'docs/audits/',
|
|
76
|
+
'docs/governance/',
|
|
77
|
+
'docs/adr/0016-trust-boundary-server-enforced-permissions.md',
|
|
78
|
+
'docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md',
|
|
79
|
+
], 'adding or removing a read-gated doc must be a deliberate edit here, not a silent one');
|
|
80
|
+
|
|
81
|
+
// A trailing '/' means "subtree" to the glob dialect this shares with
|
|
82
|
+
// permission-path-check; a missing one would gate only an exact file of that name.
|
|
83
|
+
for (const g of ['docs/security/', 'docs/audits/', 'docs/governance/']) {
|
|
84
|
+
assert.ok(SENSITIVE_DOC_GLOBS.includes(g), `${g} must keep its trailing slash to gate the subtree`);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// The publish boundary reads this same array, so a change here silently moves what the
|
|
89
|
+
// public AGPL mirror ships. Pin the derivation, not just the source (task 1002882).
|
|
90
|
+
test('SENSITIVE_DOC_EXCLUDE derives docs/security + docs/audits from the same array', () => {
|
|
91
|
+
const derived = SENSITIVE_DOC_GLOBS.filter(
|
|
92
|
+
(g) => g.startsWith('docs/security') || g.startsWith('docs/audits')
|
|
93
|
+
);
|
|
94
|
+
assert.deepEqual(derived, ['docs/security/', 'docs/audits/'],
|
|
95
|
+
'publish-manifest.js:185 applies exactly this filter — if the array gains another '
|
|
96
|
+
+ 'docs/security* or docs/audits* entry, the publish denylist changes with it');
|
|
61
97
|
});
|
|
62
98
|
|
|
63
99
|
test('collectMarkdown finds the repo corpus but excludes node_modules/.git/worktrees/public', () => {
|